From 881da7b8c2b1658e81d4ca7fca01e23bf27d9faf Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 02:05:47 +0000 Subject: [PATCH 1/7] =?UTF-8?q?zslp:=20NFT=20Phase=20A=20=E2=80=94=20token?= =?UTF-8?q?=20indexer=20+=20read-only=20RPCs=20(NON-consensus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only ZSLP (Simple Ledger Protocol) token indexer that observes OP_RETURN messages and exposes them over RPC. Touches NO validation, PoW, or wallet spends — it only reads connected/disconnected blocks off the existing validation signal bus. STORE (src/zslp/zslpstore.{h,cpp}): LevelDB-backed (CDBWrapper) re-implementation of the zclassic-c sqlite token model. Persists token genesis records (id, ticker, name, document url/hash, decimals, genesis height, mint-baton state, total_minted), per-vout transfer records, and per-(token,address) balances. Key schema: 't'+tokenId, 'x'+tokenId+BE(height)+txid+BE(vout), 'b'+tokenId+address, 'r'+blockHash+seq (reorg undo log), 'T' (tip marker for crash-resume). Big-endian height/vout keep transfers contiguous and height-ordered; a per-block undo log records every put/credit so a disconnect reverses exactly what the matching connect applied. INDEXER (src/zslp/zslpindexer.{h,cpp}): a CValidationInterface that hooks the ChainTip signal (added=true connect / false disconnect; both deliver the CBlock, so no disk read is needed). On connect it scans each tx's TX_NULL_DATA outputs, parses them via the C slp_parse(), and persists genesis/mint/send records tagged with block hash + height + txid, crediting the dust-output recipients. On disconnect it replays the block's undo log in reverse. Registered behind -zslpindex (default ON for this branch) in init.cpp, with a clean shutdown. A thin bridge (src/zslp/zslpmsg.{h,cpp}) wraps slp_parse() because the protocol library's plain-C `struct uint256` collides with the daemon's `class uint256`; the bridge is the only TU that sees the C header and returns plain byte arrays. READ RPCs (src/rpc/zslp.cpp, RegisterZSLPRPCCommands): zslp_gettoken, zslp_listtokens, zslp_listtransfers (all bounded), and zslp_listmytokens which intersects store balances with the wallet's t-addresses (ZSLP rides transparent dust). All read-only with sane limits. gtest (src/gtest/test_zslp_indexer.cpp): store put/get/list, balance accounting, and the reorg invariant — connect then disconnect restores the store exactly, including total_minted and mint-baton reversal. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Makefile.am | 9 +- src/Makefile.gtest.include | 3 +- src/gtest/test_zslp_indexer.cpp | 314 ++++++++++++++++++ src/init.cpp | 11 + src/rpc/register.h | 3 + src/rpc/zslp.cpp | 274 ++++++++++++++++ src/zslp/zslpindexer.cpp | 212 ++++++++++++ src/zslp/zslpindexer.h | 57 ++++ src/zslp/zslpmsg.cpp | 53 +++ src/zslp/zslpmsg.h | 65 ++++ src/zslp/zslpstore.cpp | 564 ++++++++++++++++++++++++++++++++ src/zslp/zslpstore.h | 269 +++++++++++++++ 12 files changed, 1832 insertions(+), 2 deletions(-) create mode 100644 src/gtest/test_zslp_indexer.cpp create mode 100644 src/rpc/zslp.cpp create mode 100644 src/zslp/zslpindexer.cpp create mode 100644 src/zslp/zslpindexer.h create mode 100644 src/zslp/zslpmsg.cpp create mode 100644 src/zslp/zslpmsg.h create mode 100644 src/zslp/zslpstore.cpp create mode 100644 src/zslp/zslpstore.h diff --git a/src/Makefile.am b/src/Makefile.am index fca9356e7e7..980d7c66eb7 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -238,7 +238,10 @@ BITCOIN_CORE_H = \ zmq/zmqpublishnotifier.h \ zslp/slp.h \ zslp/op_return_push.h \ - zslp/uint256_c.h + zslp/uint256_c.h \ + zslp/zslpmsg.h \ + zslp/zslpstore.h \ + zslp/zslpindexer.h obj/build.h: FORCE @@ -282,12 +285,16 @@ libbitcoin_server_a_SOURCES = \ rpc/net.cpp \ rpc/rawtransaction.cpp \ rpc/server.cpp \ + rpc/zslp.cpp \ script/sigcache.cpp \ timedata.cpp \ torcontrol.cpp \ txdb.cpp \ txmempool.cpp \ validationinterface.cpp \ + zslp/zslpmsg.cpp \ + zslp/zslpstore.cpp \ + zslp/zslpindexer.cpp \ $(BITCOIN_CORE_H) \ $(LIBZCASH_H) diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index bd26107393c..04b2d480fac 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -45,7 +45,8 @@ zcash_gtest_SOURCES += \ gtest/test_pedersen_hash.cpp \ gtest/test_checkblock.cpp \ gtest/test_zip32.cpp \ - gtest/test_zslp.cpp + gtest/test_zslp.cpp \ + gtest/test_zslp_indexer.cpp if ENABLE_WALLET zcash_gtest_SOURCES += \ wallet/gtest/test_paymentdisclosure.cpp \ diff --git a/src/gtest/test_zslp_indexer.cpp b/src/gtest/test_zslp_indexer.cpp new file mode 100644 index 00000000000..7f075daf3ff --- /dev/null +++ b/src/gtest/test_zslp_indexer.cpp @@ -0,0 +1,314 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// Unit tests for the ZSLP token store (CZSLPStore): put/get/list, balance +// accounting, and the reorg invariant — connecting then disconnecting a +// block must restore the store byte-for-byte to its prior state. +// +// These tests feed parsed messages directly to the store (no full chain), +// exercising the same code path the indexer drives. + +#include + +#include "zslp/zslpstore.h" +#include "uint256.h" + +#include +#include + +namespace { + +uint256 HashFromByte(uint8_t b) +{ + std::vector v(32, 0); + v[0] = b; + return uint256(v); +} + +CZSLPToken MakeToken(const uint256& id, const std::string& ticker, + int64_t height, uint8_t baton = 0) +{ + CZSLPToken t; + t.tokenId = id; + t.ticker = ticker; + t.name = ticker + " Token"; + t.documentUrl = "https://example.com/" + ticker; + t.decimals = 2; + t.mintBatonVout = baton; + t.genesisHeight = height; + return t; +} + +// Build an in-memory store (leveldb memenv) for a test. +CZSLPStore* NewMemStore() +{ + return new CZSLPStore("zslp-test", 1 << 20, /*fMemory=*/true, /*fWipe=*/true); +} + +} // namespace + +// ── Genesis put/get + balance + total_minted ─────────────────────── + +TEST(ZSLPStore, GenesisPutGet) +{ + CZSLPStore* s = NewMemStore(); + + uint256 blk = HashFromByte(0x10); + uint256 tid = HashFromByte(0xA1); + std::string addr = "t1ExampleAddressAaa"; + + s->ConnectBlockBegin(blk); + CZSLPToken token = MakeToken(tid, "ABC", 100, /*baton=*/2); + ASSERT_TRUE(s->ApplyGenesis(token, addr, tid, 1, 1000)); + s->ConnectBlockEnd(100, blk); + + CZSLPToken got; + ASSERT_TRUE(s->GetToken(tid, got)); + EXPECT_EQ(got.tokenId, tid); + EXPECT_EQ(got.ticker, "ABC"); + EXPECT_EQ(got.decimals, 2); + EXPECT_EQ(got.genesisHeight, 100); + EXPECT_EQ(got.totalMinted, 1000); + EXPECT_EQ(got.mintBatonVout, 2); + + EXPECT_EQ(s->GetBalance(tid, addr), 1000); + EXPECT_EQ(s->TokenCount(), 1); + + int64_t h; uint256 bh; + ASSERT_TRUE(s->ReadTip(h, bh)); + EXPECT_EQ(h, 100); + EXPECT_EQ(bh, blk); + + delete s; +} + +// ── Mint increases total_minted and balance ──────────────────────── + +TEST(ZSLPStore, MintAccounting) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0xB2); + std::string addr = "t1MintRecipient"; + + s->ConnectBlockBegin(HashFromByte(0x20)); + ASSERT_TRUE(s->ApplyGenesis(MakeToken(tid, "MNT", 200, 2), addr, tid, 1, 500)); + s->ConnectBlockEnd(200, HashFromByte(0x20)); + + uint256 mintTx = HashFromByte(0xC3); + s->ConnectBlockBegin(HashFromByte(0x21)); + ASSERT_TRUE(s->ApplyMint(tid, addr, mintTx, 201, 1, 250, + /*batonMoved=*/false, 2)); + s->ConnectBlockEnd(201, HashFromByte(0x21)); + + CZSLPToken got; + ASSERT_TRUE(s->GetToken(tid, got)); + EXPECT_EQ(got.totalMinted, 750); + EXPECT_EQ(s->GetBalance(tid, addr), 750); + delete s; +} + +// ── Send credits recipients; list newest-first ───────────────────── + +TEST(ZSLPStore, SendAndListTransfers) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0xD4); + std::string a1 = "t1Sender"; + std::string a2 = "t1Recipient"; + + s->ConnectBlockBegin(HashFromByte(0x30)); + ASSERT_TRUE(s->ApplyGenesis(MakeToken(tid, "SND", 300), a1, tid, 1, 1000)); + s->ConnectBlockEnd(300, HashFromByte(0x30)); + + s->ConnectBlockBegin(HashFromByte(0x31)); + uint256 sendTx = HashFromByte(0xE5); + ASSERT_TRUE(s->ApplySend(tid, a2, sendTx, 305, 1, 400)); + s->ConnectBlockEnd(305, HashFromByte(0x31)); + + EXPECT_EQ(s->GetBalance(tid, a2), 400); + + std::vector xfers; + int n = s->ListTransfers(tid, 0, 100, xfers); + EXPECT_EQ(n, 2); + // Newest first: the SEND at height 305 should come before the genesis. + ASSERT_EQ(xfers.size(), 2u); + EXPECT_EQ(xfers[0].blockHeight, 305); + EXPECT_EQ(xfers[0].txType, ZSLP_TX_SEND); + EXPECT_EQ(xfers[1].blockHeight, 300); + EXPECT_EQ(xfers[1].txType, ZSLP_TX_GENESIS); + delete s; +} + +// ── ListTokens bounding ──────────────────────────────────────────── + +TEST(ZSLPStore, ListTokensBounded) +{ + CZSLPStore* s = NewMemStore(); + for (int i = 0; i < 5; ++i) { + uint256 tid = HashFromByte((uint8_t)(0x40 + i)); + s->ConnectBlockBegin(HashFromByte((uint8_t)(0x50 + i))); + s->ApplyGenesis(MakeToken(tid, "T", 400 + i), "t1addr", tid, 1, 10); + s->ConnectBlockEnd(400 + i, HashFromByte((uint8_t)(0x50 + i))); + } + EXPECT_EQ(s->TokenCount(), 5); + + std::vector page; + EXPECT_EQ(s->ListTokens(0, 2, page), 2); + EXPECT_EQ(s->ListTokens(2, 2, page), 2); + EXPECT_EQ(s->ListTokens(4, 10, page), 1); + EXPECT_EQ(s->ListTokens(0, 0, page), 0); + delete s; +} + +// ── GetTokensForAddress (the listmytokens primitive) ─────────────── + +TEST(ZSLPStore, TokensForAddress) +{ + CZSLPStore* s = NewMemStore(); + uint256 t1 = HashFromByte(0x71); + uint256 t2 = HashFromByte(0x72); + std::string mine = "t1Mine"; + std::string other = "t1Other"; + + s->ConnectBlockBegin(HashFromByte(0x80)); + ASSERT_TRUE(s->ApplyGenesis(MakeToken(t1, "AAA", 500), mine, t1, 1, 100)); + s->ConnectBlockEnd(500, HashFromByte(0x80)); + + s->ConnectBlockBegin(HashFromByte(0x81)); + ASSERT_TRUE(s->ApplyGenesis(MakeToken(t2, "BBB", 501), other, t2, 1, 200)); + s->ConnectBlockEnd(501, HashFromByte(0x81)); + + std::vector > rows; + s->GetTokensForAddress(mine, rows); + ASSERT_EQ(rows.size(), 1u); + EXPECT_EQ(rows[0].first, t1); + EXPECT_EQ(rows[0].second, 100); + + s->GetTokensForAddress(other, rows); + ASSERT_EQ(rows.size(), 1u); + EXPECT_EQ(rows[0].first, t2); + delete s; +} + +// ── REORG INVARIANT: connect then disconnect == prior state ──────── + +TEST(ZSLPStore, ReorgGenesisRoundTrip) +{ + CZSLPStore* s = NewMemStore(); + + // Pre-state: one token already present from an earlier block. + uint256 baseBlk = HashFromByte(0x01); + uint256 baseTok = HashFromByte(0x02); + std::string baseAddr = "t1Base"; + s->ConnectBlockBegin(baseBlk); + ASSERT_TRUE(s->ApplyGenesis(MakeToken(baseTok, "BASE", 10), baseAddr, + baseTok, 1, 5000)); + s->ConnectBlockEnd(10, baseBlk); + + const int64_t preCount = s->TokenCount(); + const int64_t preBaseBal = s->GetBalance(baseTok, baseAddr); + + // Connect a new block carrying a fresh genesis + a send of the base token. + uint256 newBlk = HashFromByte(0x03); + uint256 newTok = HashFromByte(0x04); + std::string addrA = "t1New"; + s->ConnectBlockBegin(newBlk); + ASSERT_TRUE(s->ApplyGenesis(MakeToken(newTok, "NEW", 11), addrA, + newTok, 1, 1000)); + uint256 sendTx = HashFromByte(0x05); + ASSERT_TRUE(s->ApplySend(baseTok, addrA, sendTx, 11, 1, 1500)); + s->ConnectBlockEnd(11, newBlk); + + // Post-connect: state changed. + EXPECT_EQ(s->TokenCount(), preCount + 1); + EXPECT_EQ(s->GetBalance(newTok, addrA), 1000); + EXPECT_EQ(s->GetBalance(baseTok, addrA), 1500); + int64_t h; uint256 bh; + ASSERT_TRUE(s->ReadTip(h, bh)); + EXPECT_EQ(h, 11); + EXPECT_EQ(bh, newBlk); + + // Disconnect (reorg) the new block; must restore the prior state exactly. + ASSERT_TRUE(s->DisconnectBlock(newBlk, 10, baseBlk)); + + EXPECT_EQ(s->TokenCount(), preCount); + CZSLPToken gone; + EXPECT_FALSE(s->GetToken(newTok, gone)); // new genesis erased + EXPECT_EQ(s->GetBalance(newTok, addrA), 0); // its balance erased + EXPECT_EQ(s->GetBalance(baseTok, addrA), 0); // send credit reversed + EXPECT_EQ(s->GetBalance(baseTok, baseAddr), preBaseBal); // base untouched + + // The new token's transfer rows are gone; base token keeps only genesis. + std::vector xfers; + EXPECT_EQ(s->ListTransfers(newTok, 0, 100, xfers), 0); + EXPECT_EQ(s->ListTransfers(baseTok, 0, 100, xfers), 1); + + // Tip rewound. + ASSERT_TRUE(s->ReadTip(h, bh)); + EXPECT_EQ(h, 10); + EXPECT_EQ(bh, baseBlk); + + delete s; +} + +// ── REORG INVARIANT: mint baton + total_minted reversal ──────────── + +TEST(ZSLPStore, ReorgMintRoundTrip) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0x90); + std::string addr = "t1MintAddr"; + + s->ConnectBlockBegin(HashFromByte(0xA0)); + ASSERT_TRUE(s->ApplyGenesis(MakeToken(tid, "BAT", 20, /*baton=*/2), addr, + tid, 1, 1000)); + s->ConnectBlockEnd(20, HashFromByte(0xA0)); + + CZSLPToken before; + ASSERT_TRUE(s->GetToken(tid, before)); + const int64_t preMinted = before.totalMinted; + const uint8_t preBaton = before.mintBatonVout; + const int64_t preBal = s->GetBalance(tid, addr); + + // Mint more and move the baton to vout 3. + uint256 mintBlk = HashFromByte(0xA1); + uint256 mintTx = HashFromByte(0xA2); + s->ConnectBlockBegin(mintBlk); + ASSERT_TRUE(s->ApplyMint(tid, addr, mintTx, 21, 1, 500, + /*batonMoved=*/true, /*newBatonVout=*/3)); + s->ConnectBlockEnd(21, mintBlk); + + CZSLPToken mid; + ASSERT_TRUE(s->GetToken(tid, mid)); + EXPECT_EQ(mid.totalMinted, preMinted + 500); + EXPECT_EQ(mid.mintBatonVout, 3); + EXPECT_EQ(s->GetBalance(tid, addr), preBal + 500); + + // Disconnect: total_minted, baton, and balance must all revert. + ASSERT_TRUE(s->DisconnectBlock(mintBlk, 20, HashFromByte(0xA0))); + + CZSLPToken after; + ASSERT_TRUE(s->GetToken(tid, after)); + EXPECT_EQ(after.totalMinted, preMinted); + EXPECT_EQ(after.mintBatonVout, preBaton); + EXPECT_EQ(s->GetBalance(tid, addr), preBal); + delete s; +} + +// ── Disconnecting a ZSLP-empty block is a safe tip rewind ────────── + +TEST(ZSLPStore, DisconnectEmptyBlock) +{ + CZSLPStore* s = NewMemStore(); + uint256 blk = HashFromByte(0xF0); + s->ConnectBlockBegin(blk); + s->ConnectBlockEnd(50, blk); // no ZSLP records applied + + EXPECT_EQ(s->TokenCount(), 0); + ASSERT_TRUE(s->DisconnectBlock(blk, 49, HashFromByte(0xEF))); + + int64_t h; uint256 bh; + ASSERT_TRUE(s->ReadTip(h, bh)); + EXPECT_EQ(h, 49); + EXPECT_EQ(bh, HashFromByte(0xEF)); + delete s; +} diff --git a/src/init.cpp b/src/init.cpp index ac853e6b495..e3b123c930e 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -39,6 +39,7 @@ #include "util.h" #include "utilmoneystr.h" #include "validationinterface.h" +#include "zslp/zslpindexer.h" #ifdef ENABLE_WALLET #include "wallet/wallet.h" #include "wallet/walletdb.h" @@ -282,6 +283,9 @@ void Shutdown() pwalletMain->Flush(true); #endif + // ZSLP indexer: unregister from the validation bus and release the store. + StopZSLPIndexer(); + #if ENABLE_ZMQ if (pzmqNotificationInterface) { UnregisterValidationInterface(pzmqNotificationInterface); @@ -519,6 +523,7 @@ std::string HelpMessage(HelpMessageMode mode) strUsage += HelpMessageOpt("-debug=", strprintf(_("Output debugging information (default: %u, supplying is optional)"), 0) + ". " + _("If is not supplied or if = 1, output all debugging information.") + " " + _(" can be:") + " " + debugCategories + "."); strUsage += HelpMessageOpt("-experimentalfeatures", _("Enable use of experimental features")); + strUsage += HelpMessageOpt("-zslpindex", strprintf(_("Maintain a read-only index of ZSLP token OP_RETURN messages, for the zslp_* RPCs (default: %u)"), 1)); strUsage += HelpMessageOpt("-help-debug", _("Show all debugging options (usage: --help -help-debug)")); strUsage += HelpMessageOpt("-logips", strprintf(_("Include IP addresses in debug output (default: %u)"), 0)); strUsage += HelpMessageOpt("-debuglogfile", _("Write debug output to debug.log file (default: 0, disabled for privacy)")); @@ -3262,6 +3267,12 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) StartNode(threadGroup, scheduler); g_startupTimer.mark("start node"); + // ZSLP token indexer (NON-consensus, read-only OP_RETURN observation). + // Default ON for this feature branch; opt out with -zslpindex=0. + if (GetBoolArg("-zslpindex", true)) { + StartZSLPIndexer(); + } + // Monitor the chain every minute, and alert if we get blocks much quicker or slower than expected. CScheduler::Function f = boost::bind(&PartitionCheck, &IsInitialBlockDownload, boost::ref(cs_main), boost::cref(pindexBestHeader)); diff --git a/src/rpc/register.h b/src/rpc/register.h index 01aa58a25d8..aa0f15908ab 100644 --- a/src/rpc/register.h +++ b/src/rpc/register.h @@ -19,6 +19,8 @@ void RegisterMiscRPCCommands(CRPCTable &tableRPC); void RegisterMiningRPCCommands(CRPCTable &tableRPC); /** Register raw transaction RPC commands */ void RegisterRawTransactionRPCCommands(CRPCTable &tableRPC); +/** Register ZSLP token read-only RPC commands */ +void RegisterZSLPRPCCommands(CRPCTable &tableRPC); static inline void RegisterAllCoreRPCCommands(CRPCTable &tableRPC) { @@ -27,6 +29,7 @@ static inline void RegisterAllCoreRPCCommands(CRPCTable &tableRPC) RegisterMiscRPCCommands(tableRPC); RegisterMiningRPCCommands(tableRPC); RegisterRawTransactionRPCCommands(tableRPC); + RegisterZSLPRPCCommands(tableRPC); } #endif diff --git a/src/rpc/zslp.cpp b/src/rpc/zslp.cpp new file mode 100644 index 00000000000..80b8e434fe1 --- /dev/null +++ b/src/rpc/zslp.cpp @@ -0,0 +1,274 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP read-only RPCs. All commands here are pure reads of the ZSLP token +// store (populated by the NON-consensus indexer). They never construct, +// sign, or broadcast transactions and never touch validation/PoW. +// +// zslp_gettoken "token_id" -> token metadata +// zslp_listtokens (count, from) -> bounded token list +// zslp_listtransfers "token_id" (count, from) -> bounded transfer list +// zslp_listmytokens -> tokens with balance>0 at the +// wallet's t-addresses + +#include "rpc/server.h" + +#include "key_io.h" +#include "rpc/protocol.h" +#include "script/standard.h" +#include "util.h" +#include "zslp/zslpindexer.h" +#include "zslp/zslpstore.h" + +#ifdef ENABLE_WALLET +#include "init.h" // pwalletMain +#include "main.h" // cs_main +#include "wallet/wallet.h" +#endif + +#include +#include + +// Return the active store or throw a friendly error when the index is off. +static CZSLPStore* GetZSLPStoreOrThrow() +{ + if (g_zslpIndexer == NULL || g_zslpIndexer->Store() == NULL) + throw JSONRPCError(RPC_MISC_ERROR, + "ZSLP index is not enabled. Start zclassicd with -zslpindex."); + return g_zslpIndexer->Store(); +} + +static UniValue TokenToJSON(const CZSLPToken& t) +{ + UniValue o(UniValue::VOBJ); + o.push_back(Pair("tokenid", t.tokenId.GetHex())); + o.push_back(Pair("ticker", t.ticker)); + o.push_back(Pair("name", t.name)); + o.push_back(Pair("documenturl", t.documentUrl)); + o.push_back(Pair("documenthash", + t.hasDocumentHash ? t.documentHash.GetHex() : std::string(""))); + o.push_back(Pair("decimals", (int)t.decimals)); + o.push_back(Pair("genesisheight", (int64_t)t.genesisHeight)); + o.push_back(Pair("totalminted", (int64_t)t.totalMinted)); + o.push_back(Pair("mintbatonvout", (int)t.mintBatonVout)); + o.push_back(Pair("hasmintbaton", t.mintBatonVout >= 2)); + return o; +} + +static const char* TxTypeName(uint8_t t) +{ + switch (t) { + case ZSLP_TX_GENESIS: return "GENESIS"; + case ZSLP_TX_MINT: return "MINT"; + case ZSLP_TX_SEND: return "SEND"; + default: return "UNKNOWN"; + } +} + +UniValue zslp_gettoken(const UniValue& params, bool fHelp) +{ + if (fHelp || params.size() != 1) + throw std::runtime_error( + "zslp_gettoken \"token_id\"\n" + "\nReturns metadata for a ZSLP token (read-only).\n" + "\nArguments:\n" + "1. \"token_id\" (string, required) the token id (genesis txid, hex)\n" + "\nResult:\n" + "{\n" + " \"tokenid\": \"hex\",\n" + " \"ticker\": \"...\",\n" + " \"name\": \"...\",\n" + " \"documenturl\": \"...\",\n" + " \"documenthash\": \"hex\",\n" + " \"decimals\": n,\n" + " \"genesisheight\": n,\n" + " \"totalminted\": n,\n" + " \"mintbatonvout\": n,\n" + " \"hasmintbaton\": true|false\n" + "}\n" + "\nExamples:\n" + + HelpExampleCli("zslp_gettoken", "\"\"") + + HelpExampleRpc("zslp_gettoken", "\"\"")); + + CZSLPStore* store = GetZSLPStoreOrThrow(); + uint256 tokenId = ParseHashV(params[0], "token_id"); + + CZSLPToken token; + if (!store->GetToken(tokenId, token)) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Token not found"); + return TokenToJSON(token); +} + +UniValue zslp_listtokens(const UniValue& params, bool fHelp) +{ + if (fHelp || params.size() > 2) + throw std::runtime_error( + "zslp_listtokens ( count from )\n" + "\nLists known ZSLP tokens (read-only, bounded).\n" + "\nArguments:\n" + "1. count (numeric, optional, default=100) max tokens to return (<=" + std::to_string(ZSLP_LIST_MAX) + ")\n" + "2. from (numeric, optional, default=0) number of tokens to skip\n" + "\nResult: [ { token... }, ... ]\n" + "\nExamples:\n" + + HelpExampleCli("zslp_listtokens", "100 0") + + HelpExampleRpc("zslp_listtokens", "100, 0")); + + CZSLPStore* store = GetZSLPStoreOrThrow(); + + int count = 100; + int from = 0; + if (params.size() > 0) + count = params[0].get_int(); + if (params.size() > 1) + from = params[1].get_int(); + if (count < 0) count = 0; + if (count > ZSLP_LIST_MAX) count = ZSLP_LIST_MAX; + if (from < 0) from = 0; + + std::vector tokens; + store->ListTokens(from, count, tokens); + + UniValue arr(UniValue::VARR); + for (size_t i = 0; i < tokens.size(); ++i) + arr.push_back(TokenToJSON(tokens[i])); + return arr; +} + +UniValue zslp_listtransfers(const UniValue& params, bool fHelp) +{ + if (fHelp || params.size() < 1 || params.size() > 3) + throw std::runtime_error( + "zslp_listtransfers \"token_id\" ( count from )\n" + "\nLists transfers for a ZSLP token, newest first (read-only, bounded).\n" + "\nArguments:\n" + "1. \"token_id\" (string, required) the token id (hex)\n" + "2. count (numeric, optional, default=100) max rows (<=" + std::to_string(ZSLP_LIST_MAX) + ")\n" + "3. from (numeric, optional, default=0) rows to skip\n" + "\nResult: [ { \"txid\", \"tokenid\", \"type\", \"amount\", \"vout\",\n" + " \"height\", \"blockhash\", \"address\" }, ... ]\n" + "\nExamples:\n" + + HelpExampleCli("zslp_listtransfers", "\"\" 100 0") + + HelpExampleRpc("zslp_listtransfers", "\"\", 100, 0")); + + CZSLPStore* store = GetZSLPStoreOrThrow(); + uint256 tokenId = ParseHashV(params[0], "token_id"); + + int count = 100; + int from = 0; + if (params.size() > 1) + count = params[1].get_int(); + if (params.size() > 2) + from = params[2].get_int(); + if (count < 0) count = 0; + if (count > ZSLP_LIST_MAX) count = ZSLP_LIST_MAX; + if (from < 0) from = 0; + + std::vector xfers; + store->ListTransfers(tokenId, from, count, xfers); + + UniValue arr(UniValue::VARR); + for (size_t i = 0; i < xfers.size(); ++i) { + const CZSLPTransfer& x = xfers[i]; + UniValue o(UniValue::VOBJ); + o.push_back(Pair("txid", x.txid.GetHex())); + o.push_back(Pair("tokenid", x.tokenId.GetHex())); + o.push_back(Pair("type", TxTypeName(x.txType))); + o.push_back(Pair("amount", (int64_t)x.amount)); + o.push_back(Pair("vout", (int)x.vout)); + o.push_back(Pair("height", (int64_t)x.blockHeight)); + o.push_back(Pair("blockhash", x.blockHash.GetHex())); + o.push_back(Pair("address", x.address)); + arr.push_back(o); + } + return arr; +} + +UniValue zslp_listmytokens(const UniValue& params, bool fHelp) +{ + if (fHelp || params.size() != 0) + throw std::runtime_error( + "zslp_listmytokens\n" + "\nLists ZSLP tokens with a positive balance at any of this\n" + "wallet's transparent addresses (read-only). ZSLP rides\n" + "transparent dust, so only t-addresses are considered.\n" + "\nResult: [ { \"tokenid\", \"ticker\", \"name\", \"decimals\",\n" + " \"balance\", \"addresses\": [ ... ] }, ... ]\n" + "\nExamples:\n" + + HelpExampleCli("zslp_listmytokens", "") + + HelpExampleRpc("zslp_listmytokens", "")); + + CZSLPStore* store = GetZSLPStoreOrThrow(); + + UniValue arr(UniValue::VARR); + +#ifdef ENABLE_WALLET + if (pwalletMain == NULL) + return arr; // no wallet: nothing to intersect + + LOCK2(cs_main, pwalletMain->cs_wallet); + + // Collect this wallet's t-addresses. + std::set keyids; + pwalletMain->GetKeys(keyids); + std::vector myAddrs; + myAddrs.reserve(keyids.size()); + for (std::set::const_iterator it = keyids.begin(); + it != keyids.end(); ++it) { + myAddrs.push_back(EncodeDestination(CTxDestination(*it))); + } + + // Aggregate balances per token across all my addresses. + std::map totals; + std::map tokenAddrs; + for (size_t i = 0; i < myAddrs.size(); ++i) { + std::vector > rows; + store->GetTokensForAddress(myAddrs[i], rows); + for (size_t j = 0; j < rows.size(); ++j) { + const uint256& tid = rows[j].first; + int64_t bal = rows[j].second; + totals[tid] += bal; + if (tokenAddrs.find(tid) == tokenAddrs.end()) + tokenAddrs[tid] = UniValue(UniValue::VARR); + UniValue a(UniValue::VOBJ); + a.push_back(Pair("address", myAddrs[i])); + a.push_back(Pair("balance", bal)); + tokenAddrs[tid].push_back(a); + } + } + + for (std::map::const_iterator it = totals.begin(); + it != totals.end(); ++it) { + if (it->second <= 0) + continue; + CZSLPToken token; + store->GetToken(it->first, token); + UniValue o(UniValue::VOBJ); + o.push_back(Pair("tokenid", it->first.GetHex())); + o.push_back(Pair("ticker", token.ticker)); + o.push_back(Pair("name", token.name)); + o.push_back(Pair("decimals", (int)token.decimals)); + o.push_back(Pair("balance", it->second)); + o.push_back(Pair("addresses", tokenAddrs[it->first])); + arr.push_back(o); + } +#else + throw JSONRPCError(RPC_MISC_ERROR, + "zslp_listmytokens requires a wallet-enabled build"); +#endif + + return arr; +} + +static const CRPCCommand commands[] = +{ // category name actor (function) okSafeMode + // --------- -------------------- ------------------- ---------- + { "zslp", "zslp_gettoken", &zslp_gettoken, true }, + { "zslp", "zslp_listtokens", &zslp_listtokens, true }, + { "zslp", "zslp_listtransfers", &zslp_listtransfers, true }, + { "zslp", "zslp_listmytokens", &zslp_listmytokens, true }, +}; + +void RegisterZSLPRPCCommands(CRPCTable& tableRPC) +{ + for (unsigned int vcidx = 0; vcidx < ARRAYLEN(commands); vcidx++) + tableRPC.appendCommand(commands[vcidx].name, &commands[vcidx]); +} diff --git a/src/zslp/zslpindexer.cpp b/src/zslp/zslpindexer.cpp new file mode 100644 index 00000000000..5e3ac9f2bf3 --- /dev/null +++ b/src/zslp/zslpindexer.cpp @@ -0,0 +1,212 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP indexer implementation. See zslpindexer.h. +// +// NON-consensus observer: reads connected/disconnected blocks off the +// validation signal bus and projects ZSLP OP_RETURN messages into the store. + +#include "zslp/zslpindexer.h" + +#include "chain.h" +#include "key_io.h" +#include "primitives/block.h" +#include "primitives/transaction.h" +#include "script/standard.h" +#include "util.h" +#include "zslp/zslpmsg.h" +#include "zslp/zslpstore.h" + +CZSLPIndexer* g_zslpIndexer = NULL; + +// LevelDB cache size for the ZSLP store (modest; this is auxiliary data). +static const size_t ZSLP_DB_CACHE = 8 << 20; // 8 MiB + +void StartZSLPIndexer() +{ + if (g_zslpIndexer != NULL) + return; + g_zslpIndexer = new CZSLPIndexer(); + RegisterValidationInterface(g_zslpIndexer); + LogPrintf("ZSLP: token indexer started (read-only OP_RETURN observation)\n"); +} + +void StopZSLPIndexer() +{ + if (g_zslpIndexer == NULL) + return; + UnregisterValidationInterface(g_zslpIndexer); + delete g_zslpIndexer; + g_zslpIndexer = NULL; +} + +CZSLPIndexer::CZSLPIndexer() +{ + boost::filesystem::path path = GetDataDir() / "blocks" / "zslp"; + store.reset(new CZSLPStore(path, ZSLP_DB_CACHE)); +} + +CZSLPIndexer::~CZSLPIndexer() {} + +// ── Address extraction ───────────────────────────────────────────── + +// Decode the t-address paid by a given vout's scriptPubKey, or "" if it is +// not a standard pay-to-address output (e.g. the OP_RETURN itself). +static std::string AddressOfVout(const CTransaction& tx, uint32_t voutIdx) +{ + if (voutIdx >= tx.vout.size()) + return std::string(); + CTxDestination dest; + if (!ExtractDestination(tx.vout[voutIdx].scriptPubKey, dest)) + return std::string(); + if (!IsValidDestination(dest)) + return std::string(); + return EncodeDestination(dest); +} + +// Convert an SLP message's on-chain token_id (big-endian / display order) to +// the daemon uint256 (internal little-endian) so it matches the genesis txid +// as the daemon computes it. +static uint256 TokenIdToUint256(const uint8_t tokenId[32]) +{ + std::vector v(32); + for (int i = 0; i < 32; ++i) + v[i] = tokenId[31 - i]; + return uint256(v); +} + +// ── Signal hook ──────────────────────────────────────────────────── + +void CZSLPIndexer::ChainTip(const CBlockIndex* pindex, const CBlock* pblock, + SproutMerkleTree, SaplingMerkleTree, bool added) +{ + if (pindex == NULL || pblock == NULL) + return; + if (added) + ConnectBlock(pindex, *pblock); + else + DisconnectBlock(pindex, *pblock); +} + +// ── Connect ──────────────────────────────────────────────────────── + +void CZSLPIndexer::ConnectBlock(const CBlockIndex* pindex, const CBlock& block) +{ + CZSLPStore* s = store.get(); + if (s == NULL) + return; + + const uint256 blockHash = pindex->GetBlockHash(); + + // Idempotence guard: if we already advanced past this block, skip. (A + // re-delivered connect for the current tip would otherwise double-count.) + int64_t tipHeight = -1; + uint256 tipHash; + if (s->ReadTip(tipHeight, tipHash) && tipHash == blockHash) + return; + + s->ConnectBlockBegin(blockHash); + for (size_t i = 0; i < block.vtx.size(); ++i) + IndexTransaction(block.vtx[i], pindex); + s->ConnectBlockEnd(pindex->nHeight, blockHash); +} + +void CZSLPIndexer::IndexTransaction(const CTransaction& tx, + const CBlockIndex* pindex) +{ + CZSLPStore* s = store.get(); + const int64_t height = pindex->nHeight; + const uint256 txid = tx.GetHash(); + + // Find the first OP_RETURN (TX_NULL_DATA) output and try to parse it. + for (size_t vo = 0; vo < tx.vout.size(); ++vo) { + const CScript& spk = tx.vout[vo].scriptPubKey; + txnouttype whichType; + std::vector > solutions; + if (!Solver(spk, whichType, solutions) || whichType != TX_NULL_DATA) + continue; + + // Raw script bytes for the SLP parser. + std::vector raw(spk.begin(), spk.end()); + if (raw.empty()) + continue; + + ZSLPMessage msg; + if (!ZSLPParseScript(raw.data(), raw.size(), msg)) + continue; // not an SLP message; keep scanning other vouts + + switch (msg.type) { + case ZSLPMSG_GENESIS: { + // Token id == the genesis transaction id (canonical SLP rule). + // Minted quantity is paid to vout[1]; baton (if any) to its vout. + CZSLPToken token; + token.tokenId = txid; + token.ticker = msg.ticker; + token.name = msg.name; + token.documentUrl = msg.documentUrl; + token.hasDocumentHash = msg.hasDocumentHash; + if (msg.hasDocumentHash) { + // document_hash is an arbitrary 32-byte hash (not a txid). + // uint256::GetHex() prints internal bytes reversed, so reverse + // here to make the RPC display the on-chain byte order. + std::vector dh(32); + for (int b = 0; b < 32; ++b) + dh[b] = msg.documentHash[31 - b]; + token.documentHash = uint256(dh); + } + token.decimals = msg.decimals; + token.mintBatonVout = msg.mintBatonVout; + token.genesisHeight = height; + + std::string recipient = AddressOfVout(tx, 1); + s->ApplyGenesis(token, recipient, txid, 1, + (int64_t)msg.initialQuantity); + return; // one SLP message per tx + } + case ZSLPMSG_MINT: { + uint256 tokenId = TokenIdToUint256(msg.tokenId); + std::string recipient = AddressOfVout(tx, 1); + bool batonMoved = (msg.mintBatonVout >= 2); + s->ApplyMint(tokenId, recipient, txid, height, 1, + (int64_t)msg.additionalQuantity, batonMoved, + msg.mintBatonVout); + return; + } + case ZSLPMSG_SEND: { + uint256 tokenId = TokenIdToUint256(msg.tokenId); + // outputQuantities[j] is paid to vout[1+j]. + for (int j = 0; j < msg.numOutputs; ++j) { + uint64_t qty = msg.outputQuantities[j]; + if (qty == 0) + continue; + uint32_t voutIdx = (uint32_t)(j + 1); + std::string recipient = AddressOfVout(tx, voutIdx); + s->ApplySend(tokenId, recipient, txid, height, + (int32_t)voutIdx, (int64_t)qty); + } + return; + } + default: + return; + } + } +} + +// ── Disconnect (reorg) ───────────────────────────────────────────── + +void CZSLPIndexer::DisconnectBlock(const CBlockIndex* pindex, + const CBlock& /*block*/) +{ + CZSLPStore* s = store.get(); + if (s == NULL) + return; + + const uint256 blockHash = pindex->GetBlockHash(); + const CBlockIndex* pprev = pindex->pprev; + int64_t prevHeight = pprev ? (int64_t)pprev->nHeight : -1; + uint256 prevHash = pprev ? pprev->GetBlockHash() : uint256(); + + // Replays the block's undo log in reverse and moves the tip back. If the + // block left no undo log (it carried no ZSLP records) this is a no-op + // except for the tip-marker rewind, which keeps crash-resume consistent. + s->DisconnectBlock(blockHash, prevHeight, prevHash); +} diff --git a/src/zslp/zslpindexer.h b/src/zslp/zslpindexer.h new file mode 100644 index 00000000000..24cf4128cf1 --- /dev/null +++ b/src/zslp/zslpindexer.h @@ -0,0 +1,57 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP indexer — a CValidationInterface that observes block connects and +// disconnects and feeds parsed SLP OP_RETURN messages into the CZSLPStore. +// +// NON-consensus: this is a pure observer. It registers with the validation +// signal bus only to *read* the connected/disconnected block; it never votes +// on validity, never affects PoW, the mempool, or wallet spends. Removing it +// (the -zslpindex flag) changes nothing about consensus. + +#ifndef BITCOIN_ZSLP_ZSLPINDEXER_H +#define BITCOIN_ZSLP_ZSLPINDEXER_H + +#include "validationinterface.h" + +#include + +class CZSLPStore; +class CBlock; +class CBlockIndex; +class CTransaction; + +/** Global indexer instance, non-NULL when -zslpindex is enabled. */ +class CZSLPIndexer; +extern CZSLPIndexer* g_zslpIndexer; + +/** Init/shutdown helpers, called from init.cpp behind -zslpindex. */ +void StartZSLPIndexer(); +void StopZSLPIndexer(); + +class CZSLPIndexer : public CValidationInterface +{ +public: + CZSLPIndexer(); + ~CZSLPIndexer(); + + /** Accessor for the read RPCs. May be NULL if the index is disabled. */ + CZSLPStore* Store() { return store.get(); } + +protected: + // CValidationInterface hook: added=true on connect, false on disconnect. + // Provides the (dis)connected CBlock directly, so no disk read is needed. + void ChainTip(const CBlockIndex* pindex, const CBlock* pblock, + SproutMerkleTree sproutTree, SaplingMerkleTree saplingTree, + bool added) override; + +private: + std::unique_ptr store; + + void ConnectBlock(const CBlockIndex* pindex, const CBlock& block); + void DisconnectBlock(const CBlockIndex* pindex, const CBlock& block); + + // Per-transaction scan: find the OP_RETURN, parse SLP, persist. + void IndexTransaction(const CTransaction& tx, const CBlockIndex* pindex); +}; + +#endif // BITCOIN_ZSLP_ZSLPINDEXER_H diff --git a/src/zslp/zslpmsg.cpp b/src/zslp/zslpmsg.cpp new file mode 100644 index 00000000000..084fcc469ed --- /dev/null +++ b/src/zslp/zslpmsg.cpp @@ -0,0 +1,53 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP message bridge implementation. This translation unit includes ONLY the +// plain-C SLP header (which declares `struct uint256`) and MUST NOT include +// the daemon's src/uint256.h (`class uint256`) — the two share an identifier +// and would clash. Keep this file's includes minimal. + +#include "zslp/zslpmsg.h" + +#include + +extern "C" { +#include "zslp/slp.h" +} + +bool ZSLPParseScript(const uint8_t* script, size_t scriptLen, ZSLPMessage& out) +{ + struct slp_message msg; + if (!slp_parse(script, scriptLen, &msg)) + return false; + + out = ZSLPMessage(); + + switch (msg.type) { + case SLP_TX_GENESIS: + out.type = ZSLPMSG_GENESIS; + out.ticker = msg.ticker; + out.name = msg.name; + out.documentUrl = msg.document_url; + out.hasDocumentHash = msg.has_document_hash; + if (msg.has_document_hash) + memcpy(out.documentHash, msg.document_hash, 32); + out.decimals = msg.decimals; + out.mintBatonVout = msg.mint_baton_vout; + out.initialQuantity = msg.initial_quantity; + return true; + case SLP_TX_MINT: + out.type = ZSLPMSG_MINT; + memcpy(out.tokenId, msg.token_id.data, 32); + out.mintBatonVout = msg.mint_baton_vout; + out.additionalQuantity = msg.additional_quantity; + return true; + case SLP_TX_SEND: + out.type = ZSLPMSG_SEND; + memcpy(out.tokenId, msg.token_id.data, 32); + out.numOutputs = msg.num_outputs; + for (int i = 0; i < msg.num_outputs && i < 20; ++i) + out.outputQuantities[i] = msg.output_quantities[i]; + return true; + default: + return false; + } +} diff --git a/src/zslp/zslpmsg.h b/src/zslp/zslpmsg.h new file mode 100644 index 00000000000..2cb2fc91817 --- /dev/null +++ b/src/zslp/zslpmsg.h @@ -0,0 +1,65 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP message bridge — a thin C++ wrapper around the plain-C slp_parse() so +// the rest of the daemon never has to include zslp/slp.h directly. +// +// WHY THIS EXISTS: the protocol library's uint256_c.h declares a plain-C +// `struct uint256`, which is the *same identifier* as the daemon's +// `class uint256` (src/uint256.h). The two cannot coexist in one translation +// unit (redefinition error). This bridge is compiled against ONLY the C SLP +// header and exposes a parsed result using plain byte arrays, so callers +// (the indexer, tests) can freely use the daemon's uint256. + +#ifndef BITCOIN_ZSLP_ZSLPMSG_H +#define BITCOIN_ZSLP_ZSLPMSG_H + +#include +#include +#include + +/** SLP message kinds (mirror enum slp_tx_type, but daemon-side). */ +enum ZSLPMsgType { + ZSLPMSG_INVALID = 0, + ZSLPMSG_GENESIS = 1, + ZSLPMSG_MINT = 2, + ZSLPMSG_SEND = 3, +}; + +/** Parsed SLP message in a daemon-friendly POD form (no struct uint256). */ +struct ZSLPMessage { + ZSLPMsgType type; + + // GENESIS + std::string ticker; + std::string name; + std::string documentUrl; + bool hasDocumentHash; + uint8_t documentHash[32]; + uint8_t decimals; + uint8_t mintBatonVout; // 0 = none + uint64_t initialQuantity; + + // MINT / SEND share tokenId (big-endian display order, as on chain). + uint8_t tokenId[32]; + uint64_t additionalQuantity; // MINT + + // SEND + uint64_t outputQuantities[20]; + int numOutputs; + + ZSLPMessage() : type(ZSLPMSG_INVALID), hasDocumentHash(false), + decimals(0), mintBatonVout(0), initialQuantity(0), + additionalQuantity(0), numOutputs(0) + { + for (int i = 0; i < 32; ++i) { documentHash[i] = 0; tokenId[i] = 0; } + for (int i = 0; i < 20; ++i) outputQuantities[i] = 0; + } +}; + +/** + * Parse a raw OP_RETURN scriptPubKey into an SLP message. + * Returns true and fills `out` when the script is a valid SLP message. + */ +bool ZSLPParseScript(const uint8_t* script, size_t scriptLen, ZSLPMessage& out); + +#endif // BITCOIN_ZSLP_ZSLPMSG_H diff --git a/src/zslp/zslpstore.cpp b/src/zslp/zslpstore.cpp new file mode 100644 index 00000000000..57666c24e66 --- /dev/null +++ b/src/zslp/zslpstore.cpp @@ -0,0 +1,564 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP token store implementation — LevelDB (CDBWrapper) backing for the +// SLP token data model. See zslpstore.h for the schema and contract. +// +// NON-consensus: read-only observation. Never touches validation/PoW/wallet. + +#include "zslp/zslpstore.h" + +#include "util.h" + +#include +#include +#include + +// Record-type discriminators (first key byte). +static const char DB_TOKEN = 't'; +static const char DB_TRANSFER = 'x'; +static const char DB_BALANCE = 'b'; +static const char DB_UNDO = 'r'; +static const char DB_TIP = 'T'; + +namespace { + +// Composite keys. std::tuple has no serializer in this tree, and nested +// std::pair keys are error-prone, so we use tiny explicit key structs. +// Field order defines the leveldb sort order (uint256 serializes its raw +// bytes, and std::string serializes a length prefix then the bytes — fine +// for grouping by token then address since the discriminator + tokenId +// prefix is identical for one token). + +struct BalanceKey { + char prefix; + uint256 tokenId; + std::string address; + BalanceKey() : prefix(DB_BALANCE) {} + BalanceKey(const uint256& t, const std::string& a) + : prefix(DB_BALANCE), tokenId(t), address(a) {} + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(prefix); + READWRITE(tokenId); + READWRITE(address); + } +}; + +struct UndoKey { + char prefix; + uint256 blockHash; + uint32_t seq; + UndoKey() : prefix(DB_UNDO), seq(0) {} + UndoKey(const uint256& h, uint32_t s) : prefix(DB_UNDO), blockHash(h), seq(s) {} + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(prefix); + READWRITE(blockHash); + // Big-endian seq so the undo log iterates in append order. + if (ser_action.ForRead()) { + uint8_t b[4]; + for (int i = 0; i < 4; ++i) READWRITE(b[i]); + seq = ((uint32_t)b[0] << 24) | ((uint32_t)b[1] << 16) | + ((uint32_t)b[2] << 8) | (uint32_t)b[3]; + } else { + uint8_t b[4] = { + (uint8_t)((seq >> 24) & 0xff), (uint8_t)((seq >> 16) & 0xff), + (uint8_t)((seq >> 8) & 0xff), (uint8_t)(seq & 0xff) }; + for (int i = 0; i < 4; ++i) READWRITE(b[i]); + } + } +}; + +// Transfer key: 'x' + tokenId + BE(height) + txid + BE(vout). Big-endian +// height/vout make lexicographic leveldb order match numeric order, so all of +// one token's transfers are contiguous and height-ascending. We serialize +// fields directly (no length prefixes), so a Seek to a TransferPrefix is a +// true byte-prefix of the full keys (unlike a length-prefixed std::vector). +struct TransferKey { + char prefix; + uint256 tokenId; + int64_t height; + uint256 txid; + int32_t vout; + TransferKey() : prefix(DB_TRANSFER), height(0), vout(0) {} + TransferKey(const uint256& t, int64_t h, const uint256& x, int32_t v) + : prefix(DB_TRANSFER), tokenId(t), height(h), txid(x), vout(v) {} + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(prefix); + READWRITE(tokenId); + if (ser_action.ForRead()) { + uint8_t hb[8], vb[4]; + for (int i = 0; i < 8; ++i) READWRITE(hb[i]); + READWRITE(txid); + for (int i = 0; i < 4; ++i) READWRITE(vb[i]); + uint64_t hu = 0; for (int i = 0; i < 8; ++i) hu = (hu << 8) | hb[i]; + uint32_t vu = 0; for (int i = 0; i < 4; ++i) vu = (vu << 8) | vb[i]; + height = (int64_t)hu; + vout = (int32_t)vu; + } else { + uint64_t hu = (uint64_t)height; + uint32_t vu = (uint32_t)vout; + uint8_t hb[8], vb[4]; + for (int i = 7; i >= 0; --i) { hb[i] = (uint8_t)(hu & 0xff); hu >>= 8; } + for (int i = 3; i >= 0; --i) { vb[i] = (uint8_t)(vu & 0xff); vu >>= 8; } + for (int i = 0; i < 8; ++i) READWRITE(hb[i]); + READWRITE(txid); + for (int i = 0; i < 4; ++i) READWRITE(vb[i]); + } + } +}; + +// Prefix used only for Seek: 'x' + tokenId. Serializes the same leading bytes +// as TransferKey, so the iterator lands at this token's first transfer. +struct TransferPrefix { + char prefix; + uint256 tokenId; + explicit TransferPrefix(const uint256& t) : prefix(DB_TRANSFER), tokenId(t) {} + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(prefix); + READWRITE(tokenId); + } +}; + +} // namespace + +CZSLPStore::CZSLPStore(const boost::filesystem::path& path, size_t nCacheSize, + bool fMemory, bool fWipe) + : db(path, nCacheSize, fMemory, fWipe), nUndoSeq(0) +{ + hashConnecting.SetNull(); +} + +// ── Tip marker ───────────────────────────────────────────────────── + +bool CZSLPStore::WriteTip(int64_t height, const uint256& blockHash) +{ + return db.Write(std::make_pair(DB_TIP, (char)0), + std::make_pair(height, blockHash)); +} + +bool CZSLPStore::ReadTip(int64_t& height, uint256& blockHash) const +{ + std::pair val; + if (!db.Read(std::make_pair(DB_TIP, (char)0), val)) + return false; + height = val.first; + blockHash = val.second; + return true; +} + +// ── Token helpers ────────────────────────────────────────────────── + +bool CZSLPStore::readToken(const uint256& tokenId, CZSLPToken& out) const +{ + return db.Read(std::make_pair(DB_TOKEN, tokenId), out); +} + +bool CZSLPStore::GetToken(const uint256& tokenId, CZSLPToken& out) const +{ + return readToken(tokenId, out); +} + +void CZSLPStore::writeTokenBatch(CDBBatch& batch, const CZSLPToken& token) +{ + batch.Write(std::make_pair(DB_TOKEN, token.tokenId), token); +} + +int64_t CZSLPStore::readBalance(const uint256& tokenId, + const std::string& address) const +{ + int64_t bal = 0; + db.Read(BalanceKey(tokenId, address), bal); + return bal; +} + +int64_t CZSLPStore::GetBalance(const uint256& tokenId, + const std::string& address) const +{ + return readBalance(tokenId, address); +} + +int64_t CZSLPStore::TokenCount() const +{ + int64_t n = 0; + boost::scoped_ptr it(const_cast(db).NewIterator()); + for (it->Seek(std::make_pair(DB_TOKEN, uint256())); it->Valid(); it->Next()) { + std::pair key; + if (!it->GetKey(key) || key.first != DB_TOKEN) + break; + ++n; + } + return n; +} + +// ── Undo log ─────────────────────────────────────────────────────── + +void CZSLPStore::appendUndo(CDBBatch& batch, const CZSLPUndoOp& op) +{ + batch.Write(UndoKey(hashConnecting, nUndoSeq), op); + nUndoSeq++; +} + +// ── Connect path ─────────────────────────────────────────────────── + +void CZSLPStore::ConnectBlockBegin(const uint256& blockHash) +{ + hashConnecting = blockHash; + nUndoSeq = 0; +} + +bool CZSLPStore::ApplyGenesis(const CZSLPToken& tokenIn, + const std::string& recipient, + const uint256& txid, int32_t vout, + int64_t initialQty) +{ + // GENESIS for an already-known token is a no-op (first genesis wins), + // mirroring the reference's INSERT OR IGNORE on the token row. + CZSLPToken existing; + if (readToken(tokenIn.tokenId, existing)) + return true; + + CDBBatch batch(db); + + CZSLPToken token = tokenIn; + token.totalMinted = initialQty; + writeTokenBatch(batch, token); + + CZSLPUndoOp tok; + tok.kind = UNDO_TOKEN_PUT; + tok.tokenId = token.tokenId; + appendUndo(batch, tok); + + // Transfer record for the genesis mint output. + CZSLPTransfer xfer; + xfer.tokenId = token.tokenId; + xfer.txid = txid; + xfer.blockHash = hashConnecting; + xfer.blockHeight = token.genesisHeight; + xfer.txType = ZSLP_TX_GENESIS; + xfer.amount = initialQty; + xfer.vout = vout; + xfer.address = recipient; + + batch.Write(TransferKey(token.tokenId, token.genesisHeight, txid, vout), xfer); + CZSLPUndoOp xu; + xu.kind = UNDO_TRANSFER_PUT; + xu.tokenId = token.tokenId; + xu.txid = txid; + xu.blockHeight = token.genesisHeight; + xu.vout = vout; + appendUndo(batch, xu); + + // Credit the recipient balance (skip empty/undecodable addresses). + if (!recipient.empty() && initialQty > 0) { + int64_t bal = readBalance(token.tokenId, recipient); + if (bal <= std::numeric_limits::max() - initialQty) { + batch.Write(BalanceKey(token.tokenId, recipient), bal + initialQty); + CZSLPUndoOp bu; + bu.kind = UNDO_BALANCE_ADD; + bu.tokenId = token.tokenId; + bu.address = recipient; + bu.amount = initialQty; + appendUndo(batch, bu); + } + } + + return db.WriteBatch(batch); +} + +bool CZSLPStore::ApplyMint(const uint256& tokenId, const std::string& recipient, + const uint256& txid, int64_t blockHeight, + int32_t vout, int64_t addQty, bool batonMoved, + uint8_t newBatonVout) +{ + CZSLPToken token; + if (!readToken(tokenId, token)) + return false; // MINT of an unknown token: ignore (reference no-ops too) + + CDBBatch batch(db); + + // total_minted += addQty (overflow-guarded). + if (addQty > 0 && + token.totalMinted <= std::numeric_limits::max() - addQty) { + token.totalMinted += addQty; + CZSLPUndoOp mu; + mu.kind = UNDO_MINTED_ADD; + mu.tokenId = tokenId; + mu.amount = addQty; + appendUndo(batch, mu); + } + + if (batonMoved && token.mintBatonVout != newBatonVout) { + CZSLPUndoOp bsu; + bsu.kind = UNDO_BATON_SET; + bsu.tokenId = tokenId; + bsu.prevBaton = token.mintBatonVout; + appendUndo(batch, bsu); + token.mintBatonVout = newBatonVout; + } + + writeTokenBatch(batch, token); + + CZSLPTransfer xfer; + xfer.tokenId = tokenId; + xfer.txid = txid; + xfer.blockHash = hashConnecting; + xfer.blockHeight = blockHeight; + xfer.txType = ZSLP_TX_MINT; + xfer.amount = addQty; + xfer.vout = vout; + xfer.address = recipient; + batch.Write(TransferKey(tokenId, blockHeight, txid, vout), xfer); + CZSLPUndoOp xu; + xu.kind = UNDO_TRANSFER_PUT; + xu.tokenId = tokenId; + xu.txid = txid; + xu.blockHeight = blockHeight; + xu.vout = vout; + appendUndo(batch, xu); + + if (!recipient.empty() && addQty > 0) { + int64_t bal = readBalance(tokenId, recipient); + if (bal <= std::numeric_limits::max() - addQty) { + batch.Write(BalanceKey(tokenId, recipient), bal + addQty); + CZSLPUndoOp bu; + bu.kind = UNDO_BALANCE_ADD; + bu.tokenId = tokenId; + bu.address = recipient; + bu.amount = addQty; + appendUndo(batch, bu); + } + } + + return db.WriteBatch(batch); +} + +bool CZSLPStore::ApplySend(const uint256& tokenId, const std::string& recipient, + const uint256& txid, int64_t blockHeight, + int32_t vout, int64_t amount) +{ + // Only record SENDs of a known token (genesis must have been seen). + if (!db.Exists(std::make_pair(DB_TOKEN, tokenId))) + return false; + + CDBBatch batch(db); + + CZSLPTransfer xfer; + xfer.tokenId = tokenId; + xfer.txid = txid; + xfer.blockHash = hashConnecting; + xfer.blockHeight = blockHeight; + xfer.txType = ZSLP_TX_SEND; + xfer.amount = amount; + xfer.vout = vout; + xfer.address = recipient; + batch.Write(TransferKey(tokenId, blockHeight, txid, vout), xfer); + CZSLPUndoOp xu; + xu.kind = UNDO_TRANSFER_PUT; + xu.tokenId = tokenId; + xu.txid = txid; + xu.blockHeight = blockHeight; + xu.vout = vout; + appendUndo(batch, xu); + + if (!recipient.empty() && amount > 0) { + int64_t bal = readBalance(tokenId, recipient); + if (bal <= std::numeric_limits::max() - amount) { + batch.Write(BalanceKey(tokenId, recipient), bal + amount); + CZSLPUndoOp bu; + bu.kind = UNDO_BALANCE_ADD; + bu.tokenId = tokenId; + bu.address = recipient; + bu.amount = amount; + appendUndo(batch, bu); + } + } + + return db.WriteBatch(batch); +} + +bool CZSLPStore::ConnectBlockEnd(int64_t height, const uint256& blockHash) +{ + hashConnecting.SetNull(); + nUndoSeq = 0; + return WriteTip(height, blockHash); +} + +// ── Disconnect path (reorg) ──────────────────────────────────────── + +bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, + const uint256& prevHash) +{ + // Gather the block's undo ops (ascending seq), then replay in reverse. + std::vector ops; + std::vector seqs; + { + boost::scoped_ptr it(const_cast(db).NewIterator()); + for (it->Seek(UndoKey(blockHash, 0)); it->Valid(); it->Next()) { + UndoKey key; + if (!it->GetKey(key)) + break; + if (key.prefix != DB_UNDO || key.blockHash != blockHash) + break; + CZSLPUndoOp op; + if (!it->GetValue(op)) + return false; + ops.push_back(op); + seqs.push_back(key.seq); + } + } + + CDBBatch batch(db); + + for (int i = (int)ops.size() - 1; i >= 0; --i) { + const CZSLPUndoOp& op = ops[i]; + switch (op.kind) { + case UNDO_TOKEN_PUT: + batch.Erase(std::make_pair(DB_TOKEN, op.tokenId)); + break; + case UNDO_TRANSFER_PUT: + batch.Erase(TransferKey(op.tokenId, op.blockHeight, op.txid, op.vout)); + break; + case UNDO_BALANCE_ADD: { + int64_t bal = readBalance(op.tokenId, op.address); + int64_t nv = bal - op.amount; + if (nv <= 0) + batch.Erase(BalanceKey(op.tokenId, op.address)); + else + batch.Write(BalanceKey(op.tokenId, op.address), nv); + break; + } + case UNDO_MINTED_ADD: { + CZSLPToken token; + if (readToken(op.tokenId, token)) { + token.totalMinted -= op.amount; + if (token.totalMinted < 0) + token.totalMinted = 0; + writeTokenBatch(batch, token); + } + break; + } + case UNDO_BATON_SET: { + CZSLPToken token; + if (readToken(op.tokenId, token)) { + token.mintBatonVout = op.prevBaton; + writeTokenBatch(batch, token); + } + break; + } + default: + break; + } + } + + // Drop the undo log for this block. + for (size_t i = 0; i < seqs.size(); ++i) + batch.Erase(UndoKey(blockHash, seqs[i])); + + // Move the tip marker back. + batch.Write(std::make_pair(DB_TIP, (char)0), + std::make_pair(prevHeight, prevHash)); + + return db.WriteBatch(batch); +} + +// ── Read / list API ──────────────────────────────────────────────── + +int CZSLPStore::ListTokens(int from, int count, + std::vector& out) const +{ + out.clear(); + if (count <= 0) + return 0; + if (count > ZSLP_LIST_MAX) + count = ZSLP_LIST_MAX; + if (from < 0) + from = 0; + + int skipped = 0; + boost::scoped_ptr it(const_cast(db).NewIterator()); + for (it->Seek(std::make_pair(DB_TOKEN, uint256())); + it->Valid() && (int)out.size() < count; it->Next()) { + std::pair key; + if (!it->GetKey(key) || key.first != DB_TOKEN) + break; + if (skipped < from) { + ++skipped; + continue; + } + CZSLPToken token; + if (it->GetValue(token)) + out.push_back(token); + } + return (int)out.size(); +} + +int CZSLPStore::ListTransfers(const uint256& tokenId, int from, int count, + std::vector& out) const +{ + out.clear(); + if (count <= 0) + return 0; + if (count > ZSLP_LIST_MAX) + count = ZSLP_LIST_MAX; + if (from < 0) + from = 0; + + // Keys are 'x'+tokenId+BE(height)+txid+BE(vout): ascending height. We want + // newest-first, so gather all for this token then reverse and window. + std::vector all; + { + boost::scoped_ptr it(const_cast(db).NewIterator()); + for (it->Seek(TransferPrefix(tokenId)); it->Valid(); it->Next()) { + // The key deserializes as a TransferKey; stop when we leave this + // token's contiguous keyspace (or hit a non-transfer record). + TransferKey key; + if (!it->GetKey(key) || key.prefix != DB_TRANSFER) + break; + if (key.tokenId != tokenId) + break; + CZSLPTransfer xfer; + if (!it->GetValue(xfer)) + break; + all.push_back(xfer); + } + } + + // Newest-first. + std::reverse(all.begin(), all.end()); + for (size_t i = (size_t)from; i < all.size() && (int)out.size() < count; ++i) + out.push_back(all[i]); + return (int)out.size(); +} + +void CZSLPStore::GetTokensForAddress( + const std::string& address, + std::vector >& out) const +{ + out.clear(); + if (address.empty()) + return; + + boost::scoped_ptr it(const_cast(db).NewIterator()); + for (it->Seek(BalanceKey(uint256(), std::string())); + it->Valid(); it->Next()) { + BalanceKey key; + if (!it->GetKey(key) || key.prefix != DB_BALANCE) + break; + if (key.address != address) + continue; + int64_t bal = 0; + if (it->GetValue(bal) && bal > 0) + out.push_back(std::make_pair(key.tokenId, bal)); + } +} diff --git a/src/zslp/zslpstore.h b/src/zslp/zslpstore.h new file mode 100644 index 00000000000..cd7cc3d60b8 --- /dev/null +++ b/src/zslp/zslpstore.h @@ -0,0 +1,269 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP token store — a LevelDB-backed, read-only-observation store for the +// Simple Ledger Protocol (SLP) token data carried in OP_RETURN outputs. +// +// NON-consensus: this store records what the indexer observes. It never +// participates in block/transaction validation, PoW, or wallet spends. +// +// Re-implements the data model from the zclassic-c reference +// (app/models/src/zslp.c + adapters/.../zslp_store_sqlite.c) over LevelDB +// (CDBWrapper) instead of sqlite. The on-chain semantics are identical: +// - token genesis records (metadata + total_minted + baton state) +// - transfer records (one per token-bearing vout) +// - per-(token,address) balances (ZSLP rides transparent dust) +// Records are tagged by block hash + height + txid so a reorg can delete +// exactly the records a given block added, and a tip marker enables +// crash-resume. + +#ifndef BITCOIN_ZSLP_ZSLPSTORE_H +#define BITCOIN_ZSLP_ZSLPSTORE_H + +#include "dbwrapper.h" +#include "serialize.h" +#include "uint256.h" + +#include +#include +#include + +#include + +/** SLP transaction type tags as persisted (mirror enum slp_tx_type). */ +static const uint8_t ZSLP_TX_GENESIS = 1; +static const uint8_t ZSLP_TX_MINT = 2; +static const uint8_t ZSLP_TX_SEND = 3; + +/** Default upper bound for the count argument of the list RPCs. */ +static const int ZSLP_LIST_MAX = 1000; + +/** Persisted token genesis / metadata record. */ +class CZSLPToken +{ +public: + uint256 tokenId; //!< genesis txid (the canonical token id) + std::string ticker; + std::string name; + std::string documentUrl; + uint256 documentHash; //!< 0 when absent + bool hasDocumentHash; + uint8_t decimals; + uint8_t mintBatonVout; //!< 0 = no/spent baton + int64_t genesisHeight; + int64_t totalMinted; //!< running sum of genesis + mint quantities + + CZSLPToken() { SetNull(); } + + void SetNull() + { + tokenId.SetNull(); + ticker.clear(); + name.clear(); + documentUrl.clear(); + documentHash.SetNull(); + hasDocumentHash = false; + decimals = 0; + mintBatonVout = 0; + genesisHeight = 0; + totalMinted = 0; + } + + ADD_SERIALIZE_METHODS; + + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(tokenId); + READWRITE(ticker); + READWRITE(name); + READWRITE(documentUrl); + READWRITE(documentHash); + READWRITE(hasDocumentHash); + READWRITE(decimals); + READWRITE(mintBatonVout); + READWRITE(genesisHeight); + READWRITE(totalMinted); + } +}; + +/** Persisted transfer record — one per token-bearing event/vout. */ +class CZSLPTransfer +{ +public: + uint256 tokenId; + uint256 txid; + uint256 blockHash; + int64_t blockHeight; + uint8_t txType; //!< ZSLP_TX_GENESIS / MINT / SEND + int64_t amount; + int32_t vout; + std::string address; //!< recipient t-address, "" if undecodable + + CZSLPTransfer() { SetNull(); } + + void SetNull() + { + tokenId.SetNull(); + txid.SetNull(); + blockHash.SetNull(); + blockHeight = 0; + txType = 0; + amount = 0; + vout = 0; + address.clear(); + } + + ADD_SERIALIZE_METHODS; + + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(tokenId); + READWRITE(txid); + READWRITE(blockHash); + READWRITE(blockHeight); + READWRITE(txType); + READWRITE(amount); + READWRITE(vout); + READWRITE(address); + } +}; + +/** + * LevelDB token store. + * + * Key schema (first byte is a record-type discriminator): + * 't' + tokenId -> CZSLPToken (token by id) + * 'x' + tokenId + height + txid + vout -> CZSLPTransfer (ordered transfers) + * 'b' + tokenId + address -> int64 balance (balances) + * 'r' + blockHash + seq -> CZSLPUndoOp (reorg undo log) + * 'T' -> (height, blockHash) (tip marker) + * + * The undo log records, per block, exactly which puts/credits were applied so + * that DisconnectBlock can reverse them precisely (genesis/transfer deletion + + * balance decrement + total_minted decrement), restoring the store to its + * pre-connect state. + */ +class CZSLPStore +{ +private: + CDBWrapper db; + + // The reorg undo log appends ops under 'r'+blockHash+seq while a block is + // being connected; ConnectBlockBegin resets the running sequence. + uint32_t nUndoSeq; + uint256 hashConnecting; //!< block currently being connected (for undo keys) + +public: + /** Undo-op kinds appended while connecting a block. */ + enum UndoKind : uint8_t { + UNDO_TOKEN_PUT = 1, //!< a genesis token record was created + UNDO_TRANSFER_PUT = 2, //!< a transfer record was created + UNDO_BALANCE_ADD = 3, //!< balance(token,address) was credited by amount + UNDO_MINTED_ADD = 4, //!< token.totalMinted was increased by amount + UNDO_BATON_SET = 5, //!< token.mintBatonVout changed (old value stored) + }; + + struct CZSLPUndoOp { + uint8_t kind; + uint256 tokenId; + uint256 txid; //!< for UNDO_TRANSFER_PUT key reconstruction + int64_t blockHeight; + int32_t vout; + std::string address; //!< for UNDO_BALANCE_ADD + int64_t amount; //!< for UNDO_BALANCE_ADD / UNDO_MINTED_ADD + uint8_t prevBaton; //!< for UNDO_BATON_SET + + CZSLPUndoOp() : kind(0), blockHeight(0), vout(0), amount(0), prevBaton(0) + { + tokenId.SetNull(); + txid.SetNull(); + } + + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(kind); + READWRITE(tokenId); + READWRITE(txid); + READWRITE(blockHeight); + READWRITE(vout); + READWRITE(address); + READWRITE(amount); + READWRITE(prevBaton); + } + }; + + /** + * @param[in] path leveldb directory (e.g. GetDataDir()/blocks/zslp) + * @param[in] nCacheSize leveldb cache size + * @param[in] fMemory in-memory env (used by tests) + * @param[in] fWipe wipe existing data + */ + CZSLPStore(const boost::filesystem::path& path, size_t nCacheSize, + bool fMemory = false, bool fWipe = false); + + // ── Tip marker (crash-resume) ────────────────────────────────── + bool WriteTip(int64_t height, const uint256& blockHash); + bool ReadTip(int64_t& height, uint256& blockHash) const; + + // ── Connect-side mutation (called by the indexer per block) ──── + // + // ConnectBlockBegin() starts a fresh undo log for blockHash; the Apply* + // calls append both the data record and a matching undo op; ConnectBlockEnd + // advances the tip marker. The whole block is staged in one CDBBatch by the + // caller-less helpers below for crash-atomicity. + void ConnectBlockBegin(const uint256& blockHash); + + /** Create (or no-op if exists) a token genesis record + seed its + * total_minted with the initial quantity and the recipient balance. */ + bool ApplyGenesis(const CZSLPToken& token, const std::string& recipient, + const uint256& txid, int32_t vout, int64_t initialQty); + + /** Increase total_minted (and credit recipient) for an existing token. */ + bool ApplyMint(const uint256& tokenId, const std::string& recipient, + const uint256& txid, int64_t blockHeight, int32_t vout, + int64_t addQty, bool batonMoved, uint8_t newBatonVout); + + /** Record a SEND output: credit the recipient + transfer record. */ + bool ApplySend(const uint256& tokenId, const std::string& recipient, + const uint256& txid, int64_t blockHeight, int32_t vout, + int64_t amount); + + bool ConnectBlockEnd(int64_t height, const uint256& blockHash); + + // ── Disconnect-side (reorg) ──────────────────────────────────── + // + // Replays the block's undo log in reverse, deleting exactly the records + // ConnectBlock* added and decrementing balances / total_minted, then sets + // the tip marker back to (prevHeight, prevHash). Idempotent: a block with + // no undo log is a no-op. + bool DisconnectBlock(const uint256& blockHash, int64_t prevHeight, + const uint256& prevHash); + + // ── Read API (used by the RPCs and tests) ───────────────────── + bool GetToken(const uint256& tokenId, CZSLPToken& out) const; + /** Bounded, deterministic token list (skip `from`, take up to `count`). */ + int ListTokens(int from, int count, std::vector& out) const; + /** Bounded transfer list for one token, newest height first. */ + int ListTransfers(const uint256& tokenId, int from, int count, + std::vector& out) const; + /** Balance of (token, address); 0 if absent. */ + int64_t GetBalance(const uint256& tokenId, const std::string& address) const; + /** All (token, balance) pairs with balance>0 for `address`. */ + void GetTokensForAddress(const std::string& address, + std::vector >& out) const; + + int64_t TokenCount() const; + +private: + // Internal helpers (single-record writes; the batch variants are used by + // the Apply* path so a block connects/disconnects atomically). + bool readToken(const uint256& tokenId, CZSLPToken& out) const; + void writeTokenBatch(CDBBatch& batch, const CZSLPToken& token); + int64_t readBalance(const uint256& tokenId, const std::string& address) const; + void appendUndo(CDBBatch& batch, const CZSLPUndoOp& op); +}; + +#endif // BITCOIN_ZSLP_ZSLPSTORE_H From 209befcccc3cc439f08f25bb84f36f1ca663aa60 Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 02:10:04 +0000 Subject: [PATCH 2/7] zslp: bound zslp_listmytokens response (ZSLP_LIST_MAX) per review Co-Authored-By: Claude Opus 4.8 (1M context) --- src/rpc/zslp.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rpc/zslp.cpp b/src/rpc/zslp.cpp index 80b8e434fe1..07dd15881e1 100644 --- a/src/rpc/zslp.cpp +++ b/src/rpc/zslp.cpp @@ -239,6 +239,8 @@ UniValue zslp_listmytokens(const UniValue& params, bool fHelp) it != totals.end(); ++it) { if (it->second <= 0) continue; + if ((int)arr.size() >= ZSLP_LIST_MAX) + break; // bound the response size (wallet-size-bound), matching the other list RPCs CZSLPToken token; store->GetToken(it->first, token); UniValue o(UniValue::VOBJ); From 43540845820438c592ccf7829c510bb73f21a41d Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 02:17:20 +0000 Subject: [PATCH 3/7] =?UTF-8?q?zslp:=20fix=20reorg=20undo=20clobber=20?= =?UTF-8?q?=E2=80=94=20accumulate=20per-token/per-balance=20changes,=20wri?= =?UTF-8?q?te=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DisconnectBlock replayed undo ops with per-op read-modify-write-whole-record, reading the committed DB (not the pending batch). When one block touched a record via multiple undo ops (MINT logs UNDO_MINTED_ADD + UNDO_BATON_SET; multiple credits to one address), later writes clobbered earlier ones — the mint-baton revert was lost behind the total_minted revert (ZSLPStore.ReorgMintRoundTrip). Accumulate token and balance changes in maps and write each record exactly once. Adds /. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/zslp/zslpstore.cpp | 69 ++++++++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/src/zslp/zslpstore.cpp b/src/zslp/zslpstore.cpp index 57666c24e66..dffc9199f70 100644 --- a/src/zslp/zslpstore.cpp +++ b/src/zslp/zslpstore.cpp @@ -11,6 +11,8 @@ #include #include +#include +#include #include // Record-type discriminators (first key byte). @@ -420,40 +422,61 @@ bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, CDBBatch batch(db); + // Accumulate per-token and per-balance changes in memory and write each + // record exactly ONCE. A single block can log multiple undo ops against the + // same record (a MINT logs both UNDO_MINTED_ADD and UNDO_BATON_SET; several + // mints can credit one address). readToken/readBalance see only the committed + // DB — not this pending batch — so a per-op read-modify-write would clobber + // a sibling op's change (e.g. the baton revert lost behind the minted revert). + std::map tokenMods; + std::set tokenErased; + std::map, int64_t> balMods; + for (int i = (int)ops.size() - 1; i >= 0; --i) { const CZSLPUndoOp& op = ops[i]; switch (op.kind) { case UNDO_TOKEN_PUT: batch.Erase(std::make_pair(DB_TOKEN, op.tokenId)); + tokenMods.erase(op.tokenId); + tokenErased.insert(op.tokenId); break; case UNDO_TRANSFER_PUT: batch.Erase(TransferKey(op.tokenId, op.blockHeight, op.txid, op.vout)); break; case UNDO_BALANCE_ADD: { - int64_t bal = readBalance(op.tokenId, op.address); - int64_t nv = bal - op.amount; - if (nv <= 0) - batch.Erase(BalanceKey(op.tokenId, op.address)); - else - batch.Write(BalanceKey(op.tokenId, op.address), nv); + std::pair bk(op.tokenId, op.address); + std::map, int64_t>::iterator bit = balMods.find(bk); + if (bit == balMods.end()) + bit = balMods.insert(std::make_pair(bk, readBalance(op.tokenId, op.address))).first; + bit->second -= op.amount; break; } case UNDO_MINTED_ADD: { - CZSLPToken token; - if (readToken(op.tokenId, token)) { - token.totalMinted -= op.amount; - if (token.totalMinted < 0) - token.totalMinted = 0; - writeTokenBatch(batch, token); + if (tokenErased.count(op.tokenId)) + break; + std::map::iterator mit = tokenMods.find(op.tokenId); + if (mit == tokenMods.end()) { + CZSLPToken t; + if (!readToken(op.tokenId, t)) + break; + mit = tokenMods.insert(std::make_pair(op.tokenId, t)).first; } + mit->second.totalMinted -= op.amount; + if (mit->second.totalMinted < 0) + mit->second.totalMinted = 0; break; } case UNDO_BATON_SET: { - CZSLPToken token; - if (readToken(op.tokenId, token)) { - token.mintBatonVout = op.prevBaton; - writeTokenBatch(batch, token); + if (tokenErased.count(op.tokenId)) + break; + std::map::iterator mit = tokenMods.find(op.tokenId); + if (mit == tokenMods.end()) { + CZSLPToken t; + if (!readToken(op.tokenId, t)) + break; + mit = tokenMods.insert(std::make_pair(op.tokenId, t)).first; } + mit->second.mintBatonVout = op.prevBaton; break; } default: @@ -461,6 +484,20 @@ bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, } } + // Write each accumulated token modification once (siblings already merged). + for (std::map::const_iterator it2 = tokenMods.begin(); + it2 != tokenMods.end(); ++it2) + writeTokenBatch(batch, it2->second); + + // Write each accumulated balance once (erase if depleted to <= 0). + for (std::map, int64_t>::const_iterator it3 = balMods.begin(); + it3 != balMods.end(); ++it3) { + if (it3->second <= 0) + batch.Erase(BalanceKey(it3->first.first, it3->first.second)); + else + batch.Write(BalanceKey(it3->first.first, it3->first.second), it3->second); + } + // Drop the undo log for this block. for (size_t i = 0; i < seqs.size(); ++i) batch.Erase(UndoKey(blockHash, seqs[i])); From 77cdc02415c3ab5d20876174d649efea195febd8 Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 11:16:17 +0000 Subject: [PATCH 4/7] =?UTF-8?q?nft:=20native=20ZSLP=20NFT=20feature=20?= =?UTF-8?q?=E2=80=94=20mint,=20view,=20shield,=20sell=20(non-consensus=20o?= =?UTF-8?q?verlay)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a complete native NFT capability to the ZClassic full node as a NON-CONSENSUS ZSLP (SLP token-type-1 over OP_RETURN) overlay: old unmodified nodes relay and mine every transaction unchanged; security comes from honest wallets deterministically re-validating confirmed history (a forgery can be mined but credits nobody). No consensus/main.cpp/pow change. Four pillars (daemon side; GUI lives in zcl-qt-wallet): MINT - zslp_genesis (nft:true forces decimals 0 / qty 1 / no baton), zslp_mint (re-issue via baton), zslp_send (transfer) in src/rpc/zslp.cpp. - Deterministic builder src/wallet/zslpwallet.cpp BuildAndCommitZSLP: OP_RETURN@vout[0] / token dust vout[1..N] / ZCL change LAST (stock random-change insertion is fatal for SLP and is bypassed); self-validates the FINAL signed tx via CZSLPIndexer::ParseTx + CZSLPStore::WouldBeValid before CommitTransaction. VIEW - Read RPCs zslp_gettoken / zslp_listtokens / zslp_listtransfers / zslp_listmytokens. Confirmed-history indexer only (no 0-conf/mempool path). SHIELD (private file/data transfer; default-OFF behind -datachannel) - ZDC1 codec src/datachannel/zdc.{h,cpp} (framing + per-transfer ChaCha20-Poly1305 AEAD + ciphertext fingerprint + verify-before-decrypt). - z_senddatafile / z_listdatatransfers / z_getdatatransfer via a dedicated AsyncRPCOperation_senddatafile (N same-recipient Sapling memo outputs in one shielded tx). Daemon-enforced acknowledge_permanent, random transfer_id, 40000-byte cap rejected up-front, DoS caps; RPCs return -32601 when off. No spending-key/ivk ever leaves the wallet. SELL (atomic swap for ZCL on existing consensus) - nft_makeoffer / nft_verifyoffer / nft_takeoffer / nft_listoffers / nft_canceloffer / nft_requestbuy in src/rpc/nftoffer.cpp. Fixed-template SIGHASH_ALL|ANYONECANPAY offer (OP_RETURN ZSLP SEND@0 / buyer NFT dust@1 / seller ZCL payout@2): seller signs only vin[0]; buyer appends funding inputs; price + NFT recipient are cryptographically pinned. nft_verifyoffer VerifyScripts the seller vin[0] before the buyer pays; nft_takeoffer excludes ZSLP-protected outpoints from funding and requires acknowledge on overshoot. Reuses the ZSLP SEND encoder (never createrawtransaction/fundrawtransaction). SAFETY / ANTI-BURN - CWallet::AvailableCoins fExcludeZSLPTokens (default true) via the shared ZSLPIsProtectedTokenOutpoint classifier: every automatic spend path (sendtoaddress / z_sendmany / z_shieldcoinbase / z_mergetoaddress / send-max / listunspent) skips confirmed token UTXOs/batons AND the wallet's own 0-conf token change. Sell offers additionally LockCoin the NFT. - Parser/determinism hardening: canonical vout[0]-only parse, high-bit/overflow + length gates, single-SEND output cap, totalMinted-created-only, coinbase skip; UTXO-bound conservation (availIn == requiredOut else burn). TESTS - gtests: test_zslp*, test_zdc (25), test_nftoffer (6) — ~115 ZSLP/ZDC/sell tests; full suite green. No-fork guarantee is mutation-proven: GENESIS+SEND carriers are IsStandardTx==true under mainnet params with the OP_RETURN cap tied to the policy constant (not a magic literal). Anti-burn predicate + multi-token mixed-input burn unit-tested. - Committed live regtest harnesses qa/zslp/zslp-nft-regtest.sh (mint/transfer/ mint-baton/anti-burn/0-conf/self-validate) and qa/zslp/nft-sell-regtest.sh (one-tx atomic swap + tamper/forged/token-funding/overshoot refusals). DOCS - doc/nft/ — start at NATIVE_NFT_GUIDE.md; NFT_FEATURE_CHECKLIST.md is the capability matrix; NFT_SELL_DESIGN.md is authoritative for trades; NFT_FINAL_REVIEW.md is the whole-feature security/honesty review. READMEs gained an NFT section + CLI walkthrough. Status: dev/testnet-ready. Known follow-ups before mainnet/real-user ship: SHIELD cross-wallet receive (recipient key-ingest), the GUI Sell/Shield dialogs + "private ownership" honesty fix, and the pre-mainnet hardening checklist (live reorg, second-wallet receive, mempool eviction). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 77 ++ doc/nft/CANONICAL_VALIDATION_SPEC.md | 211 +++ doc/nft/CAPABILITY_MAP.md | 197 +++ doc/nft/CONTENT_MODEL.md | 549 ++++++++ doc/nft/ENABLEMENT.md | 202 +++ doc/nft/IMPERSONATION_UNIQUENESS.md | 513 +++++++ doc/nft/MINT_TRANSFER_SPEC.md | 421 ++++++ doc/nft/NATIVE_NFT_GUIDE.md | 805 +++++++++++ doc/nft/NATIVE_UI_BUILD_PLAN.md | 817 +++++++++++ doc/nft/NATIVE_UI_CONSOLIDATED_SPEC.md | 517 +++++++ doc/nft/NATIVE_UX.md | 399 ++++++ doc/nft/NFT_FEATURE_CHECKLIST.md | 245 ++++ doc/nft/NFT_FINAL_REVIEW.md | 312 +++++ doc/nft/NFT_GUI_PLAN.md | 274 ++++ doc/nft/NFT_SELL_DESIGN.md | 548 ++++++++ doc/nft/ONCHAIN_TRADES.md | 265 ++++ doc/nft/PRIVACY.md | 542 ++++++++ doc/nft/PRIVACY_STACK.md | 308 +++++ doc/nft/PRIVACY_UX.md | 383 ++++++ doc/nft/README.md | 121 ++ doc/nft/REORG_CONFIRMATION_REQUIREMENTS.md | 148 ++ doc/nft/REORG_CONFIRMATION_SAFETY.md | 416 ++++++ doc/nft/REQUIREMENTS_DOS_SPAM_GRIEF.md | 137 ++ doc/nft/SECURITY_MODEL.md | 307 +++++ doc/nft/THREATS_DOS_SPAM_GRIEF.md | 323 +++++ doc/nft/ZDC1_CODEC_SPEC.md | 357 +++++ doc/nft/holder-anti-burn-requirements.md | 215 +++ doc/nft/holder-anti-burn-threat-model.md | 230 ++++ ...onical-validation-conformance-checklist.md | 171 +++ doc/nft/zslp-determinism-spec.md | 386 ++++++ .../zslp-forgery-conservation-threat-model.md | 359 +++++ doc/nft/zslp-security-model.md | 110 ++ doc/nft/zslp-wallet-antiburn-ux-honesty.md | 114 ++ qa/zslp/README.md | 59 + qa/zslp/nft-sell-regtest.sh | 656 +++++++++ qa/zslp/zslp-nft-regtest.sh | 381 ++++++ src/Makefile.am | 10 +- src/Makefile.gtest.include | 6 +- src/datachannel/test/zdc_test.cpp | 562 ++++++++ src/datachannel/zdc.cpp | 574 ++++++++ src/datachannel/zdc.h | 340 +++++ src/gtest/test_nftoffer.cpp | 390 ++++++ src/gtest/test_zdc.cpp | 515 +++++++ src/gtest/test_zslp.cpp | 36 +- src/gtest/test_zslp_indexer.cpp | 631 ++++++++- src/gtest/test_zslp_vectors.cpp | 1007 ++++++++++++++ src/gtest/test_zslp_wallet.cpp | 834 ++++++++++++ src/init.cpp | 1 + src/rpc/client.cpp | 25 +- src/rpc/datachannel.cpp | 612 +++++++++ src/rpc/nftoffer.cpp | 1194 +++++++++++++++++ src/rpc/register.h | 6 + src/rpc/zslp.cpp | 393 ++++++ src/wallet/asyncrpcoperation_senddatafile.cpp | 321 +++++ src/wallet/asyncrpcoperation_senddatafile.h | 93 ++ src/wallet/wallet.cpp | 22 +- src/wallet/wallet.h | 47 +- src/wallet/zslpwallet.cpp | 480 +++++++ src/wallet/zslpwallet.h | 106 ++ src/zslp/slp.c | 69 +- src/zslp/slp.h | 13 +- src/zslp/zslpindexer.cpp | 286 ++-- src/zslp/zslpindexer.h | 28 +- src/zslp/zslpmsg.cpp | 76 +- src/zslp/zslpmsg.h | 49 +- src/zslp/zslpstore.cpp | 765 ++++++++--- src/zslp/zslpstore.h | 282 +++- 67 files changed, 21457 insertions(+), 391 deletions(-) create mode 100644 doc/nft/CANONICAL_VALIDATION_SPEC.md create mode 100644 doc/nft/CAPABILITY_MAP.md create mode 100644 doc/nft/CONTENT_MODEL.md create mode 100644 doc/nft/ENABLEMENT.md create mode 100644 doc/nft/IMPERSONATION_UNIQUENESS.md create mode 100644 doc/nft/MINT_TRANSFER_SPEC.md create mode 100644 doc/nft/NATIVE_NFT_GUIDE.md create mode 100644 doc/nft/NATIVE_UI_BUILD_PLAN.md create mode 100644 doc/nft/NATIVE_UI_CONSOLIDATED_SPEC.md create mode 100644 doc/nft/NATIVE_UX.md create mode 100644 doc/nft/NFT_FEATURE_CHECKLIST.md create mode 100644 doc/nft/NFT_FINAL_REVIEW.md create mode 100644 doc/nft/NFT_GUI_PLAN.md create mode 100644 doc/nft/NFT_SELL_DESIGN.md create mode 100644 doc/nft/ONCHAIN_TRADES.md create mode 100644 doc/nft/PRIVACY.md create mode 100644 doc/nft/PRIVACY_STACK.md create mode 100644 doc/nft/PRIVACY_UX.md create mode 100644 doc/nft/README.md create mode 100644 doc/nft/REORG_CONFIRMATION_REQUIREMENTS.md create mode 100644 doc/nft/REORG_CONFIRMATION_SAFETY.md create mode 100644 doc/nft/REQUIREMENTS_DOS_SPAM_GRIEF.md create mode 100644 doc/nft/SECURITY_MODEL.md create mode 100644 doc/nft/THREATS_DOS_SPAM_GRIEF.md create mode 100644 doc/nft/ZDC1_CODEC_SPEC.md create mode 100644 doc/nft/holder-anti-burn-requirements.md create mode 100644 doc/nft/holder-anti-burn-threat-model.md create mode 100644 doc/nft/zslp-canonical-validation-conformance-checklist.md create mode 100644 doc/nft/zslp-determinism-spec.md create mode 100644 doc/nft/zslp-forgery-conservation-threat-model.md create mode 100644 doc/nft/zslp-security-model.md create mode 100644 doc/nft/zslp-wallet-antiburn-ux-honesty.md create mode 100644 qa/zslp/README.md create mode 100755 qa/zslp/nft-sell-regtest.sh create mode 100755 qa/zslp/zslp-nft-regtest.sh create mode 100644 src/datachannel/test/zdc_test.cpp create mode 100644 src/datachannel/zdc.cpp create mode 100644 src/datachannel/zdc.h create mode 100644 src/gtest/test_nftoffer.cpp create mode 100644 src/gtest/test_zdc.cpp create mode 100644 src/gtest/test_zslp_vectors.cpp create mode 100644 src/gtest/test_zslp_wallet.cpp create mode 100644 src/rpc/datachannel.cpp create mode 100644 src/rpc/nftoffer.cpp create mode 100644 src/wallet/asyncrpcoperation_senddatafile.cpp create mode 100644 src/wallet/asyncrpcoperation_senddatafile.h create mode 100644 src/wallet/zslpwallet.cpp create mode 100644 src/wallet/zslpwallet.h diff --git a/README.md b/README.md index ea16ad919be..448e588b124 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,83 @@ Interact with the running daemon via JSON-RPC: --- +## NFTs / Collectibles (ZSLP) — dev/testnet stage + +ZClassic can carry **NFTs and tokens** as a **non-consensus overlay** called ZSLP (an SLP Token Type 1 message in a single `OP_RETURN`). An NFT is a 1-of-1 token: a baton-less GENESIS with `decimals=0`, `quantity=1`. The token id is the genesis transaction id. The file itself never goes on-chain — only a 32-byte fingerprint (`document_hash`, a SHA-256 of the file) is recorded, so anyone can verify that a given file matches what was minted. + +**Security model, in one paragraph.** ZClassic consensus does not know NFTs exist — it never changes for this feature. Instead, every node that runs the indexer re-derives the same token ledger as a deterministic function of the confirmed chain: it reads each `OP_RETURN`, debits the spent token inputs, credits the outputs, and enforces conservation (a transfer is valid only if tokens-in ≥ tokens-out). The consequence, stated honestly: a forged token message **can be mined, but it credits nobody** and every honest node agrees it is invalid. Security here is *agreement*, not chain rejection — so treat ZSLP as **dev/testnet-stage**, not mainnet-ready. + +**The index is ON by default.** The `zslp_*` RPCs need the read-only ZSLP index, which defaults **on** (`-zslpindex`, default `1`). It does a one-time catch-up scan in the background. Opt out with `-zslpindex=0`. + +### CLI walkthrough (mint → inspect → transfer → list) + +All arguments are **positional**. (`zslp_genesis` takes one JSON object; the rest take plain positional args.) + +```bash +# 1. Compute the file fingerprint (32-byte SHA-256, lowercase hex) +HASH=$(sha256sum my-art.png | cut -d' ' -f1) + +# 2. Mint a 1-of-1 NFT (nft:true forces decimals 0, quantity 1, no re-issue baton) +./src/zclassic-cli zslp_genesis "{\"nft\":true,\"name\":\"My Photo #1\",\"document_url\":\"\",\"document_hash\":\"$HASH\"}" +# -> { "txid": "", "tokenid": "" } (tokenid == txid == the NFT's identity) + +# 3. Inspect the token you just minted +./src/zclassic-cli zslp_gettoken "" +# -> name, document_hash, decimals 0, totalMinted 1, hasMintBaton false (a real 1-of-1) + +# 4. Transfer / gift it to someone (positional: tokenid, recipient, amount[, change_addr]) +./src/zclassic-cli zslp_send "" "t1RecipientAddress..." 1 +# -> { "txid": "" } + +# 5. List the NFTs/tokens this wallet holds +./src/zclassic-cli zslp_listmytokens +``` + +> **Honest limits.** A transfer is public and irreversible (a send to the wrong address cannot be undone). The name and image are **not** unique — anyone can mint another token reusing them; only the token id (genesis txid) is one of a kind. The fingerprint proves *which bytes* were minted, never that a creator is "genuine" or "official." ZSLP is a non-consensus overlay (dev/testnet-stage). + +### Private files / private NFTs (shielded data channel) — dev/testnet, default-OFF + +You can also send a **private file or message** over the shielded pool: the bytes are encrypted, framed into Sapling memos, and carried in one shielded transaction. This is **default-OFF** and experimental — start `zclassicd` with `-experimentalfeatures -datachannel` to enable it (the RPCs return `-32601` when off). + +```bash +# SENDER (both addresses must be Sapling z-addrs in your wallet; acknowledge_permanent is REQUIRED) +./src/zclassic-cli z_senddatafile '{"fromaddress":"zs1...","toaddress":"zs1...","filepath":"/path/to/secret.png","acknowledge_permanent":true}' +# -> { "operationid", "transfer_id", "fingerprint", "frames", "key" } +# "fingerprint" is the 32-byte ciphertext anchor (= an NFT's document_hash); "key" is yours to disclose selectively. + +# List the transfers this node knows about (sent this session) +./src/zclassic-cli z_listdatatransfers + +# RECIPIENT: reassemble + verify-before-decrypt, then decrypt +./src/zclassic-cli z_getdatatransfer '{"transfer_id":"<16hex>"}' +# -> { "verified", "complete", "frames_received", "hexdata", "filename", "content_type", ... } +``` + +> **What this protects / does not.** Hidden: who it's from, who it's to, the amount, the contents. Visible: *that* a private transfer happened, roughly *when*, and roughly *how big*. It is **permanent** on every full node forever and **not deletable** — private ≠ undetectable. Keep files small (per-file cap ~40000 bytes). The recipient verifies the on-chain ciphertext fingerprint **before** decrypting; selectively disclose by handing over the returned `key`, or `z_exportviewingkey` for the receiving z-addr (read/prove only, never spend). + +### Sell an NFT for ZCL (atomic swap) — dev/testnet + +You can sell a transparent NFT for transparent ZCL in a single `ALL|ANYONECANPAY` atomic swap. + +```bash +# SELLER: build a signed offer for the NFT +OFFER=$(./src/zclassic-cli nft_makeoffer '{"tokenId":"","priceZat":"100000000","buyerNftAddr":"t1Buyer...","payoutAddr":"t1Seller..."}') +# -> { "offerBlob": "znftoffer:..." } (hand this blob to the buyer) + +# BUYER (mandatory): verify the offer BEFORE taking it +./src/zclassic-cli nft_verifyoffer '{"offerBlob":"znftoffer:..."}' +# -> { "ok": true, "priceZat": ..., ... } + +# BUYER: take the offer (appends funding, broadcasts the single atomic tx) +./src/zclassic-cli nft_takeoffer '{"offerBlob":"znftoffer:..."}' +``` + +> **Honest limit.** The single-tx swap makes the **coin** legs consensus-atomic, but token attribution is an indexer convention — so this is **trust-minimized, not trustless**. Always run `nft_verifyoffer` before `nft_takeoffer`. No shielded leg can be atomic. (Other SELL RPCs: `nft_listoffers`, `nft_canceloffer`, `nft_requestbuy`.) + +For the full design — mint/transfer write path, anti-burn protection, content addressing, the private data channel, and the NFT→ZCL sell design — see [doc/nft/README.md](doc/nft/README.md) (start with `NATIVE_NFT_GUIDE.md`). Runnable end-to-end proofs live in `qa/zslp/` (`zslp-nft-regtest.sh`, `nft-sell-regtest.sh`; see `qa/zslp/README.md`). + +--- + ## Advanced: Bootstrap Snapshots See [doc/bootstrap-snapshots.md](doc/bootstrap-snapshots.md) for: diff --git a/doc/nft/CANONICAL_VALIDATION_SPEC.md b/doc/nft/CANONICAL_VALIDATION_SPEC.md new file mode 100644 index 00000000000..6429257311f --- /dev/null +++ b/doc/nft/CANONICAL_VALIDATION_SPEC.md @@ -0,0 +1,211 @@ +# ZSLP Canonical Validation Spec (determinism-critical) + +This is the SINGLE source of truth every honest observer (the `-zslpindex` +indexer, any compatible wallet/explorer) MUST implement **bit-identically**. +Disagreement on any rule here forks the token ledger and is a critical +DoS/grief vector (see `THREATS_DOS_SPAM_GRIEF.md` §2). All rules are stated so +that two independent implementations compute the identical ledger over the same +consensus-ordered confirmed block history. + +Token id is the genesis txid (little-endian internal / big-endian display), +unique because consensus makes txids unique. The overlay is NON-consensus: an +on-chain tx that violates a rule is interpreted as **crediting nobody / burning +its token inputs**; it is never "rejected" (consensus already confirmed it). + +References below cite current code; where the code diverges from the canonical +rule it is flagged **MUST FIX**. + +--- + +## R1 — The SLP message lives at vout[0] ONLY (closes T1, T2) + +- Parse the SLP message from `tx.vout[0].scriptPubKey` and **nowhere else**. +- If `tx.vout[0]` is not a `TX_NULL_DATA` output, or does not parse as a valid + SLP Token-Type-1 message, the tx is **non-SLP**: it creates no token outputs; + it still burns any token UTXO it spends (R7). +- OP_RETURNs at vout ≥ 1 are **ignored entirely**. Multiple OP_RETURNs cannot + change the result. + +**MUST FIX:** `zslpindexer.cpp:211` currently scans every vout +(`for (size_t vo = 0; vo < tx.vout.size(); ++vo) ... if (msgPresent) break;`) +taking the first vout that parses. Replace with a vout[0]-only check. The header +already declares the correct rule (`slp.h:5,7`). + +--- + +## R2 — Canonical OP_RETURN / push grammar + +- The script MUST begin with `0x6a` (OP_RETURN) (`slp.c:44`). +- Each field is a single canonical data push read by `read_push` + (`op_return_push.h:24-46`): direct push `0x01..0x4b`, `OP_PUSHDATA1` (0x4c), + `OP_PUSHDATA2` (0x4d). Any other opcode ⇒ parse fail ⇒ non-SLP. (Note: SLP + upstream additionally requires *minimally-encoded* pushes; the canonical rule + for this overlay is "exactly what `read_push` accepts" — pin it and test it so a + second implementation matches, including the empty-push encoding `0x4c 0x00`, + `op_return_push.h:74-79`.) +- lokad_id field MUST be exactly the 4 bytes `"SLP\0"` (`slp.c:50`); else non-SLP. +- token_type MUST be 1, encoded in 1–2 bytes (`slp.c:54-57`); else non-SLP. +- A push that runs past end-of-script ⇒ parse fail ⇒ non-SLP + (`op_return_push.h:43`). + +--- + +## R3 — GENESIS + +- tx_type push == `"GENESIS"` (7 bytes) (`slp.c:63`). +- Fields, in order (`slp.c:66-114`): ticker, name, document_url, document_hash + (0 or 32 bytes; 32 ⇒ recorded), decimals (exactly 1 byte, value 0–9, else + non-SLP), mint_baton_vout (0 or 1 byte; if present MUST be ≥ 2, else non-SLP), + initial_token_mint_quantity (exactly 8 bytes, big-endian). +- token id := this tx's txid (`zslpindexer.cpp:229`, `zslpstore.cpp:453`). +- **First-genesis-wins:** if a token row for this id already exists, do not + overwrite (`zslpstore.cpp:457`). (Txids are unique, so this only matters under + re-delivery; keep it.) +- Mint output: `initial_quantity` is created at **vout[1]** iff `voutCount > 1` + (`zslpstore.cpp:474-477`). If vout[1] doesn't exist, the quantity is **not** + created (effectively burned). Canonical. +- Baton: created at `mint_baton_vout` iff `2 ≤ mint_baton_vout < voutCount` + (`zslpstore.cpp:463-466,479-483`). Out-of-range baton vout ⇒ **no baton** + (token mints a fixed supply). Canonical. +- Metadata strings (`ticker`/`name`/`document_url`) are clamped to the parser's + fixed buffers (`slp.h:44-46`: 63/127/255 usable bytes). A field longer than the + buffer is **dropped to empty** (`slp.c:69,77,85` only copy when `len < sizeof`). + This length-clamp behavior is determinism-critical (a 255-vs-256 byte name must + resolve identically everywhere) — pin and test it. Treat all three as untrusted + display text (R10 / T5). + +--- + +## R4 — MINT + +- tx_type push == `"MINT"` (`slp.c:118`); token_id (32 bytes, `slp.c:122-124`); + mint_baton_vout (0/1 byte, ≥2 if present); additional_quantity (8 bytes BE). +- VALID iff (a) the token id is a **known** token (`zslpstore.cpp:490`) AND (b) a + **mint baton UTXO for that token id is on a spent input** + (`zslpstore.cpp:441-442,493`). Missing either ⇒ INVALID: create nothing; + consumed inputs stay burned. (gtest `MintWithoutBatonRejected`.) +- On valid MINT: `totalMinted += additional_quantity` (overflow-guarded, else the + add is skipped — `zslpstore.cpp:497-506`); additional_quantity created at + **vout[1]** iff `voutCount > 1`; baton continues at `mint_baton_vout` iff in + range, else baton ends (`zslpstore.cpp:508-528`). + +--- + +## R5 — SEND (the highest-risk arithmetic; closes T3) + +- tx_type push == `"SEND"` (`slp.c:141`); token_id (32 bytes); then 1..N output + quantity pushes, **each exactly 8 bytes big-endian**. +- **Canonical output count cap = 19** SEND outputs (mapping to vout[1..19]). The + parser stops at 19 (`slp.c:151`). **MUST FIX consistency:** the bridge/store + clamp to **20** (`zslpindexer.cpp:267`, `zslpstore.cpp:542`, + `zslpstore.h:207` array `[20]`). Pick ONE number (19 recommended, matching the + parser and SLP) and use it in parser, bridge, store, and the array bound, with a + test that a 20th 8-byte push is treated identically by all layers. As written, + the parser never emits a 20th, so the store clamp is dead — but a second + implementation MUST be told the canonical cap is 19, or it diverges. +- **Quantity domain:** quantities are uint64 on the wire (`slp.c:158`). The store + casts to int64 (`zslpstore.cpp:270,544`). Canonical rule to pin: **any output + quantity with the high bit set (≥ 2^63) ⇒ the SEND is INVALID** (the store + already treats the resulting negative int64 as overflow, `zslpstore.cpp:545`). + Declare this explicitly so a uint64-native implementation matches. +- **Sum:** `requiredOut = Σ outputQuantities`, computed with an overflow guard; + on overflow the SEND is **INVALID** (`zslpstore.cpp:537-550,567`). +- **Available:** `availIn = Σ amount of spent input UTXOs whose tokenId == + msg.tokenId` (batons contribute 0; non-token / unknown inputs contribute 0 via + the `readUtxo` miss `continue`, `zslpstore.cpp:437-446`). This is the canonical + "input not a recognized token UTXO ⇒ ZERO" rule. +- **Validity:** SEND is VALID iff `!overflow && availIn >= requiredOut` + (`zslpstore.cpp:552`). INVALID ⇒ create nothing; all that-token inputs already + burned. (gtest `OverSendBurnsInputsNoOutputs`, `ForgeSendWithoutInputCredits- + Nobody`.) +- **Output mapping (positional, zero-preserving):** quantity j (0-based) maps to + **vout[1+j]** (`zslpstore.cpp:559`). A zero-qty output consumes its slot but + creates nothing (`zslpstore.cpp:557`). If `1+j >= voutCount`, that quantity is + **burned** (skipped), remaining outputs still applied (`zslpstore.cpp:560-561`). + Pin "out-of-range output index ⇒ that quantity burned, SEND otherwise valid". +- **Implicit burn:** `availIn - requiredOut` (the change the SEND chose not to + re-emit) is burned (`zslpstore.cpp:565`). Canonical. + +--- + +## R6 — Token-id byte order + +- On-chain token_id bytes (display / big-endian) are reversed to the daemon's + internal little-endian uint256 for MINT/SEND (`TokenIdToUint256`, + `zslpindexer.cpp:147-153`); GENESIS uses the computed txid directly + (`zslpindexer.cpp:229`). A second implementation MUST apply the identical + reversal or it will look up the wrong token. Pin + test with a known + genesis-txid round-trip. + +--- + +## R7 — Every tx burns the token UTXOs it spends (non-SLP included) + +- Before dispatching on the message, ALL token UTXOs referenced by `tx.vin` are + consumed/erased (`zslpstore.cpp:432-446`). A non-SLP tx (msg == NULL) therefore + burns any token dust it spends (gtest `NonSlpSpendBurnsUtxo`). This is the rule + that makes T6 (wallet burn) real and is canonical — the wallet, not the + indexer, is responsible for not spending tokens (R9 / §requirements). + +--- + +## R8 — Bounded, streaming read APIs (closes the T4 amplification) + +- All list RPCs MUST bound BOTH the returned slice AND the work performed. + `count` is clamped to `ZSLP_LIST_MAX = 1000` (`zslpstore.h:49`, + `rpc/zslp.cpp:124,162`). ✔ for the slice. +- **MUST FIX:** `ListTransfers` gathers the **entire** transfer set for a token + into `all` then reverses (`zslpstore.cpp:777-799`) — unbounded by `count`. + Re-implement to iterate the token's transfer keyspace and early-stop, or to + seek from the high end, so peak memory/CPU is O(count+from), not O(total + transfers for the token). A spammed token must not let one RPC allocate + millions of rows. +- `GetTokensForAddress` is a full 'b'-keyspace scan (`zslpstore.cpp:810-822`) run + once per wallet key by `zslp_listmytokens` (`rpc/zslp.cpp:222`). Either add an + address-keyed index, or cap total scanned records and document it as + best-effort, so a wallet with many keys × a flooded balance table cannot wedge + the RPC thread. + +--- + +## R9 — Wallet token-safety (closes T6) — fail CLOSED + +- The wallet MUST treat a UTXO as **token-bearing** if the index reports a + `(txid,vout)` token UTXO (`GetUtxo`, `zslpstore.h:357`) OR if the index is not + yet synced past that UTXO's height (unknown ⇒ assume possibly-token ⇒ warn). +- Token-bearing UTXOs MUST be excluded from automatic coin selection and shown in + coin-control. A deliberate token transfer MUST emit the canonical SEND at + vout[0] (R1/R5) or warn that the token will burn. + +--- + +## R10 — Untrusted-content handling (closes T5/T7 presentation) + +- `ticker`/`name`/`document_url`/`document_hash` are attacker-controlled. Display + rules (canonical for any compliant wallet/explorer): treat as plain text (no + markup/HTML), strip control characters, length-clamp on display, NEVER + auto-fetch or render `document_url` or any media, and always show the + genesis-txid fingerprint alongside the name. Identity/verification is + out-of-band, not a chain fact. + +--- + +## R11 — Idempotence / crash-resume / reorg determinism + +- A connect re-delivered for the current tip is a no-op (`zslpindexer.cpp:180-183`). +- The tip marker + version stamp drive crash-resume and version-migration wipe + (`zslpindexer.cpp:62-89,99-126`). Index is fully derivable; a wipe+rebuild is a + valid migration. +- DisconnectBlock MUST restore the byte-identical pre-connect state via the undo + log (`zslpstore.cpp:591-731`; gtests `ReorgGenesisRoundTrip`, + `ReorgMintRoundTrip`). Any divergence here is a self-inflicted ledger fork. + +--- + +## R12 — Published test vectors (the agreement mechanism) + +There is no consensus to fall back on, so cross-implementation agreement MUST be +proven by a shared, versioned set of **input→ledger** test vectors covering every +rule above (especially R1–R6). Ship them in-tree and treat them as the +interoperability contract. See `REQUIREMENTS_DOS_SPAM_GRIEF.md` for the exact +required cases. diff --git a/doc/nft/CAPABILITY_MAP.md b/doc/nft/CAPABILITY_MAP.md new file mode 100644 index 00000000000..7f83f81a9be --- /dev/null +++ b/doc/nft/CAPABILITY_MAP.md @@ -0,0 +1,197 @@ +# ZClassic Native NFTs — The Honest Capability Map + +*What a user can actually do with NFTs from inside the full-node GUI — every item tagged +works-now / building-now / next, tied to ground truth in the code, and independently checked +to require **no consensus change** and to be **secure under the non-consensus model**.* + +> **Read this first.** This is the plain-language, build-status-accurate front door. For the +> normative rules see `SECURITY_MODEL.md` (the single source of truth on validation/threats); +> for the write path see `MINT_TRANSFER_SPEC.md`; for trades see `NFT_SELL_DESIGN.md` (the older +`ONCHAIN_TRADES.md` is SUPERSEDED — its `SINGLE|ANYONECANPAY` layout is funds-losing); for the +> private stack see `PRIVACY_STACK.md` + `ZDC1_CODEC_SPEC.md`. This guide SUPERSEDES the +> capability-status portions of `ENABLEMENT.md` (see "Consolidation" at the end) — where it and +> ENABLEMENT disagree on what is built, **this guide and the code win.** + +--- + +## The one-paragraph model (so every status below is read correctly) + +ZClassic consensus does not know NFTs exist. An NFT is a **non-consensus overlay**: a token is a +baton-less SLP GENESIS (`decimals=0, quantity=1`) carried in a single `OP_RETURN`, and every +honest wallet/indexer recomputes the same token ledger as a deterministic function of the +confirmed chain. The hard consequence, stated honestly everywhere in the UI: **a forgery can be +mined, but it credits nobody** — the chain will relay/mine an invalid token tx, and every correct +observer interprets it as crediting no one (and burning its token inputs). Three corollaries the +UI must hold to: (1) **ownership is PENDING until ~10 confirmations** (`DEFAULT_MAX_REORG_DEPTH`), +because 1–9 confs are reorg-reversible; (2) the **image badge means only "these bytes match this +token's on-chain fingerprint"** — never genuine/official/original; (3) **identity is the genesis +txid**, never the name/ticker/image (those are freely reusable). + +--- + +## Capability map + +> **Status note (folded into the guide).** This map is kept for its file:line citations; the +> single live status table is `NATIVE_NFT_GUIDE.md §1`. Since this map was first written, the +> write path, the SHIELD data-channel RPCs, and the SELL RPCs have all landed — the cells below +> are updated to match the built tree on `feature/zslp-nft-indexer`. + +Legend — **works-now** = code in-tree and built (cited file); **building-now** = a separate +workflow is actively writing it against a fixed contract; **next** = designed, not yet written. + +### A. Discover, verify, and inspect (the read path) — **works-now** + +| What the user does | Mechanism (verified in code) | Requires | +|---|---|---| +| **See the NFTs this wallet owns**, in a native dark gallery with a verify badge and public/private pill — no browser | GUI `RPC::refreshNFTs()` calls the real `zslp_listmytokens` then `zslp_gettoken` per token and feeds `NFTGalleryModel`/`nftgallerydelegate` (`zcl-qt-wallet/src/rpc.cpp:863`; gallery files `nftgallery*.{h,cpp}`). The daemon side is `zslp_listmytokens` (`src/rpc/zslp.cpp:191`). | `-zslpindex` (default ON this branch) + wallet build | +| **Verify an image** against its on-chain fingerprint (✓ match / ✗ mismatch / ? pending) — locally, never fetching the remote URL | `ContentEngine` streaming SHA-256 + verify on a worker thread (`zcl-qt-wallet/src/contentengine.{h,cpp}`); badge copy is "matches its on-chain fingerprint" only | local cached bytes | +| **Look up any public token** by genesis txid: ticker, name, document_url, 32-byte hash, decimals, height, totalMinted, baton state | `zslp_gettoken` -> `CZSLPStore::GetToken` (`src/rpc/zslp.cpp:73`) | `-zslpindex` | +| **Confirm a real 1-of-1 + supply cap** (`totalMinted==1 && hasMintBaton==false`) | baton-less GENESIS; `totalMinted` now counts only created quantity (R-GEN-3) | `-zslpindex` | +| **Read full public transfer history** (every GENESIS/MINT/SEND, newest-first, reorg-safe) | `zslp_listtransfers` (`src/rpc/zslp.cpp:142`); ordering normative (R-RPC-2) | `-zslpindex` | +| **Browse all indexed tokens** (bounded paging) | `zslp_listtokens` (`src/rpc/zslp.cpp:107`), clamped to `ZSLP_LIST_MAX=1000` | `-zslpindex` | +| **Trust the ledger is forgery-proof** (a forged SEND/MINT credits nobody; an NFT can't be duplicated) | UTXO-bound conservation indexer; `vout[0]`-only parse landed (`src/zslp/zslpindexer.cpp:229`), single `ZSLP_SEND_MAX_OUTPUTS=19`, no mempool/0-conf path (`ChainTip`-only, `zslpindexer.h`). ~101 ZSLP gtests across `src/gtest/test_zslp*.cpp` | `-zslpindex` | + +### B. Create and move NFTs (the write path) — **works-now (daemon, working tree)** + +The daemon RPCs are built (working tree on `feature/zslp-nft-indexer`, uncommitted). The GUI +dialogs that call them are **designed** (`NATIVE_UI_BUILD_PLAN.md`) and degrade honestly until +they are wired in a build that carries the RPCs. + +| What the user will do | Daemon contract (the exact RPC names/params the UI calls) | State | +|---|---|---| +| **Mint a public 1-of-1** (drag a file, hash it locally, fill name, broadcast a baton-less GENESIS) | `zslp_genesis '{nft:true, name, document_url, document_hash, [ticker], [to (t-addr)], [mint_baton_vout]}'` (`nft:true` forces decimals 0/quantity 1/no baton) | built (working tree; `src/rpc/zslp.cpp:330`; encoders `slp_build_genesis` + builder spec `MINT_TRANSFER_SPEC.md`) | +| **Transfer / gift** an NFT to a recipient's t-address | `zslp_send` (token_id, to_address, amount=1, optional change_address) | built (working tree; `src/rpc/zslp.cpp:545`) | +| **Airdrop / batch** up to 19 token outputs in one tx | `zslp_send` multi-output (indexer crediting already works) | built | +| **Limited / numbered editions** ("N of 100") | `zslp_genesis` qty=N baton-OFF, or N separate 1-of-1s + `zslp_send` | built (rides B above) | +| **Hold an NFT without burning it** — an ordinary send/shield/sweep never spends the carrier dust | Wallet anti-burn: `AvailableCoins` now excludes protected token/dust outpoints by default (`fExcludeZSLPTokens=true`, `wallet.h:1124`; `ZSLPIsProtectedTokenOutpoint`, `wallet.cpp:3197`), built on the primitive `ZSLPFindWalletTokenUtxos` + `SLP_TOKEN_DUST=546` (`src/wallet/zslpwallet.{h,cpp}`); the self-validate-before-broadcast gate (R-WALLET-9) is wired (`zslpwallet.cpp:460`) | built (working tree) — holder safety mechanically complete; becomes the shipped guarantee on commit/merge | + +### C. Create / mint dialogs and detail view (the native UI) — **next** + +| What the user will do | Where it's designed | State | +|---|---|---| +| **Open a detail dialog** (large verified render, provenance rows, copy id/fingerprint, prev/next) | `NATIVE_UI_BUILD_PLAN.md` §2 (`NFTDetailDialog`) | next (spec build-ready; GUI dialog files not created) | +| **Create-NFT mint wizard** (drop file -> streaming fingerprint -> public/private -> review "what becomes public" -> Create) | `NATIVE_UI_BUILD_PLAN.md` §3 (`NftMintDialog`); calls `zslp_genesis` | next (depends on B's RPCs; Private radio gated off by `isPrivateMintWired()==false`) | +| **Card sets / "collect them all"** completion bar | `ENABLEMENT.md` §3.2; manifest convention only | next (no on-chain group/child field; membership is issuer-claimed, see honesty note) | + +### D. Private NFTs and the shielded data channel — **works-now (daemon, CLI; default-OFF); native GUI next** + +| What the user will do | Mechanism | State | +|---|---|---| +| **The ZDC1 codec itself** (frame/reassemble/AEAD/ciphertext fingerprint) | `src/datachannel/zdc.{h,cpp}` — built + self-tested AND **compiled into the daemon** (`src/Makefile.am:247,294`; 25 daemon gtests in `test_zdc.cpp`, ASan/UBSan-clean, secret-zeroized) | works-now (compiled into the daemon) | +| **Send a private file / message** (sealed bytes on-chain; selective disclosure via the returned key / viewing key) | Daemon RPCs `z_senddatafile` / `z_listdatatransfers` / `z_getdatatransfer` built + registered (`src/rpc/datachannel.cpp:597-599`); default-OFF behind `-datachannel`, permanence-consent, verify-before-decrypt | works-now (daemon, CLI; native GUI next) | +| **Receive a private NFT in the gallery** (decrypt locally, render natively, verify badge) | render half done; needs the GUI binary-memo branch (`rpc.cpp` ~756) + datachannel route | next (binary-memo fix gates all private receive) | + +*(A single `zslp_mint_private` RPC and a separate `z_revealkey` seal-then-reveal trigger are +designed but NOT built; the as-built private-mint path is `z_senddatafile` + an ordinary +`zslp_genesis` whose `document_hash` = the ciphertext fingerprint. See `NATIVE_NFT_GUIDE.md §3.3`.)* + +### E. Trade NFT ⇄ ZCL — **works-now (daemon, CLI)** (transparent), and a hard ceiling + +| Trade | Honest verdict | State | +|---|---|---| +| **Transparent NFT ⇄ transparent ZCL**, atomic single tx | **built** via a fixed-template `SIGHASH_ALL\|ANYONECANPAY` signed offer (seller pins the WHOLE output set — OP_RETURN ZSLP SEND@vout[0] / buyer NFT dust@vout[1] / seller ZCL payout@vout[2]; buyer appends funding inputs); coin legs are consensus-atomic, token attribution is indexer-convention (so **trust-minimized**, not trustless). `SINGLE\|ANYONECANPAY` does NOT work for ZSLP (it would pin vout[0]=OP_RETURN, not the payout, and burn the seller NFT). | built (`nft_makeoffer`/`nft_verifyoffer`/`nft_takeoffer`/`nft_listoffers`/`nft_canceloffer`/`nft_requestbuy`, `src/rpc/nftoffer.cpp:1180-1186`; regtest `qa/zslp/nft-sell-regtest.sh`, 6 gtests); only the GUI offer dialog is pending | +| **Any leg shielded**, atomic | **impossible in-codebase** — z-notes carry no script and the Sapling binding sig is single-party over the whole tx (`NFT_SELL_DESIGN.md` §4 / superseded `ONCHAIN_TRADES.md` §4, code-confirmed) | not on roadmap | +| **Escrowed/disputed sale** | possible via 2-of-3 P2SH multisig, but **trusted** (the arbiter) | next, opt-in only | + +--- + +## What a non-consensus overlay can NEVER do (structural ceilings — hold the line in UI copy) + +These are not roadmap gaps. Engineering AND product copy must never imply otherwise. + +1. **No enforced royalties / resale cut / transfer veto / clawback** — the chain is a UTXO ledger + with no transfer hook; enforcement would be a forbidden consensus change. Royalty = off-chain + goodwill; never ship a field that implies enforcement. +2. **No enforced scarcity of the underlying art** — `document_hash` proves *which* bytes, never + *exclusivity*. Anyone can copy the bytes; a private prior-holder keeps a plaintext copy forever. +3. **No ticker / name / issuer uniqueness** — anyone can mint a different token (new txid) + reusing any metadata. Identity is the genesis txid + an out-of-band signed attestation or a + tokenId-keyed verified list (centralized trust, names its maintainer). No trustless "verified + creator" exists. +4. **No on-chain set/collection membership** — no group/parent/child field; sets are manifest + convention and two indexers can legitimately disagree. (NFT1 group/child is deferred, + `SECURITY_MODEL.md` R-NFT1 / T12.) +5. **No atomic/trustless trade for any shielded leg** — privacy + atomicity are mutually exclusive + here (`ONCHAIN_TRADES.md` §4). +6. **Public ZSLP is fully public and linkable** — ownership rides transparent 546-sat dust; it + degrades wallet hygiene and correlates addresses. Tokens cannot ride shielded outputs. +7. **Private ≠ undetectable** — the shielded channel hides content/recipient/amount/metadata but + leaks the count/size/timing burst and, if fees come from a t-address, the sender. It is a + confidentiality channel, not steganography. +8. **Permanence is a node-operator liability** — every byte (incl. encrypted private NFTs) is + stored by every full node forever, no pruning. Keep assets small; default-OFF + size cap. +9. **Ownership is PENDING below ~10 confs** — 1–9 confs are reorg-reversible; the node finalizes at + depth 10 and hard-stops at 99. UI shows pending->final on a single named constant. +10. **The wallet must NEVER auto-fetch a `document_url`** (leaks IP + interest). Bytes come from + local cache or explicit user action only. No QtWebEngine/browser anywhere — native Qt only. + +--- + +## Independent safety check (no consensus change; secure under the non-consensus model) + +I verified each capability against the code on this branch: + +- **No consensus change required, anywhere.** The indexer overrides only `ChainTip` and never + `SyncTransaction` (`src/zslp/zslpindexer.h`), so there is no mempool/0-conf or validation path — + it is a derived, disposable store. The write path (`MINT_TRANSFER_SPEC.md` §6) produces an + ordinary `TX_NULL_DATA` payment that unmodified nodes relay/mine; ZSLP is referenced nowhere in + `src/main.cpp` or `src/consensus/`. The codec rides the existing Sapling memo. The trade path + composes existing `signrawtransaction`/`sendrawtransaction` and adds no opcode. +- **Forgery credits nobody — confirmed in code.** `vout[0]`-only parse landed + (`zslpindexer.cpp:229`, closing the message-position and multi-OP_RETURN forks T1/T2); a single + `ZSLP_SEND_MAX_OUTPUTS=19` constant is enforced across parser/bridge/store (closing the SEND-cap + fork T7); availIn requires recognized token inputs (forged SEND credits nobody, T4). ~101 ZSLP + gtests exercise these (`test_zslp*.cpp`, including a versioned vector corpus). +- **Honest UX is achievable and specced.** Badge copy, pending-until-10, name-not-unique cue, and + "no auto-fetch" are normative in `SECURITY_MODEL.md` R-UX-1..9 and carried verbatim into the UI + plan's banned-words list. +- **Holder anti-burn — closed in the working tree** (capability B last row). `AvailableCoins` + now excludes protected token/dust outpoints by default (`fExcludeZSLPTokens=true`, + `wallet.h:1124`; `ZSLPIsProtectedTokenOutpoint`, `wallet.cpp:3197`) and the + self-validate-before-broadcast gate is wired (`zslpwallet.cpp:460`). Holding is therefore + mechanically burn-safe in the working tree; it becomes the shipped guarantee once these + uncommitted changes are committed/merged. Do not let UI copy imply burn-proof holding outside + a build that carries these changes. + +--- + +## Consolidation plan (kill the sprawl; never delete, mark superseded) + +`doc/nft/` has 26 files with heavy, partly-stale overlap. Concrete plan: + +**1. This guide becomes the status front door.** `CAPABILITY_MAP.md` supersedes the +*capability-status* role of `ENABLEMENT.md`. Mark ENABLEMENT in `README.md` as +"superseded for build-status by CAPABILITY_MAP.md; kept for the why/aspirational discussion." Do +not delete ENABLEMENT — its limits prose and headline-experience write-ups are still useful — but +fix its stale lines (it still says the gallery is "fed by fixtures / 0 zslp_* calls" while +`refreshNFTs` already calls the real RPCs; it says "no `src/datachannel/`" while the codec exists). + +**2. Collapse the supporting threat-model pairs into SECURITY_MODEL.md (already the normative +synthesis).** Mark these six as "superseded by SECURITY_MODEL.md, kept for traceability": +`zslp-forgery-conservation-threat-model.md`, `zslp-determinism-spec.md`, +`zslp-canonical-validation-conformance-checklist.md`, `holder-anti-burn-threat-model.md`, +`holder-anti-burn-requirements.md`, `zslp-wallet-antiburn-ux-honesty.md`, +`IMPERSONATION_UNIQUENESS.md`, `REORG_CONFIRMATION_SAFETY.md`, +`REORG_CONFIRMATION_REQUIREMENTS.md`, `THREATS_DOS_SPAM_GRIEF.md`, +`REQUIREMENTS_DOS_SPAM_GRIEF.md`, and the short `zslp-security-model.md` (README already calls it +superseded). `CANONICAL_VALIDATION_SPEC.md` overlaps SECURITY_MODEL §2 nearly 1:1 — keep ONE +normative copy (SECURITY_MODEL) and reduce CANONICAL_VALIDATION_SPEC to a pointer, or fold its +file:line citations in; mark it superseded either way. + +**3. Collapse the four privacy docs.** `PRIVACY_STACK.md`, `ZDC1_CODEC_SPEC.md`, `PRIVACY_UX.md`, +`PRIVACY.md` overlap heavily and **all reference a `shielded-data-protocol.md` that does not +exist** (broken cross-ref). Pick `PRIVACY_STACK.md` as the canonical private-NFT doc (it has the +stack, wire format, honest limits, and end-to-end flow), keep `ZDC1_CODEC_SPEC.md` as the codec +reference, fold `PRIVACY_UX.md`/`PRIVACY.md` into PRIVACY_STACK, and either restore the missing +`shielded-data-protocol.md` or replace every reference to it with `PRIVACY_STACK.md`/ +`ZDC1_CODEC_SPEC.md`. + +**4. Canonical set after consolidation (the only docs a new reader needs):** +`README.md` (index) -> `CAPABILITY_MAP.md` (this, status) -> `SECURITY_MODEL.md` (normative +rules/threats) -> `MINT_TRANSFER_SPEC.md` (write path) -> `NATIVE_UX.md` + `NATIVE_UI_BUILD_PLAN.md` +(GUI) -> `CONTENT_MODEL.md` (any-file fingerprinting) -> `PRIVACY_STACK.md` + `ZDC1_CODEC_SPEC.md` +(private) -> `ONCHAIN_TRADES.md` (trades). Everything else is "superseded, kept for traceability." + +**5. Single status table, one place.** The implementation-status table belongs ONLY in this guide; +remove or replace the duplicate (and now-stale) status tables in `README.md` and `ENABLEMENT.md` +with a one-line "see CAPABILITY_MAP.md" so build-status is never maintained in three places again. diff --git a/doc/nft/CONTENT_MODEL.md b/doc/nft/CONTENT_MODEL.md new file mode 100644 index 00000000000..0898599de4d --- /dev/null +++ b/doc/nft/CONTENT_MODEL.md @@ -0,0 +1,549 @@ +# ZClassic NFT — Content Model (any file / image / video → NFT) + +**Status:** design, single source of truth. THIS workflow ships the GUI content engine +(Section 4 + 6B); the daemon mint RPC (Section 6A) is designed-now / implemented-later. + +**Hard invariants (do not violate):** +- **No consensus change.** The ZSLP genesis OP_RETURN format and `slp_parse` / + `slp_build_genesis` stay byte-identical. An NFT is just a standard tx whose `vout[0]` + is an OP_RETURN + transparent dust — already-valid script semantics. +- **Privacy.** Never auto-fetch a remote `documenturl` on the paint/poll/hover path + (IP + interest leak). Bytes come from local cache or an **explicit** user action only. + This mirrors the existing `refreshNFTs` guard that forces `cachePath=""` + (`zcl-qt-wallet/src/rpc.cpp:835,960`). +- **No browser, no QtWebEngine, ever.** No QtMultimedia (it is `-skip`'d from the static + Qt build — see Section 7). +- **C++14 only.** No `std::optional` / `std::string_view`; use empty-`QString` sentinels + and an `int verifyState`. Put includes for any header-signature type in the header. +- **DRY.** ONE content engine, not parallel copies. `nftimagecache` becomes the seed. +- **Honest about limits.** No anti-copy. No in-app video playback in v1. Don't promise it. + +--- + +## 1. The model in one page + +A file can be ANY size; a blockchain cannot hold ANY size (every node stores every byte +forever, and the OP_RETURN relay cap is **223 bytes total** — +`zclassic/src/script/standard.h:34`). So the ONLY honest way to make a 2 GB video — or any +file — an NFT without a consensus change is **content-addressing**: + +``` + ON-CHAIN (tiny, permanent) OFF-CHAIN (the bytes, free to live anywhere) + ┌───────────────────────────┐ ┌──────────────────────────────────────────┐ + │ ZSLP GENESIS OP_RETURN │ │ local disk · creator URL · ipfs://CID · │ + │ • document_hash (32B) ───┼──┐ │ ZDC1 Sapling-memo chain (small private) │ + │ = FINGERPRINT │ │ └──────────────────────────────────────────┘ + │ • document_url (≤~153B) │ │ │ + │ = structured pointer │ │ wallet HASHES the bytes (streaming) and + │ • ticker / name / qty=1 │ │ COMPARES to the on-chain fingerprint: + └───────────────────────────┘ └──► match → green "genuine" badge (verifyState 1) + differ → red "tampered" badge (verifyState 2) +``` + +- **The chain proves the FINGERPRINT, never the pixels.** Copying a file is always + possible; passing a copy off as THE NFT is not — verify fails, and you don't hold the + ZSLP UTXO that carries ownership. +- **Security** = the wallet recomputes SHA-256 (small files) or a chunked Merkle root + (large files) over the bytes and compares to the on-chain anchor. **Uniqueness / + ownership** come from the already-hardened UTXO-bound ZSLP conservation layer + (unchanged; carries the verified forgery fix in the daemon working tree). +- **PUBLIC NFT:** descriptor + fingerprint on-chain; bytes off-chain; anyone verifies. +- **PRIVATE NFT:** asset bytes encrypted; ownership shielded. Small content rides the + shielded **ZDC1** Sapling-memo data channel (designed separately). Large private video: + encrypted bytes off-chain/peer + the on-chain fingerprint anchor (over **ciphertext**) + + a key reveal on transfer. Integrity is verified BEFORE any decrypt is attempted. + +**Efficiency is mandatory:** streaming hash (never `readAll` a whole file — a 2 GB video +must hash in ~1 MiB of RAM), all hashing OFF the GUI thread on a bounded pool, a chunked +Merkle root that enables incremental / streamed verification, and an on-disk +content-addressed cache (store once, reuse). + +--- + +## 2. The content descriptor + on-chain encoding + +### 2.1 On-chain (Tier 1) — reuse the existing genesis fields, byte-for-byte + +The genesis struct (`zclassic/src/zslp/slp.h:43-52`) gives us exactly: +`ticker[64]`, `name[128]`, `document_url[256]`, `document_hash[32]` + `has_document_hash`, +`decimals`, `mint_baton_vout`, `initial_quantity`. An NFT is the 1-of-1 shape the wallet +already recognizes: **`decimals==0 && balance==1`** (`zcl-qt-wallet/src/rpc.cpp:832`), +no baton. + +**Byte budget (the binding constraint).** Empty pushes encode as `OP_PUSHDATA1 0x00` += **2 bytes** each (`op_return_push.h:74-78`). Fixed genesis cost with empty ticker/name/url +and a 32B `document_hash`: + +``` + OP_RETURN 1 + Lokad push (1+4) 5 "SLP\0" + type push (1+1) 2 0x01 + "GENESIS"push (1+7) 8 + ticker (empty) 2 + name (empty) 2 + document_url (empty) 2 + document_hash (1+32) 33 + decimals (1+1) 2 + baton (empty) 2 + qty (1+8) 9 + ────────────────────────── + FIXED TOTAL 68 → 223 − 68 = 155 bytes shared by ticker + name + url +``` + +A push ≤ 0x4b uses a 1-byte prefix; 76..255 bytes uses a 2-byte `PUSHDATA1` prefix +(`op_return_push.h:49-71`). So with empty ticker/name, the usable `document_url` payload +is **~153 bytes**. `document_url[256]` in the struct is fine; the **~153-byte WIRE cap is +the real limit** and MUST be enforced at build time (the whole encoded script length vs +223), because `slp_build_genesis` silently returns 0 on overflow (`slp.c:177-233`). + +> There is **no room for a NEW on-chain field** (a second 32B Merkle push would need 33 +> more bytes). The Merkle structure therefore lives OFF-CHAIN; `document_hash` is the +> single on-chain anchor that transitively pins everything. + +### 2.2 `document_hash` (32B) — the dual-mode anchor + +`document_hash` is parsed at exactly `len==32` (`slp.c:90-96`). We define it by file size, +and the wallet picks the algorithm deterministically (see the ambiguity rule below): + +- **SMALL file (≤ chunk_size):** `document_hash = SHA-256(file)` — exactly today's verify + (`nftimagecache.cpp:66-72`), generalized to non-image bytes. Unchanged for images. +- **LARGE file (> chunk_size):** `document_hash = MERKLE ROOT` over 1 MiB chunks (below). + The root binds BOTH the whole content AND the chunk layout, enabling streamed + verification of a 2 GB video without ever holding the file. + +### 2.3 `document_url` (≤~153B) — a STRUCTURED pointer, not a raw http link + +The fragment after the scheme always points at content (the manifest by hash, or the bytes +by content address), never embeds them, so a 4 GB video's URL is still tiny. Grammar: + +``` + ipfs:// content-addressed, the CID self-verifies (preferred) + https://host/path/.zdm creator-hosted manifest (large files) + zdm:<32-hex-prefix> manifest-by-hash; bytes resolved out-of-band + zdc1:// private: bytes over ZDC1 Sapling memos + https://host/path/file.png small public single-file (bytes directly) +``` + +Prefer **short `ipfs://CID`** over long gateway URLs — a long path overflows the ~153B cap. +`document_url` is OPTIONAL and is **never fetched by the daemon** and never auto-fetched by +the GUI. + +### 2.4 The off-chain manifest (Tier 2) — `ZDM1`, self-anchoring + +For LARGE files, `document_url` points at a small, canonical, fixed-endian **manifest** +that carries the Merkle metadata that does not fit in ~153 on-chain bytes. Two equivalent +framings were proposed — (a) the on-chain anchor is the manifest's own SHA-256, or (b) the +anchor is the **Merkle root** that the manifest re-states. We adopt **(b), root-as-anchor**, +because it preserves streamed verification (the chain anchor directly validates streamed +chunks); the manifest re-states the root and the whole-file SHA-256 for a cheap single-shot +cross-check. The manifest is resolved from local cache or explicit user action, hashed, and +its self-stated root checked against the on-chain anchor BEFORE any field is trusted. + +`ZDM1` exact layout (little-endian; deterministic so two encoders produce byte-identical +bytes → stable hash; **bounds-checked parse, never asserts** — mirror the never-assert JSON +readers in `rpc.cpp`): + +``` + off 0 magic "ZDM1" 4B + off 4 version 0x01 1B + off 5 flags 1B bit0=private/encrypted bit1=has-poster bit2=multi-part + off 6 merkle_root 32B (== document_hash on chain, large-file mode) + off 38 sha256_whole 32B (whole-file SHA-256, single-shot small-file cross-check) + off 70 file_size 8B uint64 LE (exact decrypted byte length) + off 78 chunk_size 4B uint32 LE (PINNED = 1 MiB = 1048576) + off 82 chunk_count 4B uint32 LE (= ceil(file_size / chunk_size)) + off 86 mime_len 1B; mime ≤127 ASCII ("video/mp4","image/png",…) + ... name_len 2B LE; filename UTF-8 ≤1024 (original BASENAME, no path) + ... poster_hash 32B present iff flags bit1 (SHA-256 of a small JPEG/PNG poster, + itself a separate content-addressed blob — lets the gallery + show a thumbnail for a video WITHOUT the video) + ... (private only) key_wrap: 12B nonce + AEAD-wrapped 32B content key (Tier 3) +``` + +The full per-chunk hash list (32B × chunk_count) MAY be appended (a 2 GB file → 2048 +leaves → 64 KiB), or recomputed on the fly from the bytes — the root in the header is the +authority. Typical header-only manifest ≈ 150–400 B; with the chunk list ≈ 64 KiB for 2 GB. + +### 2.5 The Merkle tree — PINNED parameters (a wire commitment) + +``` + CHUNK SIZE = 1 MiB (1048576), fixed in v1. (2 GB → 2048 leaves → 11-deep tree; + one streamed chunk needs ~11 sibling hashes ≈ 352 B proof. Matches the + streaming read buffer.) + LEAF leaf_i = SHA-256(0x00 || chunk_bytes_i) + INTERNAL node = SHA-256(0x01 || left || right) + ODD NODE PROMOTE the lone node unchanged to the next level — do NOT duplicate it. + 1-LEAF a file ≤ chunk_size has root = leaf_0 = SHA-256(0x00 || bytes). + HASH SHA-256 everywhere (the wallet's existing primitive). +``` + +- **Domain separation** (`0x00` leaf / `0x01` internal) prevents the second-preimage / + leaf-vs-internal confusion (the CVE-2012-2459-class ambiguity). +- **Odd-node PROMOTION, not duplication.** Duplicating the last node is the exact bug that + enables the Bitcoin Merkle dup-tx forgery; promotion is unambiguous. (One input design + suggested Bitcoin-style duplication; we **reject** it in favor of promotion.) +- **1-leaf ≠ bare SHA-256.** `SHA-256(0x00 || bytes) != SHA-256(bytes)`. This is the + **anchor-ambiguity rule** below; it is the main subtlety, documented loudly. + +### 2.6 ANCHOR-AMBIGUITY RULE (mandatory, deterministic) + +The wallet must know which algorithm produced `document_hash`: + +- **SMALL path (no resolvable manifest):** `document_hash = SHA-256(bytes)` (bare). +- **LARGE path (a `.zdm` manifest resolves locally or by explicit user action):** + `document_hash = Merkle root` (domain-separated, per 2.5). + +Selector: **does a `.zdm` manifest resolve?** No → bare-SHA-256 mode. Yes → root mode. +The manifest ALWAYS carries `sha256_whole` so the wallet can cross-check either way. Keep +**both code paths**; do not try to unify them silently. + +--- + +## 3. Large-file / video strategy + streamed verification + +### 3.1 What "owning a video NFT" means (state it in the UI) + +The chain proves the **fingerprint** and (via ZSLP UTXO conservation) **who holds the +1-of-1 token** — never the pixels. There is **no DRM, no anti-copy**. If the off-chain copy +disappears you own a verifiable fingerprint to nothing → **permanence is the holder's / +creator's responsibility** (pin to IPFS, keep the file). The wallet should warn at mint and +offer to keep a local content-addressed copy. + +### 3.2 Streamed / incremental verification (bounded memory) + +The worker NEVER holds the whole file. One pass, two hash contexts, a single reused 1 MiB +buffer: + +1. **Resolve + hash the small manifest first** (if large mode); compare its self-stated + root to the on-chain `document_hash`. If it fails → **MISMATCH immediately, no big read.** +2. **Stream the bytes chunk-by-chunk.** For each 1 MiB read: feed one per-chunk + `QCryptographicHash(Sha256)` to get `leaf_i`, AND a rolling whole-file + `QCryptographicHash::addData()` for the single-shot cross-check. Compare `leaf_i` to the + manifest's chunk hash **as it goes** — a tamper in chunk *K* fails at chunk *K* without + ever reading *K+1*. Combine leaves per Section 2.5; compare the final root to the anchor. + +Peak RAM = `chunk_size` + a couple of hash contexts ≈ **1–2 MiB, independent of file size**. +This satisfies "verify a 2 GB video in bounded memory" and "verify as it downloads/plays": +a future chunked-transfer pipeline calls `verifyChunk(i, bytes)` the instant chunk *i* +lands, gating playback/sharing of only-verified prefixes. + +### 3.3 Public vs private large + +- **PUBLIC video:** manifest + bytes off-chain; `document_hash` = Merkle root anchors the + manifest/bytes; anyone verifies. +- **PRIVATE large video:** encrypt plaintext with a per-asset symmetric key **BEFORE** + chunking; **the Merkle is over CIPHERTEXT** (`flags` bit0 set, `key_wrap` block present). + The on-chain anchor commits to ciphertext, so the public chain reveals nothing about + plaintext, and a holder can **verify integrity before the key arrives**. Decrypt happens + ONLY after the integrity check passes (no decrypt oracle). The small symmetric key is + revealed over the separate ZDC1 Sapling-memo channel — NOT the bytes. + +--- + +## 4. The content engine (refactor of `nftimagecache`) + +**The must-fix:** the current worker does `bytes = f.readAll()` then +`QCryptographicHash::hash(bytes,…)` (`nftimagecache.cpp:60-67`) — fine for the ≤10 MB image +guard (`kMaxFileBytes`, line 33), **fatal for a 2 GB video** (whole file in RAM). Refactor +into ONE general streaming engine; image decode becomes a consumer of it, not a parallel +copy. + +### 4.1 Plan (DRY, gallery never breaks) + +- **Step A.** Copy `nftimagecache.{h,cpp}` → `src/contentengine.{h,cpp}`, rename class + `NFTImageCache` → `ContentEngine`, keep the existing 4-arg `request()` as an inline + forwarder so callers compile unchanged this pass. +- **Step B.** Add a 1-line shim (`typedef ContentEngine NFTImageCache;` or keep + `nftimagecache.h` as an include shim) so `mainwindow.cpp:3053/3124/3204` and `rpc.cpp` + keep working with ZERO edits; flip names later. +- **Step C.** Register `contentengine.{h,cpp}` in `zcl-qt-wallet.pro` and `tests/tests.pro` + the same way `nftimagecache` is (`tests.pro:51,64`). C++14 only. +- **Step D.** Add L0 tests in `tests/tst_logic.cpp` next to + `nftCachePipelineVerifyMismatchPending()` (`:1355`) reusing the `QTemporaryDir` + + `writeTestPng` + `XDG_DATA_HOME` + `QSignalSpy` harness (`:1253,:1359`). + +### 4.2 Preserved threading contract (verbatim — it is already correct) + +- Bounded `QThreadPool` `setMaxThreadCount(4)` (`nftimagecache.cpp:139`). +- The worker touches **only** `QByteArray` / `QCryptographicHash` / `QImageReader` / + `QImage` — **NEVER `QPixmap`**. +- Cross back via `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` to a GUI-thread + `deliver()`, where the `QPixmap` is built and `onImageReady(hash, QPixmap, verifyState)` + is called (`nftimagecache.cpp:111-118,180-198`). +- `QPointer` target guard; in-flight `QSet` dedupe keyed `hash@sizePx` + (`:157-178`); atomic `QSaveFile` cache write (`:99-107`). **Zero network code.** + +### 4.3 New behavior + +- **Streaming hash** over a fixed **1 MiB** buffer, replacing `readAll`. A + `std::atomic` cancel flag checked **every 1 MiB block** (a 2 GB / 100 GB hash must + abort on shutdown / wrong-file-drop). Dtor must `cancelAll()` — `_pool.clear()` only drops + not-yet-started runnables and cannot stop a running multi-GB hash (`:143-148`). +- **Chunked Merkle** (1 MiB leaves, domain-separated, odd→promote) computed in the SAME + pass; root kept SEPARATE from the whole-file SHA-256. +- **Classify by MIME** (`QMimeDatabase`, header sniff — no full read): `image/*` → existing + `QImageReader` decode-time downscale path (UNCHANGED, gallery byte-identical); + `video/*`, `application/pdf`, `text/*`, arbitrary bytes → **no decode**, render a typed + poster (film-strip glyph for video, MIME-family glyph for documents) on the `#1d2027` + inset. **Never fake a video frame** (no codec — Section 7). +- **Content-addressed cache, keyed by hash** (store once, dedupe across NFTs): + `AppData/nft_posters/_.png` (today's thumb cache, generalized) and + `AppData/nft_content//` for verified raw bytes — the bytes store is + **opt-in, NEVER auto-populated** (privacy). A completed verify can drop a `.ok` + stamp so re-open re-verifies cheaply (stat, not re-hash). +- **Privacy guard, hard:** `request()` / verify accept a **local path or `:/resource` + ONLY**; assert/guard against `http(s)://` as `bytesPath`. `refreshNFTs` keeps + `cachePath=""` (`rpc.cpp:960`); the engine never auto-fetches a `documenturl`. + +### 4.4 POD descriptor (C++14, no `std::optional`) + +```cpp +// src/contentengine.h +struct ContentDescriptor { // POD, value-copyable aggregate + bool ok = false; // false => unreadable / empty + QByteArray merkleRoot; // 32B (== anchor in large mode) + QByteArray sha256Whole; // 32B (whole-file, cross-check) + quint64 fileSize = 0; + quint32 chunkSize = 1048576; + quint32 chunkCount = 0; + QString mime; // sniffed; "" => sniff failed + QString filename; // basename only, no path + QByteArray posterHash; // 32B or empty + bool isPrivate = false; +}; +enum VerifyState { Pending = 0, Verified = 1, Mismatch = 2 }; // matches nft.h:29 +``` + +--- + +## 5. Native media display + mint UX + +### 5.1 IN-APP VIDEO PLAYBACK — HONEST VERDICT: NOT in v1 + +**Infeasible in the static single-file bundle, on real evidence:** +- The static Qt build passes `-skip qtmultimedia` (and `-skip qtwebengine`) — + `zclbuild/focal/build/02-openssl-qt.sh:29-30`. No `QMediaPlayer`/`QVideoWidget`. +- The GUI links only `core gui network svg widgets` (`zcl-qt-wallet.pro:7,14,20`); grep + for multimedia / QMediaPlayer / gstreamer / ffmpeg / libvlc / webengine = **0 matches**. +- The bundle gate **fails the build if any optional `.so` stays dynamic** + (`04-gui-bundle.sh:120-128`) — a gstreamer-backed QtMultimedia would break the + "user installs nothing" single-file thesis, and still couldn't decode H.264/HEVC without + system codecs. + +**v1 video UX (native, bundle-safe):** the detail view shows a **poster** (creator-supplied +poster blob, or a typed film-strip placeholder — never a fake frame), an overlaid play +glyph, a `Video · · · ` caption, the genuine/tampered badge, and a +primary button **"Open in your video player"** → +`QDesktopServices::openUrl(QUrl::fromLocalFile(localPath))` — a primitive already used in +the repo (`mainwindow.cpp:1847,2558`; `sendtab.cpp:1580`), proven native and bundle-safe. +The button is **enabled only when local verified bytes exist**; for a public NFT whose +bytes aren't cached it reads **"Fetch & verify (NN.N MB)"** and downloads **on explicit +click only** (never on paint — privacy). In-app playback is a separate, later, explicitly +funded phase (libVLC/ffmpeg in the bundle — licensing/codec surface); **do not promise it.** + +### 5.2 The detail view (a NEW surface) + +The gallery `QListView` (`mainwindow.cpp:3038-3058`) has **no** `activated`/`doubleClicked` +wiring today. Add `connect(view, &QListView::activated, this, &MainWindow::openNFTDetail)`. +`openNFTDetail(QModelIndex)` builds a modeless `NFTDetailView` (QDialog or stacked panel) +styled with the dark.qss tokens, rendering per kind: + +- **Image:** full `QPixmap` (request a larger `sizePx`, e.g. 512). +- **Video:** poster / film-strip placeholder + "Open in your video player" + caption. +- **Document:** large MIME icon + "Open" + "Reveal in folder" (`openUrl` of the dir). +- **Bytes:** hex/size summary + "Save as…" (`QFileDialog`) — never auto-execute bytes. + +All kinds show: name, collection, txid (mono, copyable), genesisheight, the +genuine/tampered/pending badge, and a "Bytes: local / not downloaded" line. The +remote-fetch button is the ONLY network touch and only on explicit click. + +### 5.3 Mint wizard (GUI now; final broadcast gated on the daemon RPC) + +Entirely local until the final broadcast: + +1. **Drop / pick ANY file** → MIME + kind + name + size shown. +2. **Stream-hash with progress** (off-GUI-thread, cancelable, bounded 1 MiB buffer) → + `document_hash` (+ Merkle leaves for > 1 MiB). Populate the content-addressed cache + (store once). Key the mint job by **source PATH** (the hash isn't known yet, so the + `hash@sizePx` dedupe key doesn't apply during mint). +3. **Name / collection (ticker) / optional `document_url`** (where the creator hosts bytes). + **Live-validate** the encoded script length vs 223 and block mint before broadcast; + prefer short `ipfs://CID`. +4. **Public vs Private** toggle (green/amber tokens). Private records intent + builds the + same fingerprint (over ciphertext); bytes ride ZDC1 (separate workflow). +5. **Review:** shows EXACTLY what becomes public (name, ticker/descriptor, `document_url`, + the 32B hash, byte size) vs what stays private; shows the fee; one explicit **Mint**. + +Until the daemon mint RPC lands, the final **Mint** button is disabled / "coming soon" so +the wizard never dead-ends. + +--- + +## 6. API surface + +### 6A. Daemon mint RPCs (designed now, implemented later) + +New TU `src/rpc/zslpmint.cpp`, registered via a sibling +`RegisterZSLPMintRPCCommands(CRPCTable&)` declared+called in `rpc/register.h` (mirror the +read-only `RegisterZSLPRPCCommands` at `register.h:23,32`). `#ifdef ENABLE_WALLET` (else +`RPC_METHOD_NOT_FOUND`); `okSafeMode=false` (mutating). **Non-consensus:** they only +assemble a standard OP_RETURN + dust tx. + +**Why NOT `createrawtransaction`:** its `sendTo` arg is keyed by ADDRESS — +`DecodeDestination(name_)` + `GetScriptForDestination` (`rawtransaction.cpp:554-571`); an +OP_RETURN has no address, so it cannot be expressed and would throw +`RPC_INVALID_ADDRESS_OR_KEY`. It also yields an unfunded/unsigned skeleton. The mint must +emit a `scriptPubKey` we choose AND atomically fund+sign+broadcast — that is exactly +`CRecipient{scriptPubKey,nAmount,fSubtractFeeFromAmount}` (`wallet.h:129-134`) + +`CreateTransaction` + `CommitTransaction` (`wallet.h:1242-1244`). + +``` +zslp_opreturn "datahex" ( "toaddress" amount ) # generic OP_RETURN carrier (the core) + datahex : raw OP_RETURN PAYLOAD hex (NOT incl. 0x6a); ≤220B; daemon prepends OP_RETURN + + one canonical push. Pure carrier — does NOT understand SLP. ZDC1/ZNAM reuse it. + -> { txid, size, fee } + +zslp_mint {options} # high-level "make this an NFT" (built on ^) + options = { + "name": (string, required) ≤128 UTF-8 bytes + "ticker": (string, optional) ≤64 bytes + "documenthash":(string, optional) 64-hex (32B) = SHA-256(file) OR Merkle root + "documenturl": (string, optional) ≤~153B structured URI; HINT ONLY, never fetched + "decimals": (numeric, default 0) NFT => 0 + "quantity": (numeric, default 1) NFT => 1 + "mintbaton": (bool, default false) + "toaddress": (string, optional) t-addr for the token dust; default = fresh wallet key + "merkleroot": (string, optional) 64-hex (carried in documenthash for v1; see note) + "fee": (numeric, optional) + } + -> { txid, tokenid(==txid), vout(token dust), batonvout|null, size, fee } +``` + +**Build path:** `slp_build_genesis(script,223,…)` → `CScript opret(script,script+n)` → +`vecSend = [ {opret,0,false}, {dust→toaddr,546,false}, (baton {dust,…} iff mintbaton) ]` +→ `CreateTransaction(vecSend, wtx, reservekey, fee, chgPos, err, NULL, true)` → +`CommitTransaction`. **Validate before building:** decimals 0..9; quantity fits the 8B BE +field; documenthash/merkleroot exactly 64 lowercase-hex; name/ticker/url byte-lengths vs the +slp buffers; **the whole encoded script ≤ 223** (throw a clear error, don't let +`slp_build_genesis` silently return 0); toaddress is a valid t-addr (ZSLP rides transparent +dust only); wallet unlocked + funded. + +**Two open byte decisions to resolve before mint impl:** +1. **Change-output vout shift (highest risk).** SLP pins token to `vout[1]`, baton to + `mint_baton_vout >= 2` (`slp.c:107`). `CreateTransaction` appends change at an arbitrary + position; if it lands at `vout <= baton` it breaks the positional binding. **Pin SLP + outputs first and force change LAST** (coinControl), or post-validate `chgPos` and + rebuild. +2. **Where the Merkle root lives.** Genesis has ONE 32B `document_hash` slot. v1: ship + **`document_hash` = Merkle root for large files / bare SHA-256 for small** (Section 2.6); + `sha256_whole` lives in the manifest. The descriptor still computes the root for future + chunked transfer. (A second OP_RETURN push is rejected — 223B has no room.) + +### 6B. GUI `ContentEngine` API (this workflow — extend `nftimagecache`) + +Zero network code; all calls take a LOCAL path/resource only. + +```cpp +// async hash + describe (streaming, bounded RAM); NEVER blocks the GUI thread +void hashFile(const QString& path, quint64 token); +signals: void descriptorReady(quint64 token, ContentDescriptor d); + +// async verify bytes against an on-chain anchor -> Pending/Verified/Mismatch +void verify(const QString& path, const QString& expectedHashHex, quint64 token); +signals: void verifyDone(quint64 token, int verifyState); + +// async poster/thumbnail for ANY content (image now; video/doc => typed placeholder). +// == today's request(); delivers via the existing onImageReady contract. +void posterFor(const QString& path, const QString& hash, + const QString& expectedHashHex, int sizePx); + +// pure, testable, no I/O — for streamed/chunked verify and unit tests +bool verifyChunk(const ContentDescriptor& d, int idx, const QByteArray& chunkBytes); + +// content-addressed on-disk cache, keyed by hash (store once) +static QString cacheGet(const QString& hashHex); // "" if absent (sentinel) +static bool cachePut(const QString& hashHex, const QString& srcPath); // atomic copy + +// back-compat forwarder so existing callers compile unchanged this pass +void request(const QString& hash, const QString& bytesPath, + const QString& onChainHashHex, int sizePx); +``` + +**GUI wiring (`rpc.cpp`):** add `RPC::mintNFT(descriptor, opts, cb)` symmetric to +`refreshNFTs` (`rpc.cpp:863`): on an explicit user "Mint" action (not paint), call +`zslp_mint` with `{name, documenthash:d.merkleRootOrSha256, documenturl:userOrEmpty, +decimals:0, quantity:1}`; on success `cachePut` the local bytes so the new card verifies +instantly, then `refreshNFTs()`. `refreshNFTs` stays UNCHANGED (`cachePath=""`). When a card +is opened, `MainWindow` calls `ContentEngine::cacheGet(docHashHex)`; non-empty → `posterFor` ++ `verify`; empty → "pending" + offer "Locate file…" (user-explicit, no network). + +--- + +## 7. Build / dependency order + honest limits + +### 7.1 Build & test invocations (verified) + +- **Full pass:** `./prun bash /build/build.sh` (`zclbuild/prun` → + `focal/build/build.sh`) — 03 daemon (carries the verified UTXO-bound forgery fix) + 04 GUI + bundle + delivery gate (asserts `sha256(host) == sha256(chroot)` and the static/optional-so + gates). +- **GUI unit tests:** `/home/rhett/zclbuild/run-l0-l1.sh` — L0 `tst_logic` (guiless) and L1 + `tst_widget` (`QT_QPA_PLATFORM=offscreen`) via `qmake tests.pro && make`. Baseline + **L0 104 / L1 34**. +- Register `contentengine.{h,cpp}` in `tests/tests.pro` HEADERS+SOURCES (like + `nftimagecache` at `:51,64`) and in `zcl-qt-wallet.pro`. + +### 7.2 Implementation order + +1. `contentengine.{h,cpp}` from `nftimagecache` (Section 4) + streaming hash + chunked + Merkle + cancel + content-addressed cache; image path unchanged; back-compat shim. +2. L0 tests in `tst_logic.cpp` (Section 7.4); green L0/L1. +3. Native detail view + per-kind rendering + "Open in your video player" (Section 5). +4. Mint wizard UI, **Mint disabled** pending the daemon RPC. +5. *(Later phase)* daemon `zslp_opreturn` + `zslp_mint` (Section 6A); enable Mint. + +### 7.3 Honest limits (state in UI/docs; do not hide) + +1. **No in-app video playback** in the static bundle (Section 5.1). Verify + poster + + open-externally only. The open button requires a LOCAL verified file (`openUrl` on a + missing/remote path silently fails) — gate it. +2. **Bytes can NEVER be on-chain** (223B cap). The chain stores a 32B fingerprint; the bytes + live off-chain and **permanence is the holder's responsibility**. Warn at mint; offer a + local copy. +3. **No Merkle field on-chain** — the tree is only transitively pinned via `document_hash`. + A peer needs the (small) manifest before chunk-streaming; a withheld/garbled manifest + fails fast but isn't recoverable from the chain alone. +4. **Owning ≠ controlling the pixels.** No DRM, no anti-copy. Verify fails for a tampered + file; ownership is the ZSLP UTXO. +5. **Anchor ambiguity** (1-leaf Merkle ≠ bare SHA-256): the manifest-resolves selector is + mandatory (Section 2.6). +6. **URL byte budget** (~153B): validate the encoded script ≤ 223 BEFORE signing; prefer + `ipfs://CID`. +7. **Domain separation + odd-node promotion** are mandatory anti-malleability; a naive + duplicate-last is a latent forgery vector. +8. **Encrypted NFTs:** Merkle over CIPHERTEXT, verify integrity BEFORE decrypt (no decrypt + oracle). +9. **Privacy regression risk:** the new detail-view fetch button is the first network touch + in this subsystem — strictly explicit-click, never on paint/hover/poll. +10. **C++14:** no `std::optional`/`string_view` (cost real build cycles before); empty-QString + / sentinel-int, header includes for header-signature types. + +### 7.4 Tests to add (L0, `tst_logic.cpp`) + +- `streamHashEqualsWholeHash`: streaming SHA-256 == `QCryptographicHash::hash(readAll)` for + sizes {0, 1, 1 MiB−1, 1 MiB, 1 MiB+1, 3 MiB, 5 MiB} (parity with the old path). +- `merkleRootDeterministic` + `merkleDetectsTamperedChunk`: flip one byte in chunk *K* → + `verifyState=2`, `failedChunk=K`, chunks `< K` already verified; an unrelated byte in the + same chunk changes only that leaf. +- `merkleSingleLeafDegenerate`: file ≤ chunk_size → root = `SHA-256(0x00||bytes)` ≠ bare + SHA-256 (the ambiguity rule). +- `manifestRoundTrip`: `build`→`parse` byte-identical (hash stability); bounds-checked parse + rejects a truncated/hostile blob without asserting. +- `manifestHashShortCircuit`: a bad manifest hash fails before the big read. +- `verifyStates`: Verified / Mismatch / Pending (extend + `nftCachePipelineVerifyMismatchPending`, `:1355`). +- `cacheRoundTrip`: `cachePut`/`cacheGet` round-trip; miss → "". +- `nftKindClassification` + `nftHumanSize`: png/jpg→Image, mp4/mov→Video, pdf→Document, + unknown→Bytes; "12.3 MB". +- `boundedMemory`: hash a synthetic 64 MiB temp file and assert completion (proves no + `readAll` OOM path). Pure `verifyChunk`/`parse`/`build` are I/O-free for fast L0. diff --git a/doc/nft/ENABLEMENT.md b/doc/nft/ENABLEMENT.md new file mode 100644 index 00000000000..548130304aa --- /dev/null +++ b/doc/nft/ENABLEMENT.md @@ -0,0 +1,202 @@ +# Everything ZClassic NFTs Will Enable + +*A definitive enablement map for engineers and product — what's real today, what's a contained build, and what a non-consensus layer can never do.* + +> **Status legend** — used throughout this document: +> - ✅ **Works today** — code exists, ships in the daemon/GUI, verified in-tree. +> - 🔨 **Needs build** — a contained, well-scoped build on top of existing primitives. Not research. +> - 🌫️ **Aspirational** — possible only as off-chain/social convention, or requires a forbidden consensus change. Honest about being unenforceable. + +--- + +## 1. The big picture — what makes ZClassic NFTs distinct + +Most "NFT" chains give you one thing: a **public** token that points at an **off-chain** image (an IPFS/HTTP URL). The token is on-chain; the art is on someone's server; ownership is a public ledger entry anyone can scrape. + +ZClassic can do that public flavor too — but it is **not the differentiator**. ZClassic has a shielded pool (Sapling) with an encrypted 512-byte memo on every shielded output. That memo is a **private, encrypted data channel built into consensus-grade transport**. It changes what an NFT can *be*: + +**The differentiator: PRIVATE NFTs.** An NFT whose **image bytes themselves are encrypted on-chain** inside Sapling memos — not a hash pointing at a public URL, but the actual asset, sealed, delivered end-to-end to a recipient's viewing key. Ownership is **key possession**, not a public ledger row. Content, recipient, amount, and all metadata are hidden. This is *confidential art with no public attribution* — something a transparent-only NFT chain structurally cannot offer. + +And the **strongest design is a hybrid**: a public, auditable, transferable ZSLP ownership token whose on-chain hash commits to a *private* encrypted payload. Public chain-of-custody + private art. You choose the trade-off: full anonymity (private), full auditability (public), or provenance-without-exposure (hybrid). + +ZClassic therefore offers **two non-consensus substrates**, each with a different honest superpower: + +| Substrate | Carrier | Superpower | Honest ceiling | +|---|---|---|---| +| **Transparent ZSLP** | OP_RETURN (SLP Token Type 1, ≤223B) | Public, auditable, indexed provenance + supply cap | Public/linkable; enforces nothing but supply-cap + hash-integrity | +| **Shielded data channel** | Sapling 512B memos (ZDC1 framing) | Private encrypted bytes; ownership = key possession | Confidentiality only — sender keeps a copy; ~64KB practical ceiling | + +**One unifying truth across both:** these are **non-consensus** layers. The base chain (a UTXO ledger with no scripting hooks) never inspects them. So they can do *verifiable provenance, capped editions, public and private gifting, native hash-verified gallery UX* — and they **cannot** do enforced royalties, enforced scarcity of the underlying art, atomic trustless trades, clawback, or DRM. This document is religious about that line. + +### The one cryptographic guarantee, and the one integrity binding + +Everything honest reduces to two facts the chain *actually* gives you: + +1. **Supply cap = 1 (or N).** A baton-less GENESIS (`mint_baton_vout < 2`) emits an empty baton push, so the protocol admits **no future MINT**. Total supply is provably capped by the OP_RETURN bytes themselves. Base consensus never inspects the OP_RETURN, so this is **protocol-meaningful (to any ZSLP-aware reader)**, not enforced by the base chain — but it is the strongest of the guarantees about quantity. +2. **Image integrity binding.** `document_hash = SHA256(image_bytes)` proves that the bytes you hold are *exactly* the bytes the minter committed to. It proves **which** image, not **authorship**, not **exclusivity**, and not **rights**. + +Everything else — ticker/name uniqueness, collection membership, "official issuer," editions count, royalties, expiry — is **indexer/UI convention with zero on-chain enforcement.** + +--- + +## 2. Capability matrix (grouped by status) + +### ✅ Works today (verified in-tree) + +| Capability | What you get | Mechanism | Requires | +|---|---|---|---| +| **Confidential bytes on-chain** | Send ≤512 raw bytes encrypted to a z-addr; read them back as hex | `z_sendmany` accepts binary memo (no UTF-8 enforcement); Sapling ChaCha20-Poly1305-to-ivk; `z_listreceivedbyaddress` returns full memo via `HexStr(entry.memo)` | Nothing — *daemon* round-trips binary today (the GUI lossily coerces binary memos until the binary-safe read path lands; see the binary-memo fix below) | +| **Look up a public NFT** | Paste a genesis txid → ticker, name, documenturl, 32B hash, decimals, height, totalminted, baton state | `zslp_gettoken` → `CZSLPStore::GetToken` → `TokenToJSON` | `-zslpindex` (default ON this branch) | +| **Prove a 1-of-1 + provenance origin** | Confirm `totalminted==1 && hasmintbaton==false`; see genesis height | `zslp_gettoken` totalminted + baton; cap is real via baton-less GENESIS | `-zslpindex` | +| **Full public transfer history** | Every GENESIS/MINT/SEND with txid, amount, height, blockhash, recipient, newest-first | `zslp_listtransfers` iterates `'x'`+tokenId keyspace; reorg-safe undo log | `-zslpindex` | +| **List NFTs this wallet owns** | Public tokens held at your t-addresses, balance + holding address | `zslp_listmytokens` intersects wallet t-keys with per-(token,address) balance index | `-zslpindex` + wallet-enabled build | +| **Browse / discover indexed NFTs** | Page through all indexed tokens (bounded `ZSLP_LIST_MAX=1000`) | `zslp_listtokens` deterministic skip/take over `'t'` keyspace | `-zslpindex` | +| **Native gallery + verify pipeline** | Dark-themed QListView gallery, threaded image cache, SHA-256 verify badge (✓/✗/?), private pill — no browser | `nftgallerymodel` / `nftgallerydelegate` / `nftimagecache` (bundle sha `f8f2bde2`) | Built; **fed by fixtures** until wired | +| **Privacy properties (the hiding)** | Memo content, recipient, amount, metadata all hidden | Inherent to Sapling — applies the moment bytes ride a shielded memo | Nothing | + +### 🔨 Needs build (contained, on top of existing primitives) + +| Capability | What it unlocks | What must be built | Hard dependency | +|---|---|---|---| +| **Wire gallery to real data** | Verify badge over *real* ZSLP tokens, not fixtures | `rpc.cpp` calls `zslp_*` → map to `QVector` (today: 0 `zslp_*` calls, fixture-fed) | Read RPCs (DONE) | +| **Mint a public 1-of-1** | Creator hashes image, fills metadata, broadcasts a baton-less GENESIS | `zslp_genesis` RPC (absent) via `CRecipient.scriptPubKey` + `CWallet::CreateTransaction` (createrawtransaction has NO data branch); GUI mint dialog (threaded SHA256) | Gallery wiring | +| **Transfer / gift a public NFT** | Send a 1-of-1 to a recipient's t-address; history updates | `zslp_send` RPC (absent); GUI gift dialog reusing send-guard; same CreateTransaction OP_RETURN path | Public minting | +| **Airdrop / batch distribution** | Drop to up to **19** token outputs per tx | `zslp_send` multi-output (indexer crediting already works); GENESIS + SEND fan-out | Public minting | +| **Creator identity surface** | Show genesis txid + issuing t-address as identity (not the name) | GUI surfaces txid + issuer prominently (read data exists) | Public minting | +| **ZDC1 framing + reassembly** | Chain a file across many memos, survive out-of-order arrival | `src/datachannel/frame.{h,cpp}` (CRC32 + header), reassembly state machine keyed `(zaddr, transfer_id, seq)` (absent — no `src/datachannel/`) | Confidential bytes (DONE) | +| **App-layer AEAD** | File key independent of Sapling epk → reveal-later | `src/datachannel/aead.cpp` (libsodium already linked, zero new deps) | ZDC1 framing | +| **Private file transfer (≤64KB)** | Deliver a small private file/asset end-to-end on-chain | `z_senddatafile` / `z_listdatatransfers` / `z_getdatatransfer` / `z_receivedatafile`; GUI dialog with size/cost cap; **default-OFF + permanence consent** | ZDC1 + AEAD | +| **Fix GUI binary-memo DROP bug** | Binary frames stop being silently discarded | `rpc.cpp` ~756-760: it coerces memo hex into a QString then drops `.trimmed().isEmpty()` — needs a **ZDC1-magic-detect branch** before the text-inbox path; send path `sendtab.cpp` forces `memo.toUtf8().toHex()` | Prereq for any private receive | +| **Private NFT object** | Sealed image; ownership = key possession; nothing publicly attributable | ZSLP-shaped record inside encrypted START frame + asset in DATA frames; GUI private-mint + reveal-key affordances; feed gallery from decrypted-memo scans | Private file transfer | +| **Private receipt in gallery** | Incoming private NFT decrypts locally, renders native, shows verify badge | Magic-detect + datachannel route + model feed; render half is DONE | Private NFT object + binary-memo fix | +| **Card sets / completion** | Group header + "You own 18 of 30" completion bar | Manifest JSON schema + fetch/parse; grouped `CollectionsModel`; gallery section/completion UI (Phase D) | Public minting + gallery wiring | +| **Limited / numbered editions** | "Edition N of 100" capped supply | `zslp_genesis` (qty=N, baton OFF) + `zslp_send`; serial #N = N separate 1-of-1s | Public minting | + +### 🌫️ Aspirational (off-chain convention or forbidden consensus change) + +| Capability | Why it's aspirational | The honest most-you-can-do | +|---|---|---| +| **Hybrid public-pointer + private payload** | Union of two *unbuilt* tracks (minting + full data channel) | Real once both ship; design is sound. Trades anonymity for auditability. | +| **Royalties / resale enforcement** | UTXO model has **no** transfer hook; enforcement = forbidden consensus change | Advisory metadata only, labeled "**not enforced on-chain**." Never ship a field that implies enforcement. | +| **Atomic swap (single-tx)** | — | **NOW BUILT** (no longer aspirational): the co-signed single SEND+ZCL tx (`ALL\|ANYONECANPAY`) is implemented + regtest-proven (`src/rpc/nftoffer.cpp` `nft_makeoffer`/`verifyoffer`/`takeoffer`). Trust-minimized, transparent counterparties. (2-of-2 multisig / HTLC escrow remains research — CSV/BIP112 is inert, so only absolute-CLTV HTLCs are buildable.) | +| **Trade NFT for ZCL (buy/sell)** | — | **NOW BUILT** (no longer aspirational): the seller's `ALL\|ANYONECANPAY` signature couples the NFT spend + payment in ONE atomic tx (`nft_makeoffer`→`nft_takeoffer`); price + NFT recipient are cryptographically pinned and `nft_verifyoffer` checks them before the buyer pays. Trust-minimized, **not** fully trustless — see `NFT_SELL_DESIGN.md` §8. | +| **Marketplace / order book / price feed** | No matching engine, escrow, or oracle on this chain | External off-chain marketplace using the read RPCs; wallet links out. | +| **Time-boxed passes / expiring tickets** | No consensus expiry primitive; token stays spendable forever | UI-enforced honor system for low-stakes passes only. | +| **"Official issuer" / verified-creator badge** | No on-chain identity or issuer-uniqueness | Out-of-band published genesis txid + a *curated allowlist* (centralized trust). No trustless answer exists. | +| **Provable rarity tiers** | OP_RETURN has no room; rarity is a manifest assertion | Off-chain manifest label, content-addressed at best. "Legendary" is a label, not a chain property. | + +--- + +## 3. The headline experiences + +### 3.1 Public 1-of-1 mint (verifiable provenance) — 🔨 + +A creator picks an image; the wallet hashes it (threaded `QCryptographicHash::Sha256`), fills ticker/name/documenturl, and broadcasts a **baton-less GENESIS** (decimals=0, qty=1, no mint baton). The genesis txid *is* the permanent token id. + +- **Build:** `slp_build_genesis` → `CScript s; s << OP_RETURN << bytes` → `CreateTransaction` as `vout[0]` + a 546-sat dust recipient + change. New `zslp_genesis` RPC + GUI mint dialog. `createrawtransaction` cannot help (only `GetScriptForDestination`). +- **What's real:** supply cap (baton-less) + hash-integrity (`document_hash`). Holder's wallet shows ✓ when local bytes re-hash to the on-chain hash. +- **Honest copy for the UI:** identity is the **genesis txid + minting address**, never the name. Every mint spends real coin (fee + dust) — surface cost before broadcast. A dropped/expired tx marks the NFT **pending**, not owned. Ticker/name are **not unique** — anyone can mint "Curio Cards." + +### 3.2 Curio-style card sets (collections + "collect them all") — 🔨 → 🌫️ + +A group/parent GENESIS whose `documenturl` points at a **canonical manifest JSON** listing child token_ids + traits + per-card image hashes, plus N independent baton-less child GENESIS NFTs. + +- **Set-completion:** intersect the manifest's child token_ids with `zslp_listmytokens` (balance ≥ 1) → "18 / 30 owned" + completion bar in a grouped `CollectionsModel`. Today the gallery is a **flat** fixture-fed QListView with a single `collection` caption and no completion/series/rarity roles. +- **The hard truth:** SLP has **no** group/parent/child field anywhere (`slp_message` has only ticker/name/document_url/document_hash/decimals/mint_baton_vout/initial_quantity). Membership is **100% manifest convention** — two indexers can legitimately disagree, anyone can claim any token is "in" your set, and if the manifest host vanishes the set structure is orphaned (individual cards still verify via `document_hash`). Pin the creator's own minting wallet/genesis txids as the source of truth; content-address the manifest (IPFS CID) so it can't be silently edited. + +### 3.3 Private NFTs (the differentiator) — 🔨 + +An NFT whose **image bytes are sealed encrypted on-chain**; ownership = possession of the decryption key. Nothing publicly visible or attributable. + +- **Flow:** ZSLP-shaped metadata record carried *inside* the encrypted START frame; asset bytes ride DATA frames (ZDC1: 32B header + 480B payload). Record commits via `sha256(ciphertext)`. App-layer AEAD (`K_file` independent of Sapling epk) means a **KEY frame** can be sent in-band, deferred ≥10 confirmations, or delivered fully out-of-band → **sealed-content-then-key-reveal** (timed drops, sealed bids, "unlock at event"). +- **Receipt:** the gallery decrypts locally, renders natively (no browser, no remote fetch), shows the ✓ badge. The render half is **done** (`f8f2bde2`); the data half is entirely missing, and the **binary-memo DROP bug must be fixed first**. +- **The central honesty point:** key-possession ownership **cannot be exclusive**. The sender/prior owner always keeps a plaintext copy; you can re-deliver a key but never revoke the old one without re-encrypting and re-broadcasting. There is no public provenance — deniable by design. That is both the feature and the limitation. + +### 3.4 Private file transfer (the substrate) — 🔨 + +The general-purpose channel under private NFTs: split a file into 480B chunks, optionally AEAD-encrypt, frame each in ZDC1, batch up to ~107 outputs/tx across `z_sendmany`. START carries encrypted `{filename, size, content_type, file_sha256}`; END carries `sha256(ciphertext)`. Receiver reassembles **seq-keyed** (mandatory — `mapWallet` iterates by txid hash, not chain order), verifies both hashes, decrypts. + +- **Honest physics (two different numbers, don't conflate them):** + - 480B usable/chunk, ~948 on-chain bytes/output (≈2× expansion). The **200KB block / 102KB tx** limits are size caps on a *single block/tx*, **not** a throughput cap — saturating every block, ~210 chunk-outputs/block × ~480 usable bytes ≈ **~100 KB usable per full block**, so 1 MB ≈ ~10 full blocks ≈ **~12.5 min** at the current 75s block spacing (`POST_BUTTERCUP_POW_TARGET_SPACING`). That puts the **block-saturating consensus ceiling at ~4.8 MB/hr** at 75s spacing (~2.4 MB/hr at the legacy 150s `PRE_BUTTERCUP` spacing). + - The **recommended self-throttle** is far lower: send **one modest, polite transaction per block** instead of saturating blocks — this is a **self-imposed politeness throttle, NOT a consensus cap**, and is where a sub-MB/hr figure belongs. Be a good chain citizen, not a block hog. + - Every byte is stored by every full node **forever, no pruning.** +- **Verdict from design review:** ≤4KB **great**, ≤64KB **fine**, 50–250KB tolerable-but-slow, multi-MB **hard NO**. Ship **default-OFF** with a hard size cap and a one-time **permanence-consent** dialog. DoS guards (per-sender concurrent-transfer quota, 7-day TTL, CRC32 + END-sha256 gate before assembly) from day one. + +--- + +## 4. Honest limits & non-goals (what a non-consensus layer cannot do) + +These are not roadmap gaps — they are **structural ceilings**. Engineering and product must hold the same line in copy and design. + +1. **No enforced royalties / resale cut / transfer veto / clawback.** The chain is a UTXO ledger with no scripting hook. Enforcement would require a consensus change, which is **forbidden** (never touch consensus/PoW/validation). Any royalty is off-chain goodwill — never ship a field that *implies* enforcement. +2. **No enforced scarcity of the underlying art.** `document_hash` proves *integrity* (which bytes), never *exclusivity*. Anyone can copy the bytes; a private prior-holder keeps their plaintext forever. +3. **No ticker/name/issuer uniqueness.** Anyone can mint a token named anything. Identity is the **genesis txid + minting address** — only meaningful if the creator publishes it out-of-band against a source you already trust. A "verified creator" badge requires a curated allowlist (centralized). +4. **No on-chain set/collection membership.** No group/parent/child field exists. Sets are manifest convention; two indexers can disagree. +5. **No atomic / trustless trading.** No multisig escrow, no HTLC, no matching. A SEND is one-sided with no payment leg. Whoever moves first bears full counterparty risk. **Do not ship anything that implies atomic or trustless trading.** +6. **Public ZSLP is fully public and linkable.** Ownership and every transfer ride transparent t-address dust — the *opposite* of shielded. Each owned token is a 546-sat dust UTXO degrading wallet hygiene and correlating addresses. ZSLP cannot ride shielded outputs (z-addresses can't hold ZSLP tokens). +7. **Private ≠ undetectable.** The shielded channel hides content, recipient, amount, metadata. It **leaks** the count/size/timing of the output burst (coarse file-size signal) and, **if fees come from a t-address, the sender.** A burst of all-512B shielded memos is a distinct "data channel" signature. It is a confidentiality channel, **not** steganography. Mitigations (shielded-only funding, single-use recipient z-addr, randomized delays) are disciplined UX the user can get wrong. +8. **Permanence is a node-operator liability.** Opaque encrypted bytes are stored by every full node forever with no pruning. Keep assets ≤64KB; multi-MB is irresponsible chain bloat. +9. **Hash binding breaks on transcode/resize.** SHA-256 is over exact original bytes. The wallet must pin original bytes. If off-chain/uncached bytes vanish, the hash is orphaned — provably *which* image, just unviewable. +10. **PRIVACY HARD RULE.** The wallet must **NEVER** silently auto-fetch a `documenturl` image over the network (leaks IP + interest). Image bytes come from **local cache or explicit user action only**; an uncached NFT shows an amber "not cached" state. No `QtWebEngine`/browser anywhere — all native Qt delegate paint. + +--- + +## 5. Build dependency order (what unlocks what) + +Each layer is a hard prerequisite for the next. The first layer is **done**; the chain of builds is contained, not research (encoders are ported, libsodium is linked, the gallery render path bundles clean). + +``` +[0] Read index + read RPCs ............................ ✅ DONE + zslp_gettoken/listtokens/listtransfers/listmytokens + -zslpindex default ON; reorg-safe LevelDB store + undo log + slp_build_genesis/mint/send encoders ported + │ + ▼ +[1] Gallery wiring ................................... 🔨 (unlocks: live verify badges) + rpc.cpp → call zslp_* → map to QVector + (today: 0 zslp_* calls, fixture-fed; render pipeline already built) + │ + ▼ +[2] Public minting RPCs (Phase B) ................... 🔨 (unlocks: mint / gift / airdrop / editions / set anchors) + zslp_genesis + zslp_send via CRecipient.scriptPubKey + CreateTransaction + (createrawtransaction has NO data branch) + GUI mint + gift dialogs (threaded SHA256, send-guard) + │ + ├──────────────────────────────► [5] Card sets / completion ... 🔨 (Phase D) + │ manifest schema + grouped CollectionsModel + │ + completion bar; needs [1]+[2] + │ + ▼ +[3] Shielded data channel (ZDC1) .................... 🔨 (unlocks: any >512B private payload) + FIRST: fix GUI binary-memo DROP (rpc.cpp ~756-760) + send path (sendtab.cpp) + src/datachannel/frame.{h,cpp} (CRC32 + header) + src/datachannel/aead.cpp (libsodium, zero new deps) + z_senddatafile / z_listdatatransfers / z_getdatatransfer / z_receivedatafile + seq-keyed reassembly + DoS guards; default-OFF + permanence consent + │ + ▼ +[4] Private NFTs .................................... 🔨 (the differentiator) + ZSLP-shaped record in encrypted START frame + asset in DATA frames + KEY frame → sealed-then-reveal; gallery fed by decrypted-memo scans + │ + ▼ +[6] Hybrid (public pointer + private payload) ....... 🌫️ + union of [2] + [3]/[4]: public ZSLP token whose hash commits to private ciphertext + (transfer_id = token_id); auditable custody + confidential bytes +``` + +**Critical sequencing notes:** +- **[1] before [2]:** wire the gallery to real read data before minting, so the first thing a creator mints is immediately visible + verified. +- **Binary-memo fix gates all of [3]/[4]:** until `rpc.cpp` ~756-760 stops coercing binary memos into a QString and dropping empties, no ZDC1 frame ever reaches a handler. Fix it *first*. +- **[2] and [3] are independent tracks:** public minting (transparent) and the shielded channel (private) can be built in parallel; they only converge at [6] hybrid. +- **Default-OFF everywhere it spends/stores:** minting spends real coin; the data channel writes permanent all-node bytes. Both ship with explicit cost/permanence consent and safe defaults (Krug: "don't make me think" — obvious affordances, plain copy, instant feedback). + +--- + +### Appendix — the north star, stated plainly + +> ZClassic can deliver a **verified, locally hash-checked, native-Qt NFT gallery** with accurate "owned vs known" counts, **public 1-of-1 provenance**, and a genuinely distinct **private NFT** experience where the art itself is sealed on-chain and ownership is key possession. +> +> "Known" is always relative to a manifest you trust. "Owned privately" always means *you can decrypt it*, never *you alone have it*. Nothing on-chain stops a counterfeit "Curio Cards," enforces a royalty, or makes a trade atomic. +> +> Build the honest version. Label the limits in the UI. Never imply enforcement the chain cannot provide. diff --git a/doc/nft/IMPERSONATION_UNIQUENESS.md b/doc/nft/IMPERSONATION_UNIQUENESS.md new file mode 100644 index 00000000000..55861372ece --- /dev/null +++ b/doc/nft/IMPERSONATION_UNIQUENESS.md @@ -0,0 +1,513 @@ +# ZSLP Impersonation & Uniqueness — Security Model, Canonical Validation Spec, and Requirements + +Threat class: **impersonation-uniqueness**. Scope: what "unique" / "authentic" +actually means for a ZClassic ZSLP token (and an NFT in particular), which +impersonation attacks are unstoppable on unchanged consensus, which the +deterministic overlay neutralizes, and the social/cryptographic defenses that +need **zero consensus change**. + +This document is **spec + requirements only**. It edits no source. It grounds +every claim in the real tree (`src/zslp/*`, `src/rpc/zslp.cpp`, +`src/script/standard.cpp`) and tells the in-flight conservation rewrite and the +wallet/GUI exactly what they must satisfy. + +--- + +## 0. The one-paragraph threat model + +ZSLP is a **metaprotocol overlay**: a deterministic function computed by an +observer (`-zslpindex` → `CZSLPIndexer` → `CZSLPStore`) over the +consensus-ordered, confirmed block history. Base consensus knows nothing about +SLP — it relays and mines **any** standard transaction, including an OP_RETURN +that encodes a forged GENESIS or SEND. We can **never** make consensus reject a +token forgery. Therefore token security is not "the chain refuses bad txs"; it +is **determinism + agreement**: an on-chain tx that breaks the overlay rules is +simply *interpreted as crediting nobody (and burning its inputs)*, and **every** +honest observer running the **same canonical rules** computes the **identical** +ledger. The entire attack surface of this threat class is: (a) things the +overlay *cannot* stop because they are valid-by-design uses of an open protocol +(name/ticker/image reuse), which must be defended **socially** and surfaced +**honestly**; and (b) things the overlay *must* stop bit-exactly, where any +cross-implementation disagreement forks the ledger and lets an attacker show +two parties contradictory "ownership." + +--- + +## 1. What "unique" / "authentic" actually guarantees + +Verified against `src/zslp/slp.c`, `src/zslp/zslpindexer.cpp`, +`src/zslp/zslpstore.cpp`. + +### 1.1 What IS guaranteed (cryptographic / deterministic) + +1. **Token-id uniqueness.** `token id == genesis txid` + (`zslpindexer.cpp:229` `parsed.tokenId = txid;`, + `zslpstore.cpp:453` `const uint256 tokenId = txid;`). Txids are globally + unique under consensus, so **a token id can never collide**. Two genesis + transactions are two different tokens, full stop, even if every visible field + (ticker, name, document_url, document_hash, decimals) is byte-identical. + +2. **First-genesis-wins per id is structurally trivial.** Because the id *is* + the txid, a second tx can never re-issue an existing id — it would need to + reproduce a txid, i.e. a hash preimage collision. The `!readToken(tokenId,…)` + guard at `zslpstore.cpp:457` is belt-and-suspenders, not the actual defense. + +3. **Single-unit uncopyability (the NFT property).** An NFT is a baton-less + GENESIS with `decimals=0, qty=1`. The lone unit lives at exactly one token + UTXO `(txid,vout)` (`CZSLPTokenUtxo`, the store's "SOURCE OF TRUTH", + `zslpstore.h:113`). A SEND can only move tokens carried by **spent inputs** + (`availByToken`, `zslpstore.cpp:437-446`), and `availIn >= requiredOut` + gates creation (`zslpstore.cpp:552`). So **the one unit cannot be duplicated, + forged into existence, or moved by anyone but the current UTXO holder.** + Confirmed by `ForgedSendCreditsNobody` + (`test_zslp_indexer.cpp:477-491`) and `OverSendBurnsInputsNoOutputs`. + +4. **Image-bytes provability.** GENESIS carries an optional 32-byte + `document_hash` (`slp.c:90-96`). Anyone holding the bytes can recompute the + hash and prove "**these exact bytes are the ones recorded at genesis.**" This + binds *content* to *id*. (The on-chain field is opaque 32 bytes; the + convention SHA-256(image) is overlay/UI policy, not consensus.) + +### 1.2 What is NOT guaranteed (this is the whole threat class) + +1. **Name / ticker / document_url are NOT unique and NOT authenticated.** + GENESIS imposes **no constraint** on `ticker`/`name`/`document_url` + (`slp.c:66-89` copies whatever is pushed). Anyone can mint a *different* + token (new id) whose name/ticker/image-hash exactly reuse a victim + collection's. The overlay accepts it as a perfectly valid, distinct token. + +2. **Issuer identity is NOT on-chain.** A GENESIS has **no input requirement** + and no signed issuer field — it is valid even with `vin` of unrelated coins. + "Who minted this" is only "which key(s) signed the funding inputs," which the + overlay does not record, does not verify, and which an impersonator can make + look like anything (any address can fund any genesis). + +3. **Same image, different token, is fully legal.** `document_hash` proves the + *bytes* match; it says **nothing** about *who* is entitled to mint those + bytes. An impersonator can mint a new token that reuses the victim's exact + image hash — and the wallet's image-verify badge would (naively) go green, + because the bytes *do* match the (forged) genesis. The green check answers + "do these bytes match THIS token's recorded fingerprint?" — never "is this + the authentic/original token." + +**Bottom line:** uniqueness is at the **token-id (genesis-txid) level only**. +Authenticity of *name/brand/issuer* is a **social** problem with no trustless +on-chain answer on unchanged consensus. The defenses are issuer-identity +conventions and honest UI — Sections 4–6. + +--- + +## 2. Why base consensus cannot stop any of this + +`Solver()` classifies *any* `OP_RETURN ` as `TX_NULL_DATA` and +returns true (`script/standard.cpp:71-73`); `IsStandard` accepts it as long as +it is ≤ `nMaxDatacarrierBytes` (223) and `-datacarrier` is on +(`standard.cpp:197-200`). Consensus has **no notion** of SLP fields, token ids, +issuers, batons, or conservation. It will relay and mine: + +- a GENESIS reusing any name/ticker/image hash, +- a SEND/MINT OP_RETURN that the overlay will deem invalid, +- a transaction that spends and **burns** a token UTXO as ordinary dust. + +None of these can be rejected by a node we do not control (and we have a HARD +CONSTRAINT not to touch consensus). Security must therefore be **interpretive**: +the forgery lands on-chain but **changes no honest observer's ledger** — *if and +only if* every observer computes the same canonical function. That "if" is +Section 3. + +--- + +## 3. CANONICAL VALIDATION SPEC (the agreement contract) + +This is the normative spec the conservation rewrite (and any third-party +indexer/wallet/explorer) MUST implement **bit-exactly**. Any divergence on any +clause **forks the ledger** and lets an attacker present conflicting ownership +to two parties — the core impersonation payoff. Each clause is testable. + +### 3.1 SLP-message location and recognition + +- **C-LOC-1 (vout[0] only — NORMATIVE, currently VIOLATED).** A transaction is + an SLP transaction **iff its `vout[0]` scriptPubKey parses as a valid SLP + message.** Canonical SLP pins the message to output index 0; an SLP-looking + OP_RETURN at any other index does **not** make the tx SLP. + **Current code violates this:** `zslpindexer.cpp:211` scans + `for (vo = 0; vo < tx.vout.size(); ++vo)` and takes the *first* vout that + both is `TX_NULL_DATA` and parses (`:215`, `:223`, "first valid OP_RETURN + wins" `:278`). A tx whose vout[0] is an ordinary payment and whose vout[3] is + an SLP OP_RETURN would be treated as SLP by this indexer but as **non-SLP** + by a canonical implementation → **ledger fork**. **MUST change to: parse only + `vout[0]`; if vout[0] is not a valid SLP message, the tx is non-SLP (inputs + still burn per C-CONS-1).** + +- **C-LOC-2 (one message per tx).** Exactly the vout[0] message governs. No + scanning of later outputs for a "second" message. + +- **C-REC-1 (recognition gate).** vout[0] is a valid SLP message iff, in order: + starts with `OP_RETURN` (0x6a); field0 is a 4-byte push equal to `"SLP\0"`; + field1 (token_type) is a 1–2 byte push decoding to exactly 1; field2 is the + ASCII tx-type token (`"GENESIS"`/`"MINT"`/`"SEND"`); and all + type-specific fields parse per `slp.c`. Any failure ⇒ **not SLP** (return + false, `slp.c:34-166`). The push grammar is `read_push` + (`op_return_push.h:24-46`): only opcodes `0x01..0x4b`, `0x4c` (PUSHDATA1), + `0x4d` (PUSHDATA2) — **anything else (incl. `0x4e` PUSHDATA4, OP_0/OP_1..16, + minimal-push violations) ⇒ not SLP.** This grammar is part of the contract; + do not "fix" it to accept more. + +### 3.2 Field-parse determinism (exact byte rules) + +These mirror `slp.c` and MUST be reproduced exactly: + +- **C-FLD-1 token_type:** push len 1–2, big-endian, must equal 1 else not-SLP + (`slp.c:54-57`). +- **C-FLD-2 GENESIS decimals:** push len exactly 1, value `0..9` else not-SLP + (`slp.c:99-101`). +- **C-FLD-3 GENESIS mint_baton_vout:** push len 0 (no baton) or 1; if 1 the + value MUST be `>= 2` else **not-SLP** (`slp.c:104-109`). Values 0/1 are only + valid as the *empty* push (no baton). +- **C-FLD-4 quantities are 8-byte pushes, big-endian, uint64** + (`slp.c:111-114, 134-137, 149-159`). A quantity push of any length other than + 8 ⇒ not-SLP (GENESIS/MINT) or terminates the SEND list (SEND, see C-SEND-1). +- **C-FLD-5 token_id (MINT/SEND):** push len exactly 32 else not-SLP + (`slp.c:121-124, 144-147`). On-chain bytes are **display/big-endian** and MUST + be reversed to the daemon's internal little-endian uint256 + (`TokenIdToUint256`, `zslpindexer.cpp:147-153`). GENESIS id is the txid, + taken directly (already internal order). +- **C-FLD-6 document_hash:** present iff its push len is exactly 32; any other + length ⇒ treated as absent (`slp.c:91-96`). (For RPC/display the 32 bytes are + reversed, `zslpindexer.cpp:242-245`; this is a display detail, but MUST be + consistent so two implementations show the same hex.) +- **C-FLD-7 oversize text fields:** ticker/name/document_url longer than the + buffer are silently **dropped to empty** (the `len < sizeof(...)` guards, + `slp.c:69, 77, 85`) but the message still parses. This is a determinism trap: + the rule is "store empty if it does not fit," and the buffer sizes + (ticker 64, name 128, document_url 256, `slp.h:44-46`) are part of the + contract. **Pin these sizes; never silently widen them.** + +### 3.3 SEND quantity-list and output mapping + +- **C-SEND-1 list termination.** Read 8-byte quantity pushes for outputs + `vout[1], vout[2], …` until a non-8-byte push or end-of-script; **at least 1 + output quantity is required** else not-SLP (`slp.c:149-160`). The parser caps + at 19 (`< 19`, `slp.c:151`); the indexer/store clamp `n` to `[0,20]` + (`zslpindexer.cpp:265-268`, `zslpstore.cpp:540-542`). **The canonical cap and + the clamp MUST agree** — a SEND with more quantities than the cap must be + handled identically everywhere (current code: parser stops at 19; store would + never see >19 because `numOutputs` is bounded). Pin one number. +- **C-SEND-2 positional mapping.** `outputQuantities[j]` maps to **`vout[1+j]`** + (`zslpstore.cpp:559` `int32_t voutIdx = 1 + j;`). A zero-quantity output + **consumes a slot but creates nothing** (`zslpstore.cpp:557-558`) — the + mapping does NOT compact across zeros. This positional semantics is normative. +- **C-SEND-3 output-index out of range ⇒ that quantity is BURNED.** If + `1+j >= voutCount` the quantity is silently dropped (no UTXO created), + `zslpstore.cpp:560-561`. NOT an invalidation of the whole SEND. +- **C-SEND-4 conservation gate.** Compute `requiredOut = Σ outputQuantities` + with **overflow ⇒ INVALID whole SEND** (`zslpstore.cpp:543-550`), and a + **negative quantity (high-bit-set uint64 read as int64 < 0) ⇒ INVALID** + (`:545`). SEND is valid iff `!overflow && availIn >= requiredOut` + (`:552`). If invalid: create nothing; **all that-token inputs already burned** + (input consume in 3.5 ran regardless). `(availIn - requiredOut)` on a valid + SEND is **burned implicitly** (`:565`). +- **C-SEND-5 unknown-token / no-input ⇒ availIn = 0.** A SEND naming a token + with no token UTXO among the spent inputs has `availIn = 0` + (`zslpstore.cpp:533-535`), so any positive `requiredOut` is invalid ⇒ creates + nothing. This is the forged-SEND defense (`test:477-491`). + +### 3.4 GENESIS and MINT determinism + +- **C-GEN-1 mint output at vout[1].** Initial quantity is created at **vout[1]** + only, and only if `initialQuantity > 0 && voutCount > 1` + (`zslpstore.cpp:474-477`). Hardcoded index 1 is normative. +- **C-GEN-2 baton at declared vout.** A baton is issued iff + `mintBatonVout >= 2 && mintBatonVout < voutCount` + (`zslpstore.cpp:463-466, 479-483`). Out-of-range declared baton ⇒ **no + baton** (and `token.mintBatonVout` display-mirror stays 0). +- **C-MINT-1 baton-input requirement.** A MINT is valid iff a **mint-baton + token UTXO of that token id was on a spent input** + (`batonInputPresent`, `zslpstore.cpp:441-442, 493-494`). No baton input ⇒ + create nothing, inputs stay burned (`test:524-544`). MINT of an unknown token + ⇒ nothing (`zslpstore.cpp:490-491`). +- **C-MINT-2 baton continuation.** New baton at `mintBatonVout` iff + `>=2 && < voutCount` else the baton ends (`zslpstore.cpp:508-510`). Mint + output at vout[1] as in C-GEN-1. +- **C-SUPPLY-1 totalMinted is issued supply, not circulating.** `totalMinted` + is the running sum of genesis+mint quantities (`zslpstore.h:78`, + `:497-500`); it is **not** decreased by burns. Display must not call it + "supply" without the "issued" qualifier (a burned NFT still shows + totalMinted=1). Overflow-guarded add (`:497-499`). + +### 3.5 Universal input-burn and ordering + +- **C-CONS-1 every tx burns the token UTXOs it spends.** For **every** tx (SLP + or not), each spent input that is a known token UTXO is consumed/erased first + (`zslpstore.cpp:437-446`). A non-SLP tx (or an invalid SLP tx) that spends a + token UTXO **burns it** — this is the wallet anti-burn motivation (Section 5). +- **C-ORD-1 intra-block ordering.** Txs are applied in **block order** + (`zslpindexer.cpp:186-187`), each committing its own batch so a later tx in + the same block can spend a UTXO an earlier tx created + (`test:638-644`, store header note `zslpstore.h:313-318`). The undo seq runs + across the whole block (`:243-244`). +- **C-ORD-2 idempotence.** A re-delivered connect for the current tip is a + no-op (`zslpindexer.cpp:180-183`). Catch-up resumes one past the stored tip + (`:99-104`). These keep replay deterministic across restarts/reorgs. +- **C-REORG-1 byte-exact reversal.** DisconnectBlock replays the undo log in + reverse, restoring consumed UTXOs, erasing created ones, reversing + balance/totalMinted/baton changes, yielding a byte-identical pre-state + (`zslpstore.cpp:591-731`, header `:345-352`). Reorg determinism is part of the + agreement contract: two nodes that see the same reorg MUST land on the same + ledger. + +### 3.6 Address/derived-view determinism + +- **C-ADDR-1.** A token UTXO's owner address is `ExtractDestination` of its + scriptPubKey, encoded; non-standard/undecodable ⇒ `""` + (`zslpindexer.cpp:132-142`). A `""`-address UTXO still exists and is still + spendable/burnable (it just has no derived per-address balance row, + `zslpstore.cpp:371` skips empty addresses). The UTXO map — not the balance + view — is the source of truth, so a `""` owner does not lose the token. +- **C-BAL-1.** Per-(token,address) balance is a **derived** view maintained by + signed deltas (`zslpstore.cpp:367-403`); it must always equal the sum of live + token UTXO amounts for that (token,address). Tests must assert this invariant. + +> **Determinism test obligation:** every clause above needs a gtest that pins +> the exact behavior, **especially C-LOC-1** (vout[0]-only), which the current +> indexer violates. A cross-implementation "ledger digest" test (hash of the +> full token/UTXO/balance set at a height) is the strongest agreement check. + +--- + +## 4. Issuer identity & authenticity — defenses that need NO consensus + +None of these change consensus. They establish "**who** minted this and **is it +the brand I trust**," which the chain cannot answer. + +### 4.1 Genesis-txid fingerprint (the primary identity) + +The only trustless, collision-free identity is the **token id = genesis txid**. +A creator publishes their token id out-of-band, against a source the user +already trusts (the brand's own https site, a signed social post, a printed +card). The user/wallet then verifies the on-chain token *is that exact id*. +This is centralized **only** in "you must already trust where you got the id" — +there is no trustless way around that, and pretending otherwise is the core +dishonesty to avoid. + +### 4.2 Signed issuer attestations (cryptographic, off-chain) + +A creator proves control of an identity by **signing a message** that binds the +token id to a public identity, using either: + +- **the genesis funding key** — sign `"ZSLP-ISSUER:" + tokenid` with the + private key of an input that funded the genesis (or any address the creator + publicly claims). ZClassic already has `signmessage`/`verifymessage` + (transparent ECDSA message signing in the wallet/RPC). The attestation is: + *"address A, which I publicly own, signed this token id."* It is only as + strong as the public's belief that A belongs to the brand — but it is a real, + verifiable cryptographic statement and needs no consensus. +- **a published brand key** — the brand publishes a long-lived pubkey on its + trusted channel and signs each legitimate token id with it. Now the user + verifies one well-known key instead of trusting an id in isolation. + +Requirements: define a **canonical attestation string format** (exact bytes, +so signatures are portable) and a **verify path** in the wallet/RPC that takes +(tokenid, address/pubkey, signature) → valid/invalid. This is pure +sign/verify; no consensus, no new on-chain data required (the attestation can +live entirely off-chain or, optionally, in a later GENESIS document_url). + +### 4.3 Verified-issuer / allowlist (curated, explicitly centralized) + +A wallet ships (or fetches from a configurable, signed source) a **curated map +`{tokenid → display name, verified bool}`**. A token id in the list earns a +"Verified issuer" badge; everything else is "Unverified." This is **honest +centralization**: the badge means "this id is on a list maintained by ," never "the protocol guarantees authenticity." The list MUST be: +keyed by token id (never by name/ticker), versioned, and its provenance shown +to the user. ENABLEMENT.md already states this is the only answer and is +centralized (`doc/nft/ENABLEMENT.md:85, 133`). + +### 4.4 NFT1 group/child for set authenticity + +Set membership ("is this card part of the official Curio set?") is **not** +on-chain-authenticated by name. The defense is the **NFT1 group/child** +pattern: a single **group genesis** (its token id is the set's identity), and +each child mint is tied to the group by spending a **group baton/quantity input** +— so a child's membership is provable via the same UTXO-conservation the overlay +already enforces (C-MINT-1 / C-CONS-1). A child that merely *claims* the group +name without spending a real group input is, by conservation, **not a member**. +This converts "set authenticity" from an unforgeable-name problem (impossible) +into a baton-input problem (already solved deterministically). **Requirement:** +the group/child binding rule must be added to the canonical spec (Section 3) +with its own determinism tests **before** the GUI shows any "part of set X" +claim as authoritative; until then the GUI must label set membership as +issuer-asserted, not verified. + +--- + +## 5. Wallet anti-burn requirements (holder-side, non-consensus) + +Verified: **the wallet has ZERO ZSLP awareness today** — `grep -ril zslp +src/wallet/` returns nothing. Consequence: ordinary coin selection can spend a +token-carrying dust UTXO as a fee or change input and **silently BURN the NFT** +(C-CONS-1 burns any spent token UTXO). This is the single highest-severity +*holder-side* loss in this threat class and it has nothing to do with +attackers — the holder's own wallet destroys the asset. + +Requirements (all non-consensus, wallet-local): + +- **W-ANTIBURN-1.** Coin selection MUST identify token-carrying UTXOs (query + the store by `(txid,vout)` via `GetUtxo`, or an exported set) and **exclude + them from normal/auto coin selection** for ordinary ZCL sends, change, and + fee funding. A token UTXO is spent only by an explicit token operation. +- **W-ANTIBURN-2.** Token UTXOs MUST be surfaced in **coin control** so the + user can see and (with an explicit, warned action) spend them. +- **W-ANTIBURN-3.** Any path that *would* spend a token UTXO (manual coin + control, sweep, "send max") MUST show a **burn warning** naming the token and + requiring explicit confirm. Default-deny. +- **W-ANTIBURN-4.** The exclusion MUST be robust when `-zslpindex` is **off**: + if the wallet cannot consult the store, it MUST fall back to a conservative + rule (e.g. treat protocol-dust outputs of the wallet's own token txs as + unspendable-by-default, or refuse "send max" with a calm "token index off — + cannot guarantee your collectibles are protected" note) rather than silently + risking a burn. +- **W-ANTIBURN-5.** Sending an NFT MUST construct the SLP SEND with vout[0] = + the OP_RETURN (per C-LOC-1) and the unit's quantity at the correct positional + output (C-SEND-2), so the wallet never accidentally produces a tx the + canonical indexer reads as non-SLP (which would burn the unit). + +--- + +## 6. GUI honesty requirements (presentation of uniqueness/authenticity) + +The GUI is where impersonation is won or lost socially. NATIVE_UX.md already +defines a verify badge and a banned-jargon list; these requirements pin the +**honesty semantics** for the impersonation-uniqueness threat class +specifically. + +- **G-HONEST-1 (the green check's exact meaning).** The image "Genuine" badge + (NATIVE_UX.md §2.2) means **only** "these bytes match the fingerprint recorded + in THIS token's genesis." It MUST NOT be presented as "authentic," "official," + or "the original." Two different tokens can both show green for the same image. + Copy MUST be "matches its on-chain fingerprint," never "authentic." +- **G-HONEST-2 (identity is the id, never the name).** Every NFT/collection + detail MUST show the **token id (genesis txid)** as the identity, copyable, + and MUST state that name/ticker are **not unique** — "anyone can create a + collectible with this name." (Mirrors ENABLEMENT.md:98,133.) +- **G-HONEST-3 (impersonation warning on lookalikes).** When the wallet sees + two distinct token ids sharing a name/ticker/image-hash, it MUST surface a + **collision indicator** ("Another collectible uses this name/image") rather + than silently picking one. Never resolve a name to a single token implicitly. +- **G-HONEST-4 (verified-issuer badge is explicitly sourced).** A "Verified + issuer" badge (Section 4.3) MUST name its source and MUST be visually distinct + from the image-match badge (they answer different questions). Absence of the + badge MUST read "Unverified issuer," never "fake." +- **G-HONEST-5 (no name-based search-to-action).** Send/buy flows MUST resolve + the target by **token id**, not by user-typed name. A name search MAY help + discovery but the user MUST confirm the **id** before any value action. +- **G-HONEST-6 (set membership honesty).** Until NFT1 group/child verification + (Section 4.4) is in the canonical spec + tested, "part of set X" MUST be shown + as **issuer-claimed**, not verified. +- **G-HONEST-7 (index-off honesty).** With `-zslpindex` off, provenance/verify + fields MUST degrade to a calm "can't verify right now — token index is off," + never a false green or a crash (matches NATIVE_UX index-off states). A + wallet-local cached fingerprint MAY be checked, but the badge MUST indicate it + was not cross-checked against the live ledger. + +--- + +## 7. Severity-ranked threat table (summary) + +| # | Attack | Overlay verdict | Severity | +|---|--------|-----------------|----------| +| T1 | Forged SEND/MINT crediting attacker without holding the token/baton input | **Neutralized** — credits nobody, burns inputs (C-SEND-5, C-MINT-1; `test:477-544`) | info (already solved) | +| T2 | **vout[0]-position parse divergence** — indexer scans any vout, not vout[0] (`zslpindexer.cpp:211`) | **NOT yet neutralized** — cross-impl ledger fork → conflicting ownership | **critical** | +| T3 | Impersonation token reusing victim name/ticker/image-hash (new id) | **Cannot be neutralized on-chain** (valid by design) → must be defended socially + UI (Sec 4,6) | high | +| T4 | Wallet burns a token UTXO as fee/change (zero wallet awareness) | **Not neutralized by overlay** (C-CONS-1 burns it) → wallet anti-burn required (Sec 5) | **critical** (holder loss) | +| T5 | Determinism edge cases: overflow, neg qty, out-of-range vout, >cap outputs, oversize fields, token_type≠1, baton<2, push-grammar | **Neutralized iff bit-exact** across implementations (Sec 3.2-3.4) | high (each is a fork risk) | +| T6 | "Same image ⇒ authentic" confusion in UI | Mitigated only by honest copy (G-HONEST-1) | high (social) | +| T7 | Name-resolves-to-one-token UI shortcut → silent impersonation | Mitigated by id-not-name actions (G-HONEST-3/5) | high (social) | +| T8 | Set/group spoof by name | Mitigated by NFT1 group/child + tests (Sec 4.4); honest label until then (G-HONEST-6) | medium | +| T9 | Reorg replay divergence between nodes | Neutralized iff byte-exact reversal (C-REORG-1) | medium (fork risk) | + +--- + +## 8. Requirements checklist (testable) + +**Canonical spec / conservation rewrite MUST satisfy:** + +- [ ] **R1 (fixes T2, critical):** SLP message parsed from **vout[0] ONLY**; + vout[0] not-SLP ⇒ tx is non-SLP (inputs still burn). gtest: SLP OP_RETURN at + vout[3] with a payment at vout[0] ⇒ token effect = NONE. +- [ ] **R2:** token_type must equal 1; push-grammar limited to 0x01..0x4b / + 0x4c / 0x4d; reject 0x4e/opcodes/non-minimal as not-SLP. gtest per case. +- [ ] **R3:** GENESIS decimals ∈ 0..9 (len 1); baton push len 0 or 1-with-value≥2; + else not-SLP. gtest both rejections. +- [ ] **R4:** quantities are 8-byte BE uint64; SEND list terminates on + non-8-byte push; ≥1 quantity required; cap pinned and identical in parser + + store. gtest >cap and 0-quantity-list. +- [ ] **R5:** SEND conservation: `requiredOut` overflow ⇒ invalid; negative qty + ⇒ invalid; valid iff `availIn>=requiredOut`; surplus burned; positional + `j→vout[1+j]`; out-of-range output qty burned (not whole-tx invalid). gtests + for each (extend `OverSendBurnsInputsNoOutputs`). +- [ ] **R6:** GENESIS mint at vout[1] only; baton iff `2≤vout tokenOuts; // canonical order: maps qty j -> vout[1+j] + std::vector tokenInputs; // token/baton UTXOs to FORCE-include + // GENESIS: empty; MINT: the baton; SEND: chosen token UTXOs + uint256 selfValidateTokenId; // tokenId the built tx must conserve (0 for GENESIS) +}; + +bool BuildAndCommitZSLP(CWallet* w, const ZSLPBuildReq& req, + CWalletTx& wtxOut, std::string& err); // returns false on any failure +``` + +### 2.2 vout[0] pinning — recommended strategy (build canonical, sign last) + +Do **not** trust `CreateTransaction`'s ordering. Construct a `CMutableTransaction` by hand with a +**fixed** layout, fund the fee deterministically, and sign last: + +``` +vout[0] = CTxOut(0, opret) // OP_RETURN, value 0 +vout[1..k] = CTxOut(SLP_TOKEN_DUST, tokenOuts[i].dest) // token quantity outputs (canonical order) +vout[k+1] (opt) = CTxOut(SLP_TOKEN_DUST, ownChange) // SEND token-change (surplus), if any +[ZCL change] = appended at the TAIL, never index 0 // ordinary P2PKH change +``` + +Funding + signing, replicating the proven internals: + +1. **Coin selection for fee + dust:** run a `CreateTransaction` pass with `sign=false` (its 8th/9th + params, `wallet.h:1242-1244`) to obtain coin selection, `nFeeRet`, and the change script/amount — + **or** call `SelectCoins` directly. Either way pin token inputs and exclude all token UTXOs from + the auto-pool (§2.4). +2. **Assemble** the `CMutableTransaction` in the canonical order above; append ZCL change LAST. +3. **Sign each input yourself**, mirroring `wallet.cpp:3712-3737` exactly: + `auto consensusBranchId = CurrentEpochBranchId(chainActive.Height()+1, Params().GetConsensus());` + then per input + `ProduceSignature(TransactionSignatureCreator(w, &txConst, nIn, prevValue, SigHashType()), + scriptPubKey, sigdata, consensusBranchId); UpdateTransaction(txNew, nIn, sigdata);` + The OP_RETURN output needs no signing. Re-signing is mandatory because moving outputs changes the + sighash (`risk #2`). **Why build-then-sign and not reorder-after-sign:** moving outputs after + signing invalidates every signature, so the controlled layout MUST be finalized before signing. + +This guarantees `vout[0]==OP_RETURN` deterministically and preserves the positional `qty j -> vout[1+j]` +mapping the store relies on (`zslpstore.cpp:553-563`). + +> If a maintainer prefers to reuse `CreateTransaction` wholesale, the only safe fallback is: call it, +> then if `wtx.vout[0]` is not the OP_RETURN, **rebuild + re-sign** in canonical order. This is the +> same work as the recommended path with extra fragility; prefer the controlled build. + +### 2.3 Dust value (exact, load-bearing) + +`CTxOut::GetDustThreshold = 3 * minRelayTxFee.GetFee(serializeSize + 148)` +(`src/primitives/transaction.h:452-467`). `minRelayTxFee = CFeeRate(DEFAULT_MIN_RELAY_TX_FEE=100)` +(`src/main.h:64`, `src/main.cpp:98`). For a 34-byte P2PKH output: +`3 * 100 * (34+148)/1000 = 3 * 18 = 54 sat`. The code comment at `transaction.h:460` says exactly +"dust is a spendable txout less than 54 satoshis with default minRelayTxFee". + +- **Each token output MUST be ≥ 54 sat.** Use **`SLP_TOKEN_DUST = 546 sat`** (standard SLP/BCH + convention; ~10× over the 54-sat floor; survives a `-minrelaytxfee` bump and `CreateTransaction`'s + change-raising). The **1-sat NFT prose** in some earlier notes is illustrative only — 1 sat is + BELOW 54 and would be rejected as `dust` (`main.cpp:771`). Surface the per-output 546 sat as real + ZCL spent in the GUI fee preview. +- **The OP_RETURN carries `nValue=0`** and is dust-exempt: `IsUnspendable()` ⇒ `GetDustThreshold` + returns 0 (`transaction.h:462-463`), and `IsStandardTx` skips the dust branch for `TX_NULL_DATA` + (`main.cpp:766-771`). Keep every recipient `fSubtractFeeFromAmount=false` so the 0-value + OP_RETURN and the 546-sat outputs are never shaved (`wallet.cpp:3556-3566`). + +### 2.4 Anti-burn funding selection (R-WALLET-1..3, task #108) + +Funding for fee + dust must **never** consume a token UTXO or a baton. + +- **Pin intended inputs:** `CCoinControl cc; cc.fAllowOtherInputs = true;` and `cc.Select(outpoint)` + for each `req.tokenInputs` entry. `SelectCoins` force-includes the preset set and lets it add plain + coins for fee/change (`wallet.cpp:3383-3419` preset-input path; `coincontrol.h` `Select`/ + `fAllowOtherInputs`). +- **Exclude every other token UTXO from the auto-pool.** `AvailableCoins` is the auto-source and + honors `IsLockedCoin` (`wallet.cpp:3151-3184`, lock-skip at 3180). Two coordinated mechanisms: + - the task #108 `AvailableCoins` filter that consults `g_zslpIndexer->store->GetUtxo()` and drops + any live token/baton outpoint, **and** + - belt-and-suspenders: `LockCoin` every wallet token/baton outpoint for the duration of the build + (`wallet.cpp:4345`), released via an **RAII guard even on exception** (a failed build must not + leave the user's tokens locked, `risk #6`). +- **Defensive post-check:** before signing, assert no selected input is a token UTXO of a *different* + token or a baton (`store->GetUtxo` miss for funding inputs); abort otherwise (R-WALLET-3). +- **Fail CLOSED if `-zslpindex` is off** (R-WALLET-6): without the store the wallet cannot classify + dust → refuse to spend sub-threshold dust rather than risk a burn. GENESIS funding still needs the + store to protect *other* tokens' dust in the same wallet. + +### 2.5 SEND conservation + input enumeration (the read gap) + +The store exposes `GetUtxo(txid,vout)` and `GetTokensForAddress` (balances, **not** outpoints) +(`zslpstore.h:357,366-367`) — there is **no by-token UTXO enumeration**. Fill it the +wallet-correct way (also the anti-burn source of truth): **intersect `CWallet::AvailableCoins()` with +`store->GetUtxo()`** — for each spendable `COutput`, call `store->GetUtxo(coin.txid, coin.i, rec)`; +keep those with `rec.tokenId == target && !rec.isMintBaton && rec.amount > 0 && IsMine`. (Optionally +also add a `CZSLPStore::ListUtxosForToken` enumerator over the `'u'` key-space for non-wallet uses, +but the intersection is sufficient and correct for the builder.) + +Selection + conservation (mirrors `zslpstore.cpp:531-569`): +- Greedily accumulate token inputs (deterministic order: sort by `(height, txid, vout)`) until + `availIn = Σ input qty ≥ requiredOut = Σ output qty`. If short → fail "insufficient token balance". +- `tokenChange = availIn - requiredOut`; if `>0`, append a token-change output to the sender's own + fresh t-addr and add its quantity to the `slp_build_send` quantities array. **Surplus is NEVER + silently burned** (matches the implicit burn at `zslpstore.cpp:565`). +- Spend **only** the chosen token inputs. +- Quantities are uint64 BE; **reject any qty with the high bit set (≥ 2^63)** before encoding — the + store treats negative int64 as INVALID (`zslpstore.cpp:545-546`). +- **SEND ≤ 19 outputs** (`slp_build_send` returns 0 if `num_outputs<1||>19`, `slp.c:270`); + token-change counts toward the 19. Enforce `recipients + (tokenChange?1:0) ≤ 19`. + +### 2.6 Relay-size budget (≤ 223 bytes) + +Every builder MUST (a) treat a `slp_build_*` return of `0` as "metadata too large / limit exceeded" +(it returns 0 on buffer overflow, `slp.c:233/263/291`) and (b) assert the produced script length +≤ `MAX_OP_RETURN_RELAY = 223` (`standard.h:34`) before adding it. Computed payload sizes (§6) prove +MINT and every SEND always fit; only GENESIS with long ticker/name/url can exceed — reject pre-build +and keep `document_url` short (the 32-byte `document_hash` is the real anchor). + +--- + +## 3. `zslp_genesis` — mint RPC + +New wallet-gated RPC under `ENABLE_WALLET`, registered in a new `src/wallet/rpcwalletzslp.cpp` (or in +`src/rpc/zslp.cpp`'s `commands[]`, `zslp.cpp:263-270`), category `"zslp"`, `okSafeMode=false`. It is +the canonical NFT mint path and the write counterpart to the read RPCs. + +``` +zslp_genesis '{ "ticker"?, "name"?, "document_url"?, "document_hash"?(64-hex/32B), + "decimals"?(0..9, default 0), "quantity"(string|num, default 1 for nft), + "mint_baton_vout"?(0/1=none, >=2=baton), "to"?(t-addr), "nft"?(bool) }' + -> { "txid": "", "tokenid": "" } // tokenid == txid (zslpindexer.cpp:229) +``` + +**Validation (throw `RPC_INVALID_PARAMETER` before building anything):** +1. `decimals ∈ [0,9]`. +2. `quantity` parsed from a **string** to uint64 (JSON doubles lose precision above 2^53); **reject + high bit**: `if (q >> 63) throw "quantity exceeds 2^63-1"`. Land this on the WRITE side + regardless of any pending read-side guard (`SECURITY_MODEL.md` R-INT-1). +3. `nft=true` (or the GUI "Create NFT" flow): force `decimals=0, quantity=1, mint_baton_vout<2`; + reject conflicting explicit values → a 1-of-1, non-reissuable, indivisible token. +4. `document_hash`: if non-empty, exactly 64 hex → 32 raw bytes via `ParseHex`; pass those raw bytes + straight to `slp_build_genesis` (**not reversed** — the indexer reverses only for *display*, + `zslpindexer.cpp:242-245`, so round-trip `GUI-hash-hex == gettoken.documenthash`). Empty ⇒ empty + push. +5. ticker/name/document_url byte caps + the §2.6 relay-size pre-check. +6. `mint_baton_vout` 0/1 (none) or `[2, voutCount)`; absent for the NFT preset. + +**Build (via §2 builder):** `opret = ZSLPBuildGenesis(...)`; +`tokenOuts = [{P2PKH(to or fresh key), 546}]` carrying qty at vout[1] (`zslpstore.cpp:474-477`); +if a baton, the builder also places a 546-sat baton output at its declared vout +(`zslpstore.cpp:479-483`). `tokenInputs` empty (genesis has no token inputs). Fund + sign + self- +validate (§4.3) + `CommitTransaction`. + +**Examples:** +- NFT: `zslp_genesis '{"nft":true,"name":"My Photo #1","document_url":"ipfs://...","document_hash":"<64hex>"}'` +- Fungible w/ baton: `zslp_genesis '{"ticker":"GOLD","name":"Gold Coin","decimals":2,"quantity":"100000","mint_baton_vout":2}'` + +`zslp_mint tokenid amount [batonvout]` mirrors this for fungible re-issue (requires spending the live +baton UTXO as a pinned input; look it up via the §2.5 intersection). **NFTs never use MINT.** + +--- + +## 4. `zslp_send` — secure transfer RPC with self-validation + +``` +zslp_send "tokenid" "to_address" ( amount change_address ) -> { "txid": "" } + // POSITIONAL args only (verified src/rpc/zslp.cpp:551): + // "tokenid" (string, required) + // "to_address" (string, required) single recipient t-address + // amount (numeric/string, optional, default 1) + // change_address (string, optional) token-change t-addr (default: fresh own) + // NFT gift: amount defaults to 1; single recipient, qty 1. + // NOTE: there is NO {"taddr": amount, ...} JSON-map / multi-recipient form + // on this RPC — it takes one recipient. (The builder CAN emit up to 19 + // token outputs; that multi-output path is not exposed via this RPC's args.) +``` + +### 4.1 Algorithm (under `LOCK2(cs_main, cs_wallet)`) +> **RPC vs builder scope.** The shipped `zslp_send` RPC takes ONE positional recipient +> (`"tokenid" "to_address" ( amount change_address )`). The algorithm below describes the +> underlying `BuildAndCommitZSLP` *builder*, which is multi-output-capable (up to 19 SEND +> outputs); the single-recipient RPC is the only arg surface that exposes it today. + +1. Validate intent: `1 ≤ recipients ≤ 18` (reserve one of the 19 SEND slots for token-change); each + `qty > 0` and `< 2^63`. (Via the RPC, `recipients == 1`.) +2. Enumerate the wallet's token UTXOs of `tokenid` via the §2.5 intersection; greedily select until + `availIn ≥ requiredOut`; compute `tokenChange`. +3. `opret = ZSLPBuildSend(tokenIdBE, quantities=[recip qtys..., (tokenChange?)], n)` — convert + `tokenId` to on-chain **BE** order (inverse of `TokenIdToUint256`, `zslpindexer.cpp:147-153/256`). +4. `tokenOuts = [{P2PKH(recip_i), 546}...]` + (if `tokenChange>0`) `{P2PKH(ownFresh), 546}`. + `tokenInputs = the chosen token UTXOs`. Fund (anti-burn) + sign (§2.2). +5. **Self-validate (§4.3); refuse to broadcast on any failure.** Then `CommitTransaction`. + +### 4.2 NFT transfer (the common case) +`vin = [the NFT's 546-sat token UTXO + anti-burn-filtered fee coins]`; +`vout[0] = slp_build_send(tokenid, [1], 1)`; `vout[1] = 546 sat → recipient`; +ZCL change at the tail. `availIn(1) == requiredOut(1)` ⇒ no token-change. + +### 4.3 R-WALLET-9 self-validate-before-broadcast (non-negotiable) + +After the tx is fully built **and signed**, before `CommitTransaction`, run it through the **same +parse + conservation logic the indexer uses** so the wallet computes the identical ledger result. +**Do NOT call `CZSLPStore::ApplyTransaction`** — it WRITES leveldb (`zslpstore.cpp:579`). Add a pure +read-only predicate, e.g. `bool CZSLPStore::WouldBeValid(const CTransaction& tx, std::string& reason) +const` (or a free `ZSLPValidateBuiltTx`), factored to share literally the indexer's steps: + +1. **Parse vout[0]:** `Solver(vout[0].scriptPubKey) == TX_NULL_DATA` (mirror + `zslpindexer.cpp:211-216`) then `ZSLPParseScript` (`zslpmsg.h:63`) yields the expected + GENESIS/MINT/SEND with the intended fields. Assert the message is at **vout[0]** and is the only + OP_RETURN. +2. **Recompute availIn** read-only via `store->GetUtxo` over each `tx.vin` (mirror + `zslpstore.cpp:437-445`: batons and non-token inputs contribute 0). +3. **Conservation:** `requiredOut = Σ output quantities` with the SAME overflow guard + (`zslpstore.cpp:537-552`); assert `!overflow && availIn ≥ requiredOut`; reject any qty ≥ 2^63. +4. **Layout:** qty outputs map to `vout[1..n]` and exist; baton (if any) at its declared vout + `< voutCount`; every non-OP_RETURN output `!IsDust`; SEND `≤ 19` outputs. +5. **Belt-and-suspenders anti-burn:** no selected `vin` is a token UTXO of a *different* token or a + baton (R-WALLET-3). + +Factor steps 1–3 into ONE shared function used by both the indexer and the builder so a divergence +can never make the wallet broadcast a tx the ledger would burn (SECURITY = DETERMINISM + AGREEMENT). +Ship a gtest that builds a SEND, feeds it through the indexer, and asserts the recipient is credited +and supply is unchanged (§7). + +> **Coupled prerequisite:** the live indexer currently scans *all* vouts for the first parsable +> OP_RETURN (`zslpindexer.cpp:211`, "first valid wins" at 277-278), but `CANONICAL_VALIDATION_SPEC.md` +> R1 mandates **vout[0]-only**. The builder places the OP_RETURN at vout[0] regardless (future-proof); +> flag the indexer R1 tightening as a coupled change so a future-correct node and this builder agree. + +--- + +## 5. Native mint + transfer UX (honest about public + irreversible) + +**Mint** (`NATIVE_UX.md` §3.3): drag any file → the content engine **stream-hashes** it +(SHA-256 = `document_hash`, on a worker thread) and detects type → name / collection / ticker → +**PRIVATE (default)** vs **PUBLIC** tiles → **Review** screen that shows BOTH the network fee (from a +`sign=false` dry-run `nFeeRet`) AND **exactly** what becomes public — in plain language: +> "These bytes go on the public blockchain forever: ``, ``, ``, +> and the file fingerprint ``. The file itself stays off-chain; only its fingerprint +> is recorded." → **Create** calls `zslp_genesis`. + +- **PUBLIC** = also publish the bytes + URL to a pin/host; **PRIVATE** = only the 32-byte hash on + chain, bytes kept local. Never imply the file is private when only its hash is published. +- The Public tile may stay disabled ("Coming in this release") until these RPCs ship; a private NFT + can ship first via shielded memo (no consensus RPC needed). + +**Transfer / gift** (`NATIVE_UX.md` §3.4): open NFT → Send/Gift → pick recipient t-addr → confirm +screen states the token, recipient, the 546-sat dust + network fee, and **"This is public and +irreversible — a send to the wrong address cannot be undone"** → `zslp_send "tokenid" "addr" 1` +(positional args; see §4). +Show PENDING until the confirmation depth required by the reorg policy (`REORG_CONFIRMATION_*`). + +--- + +## 6. OLD-CONSENSUS PROOF (unchanged nodes relay + mine; consensus untouched) + +A mint/transfer tx is **structurally an ordinary payment**: normal P2PKH/P2SH inputs and outputs, +normal P2PKH change, plus **exactly one** `TX_NULL_DATA` OP_RETURN at vout[0]. Walking the actual +standardness path on an unmodified node: + +1. **Output classification — `src/script/standard.cpp:71-73`:** any `OP_RETURN ` is + classified `TX_NULL_DATA` ("So long as script passes IsUnspendable() and all but the first byte + passes IsPushOnly() we don't care what exactly is in the script"). The node **never parses SLP**; + the SLP bytes are opaque pushdata. +2. **Datacarrier RELAY gate — `src/script/standard.cpp:197-199`:** a `TX_NULL_DATA` output is + rejected only if `!GetBoolArg("-datacarrier", true)` (default **true**) **or** + `scriptPubKey.size() > nMaxDatacarrierBytes`. `nMaxDatacarrierBytes = MAX_OP_RETURN_RELAY = 223` + (`standard.cpp:19`, `standard.h:34`), overridable by `-datacarriersize` (`init.cpp:553,1833`). + These are **RELAY policy** (`-datacarrier`/`-datacarriersize`, defaults printed at + `init.cpp:552-553`), living in `IsStandard`/`IsStandardTx` — **never** in `CheckTransaction`/ + `ConnectBlock`. +3. **Per-tx standardness — `src/main.cpp:714-784`:** every vout must pass `::IsStandard` + (`main.cpp:761`); the OP_RETURN is counted (`766-767`) and **exempt from the dust check** (the + dust test is in the `else if` branch, so `TX_NULL_DATA` skips it, `766-774`); the 546-sat token + outputs clear the 54-sat dust floor (`771`); **exactly one OP_RETURN** is allowed + (`nDataOut > 1 ⇒ "multi-op-return"`, `778-781`). We emit exactly one, at vout[0]. +4. **Mempool admission — `src/main.cpp:1466`:** `Params().RequireStandard() && !IsStandardTx(...)` + is the ONLY standardness gate; pass it and the tx relays. Miners pull from the same mempool and + include it as an ordinary fee-paying tx; they never interpret SLP. **Consensus + (`CheckTransaction`/`ConnectBlock`) is untouched** — ZSLP adds/changes no consensus rule. + +**Plain statement:** an old node will happily relay/mine an SLP tx the overlay deems INVALID; that tx +simply burns its tokens in the overlay (`zslpstore.cpp:565-568`). Validity is an **overlay +convention**, not a new rule old nodes must learn — which is exactly why the builder must +self-validate (R-WALLET-9) before broadcast. + +**Payload sizes vs the 223-byte limit** (from `slp.c` encoders + `op_return_push.h` push sizing): +- **SEND, 19 outputs (max):** `1(OP_RETURN) + 5(push4 LOKAD) + 2(push1 type) + 5(push4 "SEND") + + 33(push32 tokenid) + 19×9(push8 qty) = 217 ≤ 223` ✓. SEND-1 = 55 B, SEND-2 (recip + change) = 64 B. +- **MINT:** `1 + 5 + 2 + 5(push4 "MINT") + 33 + 2(empty baton) + 9 ≈ 57 B` ✓ (always fits). +- **GENESIS fixed overhead** (`OP_RETURN + push4 LOKAD + push1 type + push7 "GENESIS" + push32 hash + + push1 decimals + baton + push8 qty`) ≈ **62–68 B**, leaving ≈ **153–161 B** for + ticker+name+document_url **including their push opcodes**. A long name/url **can exceed 223** → + the builder rejects pre-broadcast (`slp_build_genesis` returns 0, plus the explicit length check), + and the GUI keeps fields short or pushes large metadata off-chain (`CONTENT_MODEL.md`). MINT and + every SEND **always** fit. + +**Dust the old node accepts:** ≥ 54 sat (computed in §2.3 from `transaction.h:452-467`, +`main.h:64`, `main.cpp:98`); we use 546. The 0-value OP_RETURN is dust-exempt. + +--- + +## 7. Implementation order + gtest/QA plan + +**Order:** +1. Land the **anti-burn** `AvailableCoins` filter + `listunspent` annotation (task #108, + R-WALLET-1..3) — prerequisite so funding can never auto-pick a token/baton. +2. Add the C++↔C **builder bridge** `ZSLPBuild{Genesis,Mint,Send}` in `zslpmsg.{h,cpp}` (wraps the + existing `slp_build_*`; treats return 0 as error; asserts ≤ 223). +3. Add the read-only **`CZSLPStore::WouldBeValid`** predicate (shares indexer parse + conservation). +4. Add the **shared builder** `BuildAndCommitZSLP` in `src/wallet/zslpwallet.{h,cpp}` (canonical + vout order, anti-burn funding via `CCoinControl` + `LockCoin`-RAII, manual sign, self-validate, + commit). +5. Add the **`zslp_genesis` / `zslp_send` / `zslp_mint`** RPCs (thin shells → the one builder); + register beside the read commands. +6. Tighten the indexer to **vout[0]-only** (R1 coupled prerequisite) and wire the **GUI** flows. + +**gtest / QA:** +- **Builder→indexer round-trip:** build an NFT GENESIS; feed the raw tx through the indexer; assert a + token row with `tokenId==txid`, `totalMinted==1`, a qty-1 UTXO at vout[1], no baton. +- **NFT genesis → send → ownership moves:** mint to A; `zslp_send` to B; index both; assert B owns + qty 1, A owns 0, supply unchanged, and the OP_RETURN is at vout[0] in each tx. +- **Conservation + token-change:** SEND a 7-of-10 token holding; assert recipient gets the requested + qty and a 3-qty token-change output returns to the sender (no burn). +- **Self-validation rejects malformed:** construct a SEND with `Σ out > Σ in`, or qty ≥ 2^63, or + >19 outputs, or OP_RETURN not at vout[0]; assert `WouldBeValid` returns false and the builder + refuses to broadcast. +- **Anti-burn:** 1000 randomized ordinary sends never select the NFT outpoint; a tx that would route + the NFT to fee fails with a token-protection error, not a burn (R-WALLET-2/3/11). +- **Relay-size:** GENESIS with an over-long name/url is rejected pre-broadcast with a clear + "metadata too large for one OP_RETURN (max 223 bytes)" error; SEND@19 = 217 B accepted. +- **Standardness:** assert a built mint/send passes `IsStandardTx` at the current tip height and is + accepted to a local mempool with default policy. +- **`-zslpindex` off fail-safe:** sub-threshold-dust spend blocked/routed-around, never a silent burn. + +--- + +## 8. Honest limits + +- **Overlay validity is a convention, not consensus.** Old nodes relay/mine SLP-INVALID txs; the + overlay burns them. The self-validate gate (R-WALLET-9) is the only thing standing between a buggy + builder and a real burn. +- **Anti-burn is a dependency.** Until task #108's `AvailableCoins` filter lands, the builder must + carry its own `CCoinControl` + `LockCoin` fence; the global filter is the durable fix. +- **Indexer R1 (vout[0]-only) is a coupled change.** The builder is future-proof, but a fully + spec-compliant indexer must also enforce vout[0]-only so every observer agrees. +- **Relay policy ≠ consensus, but operators matter.** A node run with `-datacarrier=0` or a tiny + `-datacarriersize` won't *relay* these; any miner on defaults still includes them. Document that + operators keep defaults. +- **Metadata size is tight.** Rich NFT metadata must live off-chain; only the 32-byte hash + a short + URL are on-chain (`CONTENT_MODEL.md`). +- **Irreversibility + privacy.** A send to a wrong address is unrecoverable; the OP_RETURN publicly + reveals every mint/transfer and the `document_hash` forever. The confirm-gate copy and the honest + review screen are the only user protections. +- **Dust floor can drift.** 546 sat gives ~10× headroom over today's 54-sat floor; if + `-minrelaytxfee` is raised network-wide, recompute `GetDustThreshold` dynamically rather than + trusting the constant. diff --git a/doc/nft/NATIVE_NFT_GUIDE.md b/doc/nft/NATIVE_NFT_GUIDE.md new file mode 100644 index 00000000000..12fca8b0919 --- /dev/null +++ b/doc/nft/NATIVE_NFT_GUIDE.md @@ -0,0 +1,805 @@ +# ZClassic Native NFTs — The Build-Ready Guide (START HERE) + +*The single, canonical, build-ready document for native (no-browser) NFTs on ZClassic: +what you can do, how every screen is built, how privacy works, and the rules nothing may +break. It is status-accurate against the code on `feature/zslp-nft-indexer` (the branch that +actually carries ZSLP — `feature/native-bootstrap-sync-review` has none of it), not +aspirational. Every file:line citation below is against `feature/zslp-nft-indexer`. NOTE: the +entire write path (mint/transfer RPCs, the tx builder, and the AvailableCoins anti-burn +exclusion) currently lives in the **working tree** of that branch and is **not yet committed** +— `git status` shows `M src/rpc/zslp.cpp`, `M src/wallet/wallet.{cpp,h}`, and untracked +`?? src/wallet/zslpwallet.{h,cpp}`. The committed tip carries only the read path.* + +> **One-line model:** ZClassic consensus does not know NFTs exist. An NFT is a +> **non-consensus overlay** every honest wallet re-derives identically from the confirmed +> chain. The hard consequence, stated honestly everywhere in the UI: **a forgery can be +> mined, but it credits nobody.** Security is *agreement*, not chain rejection. + +--- + +## What this guide consolidates + +This guide is the synthesis of, and front door to, the supporting docs below. Where this +guide and an older doc disagree on **what is built**, this guide and the code win. + +| Supporting doc | Role it played | Status | +|---|---|---| +| `CAPABILITY_MAP.md` | works-now / building-now / next status, code-verified | folded into §1 (kept for file:line traceability) | +| `NATIVE_UI_CONSOLIDATED_SPEC.md` | per-screen native-Qt build spec (Audit-A) | folded into §2 (kept as the deep widget-tree reference) | +| `NATIVE_UX.md` + `NATIVE_UI_BUILD_PLAN.md` | the two source UI docs Audit-A reconciled | **superseded** by §2 / `NATIVE_UI_CONSOLIDATED_SPEC.md` | +| `PRIVACY_STACK.md` + `ZDC1_CODEC_SPEC.md` | the four-layer private stack + codec reference | folded into §3 (`ZDC1_CODEC_SPEC.md` kept as codec reference) | +| `PRIVACY.md` + `PRIVACY_UX.md` | privacy normative + UX (Audit-B reconciled) | **superseded** by §3 | +| `SECURITY_MODEL.md` | the normative validation rules / threat table (R-*) | **still normative** — §4 points here; this guide never overrides it | +| `MINT_TRANSFER_SPEC.md` | the write-path tx builder + RPC contract | **still authoritative** for the `zslp_genesis`/`zslp_send` contract | +| `CONTENT_MODEL.md` | any-file → fingerprint (SHA-256 + Merkle, streaming) | **still authoritative** for content addressing | +| `NFT_SELL_DESIGN.md` | the NFT→ZCL sell/trade design (fixed-template `SIGHASH_ALL\|ANYONECANPAY`) | **authoritative for trades — now BUILT** (daemon RPCs landed, atomic swap regtest-proven; design rows mid-fix) | +| `ONCHAIN_TRADES.md` | early transparent-trade sketch | **SUPERSEDED** by `NFT_SELL_DESIGN.md` — its `SINGLE\|ANYONECANPAY` layout is wrong/funds-losing; kept for history only | +| `ENABLEMENT.md` and the threat-model pairs | early why/aspirational + per-topic threat models | **superseded for status** (kept for traceability) | + +**Canonical reader path:** this guide → `SECURITY_MODEL.md` (normative rules) → +`MINT_TRANSFER_SPEC.md` (write path) → `NATIVE_UI_CONSOLIDATED_SPEC.md` (deep widget trees) +→ `CONTENT_MODEL.md` → `ZDC1_CODEC_SPEC.md` (codec) → `NFT_SELL_DESIGN.md` (trades). +Everything else is "superseded, kept for traceability" (including `ONCHAIN_TRADES.md`). + +--- + +# 1. WHAT YOU CAN DO + +The plain-language capability map. **works-now** = code in-tree and built (cited file); +**building-now** = a separate workflow is actively writing it against a fixed contract; +**next** = designed, not yet written. + +### The model behind every status (read once) + +A ZClassic NFT is a baton-less SLP **GENESIS** (`decimals=0, quantity=1`) carried in a single +`OP_RETURN`. Every honest wallet/indexer recomputes the same token ledger as a deterministic +function of the confirmed chain. Three things the UI must always hold to: + +1. **Ownership is PENDING until ~10 confirmations** (`DEFAULT_MAX_REORG_DEPTH`), because 1–9 + confs are reorg-reversible. +2. **The image badge means only "these bytes match this token's on-chain fingerprint"** — + never genuine / official / original. +3. **Identity is the genesis txid**, never the name / ticker / image (those are freely reusable). + +### A. Discover, verify, inspect (the read path) — **works-now** + +You can do all of this today: + +- **See the NFTs this wallet owns** in a native dark gallery with a verify badge and + public/private pill — no browser. (`RPC::refreshNFTs()` calls the real `zslp_listmytokens` + then `zslp_gettoken` per token and feeds `NFTGalleryModel`; `zcl-qt-wallet/src/rpc.cpp:863`. + Daemon `zslp_listmytokens` at `src/rpc/zslp.cpp:191`.) +- **Verify an image** against its on-chain fingerprint (✓ match / ✗ mismatch / ? pending), + locally, **never fetching the remote URL**. (`ContentEngine` streaming SHA-256 + verify on a + worker thread; `zcl-qt-wallet/src/contentengine.{h,cpp}`.) +- **Look up any public token** by genesis txid (ticker, name, document_url, 32-byte hash, + decimals, height, totalMinted, baton state). (`zslp_gettoken`; `src/rpc/zslp.cpp:73`.) +- **Confirm a real 1-of-1 + supply cap** (`totalMinted==1 && hasMintBaton==false`). +- **Read full public transfer history** (newest-first, reorg-safe). (`zslp_listtransfers`; + `src/rpc/zslp.cpp:142`.) +- **Browse all indexed tokens** (bounded paging, clamped to `ZSLP_LIST_MAX=1000`). + (`zslp_listtokens`; `src/rpc/zslp.cpp:107`.) +- **Trust the ledger is forgery-proof** — a forged SEND/MINT credits nobody; an NFT can't be + duplicated. (UTXO-bound conservation indexer; `vout[0]`-only parse at + `src/zslp/zslpindexer.cpp:229`; single `ZSLP_SEND_MAX_OUTPUTS=19`; `ChainTip`-only, no + mempool/0-conf path. ~101 ZSLP gtests across `src/gtest/test_zslp*.cpp`.) + +### B. Create and move NFTs (the write path) — **works-now (daemon, working tree); GUI next** + +These daemon RPCs are built; they are present in the `feature/zslp-nft-indexer` working tree +(uncommitted) and not yet exercised by the GUI. The GUI dialogs (§2) are designed and degrade +honestly until they are wired to call the RPCs in a build that carries them. + +- **Mint a public 1-of-1** — drag a file, hash it locally, fill in a name, broadcast a + baton-less GENESIS. → `zslp_genesis '{nft:true, name, document_url, document_hash, + [ticker], [to (t-addr)]}'` → `{txid, tokenid}`. `nft:true` forces `decimals=0`, + `quantity=1`, and no mint baton; an optional `mint_baton_vout` (must be `>=2`) issues a + re-issue baton for the fungible case. *(RPC implemented in the working tree on + `feature/zslp-nft-indexer` — `src/rpc/zslp.cpp:330`, registered in the command table at + `:661` — not yet committed/merged; encoders + `MINT_TRANSFER_SPEC.md` ready.)* +- **Transfer / gift** an NFT to a recipient. → `zslp_send(token_id, to_address, amount=1, + [change_address])` → `{txid}`. *(RPC implemented in the working tree on + `feature/zslp-nft-indexer` — `src/rpc/zslp.cpp:545`, registered at `:663` — not yet + committed/merged. `zslp_mint` (fungible re-issue) also lands here at `:466`/`:662`.)* +- **Airdrop / batch** up to 19 token outputs in one tx — rides `zslp_send` multi-output. +- **Limited / numbered editions** ("N of 100") — `zslp_genesis` qty=N baton-off, or N separate + 1-of-1s. +- **Hold an NFT without burning it** — an ordinary send/shield/sweep must never spend the + carrier dust. Two distinct mechanisms back this, and BOTH are now wired in the working tree + on `feature/zslp-nft-indexer` (uncommitted): + - **The write path's own self-validate-before-broadcast gate (R-WALLET-9) IS wired.** + `BuildAndCommitZSLP` calls `store->WouldBeValid(...)` (`src/wallet/zslpwallet.cpp:460`) + and aborts with *"self-validate: built tx would not be valid in the token ledger (…)"* + BEFORE `CommitTransaction` (`:475`). The builder never broadcasts a tx that would not be + valid in the token ledger. + - **Ordinary-send anti-burn via `AvailableCoins` token-UTXO exclusion IS wired.** + `AvailableCoins` now takes `fExcludeZSLPTokens` (default `true`, `wallet.h:1124`) and + drops protected token/dust outpoints via `ZSLPIsProtectedTokenOutpoint(...)` + (`wallet.cpp:3197`), so no ordinary send/shield/sweep coin-selection path picks up a + carrier UTXO (explicitly preset/coin-controlled inputs are exempt so the ZSLP builder can + still pin its own token inputs). The primitive itself (`ZSLPFindWalletTokenUtxos` + + `SLP_TOKEN_DUST=546`, `src/wallet/zslpwallet.{h,cpp}`) underpins both. + + Holder safety is therefore mechanically complete in the working tree — see §4. It becomes + the shipped guarantee once these uncommitted changes are committed/merged AND the standard + spend paths are confirmed to call `AvailableCoins` with the default (token-excluding) value. + Do not let any UI copy imply burn-proof holding outside a build that carries these changes. + +### C. Native mint / detail / set UI — **next** + +Designed in §2 (and `NATIVE_UI_CONSOLIDATED_SPEC.md`); GUI dialog files not yet created. + +- **Detail dialog** (`NFTDetailDialog`): large verified render, provenance rows, copy id / + fingerprint, prev/next. +- **Create-NFT mint wizard** (`NftMintDialog`): drop file → streaming fingerprint → public/ + private → review "what becomes public" → Create. Calls `zslp_genesis`. The Private tile is + gated off (`isPrivateMintWired()==false`) until the shielded channel lands. +- **Card sets / "collect them all"** completion bar (manifest convention only — see ceilings). + +### D. Private NFTs and the shielded data channel — **works-now (daemon, CLI; default-OFF, experimental); native GUI next** + +- **The ZDC1 codec itself** (frame / reassemble / AEAD / seal-then-reveal / ciphertext + fingerprint) is **built + self-tested AND now compiled into the daemon** + (`src/datachannel/zdc.{h,cpp}` in `src/Makefile.am:247,294`; codec self-checks + + 25 daemon gtests in `test_zdc.cpp`, ASan/UBSan-clean, secret-zeroized). +- **Send a private file / message** (sealed bytes on-chain, ownership = key possession, + selective disclosure via viewing key) — daemon RPCs `z_senddatafile` / + `z_listdatatransfers` / `z_getdatatransfer` ARE present and registered + (`src/rpc/datachannel.cpp:597-599`), **default-OFF** behind + `-experimentalfeatures -datachannel` (each returns `-32601` when off), with + daemon-enforced `acknowledge_permanent=true` and verify-before-decrypt. They ride the + existing Sapling binary-memo path; no consensus change. Live round-trip proven. Full + as-built contract in §3. *(Private minting — a single `zslp_mint_private` RPC — is + designed but NOT built; the built private-mint path today is `z_senddatafile` for the + sealed bytes plus an ordinary `zslp_genesis` whose `document_hash` commits to the + ciphertext fingerprint. See §3.3.)* +- **Receive a private NFT in the gallery** (decrypt locally, render natively, verify badge) — + needs the GUI binary-memo branch fix (`rpc.cpp` ~756) that sniffs the `ZDC1` magic on RAW + bytes before any `QString` conversion. No native SHIELD GUI exists yet, so SHIELD is + CLI-only on dev/testnet today. + +### E. Trade NFT ⇄ ZCL — **works-now (daemon, CLI; dev/testnet)**, and a hard ceiling + +- **Transparent NFT ⇄ transparent ZCL, atomic single tx** — **built** via a + fixed-template `SIGHASH_ALL|ANYONECANPAY` signed offer (seller signs ONLY their + NFT input `vin[0]` over the COMPLETE 3-output template: OP_RETURN ZSLP SEND@vout[0] + / buyer NFT dust@vout[1] / seller ZCL payout@vout[2]; `ANYONECANPAY` lets the buyer + append funding inputs, `ALL` pins the whole output set). Coin legs are + consensus-atomic, token attribution is indexer-convention, so **trust-minimized, + not trustless**. The daemon RPCs `nft_makeoffer` / `nft_verifyoffer` (mandatory) / + `nft_takeoffer` / `nft_listoffers` / `nft_canceloffer` / `nft_requestbuy` ARE built + and registered (`src/rpc/nftoffer.cpp:1180-1186`, compiled `src/Makefile.am:292`), + regtest-proven at `qa/zslp/nft-sell-regtest.sh` with 6 gtests; see + `NFT_SELL_DESIGN.md` (authoritative). Only the native GUI offer dialog (§2.8) is + still pending. **Note:** `SIGHASH_SINGLE|ANYONECANPAY` does + NOT work for ZSLP — SINGLE would pin the OP_RETURN (vout[0]) instead of the payout + and burn the seller's NFT; see `NFT_SELL_DESIGN.md §0`. +- **Any leg shielded, atomic** — *impossible in-codebase* (z-notes carry no script; the Sapling + binding sig is single-party over the whole tx). Not on the roadmap. +- **Escrowed / disputed sale** — possible via 2-of-3 P2SH multisig, but **trusted** (the + arbiter). Opt-in only. + +--- + +# 2. NATIVE UI SPEC + +100% native Qt (`QListView` + `QStyledItemDelegate` + `QPainter`, `QDialog`, `QLabel`/`QPixmap`). +**NO QtWebEngine, NO QtMultimedia, NO browser, anywhere.** Video = poster + open-in-external- +player. C++14 (no `std::optional`/`string_view`; empty-`QString` sentinels + `int verifyState` +0/1/2). Reuses `dark.qss` tokens — adds no new color. + +> Deep widget-tree reference (file:line-grounded) lives in `NATIVE_UI_CONSOLIDATED_SPEC.md`. +> This section is the build-ready summary every developer can build straight from. + +### 2.0 Seven binding reconciliations (the source docs were WRONG vs the live tree — follow these) + +1. **ONE ContentEngine.** The live tree already builds `nftImgCache = new NFTImageCache(nftModel, + this)` (`mainwindow.cpp:3053`) and `NFTImageCache : public ContentEngine`. Dialogs take a + `ContentEngine*` and are handed the **existing** `nftImgCache` (upcast). Do **not** create a + second engine — that's a duplicate 4-thread pool. +2. **Write RPCs are `zslp_genesis` (mint) and `zslp_send` (transfer/gift)** — never `zslp_mint` + (that's fungible re-issue), never `z_sendmany`/`executeTransaction` (that's only the *future* + private leg). +3. **Reuse `Settings::getExplorerTxURL(txid)`** (`settings.cpp:421`) — it already appends the + txid and returns "" on testnet. Do **not** add a new `getExplorerUrl()`. +4. **Add the additive `ContentEngine::posterReady(quint64 token, QImage img, int verifyState)` + signal** (mirrors `verifyDone`, emit from `deliver()`). It does not exist yet and the detail + view needs it. This is the **one and only** ContentEngine change. +5. **`getNFTThumbSize()` does NOT exist.** Either ship it as a genuinely new getter/setter, or + (recommended) **cut the density toggle from v1**. +6. **Fix the subhead copy.** The live `setupNFTTab` ships `"Your NFTs. Each asset is checked + against its on-chain hash."` (`mainwindow.cpp:3031`) — "hash" as a noun is banned. Use + **"Your NFTs. The image on each card is checked against its on-chain fingerprint."** +7. **Honor `indexOff`.** `setNFTItems` currently does `(void)indexOff;` (`mainwindow.cpp:3107`); + `refreshNFTs` already computes `indexOff` from RPC error code -1 (`rpc.cpp:989-999`). Wire it + to the 4-page stack so the index-off state can actually show. + +### 2.1 Shared visual + interaction system (specified once; every screen conforms) + +**Tokens (from `dark.qss` + `nftgallerydelegate.cpp`):** app `#0f1115` · card `#15171c` · inset +`#1d2027` · hairline `#2a2d35` · text `#e6e6e6` · dim/AA-floor `#9aa0a6` · private-green `#1f7a1f` +· hero-green `#2a9d2a`/`#34c759` · public/pending-amber `#d9822b` · mismatch-red `#c0392b`. **Add +no new color.** + +**Verify badge (status, never a control — no click).** Tinted SVG (`check`/`x`/`question`) on a +dark disc; gallery 16px, detail/mint 20px. The verdict sentences, identical everywhere: +- verified (1): **"This image matches its on-chain fingerprint."** +- mismatch (2): **"This image does NOT match what was recorded on-chain. Don't trust it."** +- pending (0): **"Checking this image…"** (no local bytes: **"Image not downloaded."**) + +**Privacy pill.** Green "Private" / amber "Public", leading dot. One-liners: Private — **"Only +you can see this. Its ownership is shielded."**; Public — **"Anyone can verify this on the public +ledger."** + +**Card anatomy** (`baseCardSize()` 168×208): square cover-fit thumb (radius 8) · verify badge +top-right of thumb · privacy pill below thumb · bold elided name · dim elided collection. + +**The one action set** (same verbs/labels/order wherever an NFT action appears): Open · **Send / +Gift** (green primary) · Save image… · Copy id · Copy fingerprint · Copy collection · Re-check +image · Open in your video player (video kind) · View in explorer (public + configured + +confirmed only). **No "open link"/network item in any browse context menu.** Private items never +expose explorer. + +**Banned from every visible string:** "hash" as a noun (say "fingerprint"), SHA-256, OP_RETURN, +GENESIS, token, mint-baton, zslpindex, ivk, "memo" (say "note"), t-addr/z-addr (say "public +(transparent) / private (shielded) address"), and never "Genuine/Authentic/Official/Original" on +the badge. + +### 2.2 Gallery (`gallery-grid`) + +**Widget tree (top→bottom):** +- **Heading row:** `QLabel#nftGalleryHeading` "Collections" + `QLabel#nftCountChip` ("12 items" + → "12 of 40" filtered, right-aligned). +- **Subhead:** `QLabel#nftGallerySubhead` = **"Your NFTs. The image on each card is checked + against its on-chain fingerprint."** (fix per §2.0.6). +- **Toolbar `QHBoxLayout#nftToolbar` (NEW, h=36):** `[search QLineEdit#nftSearch flex] [Filter ▾] + [Group ▾] [Sort ▾]`. (Density toggle optional — recommend cut for v1.) + - search placeholder "Search your collection"; Filter = All / Private only / Public only / + Verified / Needs attention; Group = No groups / By collection / By privacy; Sort = Recently + received / Name A–Z / Collection. +- **Grid:** the existing `QListView#nftGalleryView` (IconMode, wrapping, `setUniformItemSizes`) + + `NFTGalleryDelegate`. +- **State stack:** wrap the view in `QStackedWidget#nftGalleryStack` (4 pages — see §2.3). + +**Architecture:** a NEW `QSortFilterProxyModel` over the **untouched** `NFTGalleryModel` drives +search/filter/sort/group in-process (no I/O). + +**RPC:** none directly — fed by `RPC::refreshNFTs()` on the normal poll (`zslp_listmytokens` → +per-token `zslp_gettoken` → `MainWindow::setNFTItems()`). Every item gets `cachePath=""` +(privacy), so cards stay pending until local bytes exist. + +**States:** LOADING (page 3, cards shimmer + amber "?") · EMPTY (page 1, toolbar hidden) · +ZERO-RESULT (toolbar stays, "Nothing matches" + active filter in words + "Clear filters") · +VERIFIED/MISMATCH/PENDING per badge · PENDING-NO-BYTES ("Image not downloaded. Open to fetch it +yourself." — never auto-fetched) · PRIVATE/PUBLIC · INDEX-OFF (page 2) · OFFLINE (keep last good +grid, dim the count chip, no spinner-of-doom). + +**Interactions:** live search over name+collection · filter/group/sort instant via proxy · +single-click selects · **double-click/Enter/Space → `openNFTDetail` via `connect(view, +&QListView::activated, ...)` — ACTIVATED ONLY (do NOT also connect `doubleClicked`, or detail +opens twice).** Context menu = browse subset (no link/network item). + +### 2.3 First-run / empty (`first-run`, the 4-page stack) + +Same centered hero-card geometry on every page so layout never jumps (`#15171c`/hairline/radius +12, max-width 520, centered). + +- **Page 0 — gallery** (rows present). +- **Page 1 — EMPTY:** quiet-grey frame glyph (not red/amber), title **"No collectibles yet"**, + body **"When someone sends you a collectible, or you make one, it shows up here — and the + wallet checks each picture against its on-chain fingerprint. Nothing to do right now."**, green + primary **"Make your first collectible"** (opens mint when it lands; before that, **"Show me how + it works"**), flat link **"What is a collectible?"**. +- **Page 2 — INDEX-OFF (rare/legacy state):** the collectibles index is **ON by default** + (`-zslpindex` defaults true; `init.cpp:3272-3274`), so this page shows ONLY when a node was + explicitly started with `-zslpindex=0` or runs a pre-feature daemon. Amber toggle glyph, + title **"Collectibles tracking is turned off"**, body **"This node was started with + collectibles tracking off. Turn it back on and the wallet will start finding your + collectibles — a one-time catch-up scan runs in the background."**, green primary **"Turn on + collectibles"**. Managed daemon → confirm + restart + scan. Foreign/old daemon → reveal an + inset with the exact re-enable conf line **`zslpindex=1`** + **"Copy line"** (never a dead + end). *(This conf line is the ONE place the raw setting name is allowed — it's literal config, + not prose; and it is the re-enable action, since the index is on by default.)* +- **Page 3 — LOADING:** **"Looking for your collectibles…"** + indeterminate `QProgressBar` + + **"This runs in the background. You can keep using the wallet."** + +**State selection (RPC):** `refreshNFTs` already computes `indexOff` from RPC error code -1 +(`rpc.cpp:989-999`) and calls `setNFTItems(empty, indexOff)`. **Wire `setNFTItems` to honor +`indexOff`** (today `(void)indexOff;`): indexOff→page 2; success+empty→page 1; success+rows→page +0; first call outstanding→page 3. Latch the last good page so a transient poll error never +flickers back to empty/off. + +### 2.4 Detail dialog (`nft-detail`) — `NFTDetailDialog` (NEW: `src/nftdetaildialog.{h,cpp}`) + +Programmatic, modeless-modal (`open()` not `exec()` so the poll loop keeps flowing and back-fill +lands). Min 760×560. Carries the `NFTItem` by value + the ordered POD list by value (no model +pointer) for prev/next. + +```cpp +explicit NFTDetailDialog(const NFTItem& item, const QVector& ordered, int startIndex, + ContentEngine* engine, RPC* rpc, QWidget* parent = nullptr); +``` +`MainWindow::openNFTDetail(const QModelIndex&)` snapshots the ordered list from `nftModel` and +constructs the dialog with the **existing `nftImgCache`** as `ContentEngine*` (§2.0.1), then +`setAttribute(WA_DeleteOnClose); open();`. + +**Layout:** +- **Title bar (h=44):** name 16pt/700 + collection 11pt dim ("Not part of a set" if none); flat + close glyph. +- **Left — image stage (min 380×380):** centered `QLabel#nftDetailStage` painting the full QPixmap + `KeepAspectRatio SmoothTransformation` (never upscaled past 1024 native; letterboxed on + `#1d2027`). 20px verify badge top-right. Shimmer while decoding. +- **Right — info panel (fixed 320):** + 1. **Verify line:** full-width inset, 20px badge + 13pt verdict sentence; color via + `#nftDetailVerifyLine[state="verified|mismatch|pending"]` dyn-prop (NEW qss, ~3 lines). + 2. **Privacy pill row** + one-liner. + 3. **Details card:** **Mint id** (genesis txid = identity; short 8…8 + copy), **Received** (ISO + date + "block N", or **"Just arrived — confirming…"** when confs<10), **Creator** + ("Unknown" — the chain records no issuer), **Set** ("Wild Series — 7 of 30" or "Not part of + a set"), **Image fingerprint** (short 8…8 + copy). Footnote: **"This name and image aren't + unique — anyone can mint another collectible that reuses them. Only the mint id is one of a + kind."** + 4. **Action bar (pinned, h=48):** green **"Send / Gift"** primary · **"Save image…"** · **"Copy + id"** · overflow **"More"** = Copy fingerprint / Copy collection / Re-check image / View in + explorer. + +**RPC the detail dialog calls:** +- **Poster + verify (local bytes only):** `engine->posterFor(localPath, docHashHex, docHashHex, + 512)` and `engine->verify(localPath, docHashHex, token)`, where + `localPath = ContentEngine::cacheGet(m_item.docHashHex)` (empty = not on device → never + fetched). Token-guarded so a fast prev/next drops a stale neighbor's reply. Receives the decoded + pixmap via the new `posterReady` signal (§2.0.4). +- **Provenance back-fill (NEW `RPC::nftProvenance(tokenId, cb)`):** `zslp_gettoken "tokenId"` → + set Set/series from `ticker`; Creator stays "Unknown"; any error → honest defaults, no dialog. +- **Received date back-fill (NEW `RPC::txReceivedDate(txid, cb)`):** `gettransaction "txid"` → + `confirmations` + `blocktime`; confs<10 → "Just arrived — confirming…"; ≥10 → ISO date + "block + N". +- **View in explorer:** `QDesktopServices::openUrl(QUrl(Settings::getExplorerTxURL(m_item.txid)))` + after a one-time confirm **"This opens an outside website and may reveal your interest. + Continue?"** — enabled only if `!getExplorerTxURL(m_item.txid).isEmpty() && !m_item.isPrivate` + (§2.0.3). +- **Send / Gift:** opens `NFTSendDialog` pre-filled (§2.6). On a MISMATCH item, confirm "This + image failed its on-chain check. Send anyway?" first. + +**Media by kind** (`ContentEngine::classifyKind`; every branch touches only `cacheGet`): +- **Image:** full QPixmap on the stage; resize re-scales from the held source (no re-decode/ + re-hash). +- **Video:** NO in-app playback. Typed film-strip poster + play glyph + caption **"Video · + · "** + verify badge + primary **"Open in your video player"** → + `QDesktopServices::openUrl(QUrl::fromLocalFile(localPath))`, enabled only when local bytes exist + AND `verifyState == 1`. +- **Document:** typed MIME glyph + "Open" (external only). **Bytes:** typed glyph + "Save as…". + Never auto-execute. + +### 2.5 Mint-from-file (`mint-flow`) — `NftMintDialog` (NEW: `src/nftmintdialog.{h,cpp}`) + +Programmatic, modal. Recommended 3-page stack (PICK → DETAILS → REVIEW) so the +"async-hash-gates-Next" contract is trivial. + +```cpp +NftMintDialog dlg(nftImgCache /*the existing ContentEngine*/, rpc, this); +if (dlg.exec() == QDialog::Accepted) rpc->refreshNFTs(); +``` + +**Pages:** +- **1 — Your image (dropzone):** drag/drop or "Choose a file…". GUARD with + `ContentEngine::isRemoteUrl(path)` → reject http(s) drops inline ("For your privacy, drop a + local file — not a web link."). On a file: `classifyKind` for the glyph, `posterFor` for an + image poster, prefill name from the basename, then `hashFile(path, token)` (STREAMING — a 2 GB + file hashes in ~1 MiB RAM). Indeterminate progress + "Reading your file…". Next disabled until + `descriptorReady`. +- **2 — Details:** Name (required, soft 50-char counter), Collection (optional), Note. +- **3 — Who can see it:** two tiles — **Private** (green) and **Public** (amber). +- **4 — Review & confirm:** thumb + name/collection + visibility pill + "Fingerprint 1f2a…9c0d" + + size + "What goes on-chain (public)" line + the honesty line + fee row + "After this you'll have + N ZCL". + +> **Gating polarity (load-bearing).** The building-now write path is the **public** +> `zslp_genesis`; the **private** path needs the not-yet-built ZDC1 channel. So in the first +> shipped cut the **Public tile is wired and default-selected**, and the **Private tile is +> "Coming in this release"** (gated off by `isPrivateMintWired()==false`). This is the OPPOSITE +> of NATIVE_UX's "Private default." **Gate OFF whichever path's RPC is missing; never ship a +> Private-default mint with no working broadcast (a dead Create button).** Flip to Private-default +> once ZDC1 lands (build order step 6). + +**RPC the mint dialog calls — `RPC::mintNFT(descriptor, opts, cb)` → `zslp_genesis`:** +``` +zslp_genesis '{ "nft": true, "ticker": , "name": , + "document_url": , "document_hash": <64-hex anchor> }' + -> { "txid", "tokenid" } (tokenid == txid) +``` +`nft:true` forces `decimals=0, quantity=1` and no baton. `document_hash` = the descriptor's `merkleRoot` for +large files else `sha256Whole`, lowercase hex. On success, `ContentEngine::cachePut(anchorHex, +srcPath)` so the new card verifies green immediately, then `accept()` → `refreshNFTs()`. On error, +show the daemon message verbatim inline (never a fabricated success). + +**States:** EMPTY/PICK · REMOTE-URL REJECTED · HASHING (Next disabled) · READY ("Fingerprint +ready.") · UNREADABLE · PRIVATE COMING-SOON (today: Private tile disabled "Coming in this +release", Public forced-on; Create never dead) · REVIEW · CREATING · MINT ERROR (inline daemon +message) · SUCCESS (toast "NFT created — Aurora #14" + "Show it") · low-balance. + +**Key copy:** "Minting does NOT upload your file anywhere. Only its fingerprint goes on-chain — +the file stays on your computer." + +### 2.6 Send / Gift (`send-gift`) — `NFTSendDialog` (NEW: `src/nftsenddialog.{h,cpp}`) + +Modal; constructor **requires** an `NFTItem` (no empty state). windowTitle "Send a gift". + +**Cards:** +- **1 — What you're giving:** 72×72 inset thumb fed from disk cache + verify badge + name + + collection + privacy pill. Read-only. +- **2 — Who gets it:** "Send to" + the existing `AddressCombo` + "Address book". One + reserved-height live status line: valid private → green "Looks good — a private (shielded) + address"; valid public → amber "Looks good — a public (transparent) address"; invalid → red + "That doesn't look like a ZClassic address". Validation is local/debounced (no per-keystroke + RPC). +- **3 — How private:** **Public gift** [amber] — wired now via `zslp_send`. **Private gift** + [green] — the shielded-memo path, "Coming soon", disabled until ZDC1 lands. (OPPOSITE polarity + of NATIVE_UX for the same building-now reason.) +- **4 — Add a note (optional, collapsed):** public gifts hide it ("Public gifts can't include a + private note."). + +**RPC the send dialog calls — `RPC::sendNFT(tokenId, toAddress, cb)` → `zslp_send`:** +``` +zslp_send "tokenid" "to_address" 1 -> { "txid" } +``` +amount defaults to 1. The daemon builder enforces anti-burn + self-validate-before-broadcast; the +UI never builds a raw spend. Private gift (future) routes via the ZDC1 channel — NOT `zslp_send`; +this is the ONLY place `executeTransaction` would be correct, and only for the private leg. + +The green action label states the outcome: Public → "Send gift". Disabled until valid recipient +AND not a red mismatch AND not already in flight. + +**States:** ready · thumb pending/verified/MISMATCH ("This picture doesn't match its fingerprint +— we won't send it.", action disabled) · recipient empty/valid-private/valid-public/invalid · +sending · sent ("Gift sent. It's on its way to them.") · error (inline red + daemon reason + "Try +again", nothing sent) · index-off (Public still works). + +### 2.7 Set / collection board (`set-collection`) + +A stacked page **inside the Collections tab** (`QStackedWidget` index 1; index 0 = gallery), +reached by clicking a set thumbnail. Header (back "‹ Collections" + set name + "Created by +{creator} · {N} cards" + completion meter "3 of 7 collected", green track fill) · the board +(`QListView` IconMode fed by a NEW `SetBoardModel`, painted by `SetSlotDelegate : +NFTGalleryDelegate` — owned slots = the §2.1 card; missing slots = ghost variant: 55% opacity, +"#N" numeral, "Not collected", **no image request issued**) · footer help bar (only when +missing>0): "Missing {n} cards. They arrive when someone sends them to your wallet." + quiet "Show +my receive address". **No in-app buy/trade.** + +**RPC:** set membership comes from the already-fetched `zslp_gettoken` metadata (the `ticker` +groups a card-set; `refreshNFTs` maps `ticker`→`collection`, `rpc.cpp:949`). **There is no +on-chain source for the full slot list** — the daemon only knows the tokens you HOLD. Until a +creator-published manifest is resolved locally/explicitly, the board shows **owned slots + a calm +"manifest not available" note** rather than fabricating ghost slots. **Never invent slot +counts/numbers/names.** + +The creator's verified-issuer tick appears ONLY when its mint id is on a named verified-issuer +list; tooltip "On {maintainer}'s verified-issuer list" — never a bare "Verified" (it's social, +not a network guarantee). + +### 2.8 Trade UI — **next** (daemon SELL RPCs already built) + +The daemon SELL RPCs are built (atomic swap, regtest-proven — see §1.E); the remaining work is +the native GUI. The detail dialog's "More" menu gains "Offer for ZCL…" → a native offer dialog +that composes `nft_makeoffer`/`nft_verifyoffer`/`nft_takeoffer` (transparent only, fixed-template +`SIGHASH_ALL|ANYONECANPAY`). UI copy must say **trust-minimized, not trustless** (token +attribution is indexer convention), must always run `nft_verifyoffer` before `nft_takeoffer`, and +must never imply a shielded leg can be atomic. No in-app marketplace. + +### 2.9 File map + build order (grounded in the live tree) + +**New files:** `src/nftdetaildialog.{h,cpp}` · `src/nftmintdialog.{h,cpp}` · +`src/nftsenddialog.{h,cpp}` · `src/setboardmodel.{h,cpp}` · `src/setslotdelegate.{h,cpp}` · a +`QSortFilterProxyModel` (inline or small `nftgalleryproxy.{h,cpp}`). + +**Edited files:** `src/mainwindow.{h,cpp}` (4-page stack; toolbar; set-board page; `openNFTDetail` ++ the single `activated` connect; `openMintDialog`; honor `indexOff`; fix subhead; pass the +existing `nftImgCache`) · `src/contentengine.{h,cpp}` (ADD `posterReady` + emit in `deliver()` — +nothing else) · `src/rpc.{h,cpp}` (ADD `mintNFT`→`zslp_genesis`, `sendNFT`→`zslp_send`, +`nftProvenance`→`zslp_gettoken`, `txReceivedDate`→`gettransaction`; `isPrivateMintWired()` → +hard-false until ZDC1) · `src/settings.{h,cpp}` (REUSE `getExplorerTxURL`; add `getNFTThumbSize` +ONLY if density kept) · `res/styles/dark.qss` (append object-name rules using existing tokens +only) · `application.qrc` + `res/icons/` (new tinted SVGs) · `zcl-qt-wallet.pro` (add the new +sources; no new Qt module). + +**Build order (each step shippable):** (1) RPC + settings + `posterReady` scaffolding; (2) +gallery state-stack + proxy + first-run/index-off; (3) detail dialog; (4) set board; (5) mint + +send (public, `zslp_genesis`/`zslp_send`); (6) private channel (ZDC1) → flip the gating so Private +becomes the safe default per NATIVE_UX. + +**Performance/privacy contract (defect if violated):** no web/multimedia ever · bounded +`QThreadPool(4)`, worker produces only `QImage` (QPixmap built on the GUI thread) · two-tier cache, +resize re-scales from held source · in-flight dedupe · `setUniformItemSizes` (no relayout on +scroll) · one shimmer timer over visible pending only (missing slots issue ZERO image requests) · +fingerprint-guarded models + in-process proxy · hot path touches ONLY local bytes; `isRemoteUrl` +rejects http(s); the only network touches are explicit confirmed user actions + the mint/send +broadcast · RPC stays off the GUI/paint thread via `doRPC`. + +--- + +# 3. PRIVACY + +Private NFTs and a general shielded data channel. **Default-OFF**, permanence-consent-gated, rides +**unchanged consensus** (every byte is a 512-byte Sapling memo carried by the existing +`z_sendmany` hex-memo path and read back via `z_listreceivedbyaddress`). No fork, no opcode, no +builder change. + +> Codec reference: `ZDC1_CODEC_SPEC.md`. This section is the AS-BUILT daemon RPC contract +> plus the (still-pending) native UX, grounded in the live codec (`src/datachannel/zdc.h`, +> compiled into the daemon) and the built RPCs in `src/rpc/datachannel.cpp` +> (`z_senddatafile`/`z_listdatatransfers`/`z_getdatatransfer`, registered at `:597-599`, +> default-OFF behind `-datachannel`). The §3.4 UX is native-GUI design, not yet built — SHIELD +> is CLI-only today. + +### 3.1 The four-layer stack, in plain terms + +1. **Carrier — existing shielded memos.** Each frame is a 512-byte Sapling memo to one z-addr. + Consensus already hides who/whom/amount/contents; we add nothing to consensus. +2. **Framing + reassembly — ZDC1 codec.** A file/message is split into chained frames (START / + CHUNK / END, optional KEY) with a 4-byte `ZDC1` magic, grouped by `(zaddr, transfer_id)` and + reassembled. Built + self-tested AND compiled into the daemon (`src/Makefile.am:247,294`). +3. **Encryption — AEAD + ciphertext fingerprint.** Bytes are sealed with a per-transfer key; + `ciphertext_fingerprint(frames)` is a 32-byte commitment. **Verify-before-decrypt:** the public + token's `document_hash` == that fingerprint, so a holder verifies the on-chain anchor before + ever decrypting. +4. **Selective disclosure.** As built, `z_senddatafile` always emits the KEY frame on-chain and + returns the per-transfer `key` to the sender, so the sender can selectively disclose by + handing that key (or, for everything ever sent to a receiving z-addr, the **incoming viewing + key** via `z_exportviewingkey`) to an auditor/buyer — read/prove only, never spend. *(A + separate seal-now / reveal-the-key-later RPC — `z_revealkey` — is designed but NOT built; see + §3.3.)* + +**Keys live in the daemon, never in the GUI** (Option A). The GUI links no libsodium; it only +detects the `ZDC1` magic on raw memo bytes and calls the RPCs below. + +### 3.2 Cross-cutting RPC contract (applies to every RPC in §3.3) + +- **Naming/category:** `z_*` prefix, `"wallet"` category, async `opid` reused through the existing + `z_getoperationstatus`/`z_getoperationresult` (no new status RPC). Reads are `okSafeMode=true`, + mutating ops `false`. +- **Default-OFF master switch:** `fDataChannelEnabled = fExperimentalMode && GetBoolArg( + "-datachannel", false)`. When off, EVERY RPC throws **`RPC_METHOD_NOT_FOUND (-32601)`** with + *"Data channel is disabled. Start zclassicd with -experimentalfeatures -datachannel to enable + private file/message transfers (experimental, default-off)."* — the SAME code an absent method + returns, so the GUI's existing `-32601` "feature not present" latch (the one used for + `getwalletsummary` at `zcl-qt-wallet/src/rpc.cpp:1828-1835`) handles it identically and never + flickers a false error. *(Note: this `-32601` latch is the precedent to reuse here. The ZSLP + index-off path is a DIFFERENT mechanism — `refreshNFTs` detects index-off via RPC error code + `-1` (`RPC_MISC_ERROR`) at `rpc.cpp:989-999`, `indexOff = (code == -1)`, not `-32601`. Use + whichever code the daemon actually throws: the data channel throws `-32601`, so it inherits + the `getwalletsummary`-style latch, not the ZSLP index-off `-1` path.)* +- **Permanence consent is unbypassable:** every *sending* RPC takes a **REQUIRED-true** + `acknowledge_permanent` in its options. Absent/false → `RPC_INVALID_PARAMETER (-8)`: *"This + permanently writes encrypted data to every full node forever and is not deletable. Pass + acknowledge_permanent=true to confirm."* (Enforced at the daemon, so a raw-RPC caller cannot + bypass the honesty contract.) +- **Shielded-funding required** (sender de-anon foot-gun): `fromaddress` must be a Sapling z-addr; + a t-addr → `RPC_INVALID_ADDRESS_OR_KEY (-5)`: *"Private transfers must be funded from a private + (shielded) address, or the sender is deanonymized. Use a z-addr."* +- **Shielded recipient required:** `toaddress` must be a Sapling z-addr (a memo can't attach to a + t-addr anyway). +- **DoS governance (as built):** a per-file cap of **40000 bytes** (`ZDC_MAX_FILE_BYTES`, + `src/rpc/datachannel.cpp:84` — a clean advertised value below the single-tx frame ceiling; + larger files are rejected, not fanned out) · a single shielded tx per transfer, hard single-tx + frame ceiling `ZDC_MAX_FRAMES_PER_TX = 90` (3 control + up to 87 DATA frames) · `ZDC_MAX_INFLIGHT + = 256` tracked transfers · `72 h` TTL GC of inflight transfers (the codec holds no clock). + *(A larger 64 KB-default / 256 KB-hard-cap / rate-limit / multi-tx-fan-out governance scheme is + designed in `ZDC1_CODEC_SPEC.md` but the as-built daemon uses the single-tx 40000-byte cap + above.)* + +### 3.3 The as-built daemon RPC surface + +Three RPCs are built and registered (`src/rpc/datachannel.cpp:597-599`), default-OFF behind +`-experimentalfeatures -datachannel`. Each takes exactly **one JSON object** (not positional +args). Below is the as-built contract; the §3.4 native UX and the seal-then-reveal / private-mint +RPCs further down are **designed but NOT built** and are marked as such. + +**`z_senddatafile '{options}'`** — send a private file or message. Exactly one of `filepath` +(the daemon reads the local file — it does NOT auto-fetch URLs) or `hexdata` (raw bytes as hex) +is required; both ≤ **40000 bytes**. Options: +- `fromaddress` (REQUIRED, a Sapling z-addr in this wallet), +- `toaddress` (REQUIRED, recipient Sapling z-addr), +- `filepath` OR `hexdata` (exactly one, ≤ 40000 bytes), +- `acknowledge_permanent` (REQUIRED-true; absent/false → it refuses), +- `filename` (optional, recorded in metadata), +- `content_type` (optional MIME, recorded in metadata). + +It encodes the bytes into ZDC1 frames and emits them as N Sapling output memos in ONE shielded +tx. Returns: +``` +{ "operationid", "transfer_id" (random 64-bit, 16 hex), + "fingerprint" (32-byte ciphertext anchor = NFT document_hash), + "frames", "key" } +``` +The per-transfer `key` is **always** returned to the sender (for selective disclosure); the +on-chain anchor is the random-`transfer_id`-keyed `fingerprint`, NOT a `token_id`. + +**`z_listdatatransfers`** — no parameters. Returns the transfers this node knows about (sent this +session; an in-memory registry, not persisted): +``` +[ { "transfer_id", "fingerprint", "direction", "frames", + "status", "fromaddress", "toaddress", "filename" }, ... ] +``` +`status` is currently the literal string `"recorded"`. + +**`z_getdatatransfer '{options}'`** — reassemble a transfer from the on-chain Sapling memos this +wallet holds, **verify-before-decrypt** (confirm the ciphertext fingerprint matches the recorded +anchor BEFORE any AEAD decrypt), then decrypt. Options: +- `transfer_id` (16-hex id) OR `fingerprint` (64-hex anchor) — one required, +- `address` (optional; the z-addr that received the frames — defaults to the recorded `toaddress`), +- `verify_fingerprint` (optional; a 64-hex anchor known OUT OF BAND, e.g. a published NFT + `document_hash`. If given, the on-chain ciphertext MUST hash to THIS value or the call refuses + to decrypt — `ERR_HASH_MISMATCH`, no plaintext — even when the local registry anchor matches). + +Returns: +``` +{ "transfer_id", "fingerprint", + "verified" (on-chain anchor == recorded anchor), + "complete", "frames_received", + "hexdata", "size", "filename", "content_type" (only on a verified+decrypted result), + "error" (honest codec error string otherwise) } +``` + +> **Designed, NOT built (do not call these against the daemon):** +> - **`z_revealkey`** — a seal-now / reveal-the-key-later trigger that would send a final KEY +> frame for a previously sealed transfer. The as-built `z_senddatafile` instead always emits +> the KEY frame and returns the key to the sender; there is no separate reveal RPC. +> - **`zslp_mint_private`** — a single RPC to mint a 1-of-1 whose asset bytes are sealed over +> ZDC1. The as-built private-mint path is **two steps**: `z_senddatafile` for the sealed bytes +> (which returns the ciphertext `fingerprint`), then an ordinary `zslp_genesis` whose +> `document_hash` is set to that fingerprint (verify-before-decrypt). A `transfer_id == token_id` +> binding is impossible (the genesis txid is not known until the genesis is built), so the +> anchor is the random-`transfer_id` `fingerprint`. + +**Selective disclosure — REUSE, do NOT add a new RPC.** Selective disclosure to an auditor/buyer is +the per-transfer `key` returned by `z_senddatafile`, or — for everything ever sent to a receiving +z-addr — the existing **`z_exportviewingkey`** (`rpcwallet.cpp:4752`): handing over the *incoming +viewing key* lets a third party read the sealed memos (prove contents/receipt) WITHOUT spend +authority. **Honest caveat (surfaced in UX):** a viewing key reveals ALL memos to that address — +so use a single-use receiving z-addr per private NFT, making the disclosure per-item. + +**What the as-built surface deliberately omits:** no `z_receivedatafile` (folded into list+get); +no new status RPC (the async op reuses `z_getoperationstatus`/`z_getoperationresult`); no bespoke +selective-disclosure RPC (reuse `z_exportviewingkey`); the codec's structural ceiling is never the +policy (the single-tx 40000-byte cap is). + +### 3.4 Native UX for each privacy action + +100% native Qt, C++14, reuses `dark.qss` + the green **Private** pill + image-match badge. NO +QtWebEngine. Vocabulary-locked: "sealed", "the key", "note", "private (shielded) address" — never +"memo"/"ivk"/"AEAD". + +> **Status:** this §3.4 is native-GUI **design — NOT built.** No SHIELD GUI exists yet; SHIELD is +> CLI-only today (the three as-built RPCs in §3.3). Where the design below calls `z_revealkey`, +> `zslp_mint_private`, or a `keymode`/`outputformat` option, those are **designed, not built** +> (see §3.3): the as-built daemon always emits the KEY frame and returns the key to the sender, +> and the as-built `z_getdatatransfer` returns `hexdata` directly. Treat the calls below as the +> intended GUI contract, mapped onto the as-built `z_senddatafile`/`z_listdatatransfers`/ +> `z_getdatatransfer` until the optional features land. + +- **Prerequisite read-path fix (binary-safe).** Before any private item can be read, fix the lossy + memo decode at `zcl-qt-wallet/src/rpc.cpp` (~756-790): today it UTF-8-coerces binary ZDC1 frames + into replacement chars. The additive C++14 fix **sniffs the 4-byte magic on RAW bytes before any + `QString` conversion** (`raw.size()==512 && raw[0..3]==0x5A,0x44,0x43,0x31`) and routes those to + a data-channel handler that calls `z_listdatatransfers`/`z_getdatatransfer`; everything else + stays on the unchanged text-inbox path. Keys stay in the daemon. + +- **Send a private message:** a "Private" mode of the send tab (or `NFTSendPrivateDialog`). Live + recipient validation (valid private → green; valid public → amber "A private send needs a private + (shielded) address"). A `QPlainTextEdit` + "0 / 4 KB" counter. A live consequence table (no + daemon round-trip): "Becomes N small private notes" = `ceil(size/464) + 2` (+1 if send-with-key) + — the same number an observer counts on-chain, so it IS the honest size signal. The one honesty + line, always visible: *"Hidden: who it's from, who it's to, the amount, and the contents. + Visible: that a private transfer happened, roughly when, and about how big. It stays on the + network permanently."* Primary label states the outcome: send-with-key → "Send privately"; + reveal-later → "Send sealed". On press → `z_senddatafile(from, to, datahex=utf8(message), + {keymode, acknowledge_permanent:true})` → watch the returned `opid`. + +- **Send a private file:** same dialog, a dropzone ("Drop a file here", "Up to ~40 KB on the + network"). File read on a bounded worker (never `readAll` on the GUI thread); the GUI passes + `hexdata` (or `filepath`) + `filename` + `content_type`. File-too-big is a calm inline amber + state at the 40000-byte cap (never silent truncation). *(The seal-now / reveal-later choice + — `keymode` — is a designed option, not built; the as-built send always includes the key.)* + Footnote: "Either way, only they can ever open it." + +- **Receive** (drives Activity row status from `z_listdatatransfers.state`): `arriving` → + "Arriving… 3 of 5 notes" (greyed) · `sealed` → amber dot + "Waiting for the key" + "Ask sender" + + an "I have a key" paste field → `z_getdatatransfer(..., {keyhex})` (the CALM face of + seal-then-reveal, never an error tone) · `ready` → green dot + "Private message"/"Private file — + aurora.png" + **Open** / **Save…** (Save streams via `z_getdatatransfer(outputformat:"none")` + then a chunked fetch; bytes written verbatim, never auto-executed). + +- **Mint a private NFT:** in the mint dialog the **Private (only people you choose)** tile is green + and DEFAULT-selected *only once ZDC1 is wired* (until then it's "Coming in this release", §2.5). + Body: "The image and details are sealed. Stored encrypted on the ledger; only someone you give + the key to can open it." On Create → `zslp_mint_private({name, ticker, asset:hex, fromaddress, + keymode:"reveal-later", acknowledge_permanent:true})`. The "What becomes public" table lists only + name/ticker/`document_hash`(the ciphertext fingerprint)/`documenturl`; the image bytes stay + sealed. Gallery shows the SAME green Private pill and SAME image-match badge — but the badge runs + against `z_getdatatransfer` assemble (AEAD + plaintext hash) AND the on-chain `document_hash == + ciphertext_fingerprint` check. The verify-line copy is unchanged: "This image matches its + on-chain fingerprint." (a bytes-match, NOT genuine/official/original). Ownership shows PENDING + until ~10 confirmations. + +- **The consent gate (default-OFF + permanence):** first use of ANY private surface shows a + one-time consent dialog stating the limits in plain words — confidential-not-undetectable; the + size/timing/existence leaks; permanent on every node forever; no DRM; no consensus enforcement. + Accept sets a `QSettings` flag AND supplies `acknowledge_permanent:true` to the RPCs. If the + daemon returns `-32601`, show the calm "feature is off" page (latched like the ZSLP index-off + page): managed daemon → confirm → write `datachannel=1` + `experimentalfeatures=1` + restart; + foreign daemon → show the exact conf lines + "Copy". Never a dead end. + +- **Reveal the key / selectively disclose:** **Reveal the key** — one tap in Activity on a + `reveal-later` item → `z_revealkey(from, to, transfer_id, {acknowledge_permanent:true})`. Copy: + "Unlock this for them" / on success "Key sent — they can open it now." An "I'll hand over the key + myself" path shows the stored key for the user's own channel ("Sending the key on the network is + permanent; handing it over yourself keeps it off the network"). **Selectively disclose** — the + detail dialog's "More" → "Let someone verify this privately…" wraps `z_exportviewingkey` for the + item's receiving z-addr, with the honest caveat: "This lets them read everything ever sent to + this private address — not just this item. Because this item used its own one-time private + address, that means just this item." It hands over a *viewing* key (read/prove only), never the + spending key. Honest transfer limit, stated plainly: key-possession cannot stop a prior holder + keeping a copy; the chain proves the fingerprint and (via ZSLP UTXO conservation) who holds the + 1-of-1 token, never the pixels. **No DRM, no anti-copy.** + +--- + +# 4. NON-NEGOTIABLES + +These apply to every doc and every line of code here. They are not roadmap items — engineering AND +product copy must never imply otherwise. (The normative validation rules and threat table live in +`SECURITY_MODEL.md`; if anything here conflicts with it, that doc wins.) + +1. **Consensus never changes.** If a feature needs a new consensus rule, it is out of scope. + Independently verified on this branch: the indexer overrides only `ChainTip` and never + `SyncTransaction` (`src/zslp/zslpindexer.h`) — no mempool/0-conf/validation path; ZSLP is + referenced nowhere in `src/main.cpp` or `src/consensus/`; the write path emits ordinary + `TX_NULL_DATA` payments that unmodified nodes relay/mine; the codec rides existing Sapling + memos; trades compose existing raw-tx RPCs and add no opcode. Keep this grep (`zslp` in + `main.cpp`/`consensus/`) as a CI guard so any accidental coupling is caught. + +2. **Honest badge.** The green check means ONLY *"these bytes match the on-chain fingerprint"* — + **never genuine / authentic / official / original**. Issuer trust is **social** (signed + attestation / verified-issuer list keyed by mint id), not a network badge. No trustless + "verified creator" exists. + +3. **Ownership is PENDING until ~10 confirmations** (`DEFAULT_MAX_REORG_DEPTH = 10`). 1–9 confs are + reorg-reversible; the node finalizes at depth 10 and hard-stops at 99. UI shows pending→final on + that single named constant. + +4. **Never auto-fetch a `document_url`** (leaks IP + interest). Bytes come from local cache + (`ContentEngine::cacheGet`) or ONE explicit, confirmed user action. `ContentEngine::isRemoteUrl` + rejects any `http(s)://` source. **No QtWebEngine / browser / QtMultimedia anywhere — native Qt + only.** Privacy is default-OFF, permanence-consent-gated, shielded-funded by default; private ≠ + undetectable (count/size/timing leak); permanence is a node-operator liability (keep assets + small, 40000-byte per-file cap). + +5. **Holder safety.** An ordinary send / shield / sweep must **never** spend or burn an NFT's + carrier dust UTXO. Two distinct mechanisms back this, and BOTH are now wired in the working + tree on `feature/zslp-nft-indexer` (uncommitted, capability B): (a) the write path's own + self-validate-before-broadcast gate (R-WALLET-9) IS wired — `BuildAndCommitZSLP` calls + `store->WouldBeValid(...)` (`src/wallet/zslpwallet.cpp:460`) and aborts before + `CommitTransaction` (`:475`); and (b) ordinary-send anti-burn IS wired — `AvailableCoins` + takes `fExcludeZSLPTokens` (default `true`, `wallet.h:1124`) and drops protected token/dust + outpoints via `ZSLPIsProtectedTokenOutpoint(...)` (`wallet.cpp:3197`). Holding is therefore + mechanically burn-safe in the working tree; it is the shipped guarantee once these changes + are committed/merged. No UI copy or doc may imply burn-proof holding outside a build that + carries these changes. + +### Structural ceilings a non-consensus overlay can NEVER do (hold the line in UI copy) + +No enforced royalties / resale cut / transfer veto / clawback · no enforced scarcity of the +underlying art (the fingerprint proves *which* bytes, never *exclusivity*) · no ticker / name / +issuer uniqueness (identity is the genesis txid) · no on-chain set/collection membership (sets are +manifest convention; never invent slot counts) · no atomic/trustless trade for any shielded leg · +public ZSLP is fully public and linkable (ownership rides transparent 546-sat dust) · private ≠ +undetectable · permanence is permanent on every node forever. + +--- + +*Single-sourced from `CAPABILITY_MAP.md`, `NATIVE_UI_CONSOLIDATED_SPEC.md`, the Audit-B privacy +RPC+UX spec, and verified against the live tree (`zcl-qt-wallet/src` GUI + `src/zslp`/`src/rpc`/ +`src/wallet`/`src/datachannel` daemon) on `feature/zslp-nft-indexer` (the branch that carries +ZSLP; the write path is present in that branch's working tree, uncommitted). Doc-only — no source +touched, no build run. Hard rules upheld: no consensus change, no browser/multimedia, no +auto-fetch, honest badge, holder safety, C++14.* diff --git a/doc/nft/NATIVE_UI_BUILD_PLAN.md b/doc/nft/NATIVE_UI_BUILD_PLAN.md new file mode 100644 index 00000000000..5d760d74de9 --- /dev/null +++ b/doc/nft/NATIVE_UI_BUILD_PLAN.md @@ -0,0 +1,817 @@ +# ZClassic NFT — Native UI Build Plan (Detail View + Mint Dialog) + +Status: BUILD-READY SPEC (no source edited, no build run — a daemon build is in flight). +GUI repo: `/home/rhett/github/zcl-qt-wallet` @ branch `feature/nft-gallery`. +Grounding docs (this repo): `doc/nft/NATIVE_UX.md` (§3.2 detail, §3.3 mint, §2.2 honesty, §6.3 build order), +`doc/nft/CONTENT_MODEL.md` (ContentEngine API, media kinds, video-out verdict), `doc/nft/MINT_TRANSFER_SPEC.md` +(`zslp_genesis`/`zslp_mint` shape, CRecipient+CreateTransaction, "what becomes public"). + +This plan turns the two MISSING native surfaces into code-from-it detail: exact new files, classes, members, +signals/slots, every edited file with function + approx line, the per-media-kind render path, every state, the +exact microcopy, and the C++14 / threading / privacy / no-web constraints honored. + +Hard, non-negotiable owner constraints (apply to BOTH surfaces): +- Native Qt widgets ONLY. NO QtWebEngine, NO QtMultimedia, NO embedded browser. (The static bundle ships + neither; video is poster + "Open in your video player" via `QDesktopServices::openUrl`.) +- C++14 only (`zcl-qt-wallet.pro:41 CONFIG += c++14`). NO `std::optional` / `std::string_view`. Sentinels = + empty `QString` + int verifyState (0/1/2), exactly as `nft.h:29` and `contentengine.h:64`/`79`. +- DRY: reuse `ContentEngine` (never add a second hash/verify path), the delegate's `tintedIcon` SVG pattern, + the dark.qss token set, the `doRPC` error-aware connector. No new color tokens. +- Privacy floor (P8/C9): the dialogs touch ONLY local bytes. NO `documenturl` is ever auto-fetched on + open/paint/hover. The only network touches are explicit, confirmed user clicks ("Get image", "View in + explorer", the mint broadcast). +- Honesty (§2.2): the badge is ALWAYS "matches its on-chain fingerprint" (a bytes-match), NEVER + "genuine/authentic/official/original". Unknown fields render the literal "Unknown" / "Not part of a set". + Ownership is PENDING until ~10 confs (`DEFAULT_MAX_REORG_DEPTH = 10`). + +--- + +## 1. Overview + shared pieces (DRY) + +These already exist and BOTH dialogs reuse them — do not re-implement. + +### 1.1 The POD and the engine (existing, unchanged) +- `src/nft.h` — `NFTItem { QString name, collection, txid, docHashHex, cachePath; qint64 receivedHeight; bool isPrivate; int verifyState; }`. + Value-copied into the model and into the detail dialog. NOTE the POD does **not** carry creator, set + position, received-DATE, or documenturl — those are async back-fill only (see §2.6). +- `src/contentengine.h` — the ONE streaming content engine. Reused, **not** modified by the detail view except + for ONE small additive signal (see §2.2 "posterReady flag"). Key surface: + - `void posterFor(path, hash, expectedHashHex, sizePx)` — decode+downscale+verify off-thread; delivers a + QPixmap to the **model's** `onImageReady` on the GUI thread. + - `void verify(path, expectedHashHex, token)` -> emits `verifyDone(quint64 token, int verifyState)` (GUI thread). + - `void hashFile(path, token)` -> emits `descriptorReady(quint64 token, ContentDescriptor d)` (GUI thread). + - statics: `classifyKind(path, mimeOut) -> ContentKind {CK_Image,CK_Video,CK_Document,CK_Bytes}`, + `humanSize(bytes)`, `cacheGet(hashHex) -> localPath or ""`, `cachePut(hashHex, srcPath)`, `isRemoteUrl(path)`. + - `struct ContentDescriptor { bool ok; QByteArray merkleRoot, sha256Whole; quint64 fileSize; quint32 chunkSize, chunkCount; QString mime, filename; QByteArray posterHash; bool isPrivate; }` (registered metatype, `contentengine.h:229`). + +### 1.2 Shared visual vocabulary — lift from `nftgallerydelegate.cpp` +Both dialogs render the same three primitives the gallery card already paints. **Reuse the exact tokens and the +tinted-SVG helper concept** so the surfaces are visually identical: + +- **Verify badge.** SVGs already bundled: `:/icons/res/icons/{check,x,question}.svg`. Tint per state: + `1 -> #1f7a1f` (check), `2 -> #c0392b` (x), `0 -> #d9822b` (question). The delegate's `tintedIcon(resource,color,px)` + (alpha-mask -> `CompositionMode_SourceIn` fill, `nftgallerydelegate.cpp:74`) is the canonical recipe — port the + same 12-line body into each dialog as a private `tintedIcon()` helper (or a tiny shared free function in a new + `src/nfticons.h` — OPTIONAL; the duplicated 12 lines are acceptable and keep zero new headers). The detail view + uses a 20px badge; the gallery uses 16px; the mint poster uses a 40px kind glyph. +- **Privacy pill.** Green `#1f7a1f` "Private" / amber `#d9822b` "Public", filled at alpha 38 with a 1px border — + exactly `nftgallerydelegate.cpp:179-200`. Detail view draws this as a styled `QFrame` (qss, not QPainter). +- **dark.qss token set (the ONLY palette, add no new color):** app `#0f1115`, card `#15171c`, inset `#1d2027`, + hairline `#2a2d35`, text `#e6e6e6`, dim/AA-floor `#9aa0a6`, private-green `#1f7a1f`, public/pending-amber + `#d9822b`, mismatch-red `#c0392b`, hover-border `#3d4450`. + +### 1.3 The §2.6 action set (shared, reused from the gallery context menu spec) +Open · Send/Gift · Save image · Copy id · Copy image hash (fingerprint) · Copy collection · Re-check image · +View in explorer (public-only, confirmed). The detail view exposes the full set; the mint dialog exposes none +of these (it is a creation flow, not an item surface). + +### 1.4 Two NEW Settings getters (additive, both surfaces touch) +`Settings::getExplorerUrl()` and `Settings::isPrivateMintWired()`-style flags do NOT exist (`settings.cpp` only has +`getExplorerTxURL`/`getExplorerAddressURL`/`getMinerFee`/`getZCLDisplayFormat`). See §2.4 (detail) and §4 (mint) +for the exact additions. `isPrivateMintWired()` lives on **RPC** (it gates a daemon capability), `getExplorerUrl()` +lives on **Settings** (it gates a UI affordance). + +--- + +## 2. Detail View — `NFTDetailDialog` + +### 2.1 New files +- `src/nftdetaildialog.h` +- `src/nftdetaildialog.cpp` + +Programmatic build (NO `.ui`, matching `setupNFTTab` and the §3.2 spec). Modeless-modal (`open()` not `exec()`), +so the poll loop + async RPC keep flowing and back-fill lands in the already-open dialog (perf contract C10). + +### 2.2 Class `NFTDetailDialog : public QDialog` + +```cpp +// nftdetaildialog.h (C++14: includes for header-signature types live HERE) +#include +#include +#include +#include +#include +#include "nft.h" // NFTItem (by value, header signature) +class ContentEngine; // fwd — pointer member only +class RPC; // fwd — pointer member only +class QLabel; class QFrame; class QPushButton; class QToolButton; + +class NFTDetailDialog : public QDialog { + Q_OBJECT +public: + explicit NFTDetailDialog(const NFTItem& item, + const QVector& ordered, int startIndex, + ContentEngine* engine, RPC* rpc, QWidget* parent = nullptr); + +protected: + void keyPressEvent(QKeyEvent*) override; // Left/Right step; Esc closes (default) + void resizeEvent(QResizeEvent*) override; // re-scale from m_sourcePixmap (C3) + void closeEvent(QCloseEvent*) override; // save QSettings("NFTDetail/geometry") + +private slots: + void onVerifyDone(quint64 token, int verifyState); // ContentEngine::verifyDone + void onPosterReady(quint64 token, QImage img, int verifyState); // NEW signal — see flag + void stepPrev(); + void stepNext(); + void doCopyId(); + void doSaveImage(); + void doRecheck(); + void doOpenInPlayer(); + void doViewInExplorer(); + void doSendGift(); + +private: + void buildUi(); // construct the (reused) widget tree ONCE + void loadItem(int newIndex); // re-feed the SAME widgets from m_ordered[newIndex] + void renderKind(); // switch the image stage by m_kind + void applyVerifyState(int s); // set m_verifyLine dyn-prop state= + repolish + void backfillProvenance(); // async RPC: provenance + received date + void setProvenanceRow(...); // tiny helpers to repaint value cells + + // --- value state (POD-cheap copies) --- + NFTItem m_item; + QVector m_ordered; // value copy — NO model pointer + int m_index = 0; + QPointer m_engine; // QPointer: late-callback safe on forced close + QPointer m_rpc; + quint64 m_verifyToken = 0; // monotonic; drops stale neighbor replies + quint64 m_provToken = 0; // monotonic; drops stale provenance replies + QPixmap m_sourcePixmap; // full-res source; resize re-scales w/o re-decode (C3) + QString m_localBytesPath; // cacheGet(docHashHex); "" = not on this device + int m_kind = 0; // ContentKind; default CK_Image + QString m_mime; + // async back-fill (default = honest "Unknown" sentinels) + QString m_creator; // "" -> "Unknown" + QString m_setLabel; // "" -> "Not part of a set" + QString m_receivedDate; // "" -> "block N (date pending)" + int m_confirmations = -1; + + // --- widgets (raw, parented to the dialog) --- + QLabel* m_titleName = nullptr; + QLabel* m_titleCollection = nullptr; + QToolButton* m_closeBtn = nullptr; + QLabel* m_imageStage = nullptr; // the big rendered asset + QLabel* m_badgeOverlay = nullptr; // 20px verify badge, floated top-right of stage + QFrame* m_verifyLine = nullptr; // dyn-prop state= drives the qss color swap + QLabel* m_verifyIcon = nullptr; + QLabel* m_verifyText = nullptr; + QFrame* m_privacyPill = nullptr; + QLabel* m_privacyOneLiner = nullptr; + QLabel* m_valMintId = nullptr; QLabel* m_valReceived = nullptr; + QLabel* m_valCreator = nullptr; QLabel* m_valSet = nullptr; + QLabel* m_valImageHash = nullptr; + QPushButton* m_btnSendGift = nullptr; + QPushButton* m_btnSaveImage = nullptr; + QPushButton* m_btnCopyId = nullptr; + QToolButton* m_btnMore = nullptr; // QMenu: Copy image hash / Copy collection / View in explorer / Re-check + QPushButton* m_btnGetImage = nullptr; // hidden in C1 (no documenturl on POD) + QPushButton* m_btnOpenInPlayer = nullptr; // video kind only + QToolButton* m_prevBtn = nullptr; QToolButton* m_nextBtn = nullptr; +}; +``` + +The dialog holds the ordered list **by value** so prev/next walks neighbors with zero model coupling. `m_engine` +and `m_rpc` are `QPointer` for forced-close safety (both outlive the dialog as `MainWindow` members, but the +guard costs nothing). + +**OPEN IMPLEMENTER FLAG (resolve before step 2.b of the build order) — poster delivery.** +`ContentEngine::posterFor()` today delivers ONLY to `NFTGalleryModel::onImageReady` (a model slot). To feed the +dialog's large image cleanly, do the **recommended, DRY** thing: add a per-call signal to ContentEngine + +```cpp +// contentengine.h, next to verifyDone (line ~200), additive, no behavior change for the model path: +void posterReady(quint64 token, QImage img, int verifyState); +``` + +and emit it from `deliver()` (the existing GUI-thread landing, `contentengine.h:206`) **in addition to** the +model call, keyed by a token the caller passed. This is the cleanest symmetric sibling of `verifyDone`. (The +worker still produces a `QImage` only; the dialog builds nothing off-thread.) The QImage->QPixmap on the dialog +side happens on the GUI thread in `onPosterReady`. Do **NOT** give the dialog a throwaway `NFTGalleryModel`, and +do **NOT** read the on-disk poster cache by hand (racy). *(If a reviewer rejects touching ContentEngine for the +detail step, the only acceptable fallback is the on-disk poster-cache read after `posterFor` completes — but the +signal is preferred and is the planned approach. Either way the dialog gets NO model pointer.)* + +### 2.3 Edited files (detail view) + +| File | Where | Change | +|---|---|---| +| `src/mainwindow.h` | private slots near `setNFTItems` (~line 125) | declare `void openNFTDetail(const QModelIndex& index);` | +| `src/mainwindow.cpp` | end of `setupNFTTab()` (~line 3055, right after `view->setItemDelegate(...)`, before `outer->addWidget(view,1)` at 3057) | add `connect(view, &QListView::activated, this, &MainWindow::openNFTDetail);` — **activated only** (it fires on double-click AND Enter/Space per §3.1; do NOT also connect `doubleClicked` or it opens twice). Then add the `openNFTDetail` body (see §2.5). | +| `src/contentengine.h` | next to `verifyDone` (~line 200) | add `void posterReady(quint64 token, QImage img, int verifyState);` (additive signal — see the flag above) | +| `src/contentengine.cpp` | inside `deliver()` (the GUI-thread landing for poster workers) | also `emit posterReady(token, img, verifyState);` for callers that requested a per-call delivery (carry the token through the worker the same way `verify()` already carries one) | +| `src/rpc.h` | next to `refreshNFTs` (declared ~line 216, public sibling region) | add `void nftProvenance(QString tokenId, const std::function& cb);` and `void txReceivedDate(QString txid, const std::function& cb);` | +| `src/rpc.cpp` | after `refreshNFTs` (~line 1000) | implement both (see §4.2). Both follow the `doRPC(payload, successCb, errorCb)` error-aware pattern (`connection.h:356`) and the graceful-fallback style of `refreshNFTs` — any error leaves the field at its honest default, never a dialog. | +| `src/settings.h` / `src/settings.cpp` | next to `getExplorerTxURL` (`settings.cpp:421`) | add `static QString getExplorerUrl();` returning the explorer **base** (`"https://explorer.zcl.zelcore.io/tx/"`, `""` on testnet) — distinct from the existing `getExplorerTxURL(txid)` which appends a txid. The detail view's "View in explorer" needs the bare base for the enable-gate and appends `m_item.txid` itself. | +| `res/styles/dark.qss` | append | `#nftDetailVerifyLine[state="verified"] { color:#1f7a1f; }` `[state="mismatch"]{color:#c0392b;}` `[state="pending"]{color:#d9822b;}`; `#nftDetailStage{ background:#1d2027; border-radius:8px; }`; `#nftDetailCard{ background:#15171c; border:1px solid #2a2d35; border-radius:12px; }`; pill selectors `#nftDetailPrivacyPill[priv="true"]`/`[priv="false"]`. ~20 lines; no token changes. | +| `zcl-qt-wallet.pro` | `SOURCES +=` after `src/contentengine.cpp` (line 51); `HEADERS +=` after `src/contentengine.h` (line 81) | add `src/nftdetaildialog.cpp` / `.h`. No new Qt module (core gui network svg widgets already linked; QtConcurrent NOT needed). | +| `tests/tests.pro` (L0) and/or `tests/widget/tst_widget.pro` (L1) | HEADERS/SOURCES | add `nftdetaildialog.h`/`.cpp` if a `tst_widget` open/prev-next/state case is written (recommended, see build order). | + +### 2.4 Settings getter (exact) + +```cpp +// settings.h, near line 134 +static QString getExplorerUrl(); // base, e.g. "https://explorer.zcl.zelcore.io/tx/"; "" on testnet +// settings.cpp, near line 421 (mirror getExplorerTxURL's testnet guard) +QString Settings::getExplorerUrl() { + if (Settings::getInstance()->isTestnet()) return ""; + return "https://explorer.zcl.zelcore.io/tx/"; +} +``` + +### 2.5 `MainWindow::openNFTDetail` body (the only gallery edit besides the connect) + +```cpp +void MainWindow::openNFTDetail(const QModelIndex& index) { + if (!index.isValid() || !nftModel) return; + // Snapshot the ordered POD list (NO model pointer handed to the dialog). + QVector ordered; + const int n = nftModel->rowCount(); + ordered.reserve(n); + for (int r = 0; r < n; ++r) + if (nftModel->isValidRow(r)) ordered.push_back(nftModel->itemAt(r)); // itemAt/isValidRow exist, model.h:54 + const int start = index.row(); + if (start < 0 || start >= ordered.size()) return; + auto* dlg = new NFTDetailDialog(ordered.at(start), ordered, start, + nftEngine /*see mint §3 — or reuse a ContentEngine*/, rpc, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->open(); // NOT exec() — keep the poll loop + back-fill flowing (C10) +} +``` + +NOTE on the engine pointer: the detail view needs a **ContentEngine** (for `posterFor`/`verify`). The gallery +today wires `nftImgCache` (an `NFTImageCache`), `mainwindow.cpp:3053`. The mint surface (§3) adds an +`nftEngine = new ContentEngine(nftModel, this);` member — **share that one member with the detail view** (one +ContentEngine for the whole NFT subsystem). If the detail view ships before the mint surface, add the +`nftEngine` member in this step instead. Either way there is exactly ONE `ContentEngine` instance. +`NFTGalleryModel` already exposes `itemAt(int)` + `isValidRow(int)` (`nftgallerymodel.h:54`); no new accessor is +strictly required (an optional `const QVector& items() const` is a nicety, not needed). + +### 2.6 Wiring — DIALOG -> ContentEngine + RPC + +`loadItem(i)` is the single re-feed entry (initial open AND every prev/next): + +```cpp +void NFTDetailDialog::loadItem(int i) { + m_index = i; m_item = m_ordered.at(i); + // reset back-fill to honest defaults so a fast step never shows the neighbor's data + m_creator.clear(); m_setLabel.clear(); m_receivedDate.clear(); m_confirmations = -1; + // wallet-local POD fields paint INSTANTLY (no RPC on the paint path, C10) + m_titleName->setText(m_item.name); + m_titleCollection->setText(m_item.collection.isEmpty() ? tr("Not part of a set") : m_item.collection); + m_valMintId->setText(shortId(m_item.txid)); // 8…8 + m_valImageHash->setText(shortId(m_item.docHashHex)); + applyPrivacy(m_item.isPrivate); + // resolve local bytes (empty sentinel = not on this device) — PRIVACY: cacheGet only, never a URL + m_localBytesPath = ContentEngine::cacheGet(m_item.docHashHex); + m_kind = m_localBytesPath.isEmpty() ? CK_Image + : ContentEngine::classifyKind(m_localBytesPath, m_mime); + renderKind(); + // verify badge + poster — token-guarded so a stale neighbor reply is dropped + if (m_engine && !m_localBytesPath.isEmpty()) { + const quint64 t = ++m_verifyToken; + m_engine->verify(m_localBytesPath, m_item.docHashHex, t); + if (m_kind == CK_Image) + m_engine->posterFor(m_localBytesPath, m_item.docHashHex, m_item.docHashHex, 512); // delivers posterReady + } else { + applyVerifyState(m_item.verifyState); // POD's last-known state; pending/uncached states below + } + backfillProvenance(); // async RPC, bumped m_provToken + m_prevBtn->setEnabled(i > 0); + m_nextBtn->setEnabled(i + 1 < m_ordered.size()); +} +``` + +- `connect(m_engine, &ContentEngine::verifyDone, this, &NFTDetailDialog::onVerifyDone)` and + `connect(m_engine, &ContentEngine::posterReady, this, &NFTDetailDialog::onPosterReady)` are made ONCE in + `buildUi()`. Both slots guard `if (token != m_verifyToken) return;` (drops a stale neighbor's reply after a + fast prev/next). +- `onPosterReady` builds the QPixmap on the GUI thread, stores it as `m_sourcePixmap`, scales it into + `m_imageStage` with `KeepAspectRatio + SmoothTransformation`, and calls `applyVerifyState(verifyState)` (the + badge + line flip together). +- `backfillProvenance()`: + +```cpp +void NFTDetailDialog::backfillProvenance() { + if (!m_rpc) return; + const quint64 tok = ++m_provToken; + m_rpc->nftProvenance(m_item.txid, [tok,this](QString creator, QString setLabel){ + if (tok != m_provToken) return; // stale neighbor — drop + m_creator = creator; m_setLabel = setLabel; + m_valCreator->setText(creator.isEmpty() ? tr("Unknown") : creator); + m_valSet->setText(setLabel.isEmpty() ? tr("Not part of a set") : setLabel); + }); + m_rpc->txReceivedDate(m_item.txid, [tok,this](QString iso, int confs){ + if (tok != m_provToken) return; + m_receivedDate = iso; m_confirmations = confs; + if (confs >= 0 && confs < 10) // DEFAULT_MAX_REORG_DEPTH + m_valReceived->setText(tr("Just arrived — confirming…")); + else if (!iso.isEmpty()) + m_valReceived->setText(iso + tr(" · block %1").arg(m_item.receivedHeight)); + else + m_valReceived->setText(tr("block %1 (date pending)").arg(m_item.receivedHeight)); + }); +} +``` + +Both lambdas guard on the monotonic `m_provToken` bumped per `loadItem`, so a fast prev/next never paints the +previous item's provenance into the new one. + +### 2.7 Prev / Next stepping + +`m_prevBtn`/`m_nextBtn` + Left/Right in `keyPressEvent` + (optional) a `QShortcut` all call `stepPrev`/`stepNext`, +which bound-check and call `loadItem(i±1)`. `loadItem` re-feeds the **same** widgets (no new dialog, no model +pointer, no flicker — §3.2 "walks that list and re-feeds the same dialog with the neighbor's POD"). Disable +`m_prevBtn` at index 0 and `m_nextBtn` at `size-1`. + +### 2.8 Media-by-kind — `renderKind()` + +Kind from `ContentEngine::classifyKind(m_localBytesPath, m_mime)`. PRIVACY: every branch touches ONLY +`m_localBytesPath` (the cacheGet result) — NO documenturl, ever. + +- **IMAGE (`CK_Image`):** request the large pixmap via `m_engine->posterFor(localPath, docHashHex, docHashHex, 512)`; + paint the full QPixmap on `m_imageStage` with `KeepAspectRatio + SmoothTransformation`, letterboxed on + `#1d2027`, never upscaled past 1024 native. Hold the source in `m_sourcePixmap` so resize re-scales without + re-decode/re-hash (perf contract C3). +- **VIDEO (`CK_Video`):** NO in-app playback (QtMultimedia is out). Show a typed film-strip/poster placeholder + (NEVER a faked frame) with an overlaid play glyph, a `Video · · ` caption (size via + `ContentEngine::humanSize`), the verify badge, and a prominent primary `m_btnOpenInPlayer` -> + `QDesktopServices::openUrl(QUrl::fromLocalFile(m_localBytesPath))` (mirrors `mainwindow.cpp:1847/2558`). + ENABLED ONLY when `m_localBytesPath` is non-empty AND `verifyState == 1` — `openUrl` silently fails on a + missing/remote path, so gate it (honest limit #1). +- **DOCUMENT (`CK_Document`):** large typed MIME icon (tinted via the delegate's `tintedIcon` pattern) + + "Open" (`openUrl` of the file) + optional "Reveal in folder" (`openUrl` of the dir, `mainwindow.cpp:2558`). + External only, never embedded. +- **BYTES (`CK_Bytes`):** typed glyph + a size/hex summary (`humanSize`) + "Save as…" (`QFileDialog`). Never + auto-execute bytes. + +All kinds also paint: name, collection, the mono copyable mint id (`txid`), the received height/date, the +verify badge, and the local/not-on-device state. + +### 2.9 Actions + +- **Send / Gift** (`m_btnSendGift`, the bright green primary): in C0/C1 a `QMessageBox`/toast + "Sending NFTs is coming soon." (later opens the NFTSendDialog pre-filled). On a MISMATCH item (`verifyState==2`) + it first confirms "This image failed its on-chain check. Send anyway?" `[Send anyway]/[Cancel]`. +- **Save image…** (`m_btnSaveImage`): `QFileDialog::getSaveFileName` default `.png`; writes the + cached bytes VERBATIM from `m_localBytesPath` (`QFile::copy`). DISABLED until `m_localBytesPath` is non-empty. + Stays ENABLED in the MISMATCH state (the user may still want the bytes). +- **Copy id** (`m_btnCopyId`): `QApplication::clipboard()->setText(m_item.txid)`; flip label to "Copied ✓" for + 1.2 s via `QTimer::singleShot`, then revert. +- **More** (`m_btnMore`, `QToolButton` + `QMenu`): "Copy image hash" (full lowercase `m_item.docHashHex`), + "Copy collection", "Re-check image" (re-issues `m_engine->verify(...)` with a bumped token), and "View in + explorer" ENABLED ONLY if `!Settings::getExplorerUrl().isEmpty()` AND `m_item.isPrivate == false`; on trigger + ask once "This opens an outside website and may reveal your interest. Continue?" then + `QDesktopServices::openUrl(QUrl(Settings::getExplorerUrl() + m_item.txid))` (same primitive as + `mainwindow.cpp:3266` / `sendtab.cpp:1580`). +- **Get image** (`m_btnGetImage`): hidden in C1 (no documenturl on the POD). When a documenturl is back-filled + AND the item is public, show it as a single explicit, confirmed, hash-verify-before-display, one-shot fetch. + +### 2.10 States (detail) + +| State | Behavior | +|---|---| +| LOADING / decoding | `m_imageStage` shimmer; verify line amber "Checking this image…" + 20px "?" badge; Send/Gift + Copy id ENABLED (need only the id); Save + Open-in-player DISABLED. Provenance rows at honest defaults until RPC returns. | +| VERIFIED (1) | green "This image matches its on-chain fingerprint."; `m_verifyLine` `state="verified"`; 20px green check top-right; ALL actions enabled. Copy/tooltip note: a bytes-match ONLY, never "genuine/original". | +| MISMATCH (2) | red "This image does NOT match what was recorded on-chain. Don't trust it."; `state="mismatch"`; 20px red x + thin red inset hairline; image dimmed ~60%; Save stays ENABLED; Send/Gift confirms first. | +| PENDING / UNCACHED — private | amber "This image lives in your wallet's local cache." with NO fetch button (P8/C9). | +| PENDING / UNCACHED — public (documenturl known, back-filled) | amber "Image not on this device yet." + single explicit "Get image" (one-shot, hash-verify, never auto-runs). In C1 no documenturl on POD => no Get-image button. | +| PRIVATE | green "Private" pill + "Only you can see this. Its ownership is shielded."; explorer permanently disabled/absent. | +| PUBLIC | amber "Public" pill + "Anyone can verify this on the public ledger."; explorer enabled iff `getExplorerUrl()` set. | +| EMPTY METADATA | Creator => "Unknown"; Set => "Not part of a set"; unknown height => "block — (unknown)". Never blank/fabricated. | +| RECEIVED-PENDING (confs < 10) | Received row "Just arrived — confirming…"; ownership shown pending until 10 confs. >=10 => ISO date + "block 1,842,001". Until `gettransaction` returns => "block N (date pending)". | +| INDEX-OFF | wallet-local fields still render; calm note "Turn on the collectibles index to see full provenance." replaces the back-filled rows — no error dialog. | +| ERROR / not-an-image | amber "This file isn't an image we can show." + neutral broken-image glyph; never a crash. Video => film-strip + caption + "Open in your video player". Document => typed icon + "Open". Bytes => typed glyph + size + "Save as…". | +| RESIZE | re-scales from `m_sourcePixmap`, `KeepAspectRatio + SmoothTransformation`, never upscaled past 1024 native, letterboxed on `#1d2027` (no re-decode/re-hash, C3). | +| PREV/NEXT step | `loadItem()` re-feeds the same widgets; prev disabled at 0, next at last; stale neighbor verify/provenance dropped by the token guards. | + +--- + +## 3. Mint Dialog — `NftMintDialog` ("Create NFT") + +### 3.1 New files +- `src/nftmintdialog.h` — `class NftMintDialog : public QDialog` (Q_OBJECT), built programmatically (NO `.ui`, + matching `setupNFTTab` `mainwindow.cpp:3017`; a `.ui` would force a `FORMS`+uic entry and a fragile generated + layout for the dynamic preview/progress). +- `src/nftmintdialog.cpp` — ~520 lines. No new third-party deps. Includes `contentengine.h`, `rpc.h`, + `settings.h`, and `QFileDialog`, `QDragEnterEvent`, `QDropEvent`, `QMimeData`, `QDesktopServices` (already + app-wide, `sendtab.cpp:1580`), `QProgressBar`, `QStackedWidget`, `QRadioButton`, `QLineEdit`. + +Single-window, vertically-stepped (a `QStackedWidget` with 3 pages: PICK -> DETAILS -> REVIEW), NOT a wizard, +so Back/Next never loses state. (NATIVE_UX §3.3 describes a single-scroll 4-card layout; this stepped form is the +implementation-chosen equivalent — both are native, both keep state, both honor the same copy/states. Pick ONE; +this plan ships the 3-page stack because it makes the async-hash-gates-Next contract trivial.) + +### 3.2 Class `NftMintDialog : public QDialog` + +```cpp +// nftmintdialog.h +#include +#include +#include "contentengine.h" // ContentDescriptor by value (registered metatype), ContentKind +class RPC; class QStackedWidget; class QLabel; class QPushButton; class QLineEdit; +class QRadioButton; class QProgressBar; + +class NftMintDialog : public QDialog { + Q_OBJECT +public: + explicit NftMintDialog(ContentEngine* engine, RPC* rpc, QWidget* parent = nullptr); +protected: + void dragEnterEvent(QDragEnterEvent*) override; + void dropEvent(QDropEvent*) override; +private slots: + void onBrowse(); + void onFileChosen(const QString& path); + void onDescriptorReady(quint64 token, ContentDescriptor d); + void onPrivacyToggled(); + void goNext(); void goBack(); void onCreate(); + void onMintDone(bool ok, QString txidOrErr); +private: + void buildPickPage(); void buildDetailsPage(); void buildReviewPage(); + void renderPoster(const QString& path); + void refreshReview(); + void setBusy(bool b); + bool privateMintAvailable() const; // -> m_rpc->isPrivateMintWired() (hard-false today) + + QStackedWidget* stack = nullptr; + // PAGE 0 PICK + QLabel* dropZone = nullptr; QPushButton* btnBrowse = nullptr; + // PAGE 1 DETAILS + QLabel* posterLabel=nullptr; QLabel* kindLabel=nullptr; QLabel* fileMetaLabel=nullptr; + QLineEdit* edtName=nullptr; QLineEdit* edtCollection=nullptr; + QRadioButton* rdoPublic=nullptr; QRadioButton* rdoPrivate=nullptr; + QLabel* privacyExplain=nullptr; QLineEdit* edtDocUrl=nullptr; + QProgressBar* hashProgress=nullptr; QLabel* hashStatus=nullptr; + // PAGE 2 REVIEW + QLabel* reviewPoster=nullptr; QLabel* reviewSummary=nullptr; QLabel* feeLabel=nullptr; + QLabel* publicityLabel=nullptr; QLabel* honestyLabel=nullptr; QLabel* mintError=nullptr; + // FOOTER (persistent) + QPushButton* btnBack=nullptr; QPushButton* btnNext=nullptr; + QPushButton* btnCancel=nullptr; QPushButton* btnCreate=nullptr; + // state + QString m_srcPath; ContentDescriptor m_desc; int m_kind = CK_Bytes; + bool m_descReady=false; quint64 m_token=0; bool m_creating=false; + ContentEngine* m_engine=nullptr; RPC* m_rpc=nullptr; + static quint64 kHashToken; // monotonic seed for hash tokens +}; +``` + +DRY: no per-page widget classes — pages are plain `QWidget*` built by `buildXxxPage()` helpers, matching how +`setupNFTTab` builds inline. `ContentDescriptor` is taken/stored by value (registered metatype, +`contentengine.h:229`). C++14: empty-QString sentinels, `ContentDescriptor.ok` / int-state sentinels. + +### 3.3 Edited files (mint) + +| File | Where | Change | +|---|---|---| +| `src/mainwindow.cpp` | `setupNFTTab()` (3017-3079) | (a) insert a toolbar HBox `nftToolbar` under `sub` (after line 3035): `stretch + QPushButton tr("Create NFT…")` objectName `nftCreateBtn`. (b) construct the shared engine: `nftEngine = new ContentEngine(nftModel, this);` alongside `nftImgCache` (3053). (c) `connect(btnCreateNFT, &QPushButton::clicked, this, &MainWindow::openMintDialog);` at the end of `setupNFTTab`. | +| `src/mainwindow.h` | NFT block (350-356) | `ContentEngine* nftEngine = nullptr;` (fwd-declare `class ContentEngine;` near line 22 next to `NFTGalleryModel`/`NFTImageCache`); private slot `void openMintDialog();`. Keep `#include "nft.h"` (line 6). | +| `src/rpc.h` | next to `sendZTransaction` (line 65) | a POD `struct MintOpts { QString name; QString collection; QString documentUrl; bool isPrivate=false; };` + `void mintNFT(const ContentDescriptor& descriptor, const MintOpts& opts, const std::function& cb);` + `bool isPrivateMintWired() const;`. (Forward-declare `struct ContentDescriptor;` or include `contentengine.h`; take `descriptor` by const ref with the include in rpc.cpp.) | +| `src/rpc.cpp` | after `sendZTransaction` (~533) | implement `mintNFT` (see §4.1) + `isPrivateMintWired()` returning a hard `false` (until the ZDC1 private channel exists). | +| `zcl-qt-wallet.pro` | `SOURCES +=` after `src/nftdetaildialog.cpp`; `HEADERS +=` after `src/nftdetaildialog.h` | add `src/nftmintdialog.cpp`/`.h`. No `FORMS` entry (programmatic). QtSvg present (pro line 14) for the kind glyphs; widgets present (line 20). | +| `res/styles/dark.qss` | append | `#dropZone` (1px DASHED `#3d4450` border, inset `#1d2027` bg, generous radius/pad, hover border `#3d4450`/solid `#1f7a1f` on drag); `#nftCreateBtn` (accent: bg `#1f7a1f`, hover `#2a9d2a` — mirror the `QPushButton:default` rule at `dark.qss:93-96`); `#mintPrivacyExplain`/`#mintHonesty` (color `#9aa0a6`, the AA floor); `#mintFee` (color `#e6e6e6`, bold). ~25 lines; no token changes. | + +### 3.4 Entry point + wiring (mint) + +```cpp +void MainWindow::openMintDialog() { + if (!nftEngine || !rpc) return; + NftMintDialog dlg(nftEngine, rpc, this); // modal, stack-allocated (sendtab.cpp:1039 pattern) + if (dlg.exec() == QDialog::Accepted) + rpc->refreshNFTs(); // instant feedback (it also polls) +} +``` + +**FILE PICK -> HASH.** `onBrowse()` -> `QFileDialog::getOpenFileName(this, tr("Choose a file to turn into an NFT"), +lastDir, tr("All files (*)"))`. `dropEvent` reads `event->mimeData()->urls().first().toLocalFile()`. Both funnel to +`onFileChosen(path)`: +- **GUARD** with `ContentEngine::isRemoteUrl(path)` (`contentengine.h:190`) — reject any http(s) drop with an + inline red label (privacy hard rule), stay on PICK. +- `m_srcPath = path; m_kind = ContentEngine::classifyKind(path, mime)` to pick the poster glyph; `renderPoster(path)` + via `m_engine->posterFor(path, key, "", 160)` for images OR a typed glyph for video/document/bytes; prefill + `edtName` with `QFileInfo(path).completeBaseName()`; advance to DETAILS; start hashing: + `m_descReady=false; m_token = ++kHashToken; m_engine->hashFile(path, m_token)` (`contentengine.h:106` — STREAMING, + bounded RAM, never freezes the UI even for a 2 GB video). Show `hashProgress` indeterminate + + `hashStatus = tr("Reading your file… %1").arg(humanSize)`. Disable `btnNext` until the descriptor is ready. + +**ASYNC LANDING.** `connect(m_engine, &ContentEngine::descriptorReady, this, &NftMintDialog::onDescriptorReady)` in +the ctor. `onDescriptorReady(token, d)`: +```cpp +if (token != m_token) return; // a faster re-drop superseded it +if (!d.ok) { hashStatus->setText(tr("That file couldn't be read. Try another.")); /* keep Next disabled */ } +else { + m_desc = d; m_descReady = true; hashProgress->hide(); + hashStatus->setText(tr("Fingerprint ready.")); + fileMetaLabel->setText(d.filename + " • " + ContentEngine::humanSize(d.fileSize)); + btnNext->setEnabled(true); +} +``` +The descriptor carries `merkleRoot + sha256Whole + fileSize + mime + filename` (`contentengine.h:64`); the +on-chain anchor is the merkle root (large files) / sha256 (small), shown as the fingerprint in REVIEW. + +**PRIVACY TOGGLE.** `rdoPublic`/`rdoPrivate` exclusive. `onPrivacyToggled()`: +- PUBLIC -> `privacyExplain = tr("Public: anyone can look up this NFT's name and fingerprint on-chain. You may also add an optional link to where the file lives.")`; show `edtDocUrl`. +- PRIVATE -> `privacyExplain = tr("Private: only people you share it with can see it. The provenance is shielded.")`; hide `edtDocUrl`. +- **CRITICAL GATE:** `if (!privateMintAvailable()) { rdoPrivate->setEnabled(false); /* append dim tr("Coming in this release") */ rdoPublic->setChecked(true); }`. `privateMintAvailable() -> m_rpc->isPrivateMintWired()` which is hard-false until the ZDC1 channel RPC lands. This yields a clearly-disabled RADIO, NOT a dead Create button. + +**REVIEW (`refreshReview`, on entering page 2).** +```cpp +feeLabel->setText(tr("Network fee: %1").arg(Settings::getZCLDisplayFormat(Settings::getMinerFee()))); // 0.0001 ZCL +// what becomes public (honest): +if (rdoPublic->isChecked()) + publicityLabel->setText(tr("What goes on-chain (public): the file's fingerprint, the name \"%1\"%2.") + .arg(edtName->text().trimmed()) + .arg(edtDocUrl->text().trimmed().isEmpty() ? QString() + : tr(", and your link %1").arg(edtDocUrl->text().trimmed()))); +else + publicityLabel->setText(tr("What goes on-chain: only an encrypted record. The name and fingerprint are shielded.")); +honestyLabel->setText(tr("Minting does NOT upload your file anywhere. Only its fingerprint goes on-chain — the file stays on your computer.")); +// reviewSummary lists name/collection/kind/size +``` + +**CREATE.** `onCreate()`: +```cpp +setBusy(true); // disable footer; btnCreate text -> tr("Creating…") +MintOpts opts{ edtName->text().trimmed(), edtCollection->text().trimmed(), + rdoPublic->isChecked() ? edtDocUrl->text().trimmed() : QString(), + rdoPrivate->isChecked() }; +m_rpc->mintNFT(m_desc, opts, [this](bool ok, QString r){ onMintDone(ok, r); }); +``` +`onMintDone(ok, txid)`: if ok -> `ContentEngine::cachePut(fingerprintHex, m_srcPath)` (store local bytes +content-addressed so the new card verifies green immediately) -> `accept()`. else -> `setBusy(false)` + show the +inline `mintError` label with the daemon message. + +**POST-SUCCESS in MainWindow.** `openMintDialog`'s `exec()==Accepted` -> `rpc->refreshNFTs()` (`rpc.cpp:863`) +re-polls `zslp_listmytokens`; `setNFTItems` (`mainwindow.cpp:3103`) feeds the new card. Because `cachePut` stored +the bytes, a follow-up could populate `cachePath` for instant verify (a one-line future hook in `refreshNFTs`: +`cacheGet(documenthash)` per item — noted, not required for v1). + +### 3.5 Media-by-kind (mint poster — DETAILS + REVIEW, never a player) + +`ContentEngine::classifyKind(path, mime)` (`contentengine.h:165`) -> `CK_Image/CK_Video/CK_Document/CK_Bytes`. +- IMAGE: `m_engine->posterFor(path, key, "", 160)` -> decoded+downscaled QImage on the GUI thread -> `posterLabel`. +- VIDEO: typed film/play SVG glyph tinted via the SAME `tintedIcon` mask the delegate uses + (`nftgallerydelegate.cpp:74`) + caption `Video • `. NO in-app playback (no-faked-frame rule). +- DOCUMENT / BYTES: a typed document/file glyph + caption. +- PROGRESS for large files: `hashFile` streams 1 MiB blocks (`kHashBufBytes`, `contentengine.h:193`) on a + bounded-pool thread and signals ONCE at the end — so there is no per-byte callback. The dialog shows an + INDETERMINATE `QProgressBar` (`setRange(0,0)`) plus the file size in `hashStatus`, honestly communicating + "working" without faking a percentage. (A determinate bar would need a new progress signal on ContentEngine — + out of scope for v1; indeterminate is correct and non-blocking.) The worker NEVER touches a QPixmap + (`contentengine.h:16`) — `posterLabel`'s pixmap is built on the GUI thread by `posterFor`'s deliver path. + +### 3.6 States (mint) + +| State | Behavior | +|---|---| +| EMPTY / PICK (page 0) | drop zone + Browse. btnNext hidden/disabled; only Cancel active. | +| REMOTE-URL REJECTED (page 0, transient) | dropped http(s) URL -> inline red `#c0392b` "For your privacy, drop a local file — not a web link."; stay on page 0. | +| HASHING (page 1, descriptor pending) | poster + filename shown; `hashProgress` indeterminate; `hashStatus` "Reading your file…"; btnNext DISABLED. Streaming `hashFile` keeps the UI responsive for multi-GB files. | +| READY (page 1, d.ok) | `hashProgress` hidden; `hashStatus` "Fingerprint ready."; kind+poster+size; name/collection editable; privacy radios live; btnNext ENABLED. | +| UNREADABLE (page 1, d.ok==false) | `hashStatus` "That file couldn't be read. Try another."; btnNext stays DISABLED. | +| PUBLIC selected | `edtDocUrl` visible+optional; public explain. | +| PRIVATE selected (when wired) | `edtDocUrl` hidden; private explain. | +| PRIVATE COMING-SOON (RPC not wired — the DEFAULT today) | `rdoPrivate` DISABLED with adjacent dim "Coming in this release"; `rdoPublic` forced-on. Create is NEVER dead — it stays enabled for Public. | +| REVIEW (page 2) | fee + "what becomes public" + honesty line + summary; btnBack + btnCreate (accent green) + Cancel. | +| CREATING (page 2 busy) | footer disabled; btnCreate text "Creating…"; no spinner widget (text change + disabled state). | +| MINT ERROR | inline red label under the summary with the daemon message; footer re-enabled to retry or cancel. | +| SUCCESS | `accept()`; gallery refreshes; the new card appears verified-green (cachePut stored the bytes). | + +--- + +## 4. Shared RPC additions + +### 4.1 `RPC::mintNFT` + `isPrivateMintWired` (mint dialog) — `rpc.cpp` after `sendZTransaction` (~533) + +Per MINT_TRANSFER_SPEC: the GUI calls the daemon `zslp_genesis`/`zslp_mint` thin-shell RPC (which builds the +OP_RETURN tx natively via `CRecipient{scriptPubKey,nAmount,fSubtractFeeFromAmount}` -> `CWallet::CreateTransaction` +-> `CommitTransaction`, with the §2.6 vout-ordering safety so the NFT is never burned). The GUI side mirrors +`sendZTransaction` (`rpc.cpp:516-532`): + +```cpp +bool RPC::isPrivateMintWired() const { return false; } // hard-false until the ZDC1 private channel RPC exists + +void RPC::mintNFT(const ContentDescriptor& d, const MintOpts& opts, + const std::function& cb) { + if (conn == nullptr) { QTimer::singleShot(0, [cb]{ cb(false, tr("Not connected.")); }); return; } + if (opts.isPrivate && !isPrivateMintWired()) { // belt-and-suspenders; the dialog gates this first + QTimer::singleShot(0, [cb]{ cb(false, tr("Private minting is coming in this release.")); }); + return; + } + // anchor: merkle root for large files, whole-file sha256 for small (engine accepts either on verify). + const QByteArray anchor = !d.merkleRoot.isEmpty() ? d.merkleRoot : d.sha256Whole; + json params = { + { "ticker", opts.collection.toStdString() }, // collection groups a card-set (ticker) + { "name", opts.name.toStdString() }, + { "document_hash", anchor.toHex().constData() }, // 64-hex / 32B, round-trips gettoken.documenthash + { "document_url", opts.documentUrl.toStdString() }, // "" for private / no link + { "decimals", 0 }, // NFT: forced 0 + { "quantity", 1 } // NFT: forced 1 + }; + json payload = { + { "jsonrpc", "1.0" }, { "id", "someid" }, + { "method", "zslp_genesis" }, // per MINT_TRANSFER_SPEC §; (zslp_mint is fungible re-issue) + { "params", { params } } + }; + conn->doRPC(payload, + [cb](const json& reply){ cb(true, QString::fromStdString(reply.is_string() + ? reply.get() : reply.dump())); }, + [cb](QNetworkReply* rep, const json& parsed){ + QString msg = (!parsed.is_discarded() && parsed.is_object() + && parsed.contains("error") && parsed["error"].is_object() + && !parsed["error"]["message"].is_null()) + ? QString::fromStdString(parsed["error"]["message"]) + : rep->errorString(); + cb(false, msg); + }); +} +``` + +NOTE the method name: per MINT_TRANSFER_SPEC the **NFT genesis** call is `zslp_genesis` (with `decimals=0, +quantity=1` forced by the GUI "Create NFT" flow, §3 line 203); `zslp_mint tokenid amount` is fungible +re-issue. The original mint-spec brief referenced `zslp_mint` generically — implement against `zslp_genesis` for +NFT creation. As of today **neither exists in the daemon** (`grep` finds zero refs to `RPC::mintNFT`/`zslp_mint` +in the GUI; the daemon shells are step 5/6 of NATIVE_UX §6.3). So `mintNFT` is spec'd against a NOT-YET-WIRED +daemon RPC; the GUI degrades cleanly: PRIVATE is gated off by `isPrivateMintWired()==false` (a disabled radio), +and a PUBLIC call against a daemon without `zslp_genesis` returns the daemon's error verbatim into the inline +`mintError` label — never a crash, never a fabricated success. + +### 4.2 `RPC::nftProvenance` + `RPC::txReceivedDate` (detail view back-fill) — `rpc.cpp` after `refreshNFTs` (~1000) + +Both follow the error-aware `doRPC` pattern and the graceful-fallback style of `refreshNFTs` — any error leaves +the field at its honest default ("Unknown" / "block N (date pending)"), never a dialog. + +```cpp +void RPC::nftProvenance(QString tokenId, const std::function& cb) { + if (conn == nullptr) { cb(QString(), QString()); return; } // honest defaults + json payload = { {"jsonrpc","1.0"}, {"id","someid"}, + {"method","zslp_gettoken"}, {"params",{ tokenId.toStdString() }} }; + conn->doRPC(payload, + [cb](const json& tok){ + // creator stays "Unknown" (the chain records no issuer); setLabel from ticker/group when known. + QString setLabel = (tok.is_object() && tok.contains("ticker") && tok["ticker"].is_string()) + ? QString::fromStdString(tok["ticker"]) : QString(); + cb(QString(), setLabel); + }, + [cb](QNetworkReply*, const json&){ cb(QString(), QString()); }); // index-off / error -> defaults +} + +void RPC::txReceivedDate(QString txid, const std::function& cb) { + if (conn == nullptr) { cb(QString(), -1); return; } + json payload = { {"jsonrpc","1.0"}, {"id","someid"}, + {"method","gettransaction"}, {"params",{ txid.toStdString() }} }; + conn->doRPC(payload, + [cb](const json& r){ + int confs = (r.is_object() && r.contains("confirmations") && r["confirmations"].is_number()) + ? r["confirmations"].get() : -1; + QString iso; + if (r.is_object() && r.contains("blocktime") && r["blocktime"].is_number()) + iso = QDateTime::fromSecsSinceEpoch(r["blocktime"].get(), Qt::UTC) + .toString(Qt::ISODate); // drives the confs<10 "Just arrived — confirming…" rule + cb(iso, confs); + }, + [cb](QNetworkReply*, const json&){ cb(QString(), -1); }); // -> "block N (date pending)" +} +``` + +`RPC::mintNFT` belongs to the mint dialog; `nftProvenance`/`txReceivedDate` belong to the detail view. All three +are additive; none touch the consensus/money path. + +--- + +## 5. The honest copy set (verbatim, both surfaces) + +**Verify line (shared, identical wherever it appears):** +- verified: "This image matches its on-chain fingerprint." +- mismatch: "This image does NOT match what was recorded on-chain. Don't trust it." +- pending: "Checking this image…" + +**Pending / privacy:** +- pending private: "This image lives in your wallet's local cache." +- pending public: "Image not on this device yet." +- private one-liner: "Only you can see this. Its ownership is shielded." +- public one-liner: "Anyone can verify this on the public ledger." + +**Detail rows + honest defaults:** +- labels: "Mint id" · "Received" · "Creator" · "Set" · "Image hash" +- defaults: "Unknown" · "Not part of a set" · "block — (unknown)" · "Just arrived — confirming…" · "block N (date pending)" +- footnote: "This name and image aren't unique — anyone can mint another collectible that reuses them. Only the mint id is one of a kind." +- title collection fallback: "Not part of a set" + +**Detail actions + feedback:** +- "Send / Gift" · "Save image…" · "Copy id" · "More" · "Copy image hash" · "Copy collection" · "View in explorer" · "Re-check image" · "Get image" · "Open in your video player" +- "Copied ✓" (1.2 s, then revert) +- send-from-mismatch confirm: "This image failed its on-chain check. Send anyway?" `[Send anyway] / [Cancel]` +- send coming-soon (C0/C1): "Sending NFTs is coming soon." +- explorer confirm: "This opens an outside website and may reveal your interest. Continue?" +- index-off note: "Turn on the collectibles index to see full provenance." +- error not-image: "This file isn't an image we can show." +- video caption: "Video · · " (size via `ContentEngine::humanSize`) + +**Mint copy:** +- title "Create an NFT" · toolbar button "Create NFT…" +- drop zone "Drag a file here, or" + "Choose a file…" · hint "Any image, video, document, or file." +- remote reject "For your privacy, drop a local file — not a web link." +- "Reading your file…" (with size) · "Fingerprint ready." · "That file couldn't be read. Try another." +- name "Name" / placeholder "e.g. Aurora #014" · collection "Collection" / placeholder "e.g. Zcl Originals" +- public radio "Public" — "Public: anyone can look up this NFT's name and fingerprint on-chain. You may also add an optional link to where the file lives." +- private radio "Private" — "Private: only people you share it with can see it. The provenance is shielded." +- private coming-soon tag "Coming in this release" +- url "Link to the file (optional)" / placeholder "https://…" +- review fee "Network fee: 0.0001 ZCL" +- what's public (public): "What goes on-chain (public): the file's fingerprint, the name \"\"[, and your link ]." +- what's public (private): "What goes on-chain: only an encrypted record. The name and fingerprint are shielded." +- honesty (always): "Minting does NOT upload your file anywhere. Only its fingerprint goes on-chain — the file stays on your computer." +- buttons "Back" · "Next" · "Cancel" · "Create NFT" (creating: "Creating…") +- error prefix "Couldn't create the NFT: " + +**BANNED from every visible string (P1 / §2.2):** SHA-256, "hash" as a noun (use "fingerprint"), OP_RETURN, +GENESIS, token, mint-baton, zslpindex, t-addr/z-addr, ivk, memo; and NEVER "Genuine"/"Authentic"/"Official"/ +"Original" on the badge — only "matches its on-chain fingerprint". + +--- + +## 6. C++14 + threading + privacy + no-web constraints (honored) + +- **C++14 ONLY** (`.pro:41`). NO `std::optional`/`std::string_view`. Sentinels: empty `QString` + (`cacheGet` returns "" for absent; `m_creator==""` => "Unknown"), `ContentDescriptor.ok`, int verifyState + (0/1/2). MintOpts/ContentDescriptor are POD aggregates (in-class initializers only). Put includes for any + header-signature type IN the `.h` (detail: `#include "nft.h"`, ``, ``, ``, + ``; mint: `#include "contentengine.h"`, ``). +- **THREADING (CONTENT_MODEL §4.2, load-bearing):** all hashing/verify/poster-decode runs on ContentEngine's + bounded `QThreadPool` worker, which touches ONLY `QByteArray/QCryptographicHash/QImageReader/QImage` — NEVER a + QPixmap. The QPixmap is built on the GUI thread in the dialog's slot. The dialogs NEVER read/hash files + themselves — they only call `posterFor`/`verify`/`hashFile`/`cacheGet`/`classifyKind` and consume the + GUI-thread signals (`posterReady`/`verifyDone`/`descriptorReady`). Async replies are token-guarded + (`m_verifyToken`/`m_provToken` in detail; `m_token` in mint, bumped per `loadItem`/`onFileChosen`) so a fast + prev/next or re-drop never paints a stale neighbor's pixmap/verdict/provenance. +- **RPC OFF the paint path (C10):** provenance + mint go through the `doRPC` connector; the detail view renders + INSTANTLY from the value-copied POD and back-fills when replies land — `open()` not `exec()` keeps the poll + loop flowing. The mint dialog uses `exec()` (it is a self-contained modal create flow with its own async hash; + no poll-loop dependency). +- **PRIVACY (P8/C9):** `ContentEngine::isRemoteUrl` REJECTS http(s) on every dropped/typed source; the dialogs + never point the engine at a documenturl; the only network touches are the explicit, confirmed "Get image" / + "View in explorer" / mint broadcast. The optional documenturl is stored on-chain ONLY for PUBLIC mints and is + shown verbatim in REVIEW so the user sees exactly what becomes public; file bytes are NEVER uploaded + (`cachePut` copies them only into the LOCAL content-addressed blob store). +- **NO QtWebEngine / NO QtMultimedia / NO browser** anywhere (static bundle ships neither). Video => poster + + `QDesktopServices::openUrl` gated on local verified bytes. +- **DRY:** reuse ContentEngine (no parallel hash/verify path), the delegate's `tintedIcon` for glyphs/badges + (`:/icons/res/icons/{check,x,question}.svg` already bundled), the dark.qss token set (add no new color), the + `sendtab.cpp:1039` modal pattern (mint) and the `mainwindow.cpp:3266`/`sendtab.cpp:1580` `openUrl` primitive + (explorer/player), `Settings::getMinerFee()`/`getZCLDisplayFormat` for the fee. ADD only the two new getters: + `Settings::getExplorerUrl()` and `RPC::isPrivateMintWired()` (neither exists). + +--- + +## 7. BUILD ORDER (each step shippable; verify via proot + L0/L1) + +Builder: `cd /home/rhett/zclbuild && ./prun bash /build/build.sh` (incremental; `--clean` for a daemon rebuild). +Tests: `cd /home/rhett/zclbuild && ./prun bash /build/wallet/tests/... ` via `run-l0-l1.sh` — +`./prun bash /build/../run-l0-l1.sh` builds + runs L0 (`tst_logic`, guiless) and L1 (`tst_widget`, offscreen). +The host repo is bound into the proot at `/src/wallet`. NEVER edit during the in-flight daemon build; queue these +edits after it lands. Each step is GUI-only and off the money path. + +1. **Settings + RPC scaffolding (no UI yet).** + - Add `Settings::getExplorerUrl()` (settings.h/.cpp). + - Add `RPC::isPrivateMintWired()` (returns false), the `MintOpts` POD, and the THREE new RPC declarations + (`mintNFT`, `nftProvenance`, `txReceivedDate`) + implementations (rpc.h/.cpp). + - Add the additive `ContentEngine::posterReady` signal + the `deliver()` emit (contentengine.h/.cpp). + - **Verify:** `./prun bash /build/build.sh` compiles clean; `run-l0-l1.sh` L0 still 104 / L1 34 (no behavior + change, only additive symbols). This is the riskiest-to-link step done first so the surfaces build against + stable symbols. + +2. **Detail view (NATIVE_UX §6.3 step 2).** + - Create `src/nftdetaildialog.{h,cpp}`; register in `zcl-qt-wallet.pro` (SOURCES/HEADERS). + - Wire `MainWindow::openNFTDetail` + the single `connect(view,&QListView::activated,...)` in `setupNFTTab` + (also add the shared `nftEngine` ContentEngine member here if mint hasn't landed). + - Append the detail dark.qss selectors. + - **Verify:** build clean. L1 widget test (add a `tst_widget` case): construct the dialog from a fixture + `QVector`, assert (a) opens without a model pointer, (b) prev/next bound-checks + disables at ends, + (c) `applyVerifyState(1/2/0)` sets the correct dyn-prop + copy, (d) a stale `onVerifyDone(oldToken,…)` is + dropped. Offscreen: `QT_QPA_PLATFORM=offscreen`. Manual smoke under `uxmatrix.sh` (real-xcb) is optional but + confirms double-click/Enter opens exactly once. + +3. **Mint dialog (NATIVE_UX §6.3 step 5, GUI half).** + - Create `src/nftmintdialog.{h,cpp}`; register in `.pro`. + - Add the toolbar "Create NFT…" button + `nftEngine` (if not added in step 2) + `openMintDialog` in + `setupNFTTab`. + - Append the mint dark.qss selectors (`#dropZone`, `#nftCreateBtn`, `#mintPrivacyExplain`, `#mintHonesty`, + `#mintFee`). + - **Verify:** build clean. L1 case: drop/pick a fixture file -> `descriptorReady` (token-guarded) enables + Next; remote-URL drop is rejected and stays on PICK; PRIVATE radio is disabled while + `isPrivateMintWired()==false` and `rdoPublic` is forced-on; REVIEW shows fee + the honest "what becomes + public" + the does-not-upload line; `onCreate` against an unwired daemon surfaces the error inline (no + crash, no fabricated success). L0 can unit-test any pure helper extracted (e.g. the fingerprint-short + formatter) if added. + +4. **Full-suite gate + bundle.** + - `run-l0-l1.sh`: L0 (`tst_logic`) and L1 (`tst_widget`) both green at the expected counts (104 / 34 + + any new cases). + - `./prun bash /build/build.sh` end-to-end green with the DELIVERY GATE (host sha == chroot-built sha) so no + stale binary ships. + - Manual `uxmatrix.sh` xcb smoke of the Collections tab: open detail, step prev/next, open mint, drop a file, + toggle privacy, hit Create against the (unwired) daemon and confirm the calm inline error. + +Each step compiles and is independently shippable; the detail view (step 2) is the smallest usable unit and can +ship before mint. Public minting itself stays gated until the daemon `zslp_genesis` shell lands (NATIVE_UX §6.3 +step 6) — the GUI is ready and degrades honestly until then. diff --git a/doc/nft/NATIVE_UI_CONSOLIDATED_SPEC.md b/doc/nft/NATIVE_UI_CONSOLIDATED_SPEC.md new file mode 100644 index 00000000000..6fe0aea355e --- /dev/null +++ b/doc/nft/NATIVE_UI_CONSOLIDATED_SPEC.md @@ -0,0 +1,517 @@ +# ZClassic NFT — Consolidated Native UI Spec (Audit-A, build-ready) + +**Status:** BUILD-READY consolidation. Reconciles `NATIVE_UX.md` (six-screen synthesis) and +`NATIVE_UI_BUILD_PLAN.md` (detail + mint file-level plan) against the **actual** shipping GUI tree +on `zcl-qt-wallet@feature/nft-gallery`, the **actual** daemon read RPCs, and the **building-now** +write-path contract (`zslp_genesis` + `zslp_send`). Doc-only — no source edited, no build run. + +This is the single document a builder follows for every NFT screen: widget tree, every state, exact +user-facing copy, and the exact RPC each screen calls. Where the two source docs disagreed with the +code or with the building-now contract, **this doc picks the code/contract and says so loudly** (see +§0.1 reconciliations and the findings list returned alongside this file). + +**Grounding (verified against the live tree, this pass):** +- `zcl-qt-wallet/src/nft.h` — `NFTItem` POD (8 fields; line 21-30). +- `zcl-qt-wallet/src/nftgallerymodel.h` — `NFTGalleryModel` (`itemAt`/`isValidRow`/`rowCount`/roles). +- `zcl-qt-wallet/src/nftgallerydelegate.{h,cpp}` — the card paint, `tintedIcon`, `verifyColor`, + `privacyColor`, badge geometry, `baseCardSize()` 168×208. +- `zcl-qt-wallet/src/contentengine.{h,cpp}` — the ONE engine (`posterFor`/`verify`/`hashFile`/ + `classifyKind`/`humanSize`/`cacheGet`/`cachePut`/`isRemoteUrl`/`streamingSha256`; signals + `descriptorReady`, `verifyDone`). **`posterReady` does NOT yet exist** (additive, see §3.6). +- `zcl-qt-wallet/src/nftimagecache.h` — `NFTImageCache : public ContentEngine` (a thin subclass shim). + **The live `nftImgCache` member IS a `ContentEngine`** — dialogs reuse it, no new engine instance. +- `zcl-qt-wallet/src/mainwindow.cpp:3017 setupNFTTab()`, `:3103 setNFTItems()`. +- `zcl-qt-wallet/src/rpc.cpp:863 refreshNFTs()` — the live `zslp_listmytokens` + `zslp_gettoken` poll. +- `zcl-qt-wallet/src/settings.{h,cpp}` — `getShowNFTGallery()` (75), `getExplorerTxURL(txid)` (134), + `getMinerFee()` (137), `getZCLDisplayFormat(bal)` (125). **No `getExplorerUrl`, no `getNFTThumbSize`.** +- `zcl-qt-wallet/res/styles/dark.qss` — `QPushButton:default` green `#1f7a1f`→hover `#2a9d2a` (93-96); + all NFT tokens present. +- daemon `MINT_TRANSFER_SPEC.md` — write RPCs `zslp_genesis` / `zslp_send` (the building-now contract). + +--- + +## 0. Hard constraints (every screen obeys; flagged in findings if violated) + +- **100% native Qt.** `QListView`+`QStyledItemDelegate`+`QPainter`, `QDialog`, `QLabel`/`QPixmap`. + NO QtWebEngine, NO QtMultimedia, NO browser, anywhere. Video = poster + open-in-external-player. +- **No consensus change.** Everything rides unchanged nodes. Mint/send are ordinary OP_RETURN+dust txs. +- **Honest badge.** The green check means ONLY "these bytes match the on-chain fingerprint." NEVER + genuine/authentic/official/original. Issuer trust is social (signed attestation / verified-issuer + list keyed by mint id). Uniqueness lives only at the mint-id (genesis txid) level. Ownership is + **pending until ~10 confirmations** (`DEFAULT_MAX_REORG_DEPTH = 10`). +- **Privacy floor.** NEVER auto-fetch a remote `documenturl`. Bytes come from local cache + (`ContentEngine::cacheGet`) or ONE explicit, confirmed user action. `ContentEngine::isRemoteUrl` + rejects any `http(s)://` source. The live `refreshNFTs` already forces `cachePath=""` (rpc.cpp:960). +- **Holder safety.** An ordinary send must never spend an NFT carrier UTXO — enforced daemon-side by the + anti-burn builder; the UI never constructs a raw spend of a token outpoint. +- **C++14 only.** No `std::optional`/`std::string_view`. Empty-`QString` sentinels + `int verifyState` + (0/1/2). Header-signature type includes live in the `.h`. +- **DRY.** Reuse the ONE `ContentEngine`, the delegate's `tintedIcon`, the dark.qss tokens, the `doRPC` + connector. Add no new color token. +- **Banned from every visible string (P1):** SHA-256, "hash" as a noun (say "fingerprint"), OP_RETURN, + GENESIS, token, mint-baton, zslpindex, t-addr/z-addr (say "public (transparent) / private (shielded) + address"), ivk, memo (say "note"). And never "Genuine/Authentic/Official/Original" on the badge. + +### 0.1 Reconciliations (where the source docs were wrong vs the live tree — builder MUST follow these) + +1. **Engine: reuse `nftImgCache`, do NOT create a second `ContentEngine`.** `nftImgCache` is constructed + as `new NFTImageCache(nftModel, this)` (mainwindow.cpp:3053) and `NFTImageCache : public ContentEngine` + (nftimagecache.h:22). It already exposes `posterFor/verify/hashFile/classifyKind/...`. The build plan's + `nftEngine = new ContentEngine(...)` is redundant and would create a second pool. Dialogs take a + `ContentEngine*` parameter and are handed the existing `nftImgCache` (upcast). There is exactly ONE engine. +2. **Write RPCs are `zslp_genesis` (mint) and `zslp_send` (transfer/gift) — NOT `zslp_mint`, NOT + `z_sendmany`.** This is the building-now contract. The build plan's `RPC::mintNFT` already targets + `zslp_genesis` (good); its prose elsewhere drifts to `zslp_mint` — ignore that. CONTENT_MODEL §6A names + the high-level NFT call `zslp_mint` — ignore that too; `zslp_mint` is fungible re-issue, never NFT. + NATIVE_UX §3.4 routes Send/Gift through `RPC::executeTransaction`/`z_sendmany` — that is ONLY for the + later *private shielded-memo* gift, NOT the building-now public NFT transfer, which is `zslp_send`. +3. **Explorer getter: reuse `Settings::getExplorerTxURL(txid)` (settings.cpp:421), do NOT add + `getExplorerUrl()`.** It already appends the txid and returns "" on testnet. The detail view's + "View in explorer" enable-gate = `!getExplorerTxURL(m_item.txid).isEmpty() && !m_item.isPrivate`, + and it opens exactly that string. (The build plan's new base getter is unnecessary.) +4. **`posterReady` signal does not exist on `ContentEngine` yet.** It must be added (additive, mirrors + `verifyDone`) for the detail view's large image. This is a real prerequisite edit (§3.6). The + model-only `posterFor→onImageReady` path cannot feed a dialog cleanly. +5. **`getNFTThumbSize()` does not exist.** Ship the gallery density toggle as a NEW + `Settings::getNFTThumbSize()/setNFTThumbSize()` getter/setter, OR cut density from v1 (recommended: + cut it from the first cut to reduce surface — it is the lowest-value toolbar control). If kept, it is + a real new getter, not an existing one. +6. **Existing subhead copy violates P1.** `setupNFTTab` ships + `tr("Your NFTs. Each asset is checked against its on-chain hash.")` (mainwindow.cpp:3031) — "hash" is + banned. Fix to `"Your NFTs. The image on each card is checked against its on-chain fingerprint."` +7. **`indexOff` is currently ignored** (`setNFTItems` does `(void)indexOff;`, mainwindow.cpp:3107). The + first-run/empty page-2 (index-off) screen depends on it being honored. Wiring the 4-page stack makes + this real. + +--- + +## 1. Shared visual + interaction system (all screens reuse, do not re-invent) + +### 1.1 Tokens (verified in dark.qss + nftgallerydelegate.cpp namespace) +app `#0f1115` · card `#15171c` · inset `#1d2027` · hairline `#2a2d35` · text `#e6e6e6` · +dim/AA-floor `#9aa0a6` · private-green `#1f7a1f` · hero-green `#2a9d2a`/`#34c759` · public/pending-amber +`#d9822b` · mismatch-red `#c0392b` · hover-border `#3d4450`. **Add no new color.** + +### 1.2 Verify badge (one language everywhere) — `NFTGalleryDelegate::tintedIcon` recipe +SVGs already bundled: `:/icons/res/icons/{check,x,question}.svg`. Tint: 1→green check, 2→red x, +0→amber question (`verifyColor()`/`verifyIconResource()`). Dark disc behind it. Gallery/board badge +16px (`kVerifyPx`); detail/mint 20px. **Status, never a control** (no click). Verdict sentences, +identical wherever shown: +- 1 verified: **"This image matches its on-chain fingerprint."** +- 2 mismatch: **"This image does NOT match what was recorded on-chain. Don't trust it."** +- 0 pending: **"Checking this image…"** (or, no local bytes: **"Image not downloaded."**) + +### 1.3 Privacy pill (one vocabulary) — `privacyColor()`/`privacyLabel()` +Green "Private" / amber "Public", alpha-38 fill + 1px border + leading dot (P7). One-liners: +- Private: **"Only you can see this. Its ownership is shielded."** +- Public: **"Anyone can verify this on the public ledger."** + +### 1.4 Card anatomy — `NFTGalleryDelegate::baseCardSize()` = 168×208 +Square thumb (cover-fit crop, radius 8) · verify badge top-right of thumb · privacy pill below thumb · +bold name (`#e6e6e6`, elided) · collection (`#9aa0a6` 0.85×, elided). Shimmer placeholder while the +thumbnail is null (already painted by the delegate). One shared `QTimer` would animate the shimmer for +visible pending cards only (perf C6) — the live delegate paints a *static* shimmer; animating it is an +optional polish, not a correctness item. + +### 1.5 The one action set (same verbs/labels/order wherever an action on an NFT appears) +Open · **Send / Gift** (bright green primary) · Save image… · Copy id · Copy image hash · Copy +collection · Re-check image · Open in your video player (video kind) · View in explorer (public + +configured, confirmed). **No "open link"/network item in any browse context menu.** Private items never +expose explorer. + +### 1.6 Copy voice +Plain, warm, second person, no jargon, no emoji spam. State the outcome a button produces. Reuse the +exact verdict sentences so the user learns them once. + +--- + +## 2. Gallery + first-run/empty + set-board (the browse surfaces) + +These extend the existing `setupNFTTab()` page (mainwindow.cpp:3017), all additions programmatic, gated +on `Settings::getShowNFTGallery()`. No `.ui` change. + +### 2.1 Collections gallery (`gallery-grid`) + +**Widget tree (top→bottom):** +- **Heading row:** `QLabel#nftGalleryHeading` "Collections" + `QLabel#nftCountChip` ("12 items" → + "12 of 40" filtered) at the right. +- **Subhead:** `QLabel#nftGallerySubhead` (FIX the live copy per §0.1.6): + **"Your NFTs. The image on each card is checked against its on-chain fingerprint."** +- **Toolbar `QHBoxLayout#nftToolbar` (NEW, h=36):** `[search QLineEdit#nftSearch flex] [Filter ▾] + [Group ▾] [Sort ▾]` (density toggle OPTIONAL per §0.1.5 — recommend cut for v1). + - `nftSearch` placeholder "Search your collection", leading magnifier + trailing clear-X. + - `nftFilter`: All / Private only / Public only / Verified / Needs attention. + - `nftGroup`: No groups (default) / By collection / By privacy. + - `nftSort`: Recently received / Name A–Z / Collection. +- **Grid:** the existing `QListView#nftGalleryView` (IconMode, Adjust, wrapping, uniformItemSizes — + all already set) + `NFTGalleryDelegate`. +- **State stack:** wrap the view in `QStackedWidget#nftGalleryStack` (4 pages: 0 gallery, 1 empty, + 2 index-off, 3 loading — see §2.2). + +**Architecture:** a NEW `QSortFilterProxyModel` over the untouched `NFTGalleryModel` drives +search/filter/sort/group (in-process, no I/O, fingerprint-guarded source — perf C7). "By collection" +inserts non-selectable section-header rows. + +**RPC:** none directly — the gallery is fed by `RPC::refreshNFTs()` (rpc.cpp:863) on the normal poll: +`zslp_listmytokens` → for each token `zslp_gettoken "tokenid"` (batched) → `MainWindow::setNFTItems()`. +Every item gets `cachePath=""` (privacy), so cards stay pending until local bytes exist. + +**States:** LOADING (page 3 first call; cards shimmer + amber "?"; count chip shows real local count at +once) · EMPTY (page 1, toolbar hidden) · ZERO-RESULT (toolbar stays, panel "Nothing matches" + active +filter in words + "Clear filters") · VERIFIED/MISMATCH/PENDING per badge · PENDING-NO-BYTES ("Image not +downloaded. Open to fetch it yourself." — never auto-fetched) · PRIVATE/PUBLIC · INDEX-OFF (page 2) · +OFFLINE (keep last good grid, count chip dims, no spinner-of-doom). + +**Interactions:** live search over name+collection · filter/group/sort instant via proxy · single-click +select; **double-click/Enter/Space → `MainWindow::openNFTDetail` via `connect(view, +&QListView::activated, ...)` (activated ONLY — it covers double-click AND Enter/Space; do NOT also +connect `doubleClicked` or detail opens twice).** Context menu = §1.5 browse subset (Open, Copy name, +Copy fingerprint, Copy transaction id, Re-check image — no link/network item). + +**Copy:** "Collections" · the fixed subhead · "Search your collection" · "12 items"/"12 of 40" · +"No NFTs yet" · "Nothing matches" · "Clear filters" · "Checking this image…" · +"Image not downloaded. Open to fetch it yourself." · +"Public collectibles are turned off. Your private NFTs are still shown." · "Re-check image" · +"Copy fingerprint". + +### 2.2 First-run / empty (`first-run`, the 4-page stack) + +Wrap the single `QListView` in `QStackedWidget#nftGalleryStack`. Heading stays "Collections" in every +state; the subhead is state-dependent. Same centered hero-card geometry on every page so layout never +jumps (`#15171c`/hairline/radius 12/28px pad, max-width 520, centered). + +- **Page 0 — gallery** (rows present). +- **Page 1 — EMPTY:** quiet-grey `#2f343d` 56px frame glyph (NOT red/amber — empty, not broken), title + **"No collectibles yet"**, body **"When someone sends you a collectible, or you make one, it shows up + here — and the wallet checks each picture against its on-chain fingerprint. Nothing to do right + now."**, ONE green primary **"Make your first collectible"** (opens the mint dialog when it lands; + before that, **"Show me how it works"**), flat link **"What is a collectible?"**. +- **Page 2 — INDEX-OFF:** amber `#d9822b` 56px toggle glyph (attention, not broken), title + **"Collectibles tracking is turned off"**, body **"Turn this on and the wallet will start finding your + collectibles. It does a one-time catch-up scan in the background, so syncing stays fast for people who + don't collect."**, green primary **"Turn on collectibles"**, flat link **"Why is this a separate + setting?"**. Managed daemon → confirm + restart + scan. Foreign/old daemon → reveal a `#1d2027` inset + with the exact conf line **"zslpindex=1"** + **"Copy line"** (never a dead end). *(This conf line is + the one place the raw setting name is allowed — it is literal config, not user-facing prose.)* +- **Page 3 — LOADING:** **"Looking for your collectibles…"** + indeterminate `QProgressBar` + + **"This runs in the background. You can keep using the wallet."** + +**State selection (RPC):** the existing `refreshNFTs` already distinguishes the index-off case: its error +handler (rpc.cpp:978-1000) computes `indexOff` from RPC error code -1 and calls +`setNFTItems(empty, indexOff)`. **Wire `setNFTItems` to honor `indexOff`** (today it does +`(void)indexOff;`): indexOff→page 2; success+empty→page 1; success+rows→page 0; first call +outstanding→page 3. Latch the last good page fingerprint-style so a transient poll error never flickers +back to empty/off; daemon-unreachable keeps the last state. + +### 2.3 Set / collection board (`set-collection`) + +A stacked page **inside the Collections tab** (`QStackedWidget` index 1; index 0 = the gallery), reached +by clicking a set thumbnail. Header strip (back "‹ Collections" + set name + "Created by {creator} · +{N} cards" + completion meter "3 of 7 collected" with green `#1f7a1f` track fill) · the board +(`QListView` IconMode fed by a NEW `SetBoardModel`, painted by a NEW `SetSlotDelegate : +NFTGalleryDelegate` — owned slots = §1.4 card; missing slots = ghost variant: 55% opacity, "#N" numeral, +"Not collected", **no image request issued**) · footer help bar (only when missing>0): "Missing {n} +cards. They arrive when someone sends them to your wallet." + quiet "Show my receive address" (opens the +existing Receive tab). **No in-app buy/trade.** + +**RPC:** set membership comes from the already-fetched `zslp_gettoken` metadata (the `ticker` groups a +card-set; the live `refreshNFTs` maps `ticker`→`collection`, rpc.cpp:949). A full canonical-manifest +"all slots" board needs a creator-published manifest resolved locally/explicitly — until then the board +shows owned slots + a "manifest not available" calm note rather than fabricating ghost slots. + +**States:** loading-board · empty-set (all ghosts) · verified/mismatch/pending owned slot · missing slot +· private-set · index-off (public sets only; private from the memo scan) · set-complete (green pulse + +"Set complete" + footer hides) · stale/offline. + +The creator's verified-issuer tick appears ONLY when its mint id is on a named verified-issuer list; +tooltip "On {maintainer}'s verified-issuer list" — never a bare "Verified" (it is social, not a network +guarantee). + +--- + +## 3. Detail dialog (`nft-detail`) — `NFTDetailDialog` (NEW) + +New `src/nftdetaildialog.{h,cpp}`, programmatic, modeless-modal (`open()` not `exec()` so the poll loop +keeps flowing and back-fill lands). Min 760×560. Carries the `NFTItem` by value + the ordered POD list by +value (no model pointer) for prev/next. Geometry in `QSettings("NFTDetail/geometry")`. + +### 3.1 Constructor + entry +```cpp +explicit NFTDetailDialog(const NFTItem& item, const QVector& ordered, int startIndex, + ContentEngine* engine, RPC* rpc, QWidget* parent = nullptr); +``` +`MainWindow::openNFTDetail(const QModelIndex&)` snapshots the ordered list from `nftModel` +(`itemAt`/`isValidRow`/`rowCount`, all present) and constructs the dialog with **`nftImgCache`** as the +`ContentEngine*` (the existing engine — §0.1.1) and `rpc`, then `dlg->setAttribute(WA_DeleteOnClose); +dlg->open();`. Wire once: `connect(view, &QListView::activated, this, &MainWindow::openNFTDetail)`. + +### 3.2 Layout +- **Title bar (h=44):** name 16pt/700 (elided) + collection 11pt `#9aa0a6` ("Not part of a set" if + none); flat close glyph; hairline below. +- **Left — image stage (stretch 1, min 380×380):** card holding a centered `QLabel#nftDetailStage` + painting the full QPixmap `KeepAspectRatio SmoothTransformation` (never upscaled past 1024 native; + letterboxed on `#1d2027`). 20px verify badge top-right. Shimmer while decoding. +- **Right — info panel (fixed 320):** + 1. **Verify line:** full-width inset row, 20px badge + 13pt sentence; color via + `#nftDetailVerifyLine[state="verified|mismatch|pending"]` dyn-prop (NEW qss, ~3 lines). + 2. **Privacy pill row** + 11pt one-liner (§1.3). + 3. **Details card:** label/value grid. **Mint id** (the genesis txid = identity; short 8…8 + copy), + **Received** (ISO date + "block N", or "Just arrived — confirming…" when confs<10, or "block N + (date pending)" before `gettransaction` returns), **Creator** ("Unknown" — the chain records no + issuer), **Set** ("Wild Series — 7 of 30" or "Not part of a set"), **Image hash** (short + docHashHex 8…8 + copy). Footnote: **"This name and image aren't unique — anyone can mint another + collectible that reuses them. Only the mint id is one of a kind."** + 4. **Action bar (pinned, h=48):** §1.5 set — green **"Send / Gift"** primary · **"Save image…"** · + **"Copy id"** · overflow **"More" (…)** = Copy image hash / Copy collection / Re-check image / + View in explorer (disabled unless public + configured). + +### 3.3 RPC the detail dialog calls +- **Poster + verify (local bytes only):** `engine->posterFor(localPath, docHashHex, docHashHex, 512)` + and `engine->verify(localPath, docHashHex, token)` where `localPath = ContentEngine::cacheGet( + m_item.docHashHex)` (empty = not on device → never fetched). Token-guarded so a fast prev/next drops a + stale neighbor's reply. +- **Provenance back-fill (NEW `RPC::nftProvenance(tokenId, cb)`):** `zslp_gettoken "tokenId"` → on + success set Set/series from `ticker`; Creator stays "Unknown" (no issuer field on chain); any error → + honest defaults, no dialog. +- **Received date back-fill (NEW `RPC::txReceivedDate(txid, cb)`):** `gettransaction "txid"` → + `confirmations` + `blocktime`; confs<10 → "Just arrived — confirming…"; ≥10 → ISO date + "block N". +- **View in explorer:** `QDesktopServices::openUrl(QUrl(Settings::getExplorerTxURL(m_item.txid)))` after + a one-time confirm "This opens an outside website and may reveal your interest. Continue?" — enabled + only if `!getExplorerTxURL(m_item.txid).isEmpty() && !m_item.isPrivate` (§0.1.3). +- **Send / Gift:** opens `NFTSendDialog` pre-filled (§5). Until the send dialog ships, a toast + "Sending NFTs is coming soon." On a MISMATCH item, confirm "This image failed its on-chain check. + Send anyway?" first. + +### 3.4 Media by kind (`ContentEngine::classifyKind`) — every branch touches ONLY the cacheGet result +- **Image:** `posterFor` → full QPixmap on the stage; resize re-scales from the held source (no + re-decode/re-hash, perf C3). +- **Video:** NO in-app playback. Typed film-strip poster (never a faked frame) + overlaid play glyph + + caption **"Video · · "** (`humanSize`) + verify badge + primary + **"Open in your video player"** → `QDesktopServices::openUrl(QUrl::fromLocalFile(localPath))`, ENABLED + only when local bytes exist AND `verifyState == 1` (openUrl silently fails on a missing/remote path). +- **Document:** typed MIME glyph + "Open" (+ optional "Reveal in folder"). External only. +- **Bytes:** typed glyph + size summary + "Save as…". Never auto-execute. + +### 3.5 States +LOADING (shimmer; "Checking this image…"; Send/Gift + Copy id enabled; Save + Open-in-player disabled) · +VERIFIED (green; all enabled; bytes-match only) · MISMATCH (red; image dimmed ~60% + thin red inset; +Save stays enabled; Send confirms first) · PENDING/UNCACHED-private ("This image lives in your wallet's +local cache." no fetch button) · PENDING/UNCACHED-public ("Image not on this device yet." + explicit +"Get image" only when a documenturl is back-filled AND public; in C1 the POD carries no documenturl → +no Get-image button) · PRIVATE/PUBLIC · EMPTY METADATA ("Unknown"/"Not part of a set"/"block — +(unknown)") · RECEIVED-PENDING (confs<10 → "Just arrived — confirming…") · INDEX-OFF (wallet-local +fields render + "Turn on the collectibles index to see full provenance." — no error dialog) · +ERROR/not-an-image (amber "This file isn't an image we can show." + neutral glyph) · RESIZE · PREV/NEXT. + +### 3.6 Prerequisite engine edit (the one real ContentEngine change) +Add (additive, mirrors `verifyDone`) to `contentengine.h` next to line 200: +`void posterReady(quint64 token, QImage img, int verifyState);` and emit it from `deliver()` (the +GUI-thread landing) in addition to the model call, keyed by a token the caller passed. The dialog +connects to it and builds the QPixmap on the GUI thread in `onPosterReady`. Do NOT hand the dialog a +throwaway model; do NOT read the on-disk poster cache by hand (racy). *(Acceptable-only fallback if a +reviewer rejects the signal: read the on-disk poster cache after `posterFor` completes — but the signal +is the planned approach and keeps zero model coupling.)* + +--- + +## 4. Mint dialog (`mint-flow`) — `NftMintDialog` (NEW) + +New `src/nftmintdialog.{h,cpp}`, programmatic, modal (`exec()` — self-contained create flow). Either the +NATIVE_UX 4-card single-scroll layout OR the build-plan 3-page stack (PICK→DETAILS→REVIEW) — pick ONE; +both are native, keep state, and honor the same copy/states. The 3-page stack makes the +"async-hash-gates-Next" contract trivial and is the recommended cut. + +### 4.1 Entry + wiring +Toolbar "Create NFT…" button in `setupNFTTab` + `MainWindow::openMintDialog()`: +```cpp +NftMintDialog dlg(nftImgCache /*the existing ContentEngine*/, rpc, this); +if (dlg.exec() == QDialog::Accepted) rpc->refreshNFTs(); +``` + +### 4.2 Cards / pages +- **1 — Your image (dropzone):** drag/drop or "Choose a file…". GUARD with + `ContentEngine::isRemoteUrl(path)` → reject http(s) drops inline (privacy). On a file: + `classifyKind` for the poster glyph, `posterFor(path, key, "", 160)` for an image poster, prefill name + from the basename, then `hashFile(path, token)` (STREAMING — a 2 GB file hashes in ~1 MiB RAM). + Indeterminate progress + "Reading your file…". Next disabled until `descriptorReady`. +- **2 — Details:** Name (required, "e.g. Aurora #014", soft 50-char counter), Collection (optional, + "e.g. Zcl Originals"), Note (label swaps Private↔Public). +- **3 — Who can see it:** two tiles. **Private** (green, default selected) — "The image and details are + sealed…". **Public** (amber). **When the private channel RPC is unwired (TODAY), the *Private* tile is + the one gated** — see §0.1 nuance below. A consequence caption always states the current choice. +- **4 — Review & confirm:** thumb + name/collection + visibility pill + "Fingerprint 1f2a…9c0d" + size + + a "What goes on-chain (public)" line + the honesty line + fee row + (`Settings::getZCLDisplayFormat(getMinerFee())`) + "After this you'll have N ZCL". + +> **Gating nuance (resolve at build time).** Two facts collide: the building-now write path is the +> **public** `zslp_genesis`/`zslp_send`, while NATIVE_UX makes **Private** the safe default. So in the +> first shipped cut, the PUBLIC tile is the one that is actually wired (it calls `zslp_genesis`), and the +> PRIVATE tile is "Coming in this release" (it needs the ZDC1 shielded channel). This is the OPPOSITE of +> NATIVE_UX §3.3's "Private default, Public coming soon." **Builder MUST pick based on which daemon RPC +> exists**: gate OFF whichever path's RPC is missing, default-select the wired one, and keep the copy +> honest ("Coming in this release" + steer to the wired choice — never a dead Create button). The +> build-plan's `isPrivateMintWired()==false` correctly gates Private off today. Do not ship a +> Private-default mint that has no working broadcast path. + +### 4.3 RPC the mint dialog calls — `RPC::mintNFT(descriptor, opts, cb)` → `zslp_genesis` (building-now) +``` +zslp_genesis '{ "ticker": , "name": , "document_url": , + "document_hash": <64-hex anchor>, "decimals": 0, "quantity": 1 }' + -> { "txid", "tokenid" } (tokenid == txid) +``` +The GUI forces `decimals=0, quantity=1` (NFT). `document_hash` = the descriptor's `merkleRoot` for large +files else `sha256Whole`, lowercase hex (round-trips `gettoken.documenthash`). On success, +`ContentEngine::cachePut(anchorHex, srcPath)` stores the local bytes content-addressed so the new card +verifies green immediately, then `accept()` → `refreshNFTs()`. On error, show the daemon message verbatim +inline (never a crash, never a fabricated success). PRIVATE (when wired later) routes the bytes over the +ZDC1 shielded channel — a separate RPC, not `zslp_genesis`. + +### 4.4 States +EMPTY/PICK · REMOTE-URL REJECTED ("For your privacy, drop a local file — not a web link.") · HASHING +(indeterminate; Next disabled) · READY ("Fingerprint ready."; Next enabled) · UNREADABLE ("That file +couldn't be read. Try another.") · PUBLIC selected (doc-url field optional) · PRIVATE selected (when +wired) · PRIVATE COMING-SOON (the default today — Private tile disabled "Coming in this release", Public +forced-on; Create never dead) · REVIEW · CREATING ("Creating…", footer disabled) · MINT ERROR (inline +daemon message) · SUCCESS (`accept()`; toast "NFT created — Aurora #14" + "Show it"; gallery refreshes) · +low-balance ("Not enough ZCL to cover the network fee."). + +### 4.5 Copy +"Create an NFT" / "Create NFT…" · "Drop an image here" / "Choose a file…" / "PNG, JPG, GIF, SVG, WebP — +up to 20 MB" · "For your privacy, drop a local file — not a web link." · "Reading your file…" / +"Fingerprint ready." / "That file couldn't be read. Try another." · "Name"/"Collection"/"Note" · the +two privacy-tile bodies · "Coming in this release" · "Network fee" · "After this you'll have N ZCL" · +"Minting does NOT upload your file anywhere. Only its fingerprint goes on-chain — the file stays on your +computer." · "Create NFT" / "Creating…" / "Cancel" · "NFT created — Aurora #14" / "Show it". + +--- + +## 5. Send / Gift (`send-gift`) — `NFTSendDialog` (NEW) + +New `src/nftsenddialog.{h,cpp}`, modal, constructor **requires** an `NFTItem` (no empty state). +windowTitle "Send a gift". Four cards + footer. + +### 5.1 Cards +- **1 — What you're giving:** 72×72 inset thumb fed exactly like the gallery + (`engine->posterFor(docHashHex, cachePath, docHashHex, 72)` — instant from disk cache) + verify badge + + name + collection + privacy pill. Read-only. +- **2 — Who gets it:** "Send to" + the existing `AddressCombo` (autocompleting recipient widget) + + "Address book". One reserved-height live status line: valid private → green "Looks good — a private + (shielded) address"; valid public → amber "Looks good — a public (transparent) address"; invalid → + red "That doesn't look like a ZClassic address". Recipient type drives card 3. +- **3 — How private:** two radio rows. **Public gift** [amber] — wired now via `zslp_send` to a + transparent recipient. **Private gift** [green] — the shielded-memo path; "Coming soon", disabled + until the ZDC1 channel lands. (Again the OPPOSITE polarity of NATIVE_UX §3.4, for the same building-now + reason as mint — §0.1.2/§4.2.) An unsupported choice is disabled with the fix inline (P4). +- **3b — When should they get the key?** (Private only; reveal animated) — "Send it all now" / + "Send the picture now, the key when you're ready". (Future, with the private channel.) +- **4 — Add a note (optional, collapsed):** `QPlainTextEdit` + byte counter. Public gifts hide the note + ("Public gifts can't include a private note."). + +### 5.2 RPC the send dialog calls +- **Local address validation:** debounced, GUI-side (no per-keystroke RPC) — drives the status line. +- **Public gift (building-now):** `RPC::sendNFT(tokenId, toAddress, cb)` → + `zslp_send "tokenid" "to_address" 1` → `{ "txid" }`. amount defaults to 1 (single NFT). The daemon + builder enforces anti-burn + self-validate-before-broadcast; the UI never builds a raw spend. +- **Private gift (future):** the shielded-memo path via `RPC::executeTransaction`/the ZDC1 channel — + NOT `zslp_send`. This is the ONLY place `executeTransaction` is correct, and only for the private leg. +- The green action label states the outcome: Public → "Send gift"; Private+all-now → "Send gift + privately"; Private+reveal-later → "Send the picture". Disabled until valid recipient AND not a red + mismatch AND not already in flight. + +### 5.3 States +opened/ready · thumb pending/verified/MISMATCH (mismatch → "This picture doesn't match its fingerprint — +we won't send it.", action disabled) · recipient empty/valid-private/valid-public/invalid · Public chosen +· Private chosen (future) · sending ("Sending…", inputs disabled) · sent ("Gift sent. It's on its way to +them.") · error (inline red + daemon reason + "Try again", nothing sent) · index-off (Public still works; +it does not need the read index). + +### 5.4 Copy +"Send a gift" · "What you're giving" · "Send to" · "Address book" · "Who can see this gift" · "Looks good +— a private (shielded) address" / "Looks good — a public (transparent) address" / "That doesn't look like +a ZClassic address" · "Public gift" / "Private gift" · "Coming soon" · "Add a note" · "Send gift" · +"Sending…" · "Gift sent. It's on its way to them." · "Try again". + +--- + +## 6. End-to-end happy paths (building-now / public, ships first) + +1. **Browse → open → gift (public).** Collections paints instantly (shimmer + amber "?"; count chip + live). Thumbs stream from disk cache; badges flip green. Double-click → detail (verify line "This + image matches its on-chain fingerprint."). Send / Gift → NFTSendDialog pre-filled + verified. Paste a + transparent recipient → green/amber status line. Public gift pre-selected. "Send gift" → `zslp_send` + → "Gift sent." Gallery refreshes next poll. +2. **Mint (public, building-now).** Empty page → "Make your first collectible" → NftMintDialog. Drop an + image → streaming fingerprint → "Ready". Name (required). Public is the wired choice today (Private = + "Coming in this release"). Review shows what becomes public + the does-not-upload line + fee. Create + NFT → `zslp_genesis` → toast + "Show it". +3. **Collect a set.** Set card → board swaps in. Owned bright/detailed; missing dim/numbered/"Not + collected". "3 of 7 collected". Footer: "Missing 4 cards. They arrive when someone sends them to your + wallet." + receive-address button. + +--- + +## 7. Performance + privacy contract (defect if violated) + +C1 no web/multimedia ever · C2 bounded `QThreadPool(4)`; worker produces only `QImage`, never QPixmap; +QPixmap built on the GUI thread · C3 two-tier cache, re-open free, resize re-scales from held source · +C4 in-flight dedupe · C5 no relayout on scroll (`setUniformItemSizes`) · C6 one shimmer timer (visible +pending only; missing slots issue ZERO image requests) · C7 fingerprint-guarded models + in-process +proxy · C8 tinted glyphs cached · C9 hot path touches ONLY local bytes; `isRemoteUrl` rejects http(s); +the only network touches are explicit confirmed user actions + the mint/send broadcast · C10 RPC stays +off the GUI/paint thread via `doRPC`; dialogs render instantly from the value-copied POD and back-fill. + +--- + +## 8. File map (grounded in the live tree) + +**New files:** `src/nftdetaildialog.{h,cpp}` · `src/nftmintdialog.{h,cpp}` · `src/nftsenddialog.{h,cpp}` +· `src/setboardmodel.{h,cpp}` · `src/setslotdelegate.{h,cpp}` · a `QSortFilterProxyModel` (inline or a +small `nftgalleryproxy.{h,cpp}`). + +**Edited files:** +- `src/mainwindow.{h,cpp}` — 4-page `QStackedWidget#nftGalleryStack`; toolbar; set-board page; + `openNFTDetail` + the single `connect(view,&QListView::activated,...)`; `openMintDialog`; **honor + `indexOff` in `setNFTItems`** (today `(void)indexOff;`); **fix the subhead copy** (§0.1.6). Pass the + existing `nftImgCache` (a `ContentEngine*`) to the dialogs — do NOT add a second engine (§0.1.1). +- `src/nftgallerydelegate.{h,cpp}` — disc ring (P7) + mismatch inner hairline + privacy leading dot; + optional density property (only if §0.1.5 density kept). +- `src/contentengine.{h,cpp}` — ADD the additive `posterReady(quint64, QImage, int)` signal + emit in + `deliver()` (§3.6). Nothing else. +- `src/rpc.{h,cpp}` — ADD `mintNFT(descriptor, opts, cb)`→`zslp_genesis`; `sendNFT(tokenId, toAddr, + cb)`→`zslp_send`; `nftProvenance(tokenId, cb)`→`zslp_gettoken`; `txReceivedDate(txid, cb)`→ + `gettransaction`. All via `doRPC`, graceful-fallback to honest defaults. `isPrivateMintWired()` → + hard-false until ZDC1. +- `src/settings.{h,cpp}` — REUSE `getExplorerTxURL(txid)` (no new `getExplorerUrl`, §0.1.3). Add + `getNFTThumbSize()/setNFTThumbSize()` ONLY if density is kept (§0.1.5). +- `res/styles/dark.qss` — append NFT object-name rules using existing tokens only + (`#nftDetailVerifyLine[state=...]`, `#nftDetailStage`, `#nftDetailCard`, `#dropZone`, `#nftCreateBtn` + inheriting the existing green). No token changes. +- `application.qrc` + `res/icons/` — NEW SVGs (none exist yet): magnifier (search), copy glyph, + frame/picture glyph (empty-state), toggle glyph (index-off), film/play glyph (video). Tinted at + runtime via `tintedIcon()`. +- `zcl-qt-wallet.pro` — add the new `.cpp/.h` to `SOURCES`/`HEADERS`. No new Qt module. + +**Build order (each step shippable):** (1) RPC + settings + `posterReady` scaffolding; (2) gallery +state-stack + proxy + first-run/index-off; (3) detail dialog; (4) set board; (5) mint (public, +`zslp_genesis`) + send (public, `zslp_send`); (6) private channel (ZDC1) → flip the gating so Private +becomes the safe default per NATIVE_UX once the shielded path is wired. + +--- + +## 9. Honesty ledger +- No in-app marketplace / buy button. The set board says "they arrive when someone sends them to your + wallet" + a receive address. +- "Private" ≠ "only one copy can exist." Hidden from the public; a prior holder can keep their copy. +- The building-now write path is PUBLIC (`zslp_genesis`/`zslp_send`); the Private tiles are the honest + "Coming in this release" until the ZDC1 shielded channel lands — the OPPOSITE polarity of NATIVE_UX's + Private-default, intentionally, because we never ship a default with no working broadcast path. +- No silent remote fetches. A "not downloaded" image is the honest state, fetched only on explicit + confirmed action. +- Unknown stays "Unknown". Creator is "Unknown" because the chain records no issuer. +- The green check is a bytes-match only — never genuine/authentic/official/original. Ownership is + pending until ~10 confirmations. + +*Synthesized from NATIVE_UX.md + NATIVE_UI_BUILD_PLAN.md, reconciled against the live +`zcl-qt-wallet@feature/nft-gallery` tree and the building-now `zslp_genesis`/`zslp_send` contract. All +file:line references verified this pass. Hard rules upheld: no consensus change, no browser/multimedia, +no auto-fetch, honest badge, C++14, holder safety.* diff --git a/doc/nft/NATIVE_UX.md b/doc/nft/NATIVE_UX.md new file mode 100644 index 00000000000..0f3e9e29c4f --- /dev/null +++ b/doc/nft/NATIVE_UX.md @@ -0,0 +1,399 @@ +# ZClassic NFT — Native UX Design + +**Status:** Design spec (synthesis of 6 screen specs). Grounds on real, shipping code. +**Repos:** daemon `/home/rhett/github/zclassic`; GUI `/home/rhett/github/zcl-qt-wallet` (branch `feature/nft-gallery`). +**Constraint:** C++14 only (`zcl-qt-wallet.pro CONFIG += c++14`). NO `std::optional` / `std::string_view`. Use empty-`QString` sentinels and default-initialized struct members. +**Theme:** `res/styles/dark.qss` — the "Quiet+" dark wallet. All NFT surfaces reuse its existing tokens; no new color is introduced. +**Rendering:** 100% native Qt (`QListView` + `QStyledItemDelegate` + `QPainter`). NO QtWebEngine / HTML / browser, anywhere — this is a hard owner constraint and a differentiator. + +This document is the single source of truth that reconciles six screens into one coherent product: + +1. **gallery-grid** — Collections gallery (home of your NFTs) +2. **nft-detail** — single-NFT detail dialog +3. **mint-flow** — "Create an NFT" +4. **send-gift** — give an NFT to someone +5. **set-collection** — the "collect them all" board for one card-set +6. **first-run / empty** — zero-NFTs + the index-off variant + +Where two screens described the same element differently, this doc **picks one** and every screen conforms. The cross-screen contracts live in §1 (principles), §2 (visual system), and §5 (performance). The per-screen sections (§3) defer to them and only describe what is unique. + +--- + +## 0. The product in one breath + +A first-timer opens **Collections** and sees their own pictures in a dark grid that looks like the rest of the wallet — not a list of hashes, not a web page. Each card answers a collector's only two questions with color, a dot, **and** a ring (so nobody has to decode a palette): + +- **"Is this mine-and-hidden?"** → one word: **Private** (green) or **Public** (amber). +- **"Does this picture match what was recorded on-chain?"** → one corner badge: **check** (green) / **x** (red) / **question** (amber). (Match only — it does not say the collectible is the original/official one; §2.2.) + +Click a card → it opens **large**, with one plain green/red/amber sentence at the top of the info panel that answers "is this real?" without ever showing the word SHA-256. The brightest button is the safe, delightful one (**Send / Gift**, **Save image**); risky or outside-the-wallet actions are quiet and ask first. + +Three differentiators we lead with, in human terms: +1. **Local image-to-fingerprint check, natively** — the wallet recomputes the on-chain fingerprint locally and shows a green check that the picture's bytes match what this collectible recorded. No server, no browser. (It does NOT prove the collectible is the original/official one or who made it — see §2.2.) +2. **Private NFTs ship first** — because the daemon already does shielded memos, ZClassic offers *private sealed collectibles* before most chains can do public ones. +3. **It feels like the wallet** — same dark theme, same nav rail, same delegate-rendering craft as the privacy badges. + +--- + +## 1. Design principles — the "don't make me think" rules we commit to + +These are non-negotiable across all six screens (Steve Krug, adapted for a privacy wallet). + +**P1 — No crypto jargon, ever, in user-facing copy.** +"fingerprint" not SHA-256/hash. "on-chain record" / "on the ledger" not consensus/OP_RETURN. "sealed" / "only people you choose" not encrypted-memo/viewing-key. "collectible" is used in first-run/learn copy; "NFT" is acceptable in headings and buttons but never a protocol term. **Banned from all visible strings:** SHA-256, OP_RETURN, GENESIS, token, mint-baton, zslpindex, t-addr/z-addr (say "public (transparent) address" / "private (shielded) address"), ivk, memo (use "note"/"message"). + +**P2 — One obvious next action per state.** Every screen has exactly one bright (green `#1f7a1f`) primary at any moment. Empty → "Show me how it works". Zero-result → "Clear filters". Detail → "Send / Gift". Mint → "Create NFT". The eye lands on one thing. + +**P3 — Safe default is the default.** Private is pre-selected everywhere a visibility choice exists. Private leaks nothing and works on today's daemon. Public is the deliberate, explained, confirmed choice. + +**P4 — No dead ends, no silent failures.** A disabled control always carries a one-line *reason and fix* ("Private gifts need a private (shielded) address — paste one above."). Not-yet-built paths show **"Coming soon" / "Coming in this release"** disabled, never missing — so the user never wonders if they broke something. A missing image says exactly how to get it. A turned-off public index never hides the private NFTs that work today. + +**P5 — Status, not controls, for safety signals.** Verify badge and privacy pill are read-only indicators; they are never clickable controls on the grid/board. Verification *detail* lives in the detail dialog. This keeps browse surfaces uncluttered and unmisclickable. + +**P6 — Instant, tactile feedback.** Copy actions flip the button label to "Copied ✓" for ~1.2 s. Filters/search update the count chip live ("12 of 40"). Selecting a visibility tile *rewrites a live consequence table* before the user commits. Nothing waits on a daemon round-trip to render. + +**P7 — Color is never the only signal (accessibility).** Privacy = color + a leading **dot glyph** + the lead-capped word. Verify = color + glyph (check/x/question) + a **1px ring** around the badge disc. Mismatch additionally gets a red inner hairline. Screen-reader names (`setAccessibleName`) announce the verdict. + +**P8 — Privacy is the floor, not a setting.** The wallet NEVER silently fetches a remote `documenturl` image (it would leak IP + collecting interest). Image bytes come from the local cache or **one explicit user action**, behind a one-time confirm. A "not downloaded" item is the deliberate, honest representation of "not local". + +**P9 — Reuse the wallet's taught vocabulary.** Every badge, pill, card surface, and button style a user sees in Collections is the same one they already learned on the gallery. No screen teaches a new visual word. + +**P10 — Honest about limits.** "Private" = hidden from the public, not "only one copy can ever exist." Unknown facts render the literal word **"Unknown"** / **"Not part of a set"** — never blank, never fabricated. There is intentionally **no in-app marketplace / buy button** (the chain can't honor it); missing cards say "they arrive when someone sends them to your wallet" + one receive-address button. + +--- + +## 2. Visual system (shared) + +All values are **verified present** in `res/styles/dark.qss` and `src/nftgallerydelegate.cpp`. Reuse only; do not invent tokens. + +### 2.1 Color tokens + +| Token | Hex | Meaning / use | +|---|---|---| +| app bg | `#0f1115` | page / window / `QDialog` background (deepest) | +| card | `#15171c` | a card/group surface on the page | +| inset | `#1d2027` | elevated content within a card: rows, inputs, tables, thumbnails | +| hairline | `#2a2d35` | 1px border on every card/inset | +| text | `#e6e6e6` | primary live text | +| dim text | `#9aa0a6` | secondary/labels (AA floor for live text — never dimmer) | +| title white | `#ffffff` | headings, selected tab | +| private/green | `#1f7a1f` | **Private** everywhere; verified badge; primary buttons; progress fill | +| hero green | `#34c759` | brightened green for a single success tick / 100% set-complete pulse (`#2a9d2a`) | +| public/amber | `#d9822b` | **Public**/transparent everywhere; pending-verify "?"; a setting needs attention | +| danger red | `#c0392b` | RESERVED — mismatch / "this could leak or is broken" only | +| ghost dims (delegate-local `QColor` consts) | `#3d4450` ghost numeral / hover border, `#2f343d` empty-state glyph — **decorative only** (not live text). The missing-slot card **name/caption is live text and renders at `#9aa0a6` (the AA floor), never dimmer** — `dark.qss:200-201` explicitly rejects `#6b7177` (~3.1:1) for live text | declared in-code like `kDim`, used only on the set board + empty states | + +**Color discipline:** green = safe/yours/image matches its on-chain fingerprint. Amber = public/attention/pending. Red = ONLY mismatch or a real leak risk. Never use red for "empty" (empty uses quiet grey so it never reads as broken). + +### 2.2 The verify badge — one language everywhere + +Drawn via the existing `NFTGalleryDelegate::tintedIcon(resource, color, px)` cache (QSvgRenderer → tinted QPixmap, keyed by resource+color+px, rendered once). SVGs already bundled (`check.svg` / `x.svg` / `question.svg`, sha f8f2bde2). + +| `verifyState` | Icon | Color | Disc ring (1px, P7) | Plain meaning | +|---|---|---|---|---| +| 1 VERIFIED | check | `#1f7a1f` | green ring | "this picture matches its on-chain fingerprint" | +| 2 MISMATCH | x | `#c0392b` | red ring + red inner thumb hairline | "does not match its on-chain fingerprint" | +| 0 PENDING | question | `#d9822b` | amber ring | "checking this image…" / "image not downloaded" | + +- **Geometry is shared:** dark disc + tinted glyph, badge at the **top-right of the thumbnail/image**. Gallery/board = 16px; detail/mint = 20px. +- **Copy is shared** (full strings in §3 per screen, but the verdict sentences are identical wherever they appear): "This image matches its on-chain fingerprint." / "This image does not match its on-chain fingerprint. It may have been changed." / "Checking this image…". +- The badge is **status, never a control** (P5). Hover shows a tooltip repeating the sentence. + +> **What the green check means — and what it does NOT (honesty rule, non-negotiable).** The check means **only** that the picture's bytes on this device match the fingerprint this collectible recorded on the ledger. It is a bytes-match check, nothing more. **It does NOT mean the collectible is "genuine," "authentic," "official," or "the original," and it says nothing about who made it.** Anyone can create a *different* collectible (a different mint id) that reuses the same name and the same image — that copy will show the very same green check. So the badge copy is **always** "matches its on-chain fingerprint," **never** "Genuine / Authentic / Official / the Original." Three separate things must never be blurred together: +> 1. **Image match** (this badge) — "the bytes match the on-chain fingerprint." Cryptographic, local, trustless. +> 2. **Who made it (issuer)** — *not* something this badge or any "verified" pill can show. It comes only from outside the network: a signed attestation from the issuer, or a verified-issuer list **keyed by mint id** that names the human or group maintaining it. There is no network-guaranteed "verified" badge for an issuer. +> 3. **Uniqueness** — exists **only at the mint-id (genesis txid) level**. The name, ticker, and image are **not** unique and can be reused by a different collectible. Identity is the mint id, never the name or the picture. +> +> And a receipt is **pending, not final, until ~10 confirmations** (the node's finalization depth, `DEFAULT_MAX_REORG_DEPTH = 10`): a short reorg can briefly undo a just-arrived collectible, so ownership/authenticity is shown as **pending** until then (see §3.2). + +### 2.3 The privacy pill — one vocabulary everywhere + +Reuses the delegate's privacy-pill `QPainter` path and `privacyColor(bool)`. + +| State | Pill | Fill | Leading dot | One-liner (detail/send) | +|---|---|---|---|---| +| Private | green "Private" | `#1f7a1f` @ ~38 alpha | green dot | "Only you can see this. Its ownership is shielded." | +| Public | amber "Public" | `#d9822b` @ ~38 alpha | amber dot | "Anyone can verify this on the public ledger." | + +Lead-capped word + leading dot glyph (P7). A private item **never** offers a public explorer link or any remote fetch. + +### 2.4 The card — one anatomy everywhere + +The 168×208 card from `NFTGalleryDelegate::baseCardSize()` is the shared material. Gallery, set board (owned slots), detail thumbnail, mint review thumb, and send card-1 thumb all use the same paint craft so the app feels like one surface. + +``` ++--------------------------+ body: #15171c, 1px #2a2d35 hairline, radius 12 (kCardRadius) +| +--------------------+ ●| hover: border #3d4450 +| | square thumb | ◐| select: border #1f7a1f 1.6px +| | inset #1d2027 | | thumb: square inset, radius 8 (kThumbRadius), cover-fit crop +| | radius 8 | | badge●: verify badge top-right of thumb (§2.2) +| +--------------------+ | shimmer while pending (one shared QTimer, visible cards only) +| ● Private | privacy pill ● below thumb (§2.3) +| Aurora #014 | name: bold #e6e6e6, elided +| Zcl Originals | collection: #9aa0a6 0.85x, elided ++--------------------------+ +``` + +**Density:** Comfortable 168×208 (default) ↔ Compact 132×168. The delegate reads an int density role/property; paint stays geometry-driven (no new pixmaps). Persisted via a **new** `Settings::getNFTThumbSize()` getter/setter (to be added — see §6.1). + +**Variants (still the same card):** +- **Set-board owned slot:** identical, but the collection line is replaced by a small "#N" slot-number caption (collection is redundant inside a set). +- **Set-board MISSING slot ("ghost"):** body + hairline kept; thumb area is flat inset `#1d2027` with NO shimmer (it isn't loading) + a centered **decorative** `#3d4450` "#N" ghost numeral (decorative, not live text, so a sub-AA grey is fine here); caption = card **name** at `#9aa0a6` (the AA floor — live text never drops below it; `dark.qss:200-201` explicitly rejects `#6b7177` ~3.1:1 for live text) + a "Not collected" label at `#9aa0a6`; no badge, no pill. The "missing" affordance is carried by the **~55% painter opacity + ghost numeral + the "Not collected" label**, NOT by a sub-AA text color — so owned cards pop forward and completion reads from the silhouette alone while the name stays AA-legible. + +### 2.5 Typography & spacing + +- Page heading: 18–20pt / 700, `#e6e6e6`–`#ffffff`. Subhead: 12–14pt `#9aa0a6`, wordWrap. +- Card name: bold ~13–14pt `#e6e6e6`; collection ~11–12pt `#9aa0a6`. +- Dialog title: 16pt / 700 `#e6e6e6`; section/card titles 13–15pt / 600. +- IDs/hashes/values: fixed-pitch (`QFont::setFamily("monospace")`), right-aligned, shown **short** as `first8…last8` with a one-click copy and a hover tooltip carrying the full string. +- Layout rhythm matches `setupNFTTab()`: 12px outer margins, 8px grid spacing; cards 12–16px inner padding, 12–16px gap; dialog rows 14–16px. +- Card radius 12, inset/thumb radius 8 (already the qss + delegate constants). + +### 2.6 The one consistent action set + +The same verbs, same labels, same order, wherever an action on an NFT appears (card context menu, detail dialog, set-slot dialog): + +| Action | Label | Where | Notes | +|---|---|---|---| +| Open | (double-click / Enter / Space) | gallery, board | opens detail dialog | +| **Send / Gift** | "Send / Gift" (button) / "Send / Gift…" (menu) | detail, context menu, board slot | the bright primary; opens NFTSendDialog | +| Save image | "Save image…" | detail | disabled until bytes exist; writes verbatim bytes | +| Copy id | "Copy id" | detail, context menu | → "Copied ✓" 1.2s | +| Copy fingerprint | "Copy fingerprint" / "Copy image hash" | detail (More), context menu | full lowercase hex | +| Copy name / collection | "Copy name" / "Copy collection" | context menu, detail (More) | | +| Copy transaction id | "Copy transaction id" | context menu | | +| Re-check image | "Re-check image" | context menu | re-queues NFTImageCache for this hash | +| View in explorer | "View in explorer" | detail (More), disabled | only if `getExplorerUrl()` set AND `isPrivate==false`; asks once before opening a browser | + +There is **no "open link" / no network item** in any browse context menu. Private items never expose an explorer entry. + +### 2.7 Copy voice (shared) + +Plain, warm, second person, no exclamation spam, no emoji. State the outcome a button will produce ("Send gift privately" vs "Send the picture"). Reassure rather than alarm ("Nothing to do right now."). When something is off, say what to do, in one sentence. Reuse exact verdict sentences across screens so the user learns them once. + +--- + +## 3. The screens + +Each screen extends the shared system above. Only screen-unique layout, states, and copy are listed. + +### 3.1 Collections gallery (gallery-grid) + +**Role:** a browser/launcher, not a detail or mint screen. Extends the existing `setupNFTTab()` page (`mainwindow.cpp:3017`): same `QVBoxLayout(nftTab)`, 12px margins, 8px spacing. All additions programmatic, gated on `Settings::getShowNFTGallery()`. No `.ui` change. + +**Layout (top → bottom):** +- **A — Heading block (exists):** `QLabel#nftGalleryHeading` "Collections" (18pt/700 `#e6e6e6`) + subhead "Your NFTs. The image on each card is checked against its on-chain fingerprint." (`#9aa0a6`, 12pt). A live count chip at the right of the heading row (`QLabel#nftCountChip`, 11pt `#9aa0a6` on `#1d2027` pill, 6px radius): "12 items" → "12 of 40" when filtered. +- **B — Toolbar (NEW, one `QHBoxLayout#nftToolbar`, h=36, spacing 8):** + `[ search QLineEdit#nftSearch flex ] [ Filter ▾ ] [ Group ▾ ] [ Sort ▾ ] [ density ⊞ ]` + - `nftSearch`: dark.qss input, placeholder "Search your collection", leading magnifier via `QLineEdit::addAction(LeadingPosition)` (reusing the tint pattern) + trailing clear-X when non-empty; min-width 220, stretch 1. + - `nftFilter`: All / Private only / Public only / Verified / Needs attention (~140). + - `nftGroup`: No groups (default, flat per private-first decision) / By collection / By privacy (~150). + - `nftSort`: Recently received / Name A–Z / Collection (~160). + - `nftDensity`: checkable flat `QToolButton`, tinted grid glyph, tooltip "Card size". +- **C — The grid (exists):** `QListView#nftGalleryView` IconMode, `setResizeMode(Adjust)` + `setWrapping(true)` + `setUniformItemSizes(true)`. Cards from §2.4. Auto-packs 3 cards at ~620px up to 7+ at 1400px. +- **D — State overlays (NEW):** a `QStackedLayout` wrapping the view's place: grid vs empty/zero-result panel (centered, max-width 360; large tinted frame glyph, title `#e6e6e6`, body `#9aa0a6`, one primary). Toolbar is **hidden when true item count is 0** (nothing to filter), **shown when count>0 even if a filter yields zero rows** (so the user can clear it). + +**Architecture decision (consistency):** search/filter/sort/group live in a **`QSortFilterProxyModel`** (NEW) over the untouched `NFTGalleryModel` + `NFTGalleryDelegate`. The source model and delegate are unchanged; the delegate only gains a density property. "By collection" inserts non-selectable section-header rows (role-driven). Re-feeding identical data emits zero churn (the fingerprint-guard technique already in `setItems`). + +**States:** LOADING (cards render immediately with shimmer + amber "?"; count chip shows real local count at once — metadata is local, only images async) · EMPTY (toolbar hidden — see §3.6) · ZERO-RESULT (toolbar stays; panel "Nothing matches" + active filter in plain words + "Clear filters") · VERIFIED / MISMATCH / PENDING / **PENDING-NO-BYTES** ("Image not downloaded. Open to fetch it yourself." — NEVER auto-fetched, P8) · PRIVATE / PUBLIC · **INDEX-OFF** (one-line inset banner above the grid: "Public collectibles are turned off. Your private NFTs are still shown." + quiet "How to turn on" — never blocks the private grid) · OFFLINE (keep last good grid from on-disk thumb cache, count chip dims, no spinner-of-doom). + +**Interactions:** live search over name+collection (case-insensitive), count chip → "N of M", Esc/clear-X resets · Filter/Group/Sort instant via proxy · density toggle swaps size + persists · hover lightens border + plain-language tooltip · single-click select; double-click/Enter/Space opens detail · arrow keys move selection (native IconMode), Home/End/PageUp/Down; Tab/`/` focuses search · **context menu = the §2.6 set** (Open, Copy name, Copy fingerprint, Copy transaction id, Re-check image — no link/network item) · a pending-no-bytes card fetches bytes ONLY when opened and only after the in-detail "Get image" confirm. + +**Copy:** "Collections" · "Your NFTs. The image on each card is checked against its on-chain fingerprint." · "Search your collection" · filter/group/sort labels above · "12 items" / "12 of 40" · "No NFTs yet" · "Nothing matches" · "Clear filters" · "Checking this image…" · "This image does not match its on-chain fingerprint. It may have been changed." · "Image not downloaded. Open to fetch it yourself." · "Public collectibles are turned off. Your private NFTs are still shown." · "How to turn on" · "Re-check image" · "Copy fingerprint" · "Card size". + +### 3.2 Single-NFT detail (nft-detail) + +**Role:** show ONE NFT large and answer "is this really mine, does this image match its on-chain fingerprint, what can I do with it?" Modal `QDialog` (new `src/nftdetaildialog.{h,cpp}`, built programmatically like `setupNFTTab`), opened on click/Enter/double-click. Geometry remembered in `QSettings("NFTDetail/geometry")`. Min 760×560, resizable, centered on parent. Carries the `NFTItem` **by value** (cheap POD copy), plus a lightweight ordered list of the gallery's item ids (or a proxy reference) so prev/next can step — **no model pointer**. + +> **POD scope (don't over-claim what's in hand at open):** the value-copied `NFTItem` POD is only `{name, collection, txid, docHashHex, cachePath, receivedHeight, isPrivate, verifyState}` (see `src/nft.h`). **Creator, exact Set/series-position, the exact received-DATE, and `documenturl` are NOT fields on the POD** — they arrive later via async RPC (`zslp_gettoken` / `gettransaction`) and are back-filled into the already-open dialog (C10). In particular the **"Received" ISO date specifically needs a `gettransaction` lookup** to map the block height to a timestamp — the POD carries only `receivedHeight`. Until those land, the corresponding rows show "Unknown" / "Not part of a set" / "block N (date pending)", never blank or fabricated. + +**Layout:** +- **Title bar (h=44):** name 16pt/700 `#e6e6e6` (elided) + collection 11pt `#9aa0a6` ("Not part of a set" if none); right = flat close glyph (tinted x.svg). 1px `#2a2d35` hairline below. +- **Left column — image stage (stretch 1, min 380×380):** a card (`#15171c`/radius 12/hairline) holding a centered `QLabel("imageStage")` painting the full QPixmap with `KeepAspectRatio SmoothTransformation` (never upscales past 1024 native; letterboxed on `#1d2027`). Verify badge (20px, §2.2) floats top-right of the image. Shimmer while decoding (same visual language as the gallery). +- **Right column — info panel (fixed 320, `QVBoxLayout` spacing 12):** + 1. **Verify line (top, can't-miss):** full-width inset row (`#1d2027`/radius 10/hairline/pad 10) = 20px badge + one 13pt sentence; color swaps by `state` dynamic property (verified green / mismatch red / pending amber). + 2. **Privacy pill row:** §2.3 pill + an 11pt `#9aa0a6` one-liner. + 3. **Details card (`#15171c`/radius 12/hairline/pad 12):** label/value grid. Left labels 11pt `#9aa0a6`; right values 12pt `#e6e6e6`, right-aligned monospace for ids/hashes. Rows: **Mint id** (the genesis txid — *this* is the collectible's identity, not its name; short txid 8…8 + copy), **Received** (ISO date + "block 1,842,001" or "block — (unknown)"; until ~10 confirmations this row reads "Just arrived — confirming…" because a short reorg can briefly undo it, so ownership is shown **pending, not final**), **Creator** (issuer or "Unknown" — never fabricated; "Unknown" is honest because the chain does not record who minted it), **Set** ("Wild Series — 7 of 30" or "Not part of a set"), **Image hash** (short docHashHex 8…8 + copy). Hairline between rows; long values elide with hover tooltip. A 11pt `#9aa0a6` footnote under the grid: "This name and image aren't unique — anyone can mint another collectible that reuses them. Only the mint id is one of a kind." Ownership/authenticity is shown **pending until ~10 confirmations** (`DEFAULT_MAX_REORG_DEPTH = 10`), then final. + 4. **Action bar (pinned, h=48, hairline above):** the §2.6 set — primary green **"Send / Gift"** left-weighted; **"Save image…"**, **"Copy id"**; an overflow **"More" (…)** menu with "Copy image hash", "Copy collection", and a DISABLED-by-default "View in explorer" (only enabled if `getExplorerUrl()` set AND `isPrivate==false`). + +**States:** LOADING (shimmer + amber "Checking this image…"; Send/Gift + Copy id enabled — they need only the id; Save disabled until a pixmap exists) · VERIFIED (green: "This image matches its on-chain fingerprint."; all enabled — note this is a bytes-match only, NOT a "genuine/original" claim per §2.2) · MISMATCH (red: "This image does NOT match what was recorded on-chain. Don't trust it."; image dimmed 60% with thin red inset; Save stays enabled; Send/Gift shows a confirm first) · PENDING/UNCACHED (amber "Image not on this device yet." + a single explicit "Get image" button ONLY if a documenturl exists AND `isPrivate==false`; for private items the line reads "This image lives in your wallet's local cache." with no fetch button — P8) · PRIVATE / PUBLIC (one-liners per §2.3; explorer only for public+configured) · EMPTY METADATA ("Unknown" / "Not part of a set") · INDEX-OFF (wallet-local fields still show + calm note "Turn on the collectibles index to see full provenance." — no error dialog) · ERROR (amber "This file isn't an image we can show." + neutral broken-image glyph, never a crash). + +**Interactions:** resize re-scales from the held source QPixmap (no re-decode/re-hash) · click image = no-op (no lightbox in v1) · Copy flips to "Copied ✓" 1.2s · **Send / Gift** opens NFTSendDialog pre-filled; in C0/C1 it routes to a "Sending NFTs is coming soon." toast rather than a dead button; on a MISMATCH item it first confirms "This image failed its on-chain check. Send anyway?" · Save image → `QFileDialog` defaulting to `.png`, writes cached bytes verbatim · Get image (pending+public only) → one-shot worker fetch, hash-verify before display, inline progress, never auto-runs · More → explorer opens via `QDesktopServices::openUrl` only when enabled+public and asks once "This opens an outside website and may reveal your interest. Continue?" · Esc closes; left/right arrow steps to prev/next gallery item — this works because the dialog was handed the **ordered list of item ids** at construction (not a model pointer), so it walks that list and re-feeds the same dialog with the neighbor's POD (no flicker, no model coupling); verify line + details are `setAccessibleName`'d. + +**Copy:** all sentences listed in the states + "Mint id" · "Received" · "Just arrived — confirming…" · "Creator" · "Set" · "Image hash" · "Unknown" · "Not part of a set" · "block — (unknown)" · "This name and image aren't unique — anyone can mint another collectible that reuses them. Only the mint id is one of a kind." · the §2.6 action labels · "Copied ✓" · "Get image" · "Turn on the collectibles index to see full provenance." · "This image failed its on-chain check. Send anyway?" / "Send anyway" · "Sending NFTs is coming soon." · "This opens an outside website and may reveal your interest. Continue?". + +### 3.3 Create an NFT (mint-flow) + +**Role:** turn one local image into a verifiable NFT, impossible to get wrong. New `src/nftmintdialog.{h,cpp}`, modal `QDialog`, fixed 560px wide, built `setupUi`-style like `memodialog` (C++14-trivial). Body = a `QScrollArea` (frameless, transparent viewport) of **4 numbered cards** (`QFrame#card`: `#15171c`/hairline/radius 12/16px pad/14px gap); footer pinned outside the scroll. + +**Cards:** +- **1 — Your image (dropzone).** 528×180 `QFrame#dropZone`: inset `#1d2027`, 2px DASHED `#3d4450` border, radius 10. 40px tinted image glyph (`#9aa0a6`), 14pt "Drop an image here", 12pt "PNG, JPG, GIF, SVG, WebP — up to 20 MB", `QPushButton "Choose a file…"`. On drag-hover: border solid `#1f7a1f`, bg `#20242c`. After a file: collapses to a 528×96 loaded row — 72×72 thumbnail (via NFTImageCache), filename (elided middle) + "1.8 MB · 1024×1024", a "Fingerprint" mono line elided first8…last8 with copy, a green "Ready" pill once hashing completes, a flat "Replace" link top-right. +- **2 — Details.** `QFormLayout` of dark.qss inputs: **Name** (required, placeholder "e.g. Aurora #14", soft 50-char cap with live "32/50" counter), **Collection** (optional, "e.g. Zcl Originals — leave blank for a one-off"), **Note** (`QPlainTextEdit`, live "0/200"; label swaps Private↔Public — see states). +- **3 — Who can see it (the safety heart).** Two full-width selectable tiles (each a `QFrame` with a `QRadioButton`; selected gets a 2px accent border + faint tinted fill): + - **Private (only people you choose)** — green accent, default selected. Body: "The image and details are sealed. Stored encrypted on the ledger; only someone you give the key to can open it. Your balance and addresses stay shielded." + - **Public (anyone can verify and trade)** — amber accent. Body: "The name, collection, fingerprint and a link live on the public ledger forever. Anyone can look it up. The image itself stays off-chain — only its fingerprint is recorded." When public-mint RPCs are unwired, Tile B is **DISABLED** with an amber "Coming in this release" pill. + - A 12pt `#9aa0a6` caption under the tiles always states the consequence of the current choice. +- **4 — Review & confirm.** Inset summary (`#1d2027`/radius 8): 56×56 thumb + "Name · Collection", a visibility pill (§2.3), "Fingerprint 1f2a3b…9c0d", "Size 1.8 MB". Below it a **"What happens" two-column micro-table** (green-dot "Stays private" / amber-dot "Becomes public"), auto-filled from the current choice (Private → right column shows just "Nothing"). Then a fee row: "Network fee 0.0001 ZCL · After this you'll have 5.2340 ZCL" (balance-after turns `#d9822b` near a safe floor). + +**Footer (pinned):** flat "Cancel" + green default `QPushButton "Create NFT"`. Enables only when name + image + verified-hash are present (Private). Public-while-unwired keeps it disabled with helper text. + +**States:** empty ("Add an image to continue") · drag-hover · wrong-file (inline `#d9822b` "That file isn't an image we can read — try a PNG or JPG." / "That file is larger than 20 MB. Pick a smaller image." — no modal) · hashing (indeterminate bar + "Fingerprinting…", threaded, responsive) · ready (green "Ready", primary enables once Name non-empty) · verify-mismatch (red badge, "Couldn't read those bytes cleanly — choose the file again.", primary disabled — we never mint a hash we couldn't reproduce) · private-selected (default; Note label "Note (sealed with it)"; Review right column "Nothing") · public-selected-and-wired (future; Note "Note (this becomes public)"; a one-time "I understand this is permanent and public." checkbox gates the primary) · **public-UNWIRED** (greyed tile + "Coming in this release"; clicking it shows the calm "Public minting arrives in a coming update. For now, create it Private — you can always make a public copy later." and Private stays selected) · submitting (Private, when C2 lands: primary → spinner "Creating…", inputs disabled, Cancel → "Run in background") · index-off · success (closes to a slim non-modal toast "NFT created — Aurora #14" + "Show it" that selects the new card; gallery auto-refreshes) · low-balance ("Not enough ZCL to cover the network fee."). + +**Copy:** the strings above + "Create an NFT" · "1 Your image" / "2 Details" / "3 Who can see it" / "4 Review & confirm" · "Drop an image here" · "PNG, JPG, GIF, SVG, WebP — up to 20 MB" · "Choose a file…" · "Replace" · "Fingerprint" · "Ready" · "Fingerprinting…" · "Stays private" / "Becomes public" / "Nothing" · "Network fee" · "After this you'll have 5.2340 ZCL" · "A small flat network fee, paid to keep the network running. It does not go to us." · "Create NFT" · "Creating…" · "Run in background" · "Cancel" · "Discard this draft?" · "NFT created — Aurora #14" · "Show it" · "Copied" · "Public features need the token index on — turn it on in Settings.". + +### 3.4 Send / Gift (send-gift) + +**Role:** give one NFT you own to someone — pick who, optional note, Public vs Private, confirm. For Private gifts make the two-step "send the picture, hand over the key" flow feel like one calm action. New `NFTSendDialog`, modeled on `memodialog.ui` / `confirm.ui`; constructor **requires** an `NFTItem` (no empty state to design). Fixed width 520, geometry in `QSettings`. windowTitle "Send a gift". + +**Layout — four cards + footer:** +- **1 — What you're giving (anchor, ~96px).** 72×72 inset thumb (fed exactly like the gallery: `NFTImageCache::request(docHashHex, cachePath, docHashHex, 72)` — instant from disk cache, shimmer while pending), verify badge bottom-right (§2.2). Right: name (15pt `#ffffff` bold), collection (12pt `#9aa0a6`), privacy pill (§2.3). Read-only — answers "this is the one, right?". +- **2 — Who gets it.** title "Send to". `AddressCombo` (the wallet's existing autocompleting recipient widget) full width; placeholder "Paste an address or pick a contact"; a "Address book" ghost button on the title row. One reserved-height live status line below it: empty→hidden / valid private→green "Looks good — a private (shielded) address" / valid public→amber "Looks good — a public (transparent) address" / invalid→red "That doesn't look like a ZClassic address". Recipient type **drives** card 3. +- **3 — How private.** title "Who can see this gift". Two full-width `#1d2027` click-target radio rows: + - **Private gift** [green pill] — "Only you and the person you send to can see this. The picture travels hidden, and you hand over the key to unlock it." + - **Public gift** [amber pill] — "Anyone can look up that this collectible moved to them. Their wallet address becomes visible on the public ledger." + Default follows the NFT + recipient (Private NFT to a private addr → Private; Public NFT → Public). An unsupported choice is **disabled with the fix inline** (P4): Private disabled for a transparent recipient → "Private gifts need a private (shielded) address — paste one above." Public when ZSLP RPCs are absent → "Coming soon", disabled. +- **3b — When should they get the key? (reveals only when Private, animated `setVisible`).** Two rows: **Send it all now** (default) "We send the picture and the key together. They can open it the moment it arrives." / **Send the picture now, the key when you're ready** "The picture goes out sealed. You unlock it for them later with one tap from Activity — good for a surprise on a certain day." Footnote "Either way, only they can ever open it." +- **4 — Add a note (optional, collapsible, collapsed by default).** `QPlainTextEdit` (memodialog pattern) + right-aligned "0 / 512 bytes" counter (amber >480, hard cap 512). Helper "A short message that rides along, hidden, for them only." For a Public gift the note is hidden entirely with "Public gifts can't include a private note." + +**Footer:** fee line "Network fee 0.0001 ZCL · You'll still have 5.2340 ZCL" + `QDialogButtonBox` [Cancel] and a green default action whose **label states the outcome** (the don't-make-me-think payoff): Private+send-all-now → "Send gift privately"; Private+reveal-later → "Send the picture"; Public → "Send gift". Action disabled until valid recipient AND a verified-or-pending thumb (blocks on red mismatch) AND not already in flight. + +**States:** opened/ready · thumb pending (gift not blocked on a slow decode, only on a real mismatch) · thumb verified · thumb MISMATCH (red "This picture doesn't match its fingerprint — we won't send it.", action disabled) · recipient empty/valid-private/valid-public/invalid · Private chosen (3b + note reveal) · Public chosen (3b + note hidden with explanations) · sending (action → spinner "Sending…", inputs disabled, Cancel → "Close (keeps sending)") · sent-now ("Gift sent. It's on its way to them.") · sent-picture-only ("Picture sent, sealed. Unlock it for them anytime from Activity." + an Activity entry with a "Hand over key" action) · error (inline red banner + daemon's plain reason + "Try again", nothing sent) · index-off (Public disabled, Private fully works). + +**Interactions:** debounced local address validation (no per-keystroke RPC) drives the status line + enables Private/action · Address book picks an address and re-validates · choosing Private reveals 3b with a soft expand; Public collapses it and hides the note; the action label updates instantly · whole radio row is the click target (hover lifts `#1d2027`→`#23272f`, pointing hand) · note expand/collapse via chevron; Esc/Cancel before send guards a typed note with "Discard your note?" · send uses the existing `RPC::executeTransaction`/`z_sendmany` async path with the wallet's `watchTxStatus`; reveal-later sends only sealed picture frames now and records a "Hand over key" one-tap for later · fully keyboard-navigable; the action label always tells you what Enter will do. + +**Copy:** all sentences above + "Send a gift" · "What you're giving" · "Send to" · "Address book" · "Who can see this gift" · "When should they get the key?" · "Either way, only they can ever open it." · "Add a note" · "0 / 512 bytes" · "Verified — this picture matches its on-chain fingerprint" / "Checking…" / "Mismatch — not safe to send" · "Coming soon" · "Public collectibles need the collectibles index turned on (Settings ▸ Advanced)." · "Try again". + +### 3.5 Set / collection board (set-collection) + +**Role:** "collect them all" board for one card-set (Curio-Cards style) — show completion and the gap at a glance, calmly. A stacked page **inside the Collections tab** (`QStackedWidget` index 1; index 0 = the gallery `QListView`), reached by clicking a set thumbnail, with a back affordance. No new nav-rail entry. + +**Layout:** +- **1 — Header strip (~64px):** flat back "‹ Collections" (`QToolButton`, dim `#9aa0a6`→hover `#e6e6e6`, 40px hit area). Center-left: set name 20pt/700 `#e6e6e6`; under it 12pt `#9aa0a6` "Created by {creator} · {N} cards" (a creator shows a tiny green tinted check ONLY when its mint id is on a named verified-issuer list — its tooltip says "On {maintainer}'s verified-issuer list", never a bare "Verified"; this is a social/external signal, NOT a network guarantee — §2.2 item 2). Right: completion meter — 13pt "3 of 7 collected" above a 6px rounded track (inset `#1d2027` bg, **green `#1f7a1f` fill**, NOT amber, NOT a rainbow); at 7/7 the fill animates once to brighter `#2a9d2a` + a single green "Set complete" pill. +- **2 — The board (hero):** a `QListView` IconMode configured identically to the gallery, fed by a **`SetBoardModel`** (sibling of `NFTGalleryModel`) whose rows are ALL slots in canonical manifest order — owned and missing alike — painted by a **`SetSlotDelegate : NFTGalleryDelegate`**. Owned slots paint as §2.4 (with "#N" caption); missing slots paint as the ghost variant (§2.4) — dim, numbered, "Not collected", 55% opacity, **no image request issued** (nothing to decode, no network). +- **3 — Footer help bar (~44px, only when missing>0):** left 12pt dim "Missing 4 cards. They arrive when someone sends them to your wallet." Right: one quiet secondary button "Show my receive address" (hairline border, NOT a loud green CTA) → opens the existing Receive tab. **No in-app buy/trade** (P10). + +**States:** loading-board (set name/creator instant from cached token; owned slots shimmer only while decoding; missing slots final immediately so the board shape is correct on first paint) · empty-set (full board of ghosts, "0 of 7 collected", "You haven't collected any of these yet. They arrive when someone sends them to your wallet.") · verified/mismatch/pending owned slot (§2.2; mismatch shows a `#c0392b` "Image doesn't match its record" caption, never silently hidden, never auto-refetched) · missing slot (the default for any slot you don't hold) · private-set (owned slots show the green Private pill; subline adds "Private set — only you can see what you hold"; missing slots carry no pill) · **index-off** (public sets only: a single calm inset card "Turn on collection tracking to see sets" + "How to turn it on"; private sets unaffected — they come from the memo scan) · set-complete (green pulse, "Set complete" pill, footer hides entirely, brief "You've collected the whole set.") · stale/offline (last known count + dim "(updating…)", never flash a wrong lower count). + +**Interactions:** click a set card → `QStackedWidget` index 1 (built once, reused); back via "‹ Collections" / Esc / Backspace → index 0 with the gallery scroll preserved · hover owned slot brightens (border `#3d4450`, pointing hand, tooltip "Card #N · {name} · received {date}"); hover missing stays dim (tooltip "Card #N · {name} · not collected yet") — the hover difference teaches owned-vs-missing with no legend · double-click/Enter owned → the detail dialog (§3.2); missing → a lightweight "not collected" dialog (manifest preview + "Show my receive address") · arrow keys move selection; the verify badge has no click action on the board (status, not control — P5). + +**Copy:** "3 of 7 collected" · "Set complete" · "You've collected the whole set." · "Created by {creator} · {N} cards" · "Not collected" · "Missing {n} cards. They arrive when someone sends them to your wallet." · "You haven't collected any of these yet. They arrive when someone sends them to your wallet." · "Show my receive address" · "Card #{n} · {name} · not collected yet" / "Card #{n} · {name} · received {date}" · "Image doesn't match its record" · "Private set — only you can see what you hold" · "Each card is checked against its on-chain record." · "Turn on collection tracking to see sets" · "How to turn it on" · "updating…" · "‹ Collections". + +### 3.6 First-run / empty (Collections empty + index-off) + +**Role:** make a brand-new user with zero NFTs instantly understand "this is where collectible art you've been sent or made lives, and the wallet checks each picture against its on-chain fingerprint" and give them ONE obvious action; when the daemon's index is off, replace the empty state with a plain explanation + the one toggle. Never a dead end. + +**Structural change (the only one):** wrap the existing single `QListView` in a `QStackedWidget#nftGalleryStack` with **four pages** — 0=gallery, 1=empty, 2=index-off, 3=loading — and flip pages instead of show/hide juggling. The heading stays "Collections" in every state; the subhead is **state-dependent**. + +**Pages (same centered hero-card geometry so the layout never jumps — `#15171c`/hairline/radius 12/28px pad/16px spacing, max-width 520, centered):** +- **1 — EMPTY:** 56px tinted frame glyph in quiet grey `#2f343d` (NOT red, NOT amber — "empty", not "broken"), title "No collectibles yet", body "When someone sends you a collectible, or you make one, it shows up here — and the wallet checks each picture against its on-chain fingerprint. Nothing to do right now.", ONE green primary "Show me how it works" (Phase C1; becomes "Make your first collectible" when mint lands in C2), a subordinate flat link "What is a collectible?". +- **2 — INDEX-OFF:** 56px tinted toggle-off glyph in amber `#d9822b` (a setting needs attention, not broken), title "Collectibles tracking is turned off", body "Turn this on and the wallet will start finding your collectibles. It does a one-time catch-up scan in the background, so syncing stays fast for people who don't collect.", green primary "Turn on collectibles", flat link "Why is this a separate setting?". On a **managed daemon**: a confirm ("The wallet will restart and do a one-time scan in the background…" [Not now]/[Turn it on]) → writes the flag → restart → loading. On a **foreign/old daemon** (`-32601` or unmanaged): the CTA instead reveals a `#1d2027` inset with the exact conf line "zslpindex=1" + a [Copy line] button — never a dead end. +- **3 — LOADING:** "Looking for your collectibles…" + an indeterminate `QProgressBar` (`#1f7a1f` chunk) + "This runs in the background. You can keep using the wallet." Non-modal, never a blocking spinner. Resolves to Empty(1) or Gallery(0). + +**State selection** rides the existing poll loop (`rpc.cpp doRPC`) calling `zslp_listmytokens`: index-disabled error → page 2; `-32601` → page 2 conf-line variant; success+empty → page 1; success+rows → page 0; first call outstanding → page 3. The last good page is **latched fingerprint-style** (mirroring the `getwalletsummary -32601` latch at `rpc.cpp:1641`) so a transient poll error never flickers back to empty/off. Daemon-unreachable reuses the wallet's existing global "Not connected" banner and **keeps the last state** (showing empty/off would falsely imply "you have none"). + +**Copy:** subhead (first-run) "Collectibles are one-of-a-kind images you've been sent or made. The wallet checks each picture against its on-chain fingerprint." · the titles/bodies/CTAs above · "What is a collectible?" · "Why is this a separate setting?" → inline expander "It's off by default so the wallet syncs quickly for everyone. Turn it on only if you want to collect." · foreign-daemon inset "Your wallet is connected to a node you didn't start here. Add this one line to its zclassic.conf, then restart it:" / field "zslpindex=1" / "Copy line" · turn-on confirm "The wallet will restart and do a one-time scan in the background. You can keep using everything else while it runs." [Not now]/[Turn it on] · "Looking for your collectibles…" / "This runs in the background. You can keep using the wallet." + +--- + +## 4. End-to-end happy paths + +### 4.1 Browse → open → send (the everyday loop — ships first, private) +1. Tap **Collections** in the nav rail. The grid paints instantly with shimmer thumbs + amber "?" badges; the count chip shows the real local count at once. +2. Thumbs stream in from the on-disk cache; badges flip to green checks. The user scans the silhouette — green pills = mine-and-hidden, green checks = the picture matches its on-chain fingerprint (a bytes-match, not a "genuine/original" claim — §2.2). +3. Type a name in search → "12 of 40"; or pick "Verified" in Filter. Instant, no I/O. +4. Double-click a card → **detail dialog** opens large, almost always painting from cache immediately. The verify line reads "This image matches its on-chain fingerprint." +5. Click **Send / Gift** → **NFTSendDialog** opens with the picture already chosen and verified (step "is this the right one?" answered before a word is read). +6. Paste/pick a recipient → the live status line confirms "a private (shielded) address". Private gift + "Send it all now" are pre-selected. +7. The green button reads "Send gift privately". Press it → spinner → "Gift sent. It's on its way to them." The gallery refreshes on the next poll. + +### 4.2 Mint (create your first NFT) +1. From Collections empty state ("Make your first collectible", C2) or a future mint entry → **NFTMintDialog**. +2. Drop an image → threaded SHA-256 runs (indeterminate bar, "Fingerprinting…"), the dropzone collapses to a loaded row with a thumbnail and "Ready". +3. Type a Name (required). Collection optional. **Private** is pre-selected; the consequence caption + the "Stays private / Becomes public" table show "Nothing" becomes public. +4. Review shows thumb, visibility pill, fingerprint, size, and "Network fee 0.0001 ZCL · After this you'll have 5.2340 ZCL". +5. **Create NFT** → "Creating…" → broadcast → dialog closes to a toast "NFT created — Aurora #14" with "Show it". (Public path shows "Coming in this release" and steers to Private — never a dead end.) + +### 4.3 Collect a set +1. In Collections, a set card → click → the **set board** swaps in (`QStackedWidget` index 1). Owned cards are bright and detailed; missing ones are dim, numbered, "Not collected". The completion meter reads "3 of 7 collected". +2. Hover teaches owned-vs-missing (owned brightens, missing stays dim). Double-click an owned card → detail; a missing card → "not collected yet" + "Show my receive address". +3. The footer states the honest path: "Missing 4 cards. They arrive when someone sends them to your wallet." with one quiet receive-address button. +4. When the last card arrives, the green fill pulses to `#2a9d2a`, a "Set complete" pill appears, and the footer simply disappears — the reward is the absence of remaining work. + +--- + +## 5. Native performance contract (shared, enforced everywhere) + +This is a **contract**: any NFT code that violates it is a defect. + +**C1 — No web, ever.** No QtWebEngine / HTML / browser on any path. Every NFT pixel is a `QPixmap` on a `QLabel` / `QListView`, painted with `QPainter`. (Verified: the C0 delegate/cache contain none.) + +**C2 — The threading contract (already enforced in `nftimagecache.cpp`).** A bounded `QThreadPool` (`setMaxThreadCount(4)`) decodes off the GUI thread. The worker reads bytes from the AppData cache, **SHA-256-verifies** against `docHashHex`, `QImageReader::setScaledSize` down-scales huge sources **at decode time**, and produces **only a `QImage`** — it NEVER touches `QPixmap`. It hands back via `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` to `deliver()`, where the `QPixmap` is built **on the GUI thread** and `onImageReady(hash, pixmap, verifyState)` updates rows by `docHashHex`. Thumbs live in a parallel index-aligned `QVector`. + +**C3 — Caching is two-tier and re-open is free.** `QPixmapCache` (128 MB) + on-disk `AppData/nft_thumbs/{hash}_{size}.png` (and `AppData/nft_images/{hash}` for full bytes). Re-opening the gallery, opening detail, or seeing the same image in send/mint paints from cache with no decode. SHA-256 verification runs **once** per row on the worker; the result is cached — the detail dialog and board read `verifyState`, they never re-hash. Resizing the detail image re-scales from the held source pixmap (cheap `SmoothTransformation`), never re-decodes or re-hashes. + +**C4 — In-flight guard, no duplicate work.** The cache's `_inflight` set (mutex-guarded) drops a duplicate request for the same key; `Replace` in the mint dialog and a superseded decode cancel cleanly (mirrors `_inflight`). + +**C5 — No relayout on scroll.** `setUniformItemSizes(true)` + a fixed delegate `sizeHint` = `sizeHint` queried once. `resizeMode Adjust` + `setWrapping(true)` reflow columns cheaply on window resize. The density toggle changes a size constant, not the pipeline — instant. + +**C6 — One shimmer timer.** A single shared `QTimer` drives one repaint of only the **visible pending** cards (`viewport()->update` over the pending index-rect set) — not a timer per card. Missing set-board slots issue ZERO image requests and never shimmer (they aren't loading). + +**C7 — Flicker-free refresh.** Models are **fingerprint-guarded** (`NFTGalleryModel::setItems`, `SetBoardModel::setItems`): a wallet poll returning identical state emits no model signals → no relayout, no thumbnail re-request, no flicker. The `QSortFilterProxyModel` runs in-process (no I/O) over the guarded source. + +**C8 — Tinted glyphs cached.** `tintedIcon(resource, color, px)` renders each badge/dot/toolbar glyph once and caches it in a `QHash` keyed by resource+color+px (the `PrivacyBadgeDelegate` pattern). Zero pixmap allocation on hover/repaint. + +**C9 — PRIVACY = PERFORMANCE-SAFE (the hard rule, P8).** The hot path touches **only local bytes** — the file the user picked and the on-disk cache. A remote `documenturl` image is **NEVER auto-fetched** (no IP/interest leak, no surprise network stall). Bytes arrive only from cache or one explicit user action ("Get image" / "Create NFT" / "Save image"), each behind a confirm where it leaves the device. The only network action a mint/send makes is the user's explicit final broadcast. + +**C10 — RPC stays off the GUI thread and off the paint path.** Provenance (`zslp_gettoken` / `zslp_listtransfers` / `zslp_listmytokens`) and the empty-state selection ride the existing async `rpc.cpp` poll connector; dialogs render instantly with wallet-local fields (name, txid, height, privacy, verify) and fill remote provenance when it returns. No per-paint or per-scroll RPC. Public ZSLP rows come from cached `zslp_*` JSON-RPC polled on the existing refresh loop, never blocking paint. + +--- + +## 6. Implementation map (files + build order) + +Grounded in the real tree. Existing C0 files confirmed present on `feature/nft-gallery`: `src/nft.h`, `src/nftgallerymodel.{h,cpp}`, `src/nftgallerydelegate.{h,cpp}`, `src/nftimagecache.{h,cpp}`; `setupNFTTab()` at `mainwindow.cpp:3017`; `setupNavRail()` + `makeRailButton` at `mainwindow.cpp:1255`/`1278`; Collections rail button at `1311`. + +### 6.1 Existing files that change + +| File | Change | +|---|---| +| `src/mainwindow.cpp` | Wrap `nftGalleryView` in `QStackedWidget#nftGalleryStack` (4 pages, §3.6); add Region-B toolbar (§3.1); add the set-board `QStackedWidget` page + `SetBoardModel`/`SetSlotDelegate` wiring (§3.5); refine subhead/count-chip; gate on `Settings::getShowNFTGallery()`. Mind the nav-rail live-index re-sync. | +| `src/mainwindow.h` | Declarations for the stack, proxy model, set-board model/delegate, dialog launchers. | +| `src/nftgallerydelegate.{h,cpp}` | Add a density property/role (Comfortable 168×208 ↔ Compact 132×168); add the disc **ring** (P7) + mismatch inner hairline + privacy leading-dot. Source model untouched. | +| `src/rpc.{h,cpp}` | Empty-state selection over `zslp_listmytokens` with the `-32601`/index-off branches + fingerprint latch (mirror `rpc.cpp:1641`); provenance reads for the detail/board; the index-off conf-write/restart helper. **Private receive path** parses `z_listreceivedbyaddress` notes (C1). The current code at `rpc.cpp:~756-760` **lossily coerces binary memos through a `QString` conversion** (`QByteArray::fromHex(...)` → `QString`, then drops `.trimmed().isEmpty()`), which mangles non-text frames; **ADD a parallel binary-safe read path** (detect the `ZDC1` magic and route to a data-channel handler) before private NFTs can be read — leave the existing text-inbox path intact. | +| `src/settings.{h,cpp}` | `getShowNFTGallery()` exists today; **ADD `getNFTThumbSize()` and `getExplorerUrl()` as NEW getters/setters** (neither exists yet — only `getShowNFTGallery()` and the unrelated `getExplorerTxURL`/`getExplorerAddressURL` are present); persist density. | +| `res/styles/dark.qss` | Append NFT object-name rules using existing tokens ONLY: `#nftEmptyCard`/`#nftIndexOffCard`/`#nftLoadingCard`, `#nftEmptyTitle`/`#nftIndexOffTitle`, `#nftEmptyBody`, `#nftEmptyLearn`/`#nftIndexOffWhy` hover, `NFTDetailDialog#nftDetailDialog`, `#nftVerifyLine[state="verified|mismatch|pending"]` color swap, `#nftDetailsCard`. Primary buttons inherit the existing green accent (no new rule). | +| `application.qrc` + `res/icons/` | **New SVG assets needed.** Today `res/icons/` ships only the badge/privacy glyphs relevant here — `check.svg`, `x.svg`, `question.svg` (plus the existing `eye.svg` / `eye-off.svg` / `shield-lock.svg`, which the NFT screens don't reuse). The screens additionally need, **none of which exist yet**: a **magnifier** (search), a **grid/density** glyph, a **copy** glyph, a **frame/picture** glyph (empty-state), and a **toggle/index-off** glyph. Add all five (single source, tinted at runtime via `tintedIcon()`), and register them in `application.qrc`. | +| `zcl-qt-wallet.pro` | Add the new `.cpp`/`.h` to `SOURCES`/`HEADERS`. The `QThreadPool` path needs no new Qt module (`QtConcurrent` not required). C++14 only. | + +### 6.2 New files + +| File | Role | +|---|---| +| `src/nftdetaildialog.{h,cpp}` | Modal detail dialog (§3.2), programmatic, `QSettings("NFTDetail/geometry")`, async hash verify via NFTImageCache. | +| `src/nftmintdialog.{h,cpp}` | Guided create dialog (§3.3), `setupUi`-style like memodialog, worker-thread chunked SHA-256 (mirror `connection.cpp:928`). | +| `src/nftsenddialog.{h,cpp}` | Gift/transfer dialog (§3.4), constructor requires an `NFTItem`, reuses `AddressCombo` + `RPC::executeTransaction`. | +| `src/setboardmodel.{h,cpp}` | `QAbstractListModel` sibling of `NFTGalleryModel`; rows = full set in manifest order, owned|missing; fingerprint-guarded `setItems`; reuses `onImageReady`. | +| `src/setslotdelegate.{h,cpp}` | `: NFTGalleryDelegate`; forks owned-card paint + adds ghost missing-slot paint + "#N" numeral; reuses `tintedIcon()` + dark.qss QColor consts. | +| (proxy) | A `QSortFilterProxyModel` instance for the gallery search/filter/sort/group — may live inline in `mainwindow.cpp` or a small `nftgalleryproxy.{h,cpp}` if section-header rows warrant a subclass. | + +### 6.3 Build order (each step shippable, smallest-usable-first) + +1. **Gallery polish + proxy + density + empty/loading pages + index-off (page 2/3).** GUI-only, fixtures + the existing C0 pipeline. Toolbar, count chip, ring/dot accessibility, first-run hero. This is the demo, zero chain risk. (§3.1, §3.6) +2. **Detail dialog.** Opens from the gallery; reuses the cache; verify line + details grid + the §2.6 action set (Send/Gift routed to a "coming soon" toast in C0/C1). (§3.2) +3. **Private read path (C1).** Wire `rpc.cpp` to scan `z_listreceivedbyaddress` notes (first add the parallel binary-safe read path at `rpc.cpp:~756-760` — detect the `ZDC1` magic and route to a data-channel handler, rather than letting the lossy `QString` coercion mangle binary frames); the index-off page selection goes live. Real private NFTs appear; detail/badge run against real bytes. (§3.6 selection, C9/C10) +4. **Set board.** `SetBoardModel` + `SetSlotDelegate` + the in-tab `QStackedWidget` page; private sets from the memo scan. (§3.5) +5. **Mint + send (C2, private).** `NFTMintDialog` (`z_sendmany` self-send) + `NFTSendDialog` (incl. the reveal-later "Hand over key" Activity hook). Now a complete private-NFT loop on today's daemon. (§3.3, §3.4) +6. **Public ZSLP (C3, gated on daemon).** When `zslp_genesis/mint/send` exist (daemon `CRecipient.scriptPubKey` + `CreateTransaction` — NOT `createrawtransaction`), enable the Public tiles, public provenance in detail/board, and "By collection" completion. Until then every Public affordance is the honest disabled "Coming in this release". (§3.3 states, §3.5 index-off) + +--- + +## 7. Honesty ledger (what we will NOT pretend) + +- **No in-app marketplace / buy button.** The chain can't honor it; the set board says "they arrive when someone sends them to your wallet" + a receive address. +- **"Private" ≠ "only one copy can exist."** It means hidden from the public; a prior holder can keep their plaintext copy. Copy avoids implying exclusivity. +- **Public minting is not wired yet.** Every public path is a calm, disabled "Coming soon" / "Coming in this release" that steers to Private — never a dead button, never a fabricated success. +- **No silent remote fetches.** Privacy floor (P8/C9). A "not downloaded" image is the honest state, fetched only on explicit, confirmed user action. +- **Unknown stays "Unknown".** Never blank, never fabricated provenance. + +--- + +*Synthesized from six native screen specs. Verified against `zcl-qt-wallet@feature/nft-gallery` (`nft.h`, `nftgallerydelegate.cpp`, `nftimagecache.cpp`, `dark.qss`, `mainwindow.cpp`) and the daemon's ZSLP + Sapling-memo reality. Hard rules upheld: never touch consensus/PoW/validation; ZSLP + data channel are non-consensus; no auto-fetch of remote images; don't-make-me-think throughout.* diff --git a/doc/nft/NFT_FEATURE_CHECKLIST.md b/doc/nft/NFT_FEATURE_CHECKLIST.md new file mode 100644 index 00000000000..2676a9b451b --- /dev/null +++ b/doc/nft/NFT_FEATURE_CHECKLIST.md @@ -0,0 +1,245 @@ +# ZClassic NFT — Master Feature Checklist + +The single source of truth for the four NFT pillars: **Mint · View · Shield · Sell.** +Scope: daemon (`/home/rhett/github/zclassic`, branch `feature/zslp-nft-indexer`, all NFT +work UNCOMMITTED) + GUI (`/home/rhett/github/zcl-qt-wallet`, C++14). + +> Honesty rule for this doc: **"implemented" = code exists AND is reachable. "tested" = a +> real automated test exercises it. "documented" = a user/dev can find how to use it.** +> In-flight and design-only work is NEVER marked done. + +Marker legend (used in every cell): + +| Marker | Meaning | +|--------|---------| +| ✅ | Done — exists, reachable, and (for test cells) a real automated test exercises it | +| 🟡 | Partial — exists but incomplete, indirect, untested-at-this-layer, or stale/misleading | +| ❌ | Missing — does not exist at this layer | + +> **2026-06-06 GUI-LANDING UPDATE.** The NFT dialogs (`nftmintdialog.*`, +> `nftdetaildialog.*`, `nftsenddialog.*`, `nftgallerymodel/delegate`) that were previously +> in-flight (workflow `wnr918pfq`) **have LANDED** in the GUI tree and are graded here as +> real code (no more 🔭). They remain UNCOMMITTED (`??`) and so are at risk until committed. +> Grading is from a read-only audit of the now-present source + the L0/L1 test files. +> The **GUI build backlog now lives in its own doc: [`NFT_GUI_PLAN.md`](NFT_GUI_PLAN.md)**. + +--- + +## 1. Honest status header — the four pillars + +| Pillar | One-line real-state verdict | +|--------|------------------------------| +| **MINT** | **Daemon: shippable-but-unproven.** `zslp_genesis` (nft preset) is implemented, reachable, registered, helped, CLI-arg-converted, and self-validates before broadcast — but **no test drives the RPC or the `BuildAndCommitZSLP` write path** (only pure builders/parser/store/self-validate gate are gtested). **GUI mint dialog (`nftmintdialog.*`) has LANDED** (file→stream-hash→review→`zslp_genesis`, Create gated on name+fingerprint, honest "only the fingerprint goes on-chain" copy, 0-conf terminal). Infra (ContentEngine hash, `RPC::mintNFT`) is done + tested. **But the mint dialog is NEVER constructed in any test** (no L1 covers Create-gating, fingerprint streaming, the privacy-drop reject, the in-flight `closeEvent` swallow, or the success/failure terminal); `mintNFT` has no test-injection seam. In-app: no permanence/public-ledger warning, no help. | +| **VIEW** | **Most-complete pillar. Daemon read RPCs done (untested at RPC layer); GUI gallery infra done + L0-tested; detail dialog has LANDED + has one L1 test.** No web browser anywhere. Honest gaps: **a RECEIVED NFT can NEVER reach the green verify badge** — `refreshNFTs` hard-sets `cachePath=''` (rpc.cpp:960, privacy: never auto-fetch) and the ONLY cache writer is the in-session mint (`ContentEngine::cachePut`, nftmintdialog.cpp:249); there is **no "attach the file you have" affordance** anywhere (no `getOpenFileName` in the detail/gallery), so the core promise (verify the image) is structurally unreachable for anything not minted in-session. A hash-less NFT (`document_hash` optional for nft=true) is permanently unverifiable. `refreshNFTs` repaints only on a new block. **No in-app help/onboarding at all** (0 `setWhatsThis` in the whole GUI; the verify badge has no "matches fingerprint ≠ genuine/official" disambiguation); the spec'd 4-page hero stack (guide §2.3) was NOT built — only a single grey `nftStateLabel`. The detail dialog's verified/mismatch/pending **badge-copy mapping is untested** (only the no-bytes terminal branch is). | +| **SHIELD** | **~25% built, 0% reachable in the GUI.** The ZDC1 codec exists; **the privacy RPCs `z_senddatafile` / `z_getdatatransfer` / `z_listdatatransfers` are now BUILT in the daemon** (`src/rpc/datachannel.cpp`, registered, CLI-arg-converted at `client.cpp:138-139`, gated behind `-datachannel` default-OFF, `z_senddatafile` requires `acknowledge_permanent=true`) — they are **no longer "unbuilt", they are built-with-ZERO-GUI-affordance.** GUI is honestly hard-gated off (`isPrivateMintWired()==false`, rpc.h:225); no GUI caller for any datachannel RPC; the binary-safe memo-read sniff is not applied. Shielding token *ownership* is consensus-impossible (correctly out of scope). | +| **SELL** | **Daemon BUILT + atomic-swap regtest-proven (uncommitted); GUI greenfield.** The 6 `nft_*` offer RPCs exist (`src/rpc/nftoffer.cpp`, registered :1180-1186) with CLI conversion + 6 gtests + `qa/zslp/nft-sell-regtest.sh`; no GUI surface yet. The authoritative design is `NFT_SELL_DESIGN.md` (fixed-template `SIGHASH_ALL\|ANYONECANPAY`: OP_RETURN ZSLP SEND@vout[0] / buyer NFT dust@vout[1] / seller ZCL payout@vout[2]); the older `ONCHAIN_TRADES.md` is SUPERSEDED (its `SINGLE\|ANYONECANPAY` layout is funds-losing — SINGLE would pin the OP_RETURN, not the payout, and burn the seller NFT). The transparent-swap *primitives* (`signrawtransaction` ALL\|ANYONECANPAY + CombineSignatures, ZIP-243 masking, P2SH/CLTV/multisig) are reused; the two classic blockers are HANDLED in the build — `createrawtransaction` cannot emit OP_RETURN (so the builder hand-assembles vout[0] via `ZSLPBuildSend`), and `fundrawtransaction` is avoided entirely (it would insert change at a random vout and break the seller's `ALL` signature). | + +**Bottom line:** Mint + View now have **landed (uncommitted) GUI dialogs** on top of infra-complete primitives, but the dialog wiring is **almost entirely untested at the widget level** (only the detail no-bytes terminal has an L1 test) and View's headline promise — verify a *received* image — is structurally unreachable (no attach-local-bytes path). The daemon write path is still untested. Shield's privacy RPCs are now *built* in the daemon but have **zero GUI affordance** (honestly hard-gated off). Sell is design-only. Nothing is end-to-end shippable to a non-technical user today. + +--- + +## 2. Capability matrix + +Columns: **CLI impl / CLI test / CLI doc** = the daemon side. **GUI impl / GUI test / GUI doc** = the wallet side. `—` = not applicable to that layer. + +### MINT + +| Capability | Pillar | CLI impl | CLI test | CLI doc | GUI impl | GUI test | GUI doc | Notes / Gap | +|---|---|:--:|:--:|:--:|:--:|:--:|:--:|---| +| `zslp_genesis` (mint; nft preset forces decimals0/qty1/no-baton) | Mint | ✅ | ❌ | ✅ | ✅ | ❌ | 🟡 | RPC reachable + self-validates (`zslp.cpp:330`, table :661, `client.cpp:138`). **No test drives the RPC fn or NFT-preset rejections.** GUI: `RPC::mintNFT` (rpc.cpp:1037) is driven by the landed `NftMintDialog`. **GUI test ❌: the dialog is never constructed in any test; `mintNFT` has no test seam** (unlike `testSetNextZaddrResult`). | +| `zslp_mint` (fungible re-issue via baton) | Mint | ✅ | ❌ | ✅ | ❌ | ❌ | 🟡 | `zslp.cpp:466`. Requires live baton UTXO. **CLI-only by design — no GUI** (guide §1: NFT write path = genesis/send only). RPC untested. | +| `BuildAndCommitZSLP` (coin select, anti-burn funding fence, sign, self-validate, commit) | Mint | ✅ | ❌ | 🟡 | — | — | — | `zslpwallet.cpp:195`. **Single biggest write-path coverage hole** — test header (`test_zslp_wallet.cpp:25`) explicitly disclaims it; covered "by code review" only. | +| C parser `slp.c` round-trip + edge cases | Mint | ✅ | ✅ | 🟡 | — | — | — | 29 tests (`test_zslp.cpp`). Strong. | +| Builder bridge bytes + canonical layout + 223-byte cap | Mint | ✅ | 🟡 | 🟡 | — | — | — | `test_zslp_wallet.cpp:154`. 223 is a raw length assert, **NOT tied to IsStandard/`-datacarriersize`** (see HARD-CONSTRAINT row). | +| Self-validation gate `WouldBeValid` (pre-broadcast) | Mint | ✅ | ✅ | 🟡 | — | — | — | `test_zslp_wallet.cpp:314`. The exact gate the builder calls. Well covered. | +| ContentEngine `document_hash` = streaming SHA-256 of content | Mint | — | — | — | ✅ | ✅ | ✅ | `contentengine.cpp`; `RPC::mintNFT` builds the param. 14 `ce*` L0 tests. | +| GUI "Make a collectible" wizard (file→hash→review→genesis) | Mint | — | — | — | ✅ | ❌ | 🟡 | `nftmintdialog.*` LANDED (uncommitted). Create gated on name+fingerprint+!hashing+!inflight (`refreshCreateEnabled`:207); streaming fingerprint UI; web-link drop rejected inline; 0-conf "appears once it confirms" terminal; `closeEvent` swallowed while in-flight. **No L1 flow test (dialog never constructed).** Doc 🟡: honest privacy copy ships but **NO in-app permanence/public-ledger warning** and no help (G-HELP). | +| **`zslp_burn` / intentional destroy** | Mint | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | **No sanctioned burn primitive** — anti-burn prevents accidents but offers no way to intentionally retire an edition. | + +### VIEW + +| Capability | Pillar | CLI impl | CLI test | CLI doc | GUI impl | GUI test | GUI doc | Notes / Gap | +|---|---|:--:|:--:|:--:|:--:|:--:|:--:|---| +| `zslp_gettoken` | View | ✅ | 🟡 | ✅ | ✅(wrap) | 🟡 | ✅ | `zslp.cpp:73`. Store layer gtested; **RPC fn + not-found path untested.** GUI wraps it TWICE: batch in `refreshNFTs` (every card's name/hash/height) + `RPC::nftProvenance` for the detail back-fill — but **the back-fill is a no-op stub on success** (nftdetaildialog.cpp:326): Set/Creator read, not displayed. | +| `zslp_listtokens` (paged, clamped to 1000) | View | ✅ | 🟡 | ✅ | ❌ | ❌ | ✅ | `zslp.cpp:107`. Clamp tested at store, not RPC. | +| `zslp_listtransfers` (DoS-bounded paging) | View | ✅ | ✅ | ✅ | ❌ | ❌ | 🟡 | `zslp.cpp:142`. DoS bound + ordering tested at store/vector layer. **GUI: NO caller** (grep `listtransfers` in GUI = none). The detail dialog advertises "provenance" but never calls it — chain-of-custody history is unreachable from the GUI. | +| `zslp_listmytokens` (wallet roll-up, per-address breakdown) | View | ✅ | ❌ | ✅ | ✅(wrap) | 🟡 | ✅ | `zslp.cpp:191`. **Zero coverage of the wallet-intersection/aggregation** — most complex read RPC. GUI's primary feed (`refreshNFTs`). | +| Native gallery (QListView IconMode model+delegate, no browser) | View | — | — | — | ✅ | ✅ | ✅ | `nftgallerymodel/delegate`, `setupNFTTab` (`mainwindow.cpp:3019`). Fingerprint-guarded; 5+ L0 tests. | +| ContentEngine poster / chunked-Merkle verify / content-addressed cache | View | — | — | — | ✅ | ✅ | ✅ | `contentengine.cpp` (882 lines). Privacy-hard (no network). 14 `ce*` L0 tests. | +| NFT detail dialog (decode, downscale, verify badge, provenance) | View | — | — | — | ✅ | 🟡 | 🟡 | `nftdetaildialog.*` LANDED (uncommitted). **GUI test 🟡: only the no-bytes terminal branch is tested** (`nftDetail_noBytesIsTerminalNotSpinner`, tst_widget.cpp:1869); the VERIFIED/MISMATCH/PENDING badge-copy mapping (`applyVerifyBadge`:279) and the undecodable-image branch (:257) are untested. Doc 🟡: provenance back-fill (`zslp_listtransfers`) is NOT called — header advertises "provenance" but only mint-id + received height show; no help on the verify badge. | +| Verify badge semantics (0 pending / 1 verified / 2 mismatch) | View | — | — | — | ✅ | 🟡 | ✅ | `nft.h`; the enum + model-level state are L0-tested (`nftCachePipelineVerifyMismatchPending`, `nftModelRolesAndOnImageReady`). 🟡: the **dialog's** state→copy mapping ("matches" / "does NOT match" / "Checking") in `applyVerifyBadge` is NOT tested (only the no-bytes branch). | +| `getExplorerTxURL` deep-link (testnet-empty, !isPrivate gated) | View | — | — | — | ✅ | ❌ | 🟡 | `settings.cpp:418`. **No test pins the URL scheme or testnet `''` sentinel.** | +| **Content/metadata fetch RPC** (resolve document_url/hash → bytes) | View | ❌ | ❌ | ❌ | — | — | — | No daemon-side content resolve/verify RPC. Left entirely to GUI ContentEngine. | +| **0-conf freshness** (newly minted card visible pre-block) | View | — | — | — | 🟡 | ❌ | ❌ | `refreshNFTs` runs only on new block + mint/send success (`rpc.cpp:1309`); a same-block re-open can look empty. | +| **"Attach the file you have" affordance** (supply local bytes for a RECEIVED NFT) | View | — | — | — | ❌ | ❌ | ❌ | **CONFIRMED WORST GAP (G-VIEW).** `refreshNFTs` hard-sets `cachePath=''` (rpc.cpp:960); the ONLY cache writer is the in-session mint (`ContentEngine::cachePut`, nftmintdialog.cpp:249); no `getOpenFileName`/attach anywhere in `nftdetaildialog`/gallery; "Re-check image" just re-runs the empty cache. A RECEIVED NFT shows "Can't check — isn't on this computer" FOREVER. The detail no-bytes tooltip even *promises* "open it to check it yourself" — an action no button performs. | +| **`document_hash` required for nft=true** (else permanently unverifiable) | View | ❌ | ❌ | 🟡 | ❌ | ❌ | ❌ | `document_hash` is OPTIONAL for nft=true daemon-side (validated only IF present; the nft preset adds no requirement). GUI mint requires an anchor before Create, but any CLI/foreign hash-less NFT is **structurally impossible to ever verify** (no anchor → no cacheGet → badge stays neutral). Decision needed (see GUI plan). | + +### SHIELD + +| Capability | Pillar | CLI impl | CLI test | CLI doc | GUI impl | GUI test | GUI doc | Notes / Gap | +|---|---|:--:|:--:|:--:|:--:|:--:|:--:|---| +| ZDC1 codec (transport + per-transfer AEAD + KEY frame + fingerprint) | Shield | ✅ | 🟡 | ✅ | — | — | — | **CORRECTION (was 🟡/🟡, "not a gtest target"): `src/datachannel/zdc.{h,cpp}` is COMPILED** (`src/Makefile.am:247,293`) **AND has a gtest target** (`src/gtest/test_zdc.cpp`). 🟡 test = confirm the gtest exercises nonce-uniqueness/AEAD round-trip in CI (was a standalone g++ harness). | +| **Compile codec into daemon** (`Makefile.am` entry) | Infra | ✅ | — | ✅ | — | — | — | **CORRECTION (was ❌, "confirmed absent"): NOW PRESENT** — `src/Makefile.am:247` (`datachannel/zdc.h`), `:292` (`rpc/datachannel.cpp`), `:293` (`datachannel/zdc.cpp`). The pillar is no longer dead code. | +| `z_senddatafile` (+ `AsyncRPCOperation_senddatafile`) | Shield | ✅ | 🟡 | ✅ | ❌ | ❌ | ✅ | **CORRECTION (was ❌): BUILT** — `datachannel.cpp:157`, registered `:597`, CLI-arg-converted `client.cpp:138`, requires `acknowledge_permanent=true`. **GUI: zero affordance** (`isPrivateMintWired()==false`). CLI test 🟡 (confirm a regtest round-trip drives it). | +| `z_revealkey` (seal-then-reveal trigger) | Shield | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | Codec primitive `encode_key_frame` ready; **RPC + key-store glue still absent** (NOT in the datachannel command table `:594-600`). | +| `z_listdatatransfers` (Decoder reassembly, sealed-metadata hiding) | Shield | ✅ | 🟡 | ✅ | ❌ | ❌ | ✅ | **CORRECTION (was ❌): BUILT** — `datachannel.cpp:372`, registered `:598`. GUI: zero affordance. CLI test 🟡. | +| `z_getdatatransfer` (verify-before-decrypt, ERR_NO_KEY vs ERR_AEAD_FAIL) | Shield | ✅ | 🟡 | ✅ | ❌ | ❌ | ✅ | **CORRECTION (was ❌): BUILT** — `datachannel.cpp:407`, registered `:599`, CLI-arg-converted `client.cpp:139`. GUI: zero affordance. CLI test 🟡 (confirm ERR_NO_KEY/ERR_AEAD_FAIL mapping is tested). | +| `zslp_mint_private` (encrypt asset, document_hash = ciphertext_fingerprint) | Shield | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | Core "private NFT". Depends on the (now-built) datachannel send path; **the `zslp_mint_private` RPC itself is still absent.** | +| Default-OFF master gate (`-datachannel` → `-32601`) | Infra | ✅ | 🟡 | ✅ | 🟡 | — | ✅ | **CORRECTION (was ❌, "daemon gate unbuilt"): BUILT** — `RegisterDataChannelRPCCommands` (`datachannel.cpp:603`, `register.h:35`) only appends the commands when `-datachannel` is on, else dispatcher throws `-32601`; help string `init.cpp:527`, default 0. GUI already latches `-32601` as "feature not present". CLI test 🟡 (no test asserts off→-32601). | +| Safety: required-true `acknowledge_permanent` + shielded-funding/recipient checks | Infra | 🟡 | ❌ | ✅ | ❌ | ❌ | ✅ | `z_senddatafile` now REQUIRES `acknowledge_permanent=true` (`datachannel.cpp:22,156+`). Shielded-from/to validation depth + a test are still owed. | +| DoS governance: 64KB/256KB caps, rate-limit, 72h TTL-GC, max-inflight | Infra | ❌ | ❌ | ✅ | — | — | ✅ | Codec only enforces ~29MB structural ceiling; caller governance unbuilt. | +| GUI binary-safe memo read (sniff ZDC1 magic before QString) | View | — | — | — | ❌ | ❌ | ✅ | Documented confirmed bug-fix at `rpc.cpp ~756`; **not applied.** | +| GUI private-mint / private-send / private-receive UI | Shield | — | — | — | ❌ | ❌ | ✅ | Hard-gated off (`isPrivateMintWired()==false`, `rpc.h:225`); send dialog only accepts t-addr. Honest, but absent. | +| Selective disclosure = reuse `z_exportviewingkey` (no new RPC) | Shield | ✅ | 🟡 | ✅ | ❌ | ❌ | ✅ | `rpcdump.cpp:801` exists today — reachable always-on (the datachannel RPCs are also reachable now, but only behind `-datachannel`). No NFT-privacy-specific test; no GUI "verify privately" wrapper; per-item single-use-zaddr convention unenforced. | +| Shield token VALUE/ownership through Sapling | Shield | ❌(consensus) | — | — | ❌(consensus) | — | ✅ | **Genuinely impossible on existing consensus** (z-notes carry no script). Correctly out of scope. | + +### SELL + +| Capability | Pillar | CLI impl | CLI test | CLI doc | GUI impl | GUI test | GUI doc | Notes / Gap | +|---|---|:--:|:--:|:--:|:--:|:--:|:--:|---| +| `zslp_send` (transfer; one-way gift) | Sell | ✅ | ❌ | ✅ | ✅ | ❌ | 🟡 | `zslp.cpp:545`. Closest to SELL but **one-way only — not an atomic trade.** RPC fn + coin-selection/change untested. GUI: `RPC::sendNFT` (rpc.cpp:1081) driven by the landed `NFTSendDialog`. **GUI test ❌: dialog never constructed; `sendNFT` has no test seam.** | +| GUI send/gift dialog (transparent recipient only) | Sell | — | — | — | ✅ | ❌ | 🟡 | `nftsenddialog.*` LANDED (uncommitted). 4-state recipient validation; hard-rejects shielded ("Private gifts coming soon") + mismatch (verifyState==2 keeps Send disabled, red "we won't send it"). **No L1 test (dialog never constructed)** — the mismatch send-guard (strongest honesty guarantee) has ZERO coverage. Doc 🟡: no help on what gifting means / its irreversibility. | +| `nft_makeoffer` / `nft_takeoffer` / `nft_verifyoffer` / `nft_listoffers` / `nft_canceloffer` / `nft_requestbuy` | Sell | ✅ | ✅ | 🟡 | ❌ | ❌ | 🟡 | **BUILT** `src/rpc/nftoffer.cpp` (registered :1180-1186, CLI-converted). Fixed-template `ALL\|ANYONECANPAY`; reuses `ZSLPBuildSend` (DRY); anti-burn on buyer funding; `nft_verifyoffer` VerifyScripts the seller `vin[0]`; `nft_takeoffer` requires `acknowledge` on overshoot. Tested: 6 gtests (`test_nftoffer.cpp`) + committed `qa/zslp/nft-sell-regtest.sh` (atomic swap + sig-tamper/forged/token-funding/overshoot refusals). GUI surface pending (#118). | +| `signrawtransaction` ALL\|ANYONECANPAY + CombineSignatures merge | Sell | ✅ | ✅ | ✅ | — | — | — | `rawtransaction.cpp:726`. Tested generically (`rpc_tests.cpp:92`). **Used by no sell flow.** The sell design needs `ALL\|ANYONECANPAY` (seller pins the WHOLE output set incl. the OP_RETURN@vout[0]); `SINGLE\|ANYONECANPAY` is WRONG for ZSLP (it would pin vout[0]=OP_RETURN, not the payout). | +| ZIP-243 SIGHASH masking (ANYONECANPAY/ALL) | Sell | ✅ | ✅ | ✅ | — | — | — | `interpreter.cpp:1069`. Load-bearing primitive; tested; unused by NFT code. `ANYONECANPAY` zeroes prevouts/sequence (buyer appends inputs); `ALL` commits all outputs (seller fixes OP_RETURN@0 / buyer-NFT@1 / payout@2). | +| `createrawtransaction` (inputs/outputs/locktime/expiry) | Sell | 🟡 | ✅ | ✅ | — | — | — | **CANNOT emit OP_RETURN** (confirmed: 0 OP_RETURN paths) → RESOLVED: the sell builder hand-assembles vout[0] via the shared `ZSLPBuildSend` encoder (not `createrawtransaction`). | +| `decoderawtransaction` (offer inspection) | Sell | ✅ | ✅ | ✅ | — | — | — | Used by `nft_verifyoffer` (BUILT) — which also `VerifyScript`s the seller `vin[0]` against the live prevout (cryptographic pre-pay guarantee, not just field checks). | +| `sendrawtransaction` (broadcast filled swap) | Sell | ✅ | 🟡 | ✅ | — | — | — | Reusable as-is; no sell flow calls it. | +| `fundrawtransaction` (auto-funding) | Sell | 🟡 | 🟡 | ✅ | — | — | — | **FOOTGUN: it inserts change at a random vout (`wallet.cpp:3698`) AND adds an output at all — both break the seller's `ALL` signature** (the offer commits the EXACT output set: OP_RETURN@0 / buyer-NFT@1 / payout@2; any new or moved output invalidates `vin[0]`). The sell builder must hand-place inputs only — **never `fundrawtransaction`**. (This footgun broke the rejected SINGLE design too, for the same reason.) | +| P2SH / CLTV / CHECKMULTISIG / CHECKDATASIG (HTLC + escrow primitives) | Sell | 🟡 | ✅ | ✅ | — | — | — | Script primitives tested. **CSV/BIP112 ABSENT** (OP_NOP3 inert) → HTLCs limited to absolute CLTV. No redeem-script builder/flow/RPC. | +| PSBT (BIP174) interchange | Sell | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | No PSBT anywhere; offers must use raw partial-hex + CombineSignatures. | +| Wallet NFT-outpoint LOCK API (lock on makeoffer, release on cancel) | Sell | ✅ | ✅ | 🟡 | — | — | — | **BUILT**: `nft_makeoffer` `LockCoin`s the NFT outpoint; `nft_canceloffer` releases it (regtest-verified via `listlockunspent`). | +| GUI marketplace UI (offer cards, list/buy/cancel, "settles publicly" copy) | Sell | — | — | — | ❌ | ❌ | 🟡 | Entirely greenfield; can reuse RPC wrapper + ContentEngine + explorer URL. | + +### CROSS-CUTTING (infra / docs / hard constraint) + +| Capability | Pillar | CLI impl | CLI test | CLI doc | GUI impl | GUI test | GUI doc | Notes / Gap | +|---|---|:--:|:--:|:--:|:--:|:--:|:--:|---| +| **HARD CONSTRAINT: IsStandard/RequireStandard relay accepts the OP_RETURN unchanged** | Infra | 🟡 | ❌ | 🟡 | — | — | — | **The stated no-fork constraint is verified by NO test.** Only the 223-byte builder-length assert exists; not tied to mainnet `-datacarriersize`/policy. | +| Wallet anti-burn predicate `ZSLPIsProtectedTokenOutpoint` / `MsgWouldMakeTokenOutput` | Shield | ✅ | ❌ | 🟡 | — | — | — | `zslpwallet.cpp:68`, consumed `wallet.cpp:3197`. **The actual anti-burn decision fn is untested** (only its confirmed data source `store->GetUtxo` is). | +| `ZSLPFindWalletTokenUtxos` (deterministic selection; baton vs qty) | Sell | ✅ | ❌ | 🟡 | — | — | — | Sort key + filtering untested. | +| Live indexer plumbing: Init / migration / CatchUp / per-tip idempotence guard | Infra | ✅ | ❌ | 🟡 | — | — | — | `zslpindexer.cpp:62`. Migration, crash-resume, re-delivered-connect double-count guard untested. | +| Real `ConnectBlock` coinbase-skip loop (`for i=1`) | Infra | ✅ | 🟡 | 🟡 | — | — | — | Coinbase skip is only MODELED in test (author restraint), not executed. | +| Multi-token mixed-input tx (one tx spends two tokenIds) | Shield | ✅ | ❌ | 🟡 | — | — | — | **Security-relevant + untested:** non-declared token silently burned; correctness unproven. | +| Second-wallet receive-and-respend | Sell | ✅ | ❌ | 🟡 | — | — | — | The whole point of transfer; no test models a distinct receiving wallet. | +| RPC layer arg-conversion (`client.cpp`) | Infra | ✅ | ❌ | ✅ | — | — | — | Entries correct (`client.cpp:138-145`); a missing entry is a classic silent CLI bug, untested. | +| `-zslpindex` default state | Infra | ✅ | — | 🟡 | — | — | — | **Default-ON** (`init.cpp:3272` `GetBoolArg("-zslpindex", true)`), but error string + guide §2.3 imply opt-in. Misleading. | +| Main repo `README.md` discoverability | Infra | — | — | ✅ | — | — | ✅ | **DONE:** both READMEs now carry an "NFTs / Collectibles" section (daemon: model + `-zslpindex` on by default + CLI walkthrough; GUI: Collections tab + mint-from-file + verify-the-image), framed dev/testnet-stage. | +| End-to-end CLI walkthrough (mint→inspect→send→list sequence) | Infra | — | — | ✅ | — | — | — | **DONE:** the daemon `README.md` NFT section ships a copy-paste `zclassic-cli zslp_genesis → zslp_gettoken → zslp_send → zslp_listmytokens` session with the real positional RPCs. | +| User-facing GUI how-to (open Collections → make first collectible) | View | — | — | — | ✅ | ❌ | 🟡 | Dialogs LANDED, so the flow exists. 🟡 doc: no user step doc / screenshots; the spec'd 4-page hero stack (guide §2.3) was NOT built — only a single grey `nftStateLabel` (mainwindow.cpp:3051). | +| In-app NFT help / onboarding / tooltips | View | — | — | — | ❌ | ❌ | ❌ | **CONFIRMED near-total gap (G-HELP).** 0 `setWhatsThis` in the whole GUI; only 2 `setToolTip` on the verify badge, both echo the verdict. NO first-run "What is a collectible?", NO "matches fingerprint ≠ genuine/official" disambiguation (the single most-misunderstood element), NO permanence/public-ledger warning on mint, NO explanation of pending/no-bytes/0-conf, NO experimental-status notice. No L0/L1 covers any help/honesty-disambiguation surface. | +| `MINT_TRANSFER_SPEC.md` JSON multi-recipient `zslp_send` form | Mint | — | — | ✅ | — | — | — | **FIXED:** the fictional `{addr:amt}` JSON-map form is removed; the doc now states the real positional signature `zslp_send "tokenid" "to_address" ( amount change_address )` (single recipient) and notes the builder is multi-output-capable but the RPC arg surface is single-recipient. | +| ONCHAIN_TRADES.md (SUPERSEDED) | Sell | — | — | 🟡 | — | — | — | **SUPERSEDED by `NFT_SELL_DESIGN.md`** (banner added). Its `SINGLE\|ANYONECANPAY` layout is funds-losing (pins OP_RETURN, not payout → burns the seller NFT); its "ZSLP never reads tx.vin / only credits" and "no zslp_send builder / no ZSLP in wallet" claims are STALE (the live indexer debits inputs + enforces conservation; the write path + anti-burn now exist). Do not use as a build spec. | + +--- + +## 3. Prioritized gap backlog + +Ordered to reach: **all four pillars implemented, tested (CLI + GUI), documented.** Each item names the concrete file/RPC/test/doc. + +### P0 — Prove what already exists (highest ROI; closes the biggest unproven-risk gaps) + +- [ ] **Add a regtest RPC test for the full write path** (`qa/rpc-tests/zslp_nft.py` or equiv): `zslp_genesis nft:true` → capture tokenid → `zslp_gettoken` → `zslp_send 1` → `zslp_listmytokens` on a second wallet. Closes the single biggest hole (`BuildAndCommitZSLP`, coin selection, conservation, RPC arg-parsing, `client.cpp` conversions) — all currently untested. +- [ ] **gtest the anti-burn decision fn** `ZSLPIsProtectedTokenOutpoint` + `MsgWouldMakeTokenOutput` (new cases in `src/gtest/test_zslp_wallet.cpp`): cover the 0-conf/IsFromMe branch and per-type vout arithmetic. Today a regression burns a token UTXO as fee with no test failing. +- [ ] **Test the HARD CONSTRAINT**: run a built ZSLP tx through `IsStandard`/`AcceptToMemoryPool` on real mainnet `CChainParams` (`src/gtest` or `src/test`), and tie the 223 magic number to the actual `-datacarriersize` policy constant. The no-fork guarantee is currently unverified. +- [ ] **gtest the multi-token mixed-input case** (`test_zslp_indexer.cpp`): a SEND whose vin carries token A *and* token B — assert B's UTXO burns/credits nobody while A conserves. Realistic accidental-burn vector, unproven. +- [ ] **gtest the live indexer plumbing** (`test_zslp_indexer.cpp`): migration (stale/absent version → wipe+reindex), the re-delivered-connect idempotence guard (`zslpindexer.cpp:189`), and the real `ConnectBlock` `for i=1` coinbase-skip executed (not modeled). +- [ ] **Test `zslp_listmytokens` aggregation** (per-address balance roll-up) and `ZSLPFindWalletTokenUtxos` deterministic ordering. + +### P0 — Discoverability & doc-truth (cheap; unblocks every user) + +- [x] **Add an NFT section to the main `README.md`** (daemon) + GUI `README.md`: what ZSLP NFTs are, `-zslpindex` is default-ON, the `zslp_*` RPCs, a CLI walkthrough, and a link to `doc/nft/README.md`. **Done** in both repos. +- [x] **CLI walkthrough** — shipped inline in the daemon `README.md` NFT section: copy-paste `zclassic-cli zslp_genesis "{...}"` → `zslp_gettoken` → `zslp_send` → `zslp_listmytokens`. +- [x] **Fix `MINT_TRANSFER_SPEC.md`**: the non-existent `zslp_send "tokenid" {addr:amt}` JSON-map form is removed; the real positional signature is documented. +- [ ] **Fix the index-off error string** to reflect `-zslpindex` default-ON (`init.cpp:3272`). (Docs already correct: the daemon README states default-ON; the guide §2.3 INDEX-OFF page is a legitimate UI state for when a foreign/old daemon has it off, not an implication of opt-in-by-default.) +- [x] **Reconcile `ONCHAIN_TRADES.md`** — done: a SUPERSEDED banner points to `NFT_SELL_DESIGN.md`, and the stale "ZSLP never reads tx.vin / only credits / no zslp_send builder" claims (`:42-45`, appendix `:230-232`) are corrected against the now-present write path + UTXO-bound conservation indexer + wallet anti-burn. + +### P1 — Finish View (the GUI dialogs LANDED — now close honest gaps + test) + +> The detailed, prioritized, per-dialog/label/test GUI build backlog moved to its own doc: +> **[`NFT_GUI_PLAN.md`](NFT_GUI_PLAN.md)**. The items below are the checklist-level summary. + +- [x] **Land the NFT dialogs** (`nftdetaildialog.*`, `nftmintdialog.*`, `nftsenddialog.*`). **Done** (LANDED, still UNCOMMITTED `??` — commit them before they're lost). +- [ ] **Add an "attach the file you have" affordance** (G-VIEW, the worst gap) — a `getOpenFileName` on the detail dialog → `ContentEngine::cachePut(docHashHex, path)` → re-poster, so a RECEIVED NFT can reach the green verify badge. Today `cachePath=''` forever for anything not minted in-session. +- [ ] **Decide & enforce the nft=true `document_hash` requirement** so a hash-less NFT is not permanently unverifiable (GUI guard + daemon-side requirement). +- [ ] **Add L1 widget flow tests** for the now-landed dialogs (mint Create-gating + fingerprint streaming + privacy-drop reject + 0-conf terminal + in-flight `closeEvent` swallow; send recipient 4-state + verifyState==2 mismatch send-guard; detail VERIFIED/MISMATCH badge copy). Needs a `mintNFT`/`sendNFT` test-injection seam. See `NFT_GUI_PLAN.md`. +- [ ] **Make `refreshNFTs` show a 0-conf pending card** (not gated solely on `curBlock != lastBlock`, `rpc.cpp:1309`). +- [ ] **Add a unit test pinning `getExplorerTxURL` scheme + testnet `''` sentinel.** +- [ ] **Add in-app NFT help/onboarding** (G-HELP): first-run Collections intro + What's-This on the verify badge ("matches fingerprint ≠ genuine/official") + permanence/public-ledger warning on mint + pending/no-bytes/0-conf explanations. See `NFT_GUI_PLAN.md`. +- [ ] **Surface provenance**: call `zslp_listtransfers` in the detail dialog (today it's advertised but never called) and display the `zslp_gettoken` Set/Creator the back-fill already reads. +- [ ] **Optional: add a daemon content/metadata-fetch+verify RPC** so non-GUI clients can resolve `document_url`/`document_hash`. + +### P1 — Mint completeness + +- [ ] **Add `zslp_burn`** (send-to-unspendable / amount-to-no-output) as a sanctioned, anti-burn-aware destroy primitive + help + `client.cpp` entry + gtest + doc. +- [ ] **Decide & document the `zslp_mint` (fungible re-issue) UX**: either add a GUI affordance or explicitly document it as CLI-only. + +### P2 — Shield (the codec + 3 RPCs LANDED in the daemon; close the rest + build GUI) + +> **2026-06-06 correction:** the codec is compiled, has a gtest, and `z_senddatafile` / +> `z_listdatatransfers` / `z_getdatatransfer` are built + registered + arg-converted + +> gated default-OFF. The earlier "not compiled / dead code / RPCs absent" framing was stale. + +- [x] **Add `src/datachannel/zdc.{cpp,h}` to `src/Makefile.am`.** **Done** (`Makefile.am:247,293`). +- [x] **Wrap the ZDC harness as a gtest target.** **Done** (`src/gtest/test_zdc.cpp` exists). Remaining: confirm it gates nonce-uniqueness/AEAD round-trip in CI (test cell 🟡). +- [x] **Build the default-OFF master gate `-datachannel` throwing `-32601`.** **Done** (`RegisterDataChannelRPCCommands` registers only when on; `init.cpp:527`; default 0). Remaining: a test asserting off→-32601. +- [x] **Build `z_senddatafile`** (requires `acknowledge_permanent=true`) + `client.cpp` entry. **Done** (`datachannel.cpp:157,597`, `client.cpp:138`). Remaining: regtest round-trip test. +- [x] **Build `z_listdatatransfers` + `z_getdatatransfer`** (+ arg-convert). **Done** (`datachannel.cpp:372,407,598-599`, `client.cpp:139`). Remaining: regtest round-trip test + ERR_NO_KEY/ERR_AEAD_FAIL mapping test. +- [ ] **Build `z_revealkey`** (seal-then-reveal trigger) + **`zslp_mint_private`** (document_hash = ciphertext_fingerprint) — still absent (not in the datachannel command table). +- [ ] **Deepen + test `acknowledge_permanent` + shielded-from/to validation** on every sending RPC. +- [ ] **Add DoS governance**: 64KB policy cap (`-datachannelmaxbytes` clamped 256KB), token-bucket rate limit, 72h inbound TTL-GC, 256 max-inflight. +- [ ] **Apply the GUI binary-safe memo read fix** (`zcl-qt-wallet/src/rpc.cpp ~756`: sniff `0x5A,0x44,0x43,0x31` on the 512-byte QByteArray before any QString conversion) → route binary frames to a data-channel inbox. +- [ ] **Build the GUI private-mint / private-send / private-receive surface** + flip `isPrivateMintWired()` only when the daemon RPCs exist. +- [ ] **Add a "let someone verify this privately" GUI wrapper** over the existing `z_exportviewingkey`, and enforce the per-item single-use-zaddr convention in `zslp_mint_private`/`z_senddatafile`. +- [ ] **Document the consensus limit in UX copy**: ownership stays a public ZSLP UTXO; key-possession cannot stop a prior holder keeping a copy (no DRM). + +### P3 — Sell (daemon BUILT + regtest-proven; GUI greenfield) + +- [ ] **Add the OP_RETURN carrier**: a thin RPC or a `createrawtransaction` `data` output (or port `op_return_push.h`) — `createrawtransaction` confirmed cannot emit OP_RETURN today, which a fill tx needs. +- [ ] **Build `nft_makeoffer`** (seller signs ONLY `vin[0]` = the live, CONFIRMED token UTXO with `ALL\|ANYONECANPAY` over the COMPLETE fixed 3-output template — OP_RETURN ZSLP SEND@vout[0] / buyer NFT dust@vout[1] / seller ZCL payout@vout[2], dust value FEE-RATE-DERIVED; a single-input `ALL\|ANYONECANPAY` sign on the complete template returns `complete:true`; lock the NFT outpoint) — add a **wallet NFT-outpoint lock/unlock API** (lock on offer, release on cancel) on top of the existing passive anti-burn. See `NFT_SELL_DESIGN.md §2`. (NOT SINGLE\|ANYONECANPAY: SINGLE would pin vout[0]=OP_RETURN, not the payout, and burn the seller NFT.) +- [ ] **Build `nft_verifyoffer`** on `decoderawtransaction`: confirm `vout[0]` is a ZSLP SEND for the tokenid crediting `vout[1]`, `vin[0]` is the live token UTXO, the `ALL`-pinned `vout[2]` price matches, SEND mapping credits the buyer, not expired/already-spent; re-run `WouldBeValid` BEFORE the buyer signs. Mandatory. +- [ ] **Build `nft_takeoffer`** (buyer appends funding `vin[1..]` only — **NEVER `fundrawtransaction`**, whose random change vout at `wallet.cpp:3698` AND any added output break the seller's `ALL` signature; the appended funding inputs MUST EXCLUDE ZSLP-protected outpoints (re-apply the anti-burn filter, else `ApplyTransaction` burns any token UTXO the buyer accidentally funds with); signs the buyer's own inputs `ALL\|ANYONECANPAY`; merge via CombineSignatures; broadcast via `sendrawtransaction`) + `nft_listoffers` + `nft_canceloffer`. +- [ ] **Add regtest RPC test** for the full make→verify→take→settle swap (transparent legs atomic). +- [ ] **Build the GUI marketplace** (offer-cards grid reusing ContentEngine, "List for sale" modal, "Buy" confirm, cancel, honest "settles publicly — no privacy on a transparent trade" copy) + L0/L1 tests. Per-dialog/label/test breakdown in `NFT_GUI_PLAN.md` (§ SELL). +- [x] **`doc/nft/NFT_SELL_DESIGN.md` exists** at design fidelity (offer payload format, `ALL\|ANYONECANPAY` pinning rules, verify checklist, footguns). Remaining: bring it to `MINT_TRANSFER_SPEC` build-spec fidelity + add a CLI walkthrough once the RPCs exist. + +--- + +## 4. Definition of Done per pillar + +A pillar is **shippable to real users** only when ALL of its boxes below are true. + +### MINT — Done when: +- [ ] `zslp_genesis` (nft preset) is driven by an automated regtest RPC test that mints, then re-reads via `zslp_gettoken`, on a live chain (not just builders). +- [ ] `BuildAndCommitZSLP` (coin selection, anti-burn funding fence, change-LAST, pre-commit self-validate) is exercised by that test — not "code review only." +- [ ] The anti-burn predicate has a direct unit test. +- [ ] The HARD CONSTRAINT (OP_RETURN passes mainnet `IsStandard`/mempool, no fork) is test-proven. +- [ ] The GUI mint wizard is committed (not untracked) and has an L1 flow test. +- [ ] A new user can find it: README mentions NFTs + a CLI walkthrough exists. + +### VIEW — Done when: +- [ ] All four read RPCs are exercised at the RPC layer (shapes + error paths), including `zslp_listmytokens` aggregation. +- [x] The gallery + detail dialog are landed... **but NOT yet** committed or L1-tested end-to-end (only the no-bytes terminal has an L1 test; the verified/mismatch badge mapping, mint, and send dialogs have ZERO widget tests). Remains open. +- [ ] A freshly minted token shows a pending card immediately (0-conf), and there is a built path to supply local bytes so a **received** card can reach "verified" (G-VIEW — the attach-local-bytes affordance does NOT exist today). +- [ ] In-app help explains the verify badge ("matches fingerprint ≠ genuine/official") and the pending/no-bytes/0-conf states; README + a user how-to document the GUI flow. (G-HELP — entirely absent today.) +- [x] No web browser is used anywhere (true — keep it true: no QtWebEngine/QtMultimedia, `document_url` never auto-fetched). + +### SHIELD — Done when: +- [x] ZDC1 is compiled into the daemon (`Makefile.am:247,293`) and has a gtest target (`src/gtest/test_zdc.cpp`). **Done** (confirm it's a permanent CI gate). +- [ ] `z_senddatafile` / `z_listdatatransfers` / `z_getdatatransfer` exist + are CLI-arg-converted + default-OFF behind `-datachannel` (**these three DONE**); `z_revealkey` + `zslp_mint_private` still absent; and a regtest round-trip test (send a file privately → list → assemble → hash-verify) is still owed. +- [ ] `acknowledge_permanent` + shielded-from/to validation + DoS caps/TTL-GC are enforced daemon-side and tested. +- [ ] The GUI binary-safe memo read fix is applied; a private NFT can be minted, sent to a z-addr, received, and decrypted-in-gallery; `isPrivateMintWired()` flipped true. +- [ ] UX copy states the honest consensus limit (public ownership UTXO, no DRM). +- [ ] Selective disclosure via `z_exportviewingkey` has an NFT-privacy test and a GUI wrapper. + +### SELL — Done when: +- [ ] An OP_RETURN-carrying fill tx can be built (carrier RPC/path exists). +- [ ] `nft_makeoffer` / `nft_verifyoffer` / `nft_takeoffer` / `nft_listoffers` / `nft_canceloffer` exist with `client.cpp` entries, a wallet outpoint lock/unlock lifecycle, and the `fundrawtransaction` footgun guarded against. +- [ ] A regtest test proves a full transparent make→verify→take→settle swap is atomic for the coin legs and correctly attributes the token to the buyer. +- [ ] The GUI marketplace (list / browse / buy / cancel) is built, L0/L1-tested, with honest "settles publicly" copy. +- [ ] `doc/nft/NFT_SELL_DESIGN.md` is brought to build-spec fidelity + a CLI walkthrough exists. (`ONCHAIN_TRADES.md` is already reconciled — SUPERSEDED banner + stale-claim corrections.) +- [ ] Docs state plainly: any shielded leg cannot be atomic on existing consensus (transparent-only trustless trade). + +--- + +*Generated from a read-only audit of the daemon (`feature/zslp-nft-indexer`, uncommitted) and GUI (`zcl-qt-wallet`) trees. The NFT GUI dialogs (formerly in-flight, workflow `wnr918pfq`) have LANDED and are graded as real-but-mostly-untested code; the GUI build backlog lives in [`NFT_GUI_PLAN.md`](NFT_GUI_PLAN.md). Last refreshed 2026-06-06.* diff --git a/doc/nft/NFT_FINAL_REVIEW.md b/doc/nft/NFT_FINAL_REVIEW.md new file mode 100644 index 00000000000..43e7242f85e --- /dev/null +++ b/doc/nft/NFT_FINAL_REVIEW.md @@ -0,0 +1,312 @@ +# NFT Feature — Final Whole-Feature Review + +> **Scope:** The first review possible now that all four pillars coexist in one tree. +> Coin is **ZCL (ZClassic)**, never ZEC. The NFT system is a **non-consensus ZSLP overlay**: +> old/unmodified nodes relay and mine every one of these transactions unchanged; security +> comes from honest wallets **re-validating deterministically**, not from the chain rejecting +> bad transactions. *A forgery can be mined, but it credits nobody.* +> +> **Read this as:** ground-truth status as of the working tree on `feature/zslp-nft-indexer` +> (daemon) and `feature/nft-gallery` (GUI). All claims below are sourced to `file:line`. +> This document is READ-ONLY synthesis; it changes no code. + +--- + +## 1. One-screen verdict — the four pillars as a UNIT + +**Overall: dev/testnet-ready. NOT mainnet / real-user / money-at-stake ready.** + +All four pillars are real, reachable, and **compiled** (daemon `src/Makefile.am:225-345`; +GUI `zcl-qt-wallet.pro:49-88`). They are also **all uncommitted** (working-tree only on both +repos) — nothing can ship from an uncommitted tree. + +| Pillar | Daemon RPC | Native GUI | Cross-party / cross-node | Tests | Unit verdict | +|---|---|---|---|---|---| +| **MINT** | built (`zslp_genesis`/`mint`/`send`) | built, wired (`mainwindow.cpp:3022,3178,3191`) | n/a (on-chain, public) | gtest + shell regtest | **usable (transparent)** | +| **VIEW** | built (`gettoken`/`listtokens`/`listtransfers`/`listmytokens`) | built incl. **attach-file verify** (`nftdetaildialog.cpp:164,490,552`) | reads public chain | L0 strong, L1 ~1 test | **usable (transparent)** | +| **SELL** | built (`nft_makeoffer`/`verifyoffer`/`takeoffer`/...), ALL\|ANYONECANPAY atomic swap | **NONE** (greenfield) | swap shape correct; CI proof = same-node only | 6 gtests + shell regtest | **CLI-only / not user-facing** | +| **SHIELD** | built (`z_senddatafile`/`list`/`get`), default-OFF `-datachannel` | **NONE** (greenfield) | **structurally impossible today** (#117) | 25 codec gtests; **0 cross-RPC seam tests** | **sender-same-session only** | + +**Bottom line for a non-technical user with money:** +- They **can** mint and view/verify a transparent NFT end-to-end from the wallet. +- They **cannot** sell an NFT from the wallet (no GUI), and they **cannot** send a private + NFT to *anyone else* at all — the SHIELD retrieval path requires an in-process registry + record that only the sending node has, this session only. +- The honest first user-facing release is **MINT + VIEW (transparent)**; SELL and SHIELD + must be labeled CLI-only / experimental until a GUI exists and SHIELD cross-wallet + receive is solved. + +--- + +## 2. Cross-pillar security findings (SELL × SHIELD × MINT/indexer × anti-burn) + +**Verdict: APPROVE.** The pillars compose without opening a burn/grief/mis-credit vector. +The anti-burn fence is correct and defense-in-depth, independent of UI locks. + +- **SHIELD never touches token UTXOs — clean.** `z_senddatafile` funds *only* from Sapling + notes (`asyncrpcoperation_senddatafile.cpp:171,175-177`), change goes back to the z-addr + (`:248`), and its tx has no `vout[0]` OP_RETURN, so `CZSLPIndexer::ParseTx` returns false + and the indexer spends nothing token-bearing. SHIELD is fully isolated from the ledger. +- **Sell swap is indexed identically to a normal SEND — no mis-credit.** The swap tx is a + canonical ZSLP SEND at `vout[0]` crediting `vout[1]` (buyer NFT); same + `ParseTx`/`ApplyTransaction` seam, conservation `availIn==requiredOut==1` + (`nftoffer.cpp:570-580`; `zslpindexer.cpp:220-319`; `zslpstore.cpp:548-592`). `takeoffer` + re-runs the production parse + `WouldBeValid` before broadcast (`nftoffer.cpp:937-958`). +- **Buyer funding cannot grief/burn a token — triple-fenced.** Explicit `fundingInputs` + rejected if protected (`:820`); auto-select uses `AvailableCoins(fExcludeZSLPTokens=true)` + + re-check (`:847`); the confirmed-truth branch matches *any* tokenId via the global store + (`zslpwallet.cpp:107-112`), catching even a *different*-token funding burn the per-token + conservation check would miss; a final pre-broadcast post-check re-asserts no `vin[k>=1]` + is a token UTXO (`nftoffer.cpp:943`); `WouldBeValid` enforces exact conservation. +- **LockCoin / anti-burn / cancel compose correctly — no double-unlock.** `makeoffer` + LockCoins (idempotent); `cancel` UnlockCoins then self-sends, re-locking on builder + failure (`nftoffer.cpp:1104-1107`); `ScopedTokenLock` RAII unlocks on every exit and the + pinned NFT is excluded from the scoped set (`zslpwallet.cpp:227-245`). +- **Anti-burn does NOT depend on the offer lock.** A user `lockunspent`-unlock (or a restart, + which clears all in-memory locks) does **not** create a burn vector: every normal + t-send / shield path uses `AvailableCoins(fExcludeZSLPTokens=true)`, which drops the + confirmed NFT via `ZSLPIsProtectedTokenOutpoint` independent of lock state + (`wallet.cpp:3197-3199`). The lock is advisory convenience only. + +**Two cosmetic / honesty notes (no fund risk):** +- *nit:* stale offer locks persist in the SELLER's wallet after a counterparty fills the + offer, until restart — inert (outpoint no longer exists), only clutters `listlockunspent` + (`nftoffer.cpp:630,1021-1031`). +- *minor:* SHIELD on-chain bloat is bounded **only by ordinary fee economics** (identical to + any large memo tx). `ZdcRateGuard` (4/s) and `ZDC_MAX_INFLIGHT(256)` are **single-node + RPC** DoS guards, **not** network/per-block anti-spam. The real mitigation is default-OFF + `-datachannel`. Document this honestly in PRIVACY/THREATS; do not read the rate guard as + consensus/relay protection (`datachannel.cpp:84-89,131-142,608-612`). + +--- + +## 3. Private-NFT composition — status + minimal path to make it real + +**Status: COMPOSABLE-MANUALLY (CLI, 2 steps), but the verify-before-decrypt guarantee it +sells does NOT compose for the audience it is meant for.** + +**What genuinely works (byte-compatible loop):** +`z_senddatafile` → `fingerprint` (64-hex, `datachannel.cpp:301,365`) → `zslp_genesis +document_hash=` (stored unreversed, round-trips through the double byte-order +reversal so `zslp_gettoken.documenthash == fingerprint`, `zslp.cpp:377-391`, `slp.c:248-250`, +`zslpindexer.cpp:254`, `uint256.cpp:25`) → `z_getdatatransfer verify_fingerprint=` +which refuses plaintext unless the on-chain ciphertext SHA-256 equals it +(`datachannel.cpp:453-458,540-565`). The docs (`NATIVE_NFT_GUIDE §3.3`) describe this +accurately. `zslp_mint_private` is speced but **unnecessary** — a `transfer_id==token_id` +binding is impossible (genesis txid unknown at send time), so the random-transfer_id +fingerprint is the correct anchor. + +**Why it is not real for the intended audience (two `major` gaps):** +1. **verify-before-decrypt is unreachable for any viewer who is not the sender's same session.** + `z_getdatatransfer` hard-throws *"transfer not found in this node's registry"* whenever the + transfer is not in the in-process, non-persisted `g_zdcTransfers` map + (`datachannel.cpp:484-486`, populated only by `z_senddatafile` this session). The + holder/buyer/auditor who has the published `document_hash` + on-chain frames + key has + **no reachable verify path**. This is #117 surfacing inside the composition. — *Verified + verbatim at `datachannel.cpp:484-486`.* +2. **End-to-end composition is exercised NOWHERE.** Both regtests mint with a hardcoded dummy + `document_hash` (`...0001` / `...00aa`; `zslp-nft-regtest.sh:152`, + `nft-sell-regtest.sh:129`) — never a real fingerprint. `grep` over `qa/` finds zero + `z_senddatafile` / `z_getdatatransfer` / `-datachannel` usage. The 25 zdc gtests cover the + codec in isolation; the cross-RPC seam — the actual private-NFT claim, crossing three files + with a double byte-order reversal — is **untested**, which is exactly where a silent + regression hides. + +**Minimal path to make private-NFT composition REAL (in priority order):** +1. **Make `z_getdatatransfer` registry-free when `verify_fingerprint` is supplied** (+ key + + address): scan wallet Sapling notes for the `transfer_id`, recompute the ciphertext + fingerprint, gate on `== verify_fingerprint`, then decrypt with the caller-supplied key — + i.e. make `haveRec` optional iff `verify_fingerprint` is present. This is the single change + that makes "verify the document_hash == ciphertext fingerprint BEFORE decrypting" true for + someone other than the original sender. (Subsumes #117 retrieval for the verify-then-open + case; the full key-delivery problem in §5 is the remaining half.) +2. **Add a cross-RPC regtest** (`-datachannel -zslpindex`): `FP=$(z_senddatafile).fingerprint`; + `TID=$(zslp_genesis {nft,document_hash:FP}).tokenid`; assert + `zslp_gettoken(TID).documenthash == FP`; mine; assert `z_getdatatransfer(verify_fingerprint=FP)` + returns verified + correct hexdata, and a **wrong** fingerprint returns + `ERR_HASH_MISMATCH` with **no plaintext**. +3. *minor:* "mint a private NFT" needs BOTH `-zslpindex` and `-datachannel` on and is silent if + either is off (`zslp.cpp:361`, `datachannel.cpp:608-609`). Acceptable for CLI; wire the + fingerprint hand-off in the eventual SHIELD GUI so no hex is copied by hand. + +--- + +## 4. Whole-feature honesty sweep — overclaims to fix + +The **MINT / VIEW / SELL verify-badge and uniqueness copy is rigorously honest** — the green +check explicitly disclaims "genuine/official/authorized," `nft_verifyoffer` calls itself a +*mandatory* buyer check, SELL copy says trust-minimized (not trustless), and +`SECURITY_MODEL §5` / `GUIDE §4` carry an explicit "what this does NOT do" list. **Keep this +copy as the bar for the SHIELD GUI when it lands.** The honesty failures are concentrated in +the **SHIELD/private** surface and in stale docs: + +**Major overclaims (fix before any "NFT release" marketing):** +- **README headline sells private NFTs as a delivered, two-party feature.** First sentence: + *"…and — uniquely — **private** NFTs over the shielded pool."* (`README.md:3-5`, verified). + Reality: cross-wallet recipient retrieval is impossible (#117, `datachannel.cpp:484-486`) + and there is no GUI (`RPC::isPrivateMintWired()==false`, `zcl-qt-wallet/src/rpc.h:241`). + The build-status blockquote partially corrects this, but the lead reads as shipped. + **Fix:** scope it honestly, e.g. *"(experimental, sender-side today) confidential + file/asset delivery over the shielded pool,"* or drop "uniquely private NFTs" from the lead. +- **GUI "ownership is shielded" / green Private pill overclaim shielded ownership that does + not exist.** `nftdetaildialog.cpp:217-219` renders *"Private — only you can see this. Its + ownership is shielded,"* `nft.h:28` defaults `isPrivate=true` ("shielded provenance"). ZSLP + NFT ownership is **always transparent/public** (the token rides transparent dust; + acknowledged at `rpc.cpp:964`). The real load path forces `isPrivate=false` (`rpc.cpp:965`) + so users don't *see* it today, but the claim text + green-pill machinery + default+comment + are live code asserting a capability that exists nowhere. **Fix:** remove/gate the pill, + flip `nft.h:28` to false, fix the comment; if SHIELD ships, the honest claim is the *asset + bytes* are confidential, never that *ownership* is shielded. +- **GUIDE §3.2/§3.3 invents a disabled-state error and an `-experimentalfeatures` gate the + daemon doesn't implement.** Guide quotes the gate as `fExperimentalMode && -datachannel` + with a custom *"Data channel is disabled… -experimentalfeatures -datachannel…"* message + (`NATIVE_NFT_GUIDE.md:560-563,592`). As built, `RegisterDataChannelRPCCommands` gates only + on `-datachannel` (`datachannel.cpp:603-612`), no `-experimentalfeatures`, and when off the + methods are simply **absent** → generic `-32601`, no custom text. **Fix:** make doc and code + agree (drop `-experimentalfeatures` from the doc, or add it to the code). + +**Minor / nit honesty items (stale/front-door docs and help text):** +- *minor:* `PRIVACY.md:51-53,92` states the file cap as ~64 KB; as-built cap is **40000 bytes** + (`datachannel.cpp:84`). Correct to 40000 / ~40 KB. +- *minor:* `PRIVACY.md:54-59,96-99` lists "Seal now, reveal later" and "Private NFTs + (ownership shielded)" as capabilities; `z_senddatafile` always sets `include_key_frame=true` + (`datachannel.cpp:288`), `z_revealkey` is "designed, not built" (`GUIDE:643-644`), and + ownership is transparent. Mark designed/not-built or fold PRIVACY.md into the guide. +- *minor:* `GUIDE §3.2` quotes permanence + shielded-funding error strings the code does not + emit (real strings at `datachannel.cpp:211-213` and `asyncrpcoperation_senddatafile.cpp:86-103`; + the shielded-from check is in the async op, not synchronous). Substance is honest; fix the + quoted strings/locations. +- *minor:* `z_getdatatransfer` help reads as general recipient retrieval but the registry gate + makes it sender-session-only (`datachannel.cpp:414-416` vs `:484-486`). Add the one-line + caveat `z_listdatatransfers` already has (`:379`). +- *nit:* `z_senddatafile` help leads with "Send a PRIVATE file" then honestly discloses + permanence two lines down (`datachannel.cpp:166-170`). Optionally soften "PRIVATE" to + "confidential." + +--- + +## 5. SINGLE prioritized remaining-work list to reach real-user-shippable + +De-duplicated across **#112** (pre-mainnet hardening), **#117** (SHIELD cross-wallet receive), +**#118** (GUI backlog), and items newly surfaced by this whole-feature review. Each item has a +severity and a binary done-criterion. + +### BLOCKERS — cannot ship to real users until all are done + +1. **Commit the entire NFT feature on both repos.** *(blocker; tracking-only)* + The whole daemon write/sell/shield path + every GUI NFT dialog are untracked/unstaged + (daemon: 19 modified + 18 untracked incl. `src/datachannel/`, `src/rpc/{nftoffer,datachannel}.cpp`, + `src/wallet/zslpwallet.*`, `src/wallet/asyncrpcoperation_senddatafile.*`, `doc/nft/`, `qa/zslp/`; + GUI: nft*dialog + `M rpc/contentengine`). A bad checkout loses everything. + **Done when:** daemon `feature/zslp-nft-indexer` and GUI `feature/nft-gallery` have all NFT + files committed; `git status` shows no untracked NFT sources. + +2. **Decide v1 scope; do not market SELL/SHIELD as user features without a GUI.** *(blocker)* + SELL and SHIELD are CLI/daemon-only — `grep` of the GUI tree finds **no** + `makeoffer`/marketplace/`senddatafile` caller. A non-technical user cannot sell or privately + send an NFT from the wallet at all. + **Done when:** either (a) the release ships/markets **MINT+VIEW (transparent)** only, with + SELL/SHIELD explicitly labeled CLI-only/experimental in README + GUIDE; **or** (b) the Sell + and Shield GUIs (`NFT_GUI_PLAN §C/§D`) are built, wired, and L1-tested. + +3. **Make SHIELD work across wallets/nodes (#117) — or label it sender-side experimental.** *(blocker)* + The per-transfer AEAD key lives only in a process-local, never-persisted `std::map` + (`datachannel.cpp:111,353`); `z_getdatatransfer` hard-requires a local record before it + decrypts (`:484-486`, verified). A recipient on another wallet/node has no key and gets a + flat refusal. "Send a private NFT to someone" does not work. + **Done when:** (a) the recipient can obtain the key (on-chain encrypted KEY frame openable + by their ivk, or out-of-band selective disclosure) AND `z_getdatatransfer` can decrypt from + chain+ivk without a local sender record (this is the same change as §3 step 1); (b) a + **two-wallet/two-node regtest** proves round-trip receive; **until done,** README/GUIDE/help + say "sender-side experimental." + +### MAJOR — required for money-at-stake confidence + +4. **Make verify-before-decrypt reachable for non-sender viewers.** *(major; subset of #3 step a)* + See §3 step 1. **Done when:** `z_getdatatransfer` with `verify_fingerprint` (+key+address) + verifies the on-chain ciphertext hash and decrypts without a registry record; wrong + fingerprint → `ERR_HASH_MISMATCH` and no plaintext. + +5. **Add the cross-RPC private-NFT regtest (the composition is exercised nowhere).** *(major)* + See §3 step 2. **Done when:** a CI-gated regtest runs `z_senddatafile` → `zslp_genesis + document_hash=` → `zslp_gettoken` equality → `z_getdatatransfer + verify_fingerprint` happy + mismatch, all green. + +6. **Add a CI-run write-path + two-node settlement test.** *(major)* + `BuildAndCommitZSLP` (coin selection, anti-burn fence, sign, self-validate, commit) is + covered "by code review only" (`test_zslp_wallet.cpp:25`); the shell regtests are manual and + the sell regtest buys from a **second address in the same node** (`nft-sell-regtest.sh:22`), + so true cross-party atomic settlement is unproven. + **Done when:** a CI regtest mints, sends to a **second node's** wallet, re-reads via + `zslp_listmytokens` there, and runs make→verify→take→settle across two nodes — green in the + gate. + +7. **Add L1 (widget) coverage for the honesty-critical GUI paths.** *(major; part of #118)* + Only one NFT widget test exists (`tst_widget.cpp:1869`); `NftMintDialog`/`NFTSendDialog` are + never constructed in any test, and there is no `RPC::mintNFT`/`sendNFT` test-injection seam. + **Done when:** `testSetNextMintResult`/`testSetNextSendResult` seams exist and L1 tests cover + mint create-gating/streaming/0-conf, the send 4-state + `verifyState==2` mismatch guard, + attach match/mismatch, and VERIFIED/MISMATCH badge copy. + +8. **Turn #112 pre-mainnet hardening into an explicit, binary checklist.** *(major)* + No hardening/TODO markers exist in the ZSLP sources; the no-fork HARD CONSTRAINT is tested + only at `IsStandardTx` level (`test_zslp_wallet.cpp:538`, which itself notes it does not call + `AcceptToMemoryPool` with a live UTXO view). + **Done when:** #112 enumerates and ticks: (a) live public-testnet soak of mint/send/swap; + (b) full `AcceptToMemoryPool`/relay test on **mainnet** `CChainParams`; (c) gtest for + indexer migration + reconnect idempotence + multi-token mixed-input silent-burn; (d) fuzz + the `slp.c` parser + zdc codec — each with a pass/fail criterion. + +9. **Fix the SHIELD/private overclaims in front-door surfaces.** *(major; honesty)* + See §4 — README headline, GUI "ownership is shielded" + green Private pill (`nft.h:28` + default), and GUIDE §3.2 invented error/gate. + **Done when:** README lead is scoped to "experimental sender-side confidential delivery"; + the shielded-ownership claim + green pill are removed/gated and `nft.h:28` defaults false; + GUIDE §3.2/§3.3 match the actual `-datachannel`-only gate and generic `-32601`. + +### MINOR — quality / completeness, not ship-blocking for a MINT+VIEW v1 + +10. **VIEW provenance: `zslp_listtransfers` is never called from the GUI** (`zslp.cpp:142` + built; no GUI caller). Add `RPC::nftTransfers` + a compact timeline; show the Set/Creator + already read and discarded (`nftdetaildialog.cpp ~326`). **Done when:** the detail dialog + shows chain-of-custody from `zslp_listtransfers`. +11. **MINT: no sanctioned burn primitive (`zslp_burn`).** Editions can never be intentionally + retired; combined with permanence, a typo'd mint is unfixable. Add anti-burn-aware + `zslp_burn` + `client.cpp` entry + gtest + a mint-dialog permanence warning above Create. + **Done when:** `zslp_burn` exists, tested, and the mint dialog warns about permanence. +12. **SHIELD daemon completeness:** `z_revealkey` (seal-then-reveal) and `zslp_mint_private` + are unbuilt (`datachannel.cpp:594-600`); no GUI wrapper over `z_exportviewingkey`. + **Done when (after #3/#4):** either build them, or document the as-built 2-step path as the + canonical recipe. *Note: DoS caps ARE built (cap `:84`, inflight `:86`, TTL-GC, + rate-limit `:88-89`) — re-grade the stale "DoS unbuilt" rows.* +13. **Honesty doc cleanups (§4 minors/nits):** PRIVACY.md 64KB→40000B; mark seal-then-reveal & + shielded-ownership as designed/not-built; fix GUIDE §3.2 quoted error strings; add the + sender-session caveat to `z_getdatatransfer` help; optionally soften `z_senddatafile` + lead. **Done when:** each stale string matches the as-built daemon. +14. **Docs↔code reconciliation pass.** Mark G-VIEW (attach-file **built**), G-HELP (9 + `setWhatsThis` **built**), DoS governance (**built**), and the IsStandard HARD CONSTRAINT + test (now exists, `test_zslp_wallet.cpp:538`) as landed in + `NFT_FEATURE_CHECKLIST.md`/`NFT_GUI_PLAN.md`/`CAPABILITY_MAP.md`; keep **one** status table + (`GUIDE §1`) as canonical. **Done when:** the checklist no longer mis-states readiness in + either direction. + +### NIT — cosmetic + +15. Stale offer locks in the seller's wallet after a fill (`nftoffer.cpp:630,1021-1031`) — + optionally `UnlockCoin` when `nft_listoffers` recomputes filled/expired. +16. Document SHIELD on-chain bloat is bounded only by ordinary fee economics and the rate guard + is single-node, not network-level (PRIVACY/THREATS). + +--- + +## Appendix — finding tally + +- **Blockers: 3** (uncommitted tree; SELL+SHIELD have zero GUI / scope-not-user-ready; + SHIELD cross-wallet receive structurally impossible). +- **Majors: 8** (verify-before-decrypt unreachable; composition tested nowhere; README + headline overclaim; GUI shielded-ownership overclaim; GUIDE §3.2 invented gate/error; no CI + write-path/two-node settlement test; no L1 GUI coverage; #112 unscoped). +- **Cross-pillar security: APPROVE** — no burn/grief/mis-credit vector across SELL × SHIELD × + MINT/indexer × anti-burn. +- **Private-NFT status: COMPOSABLE-MANUALLY** (byte-compatible 2-step CLI loop; the + verify-before-decrypt guarantee does not yet compose for non-sender viewers). diff --git a/doc/nft/NFT_GUI_PLAN.md b/doc/nft/NFT_GUI_PLAN.md new file mode 100644 index 00000000000..b309ac7344b --- /dev/null +++ b/doc/nft/NFT_GUI_PLAN.md @@ -0,0 +1,274 @@ +# ZClassic NFT — GUI Build Backlog (prioritized) + +The prioritized, concrete build plan for the native (no-browser) NFT GUI in +`/home/rhett/github/zcl-qt-wallet` (C++14 — **no** `std::optional`/`string_view`; use +empty-`QString` sentinels). Companion to [`NFT_FEATURE_CHECKLIST.md`](NFT_FEATURE_CHECKLIST.md) +(the status matrix), [`NATIVE_NFT_GUIDE.md`](NATIVE_NFT_GUIDE.md) §2 (the native-UI spec), +and [`NFT_SELL_DESIGN.md`](NFT_SELL_DESIGN.md) (the approved Sell/Buy UX). + +> **Honesty rules for this doc.** "tested" = a real L0 (`tests/tst_logic.cpp`) or L1 +> (`tests/widget/tst_widget.cpp`, gated behind `ZCL_WIDGET_TEST` + offscreen QPA) test +> exercises it. "documented/onboarded" = a user can understand it **in-app** (no web +> browser, no external README). Coin = **ZCL** (never ZCL). +> +> **State as of 2026-06-06.** The NFT dialogs have LANDED (`nftmintdialog.*`, +> `nftdetaildialog.*`, `nftsenddialog.*`, `nftgallerymodel/delegate`) but are +> **UNCOMMITTED** and their widget wiring is **almost entirely untested** — only the +> detail no-bytes terminal has an L1 test (`nftDetail_noBytesIsTerminalNotSpinner`, +> tst_widget.cpp:1869). `NftMintDialog` and `NFTSendDialog` are **never constructed** in +> any test. The engine PRIMITIVES under these dialogs (ContentEngine streaming-hash/Merkle/ +> poster/verify, `NFTGalleryModel`, `NFTImageCache`) ARE L0-tested (~12 `ce*`/`nftModel*` +> tests); the dialog wiring that turns them into user-facing behavior is not. + +The four pillars: **MINT · VIEW · SHIELD · SELL.** Priority order across pillars below is +A → E as the task requires: (A) make the verify badge reachable for received NFTs; (B) +in-app onboarding + honesty copy; (C) Sell/Buy UI; (D) Shield private-send UI; (E) the +specific L0/L1 tests. Within each section items are listed highest-impact-first. + +--- + +## P0 — Pre-work (do first; cheap, prevents loss) + +- [ ] **Commit the landed-but-untracked NFT dialogs** (`nftmintdialog.*`, + `nftdetaildialog.*`, `nftsenddialog.*`, `nftgallerymodel.cpp`, `nftgallerydelegate.cpp`, + `nft.h`, `nftimagecache.*`) and the ContentEngine `posterForToken`/`posterReady` + additions — currently `??`, lost if not committed. (No code change; tracking only.) +- [ ] **Add a `mintNFT`/`sendNFT` test-injection seam to `RPC`** (rpc.h:194,203) mirroring + the existing `testSetNextZaddrResult` pattern (rpc.h:98) — e.g. + `testSetNextMintResult(txid, errStr)` / `testSetNextSendResult(...)` / a + `testSetNextTxReceivedDate(...)` for `txReceivedDate` (rpc.h:218). **Blocker for every + mint/send L1 success/failure test** (§E) — today the dialogs cannot be driven to a + terminal state in a test because they reach a live daemon. + +--- + +## (A) VIEW — make the verify badge REACHABLE (the worst gap, G-VIEW) + +The headline VIEW promise is "verify the image." Today a RECEIVED NFT can **never** reach +the green badge: `RPC::refreshNFTs` hard-sets `it.cachePath = QString()` for privacy +(rpc.cpp:960, never auto-fetch), and the ONLY writer of the content-addressed cache is the +in-session mint (`ContentEngine::cachePut`, nftmintdialog.cpp:249). There is **no** +attach-local-file affordance anywhere (no `getOpenFileName` in `nftdetaildialog.cpp` or the +gallery). "Re-check image" (`onRecheck`, nftdetaildialog.cpp:408) just re-runs +`requestPoster()` against the same empty cache. The no-bytes tooltip even *promises* "open +it to check it yourself" — an action no button performs. + +- [ ] **Add an "Attach the file you have" button to `NFTDetailDialog`** (next to + `m_recheckBtn`, nftdetaildialog.cpp:162). On click: `QFileDialog::getOpenFileName`, + reject remote/non-local via `ContentEngine::isRemoteUrl` (reuse the mint guard at + nftmintdialog.cpp:163), stream-hash the file, and **only if the anchor matches + `it.docHashHex`** call `ContentEngine::cachePut(docHashHex, path)` then re-run + `requestPoster()` (nftdetaildialog.cpp:220) so the badge flips to VERIFIED. If the + attached file's fingerprint does NOT match, show the honest red "this file does NOT match + this collectible's on-chain fingerprint" inline — do **not** cache it. +- [ ] **Show the Attach button only in the no-bytes terminal state** (`applyNoBytesBadge`, + nftdetaildialog.cpp:302) so it appears exactly when it's useful; hide it once bytes exist. +- [ ] **Fix the no-bytes tooltip to match reality** — until Attach lands, the gallery + tooltip (nftgallerymodel.cpp:66-70) and detail no-bytes copy must not promise "open it to + check it yourself"; after Attach lands, update the copy to point at the new button. +- [ ] **Decide & enforce the `nft=true` `document_hash` requirement.** Daemon-side + `zslp_genesis` makes `document_hash` OPTIONAL for `nft=true` (validated only IF present; + the nft preset adds no requirement), so a hash-less NFT is **structurally impossible to + ever verify** in the GUI (no anchor → no `cacheGet` → badge stays neutral, nftdetaildialog.cpp:226). + The GUI mint wizard already requires an anchor before Create (`refreshCreateEnabled`, + nftmintdialog.cpp:211), so GUI-minted NFTs always carry a hash; the gap is CLI/foreign + hash-less NFTs. **Decision:** either (a) require `document_hash` for `nft=true` daemon-side + (cleanest — closes it for all clients), or (b) show an honest "no fingerprint was recorded + for this collectible — it can't be verified" terminal in the detail dialog when + `it.docHashHex` is empty (nftdetaildialog.cpp:194-196 already labels it "none recorded + on-chain" but does not explain the consequence). Pick (a) if a daemon change is in scope; + else ship (b) as a GUI-only honest dead-end. + +--- + +## (B) VIEW — in-app onboarding + honesty copy (G-HELP) + +Near-total gap: **0** `setWhatsThis` in the entire GUI; only 2 `setToolTip` on the verify +badge (nftdetaildialog.cpp:296,311), both merely echo the verdict. The spec'd 4-page hero +stack (NATIVE_NFT_GUIDE.md §2.3) was NOT built — only a single grey `nftStateLabel` +(mainwindow.cpp:3051-3056, 3175-3190). The honest substance that ships (uniqueness +footnote, "matches fingerprint" never says "genuine", privacy pills, explorer reveal +warning) is presented passively as static labels a user must already know to read. + +- [ ] **Verify-badge disambiguation (highest impact — the single most-misunderstood + element).** Add a richer `setToolTip`/What's-This directly on the badge + `m_verifyLine` + (nftdetaildialog.cpp:296, in `applyVerifyBadge`:279) that states what a green check does + NOT mean: e.g. *"Green means the picture on your computer is exactly the one recorded + on-chain. It does NOT mean this collectible is official, genuine, or made by the original + artist — anyone can mint a copy."* Mirror a shorter form into the gallery card + `ToolTipRole` (nftgallerymodel.cpp:63-65). +- [ ] **First-run Collections hero (NATIVE_NFT_GUIDE §2.3).** Replace the single + `nftStateLabel` empty-state (mainwindow.cpp:3051) with the spec'd centered hero card: + Page-1 EMPTY title "No collectibles yet" + green primary **"Make your first collectible"** + (opens `NftMintDialog`) + flat link **"What is a collectible?"** opening a short *in-app* + explainer (a `QDialog`/`QLabel` — **no web browser**). None of those strings exist in the + source today (grepped). +- [ ] **Index-off page with a copyable `zslpindex=1` line.** The index-off branch + (mainwindow.cpp:3175-3190) is prose only — a dead end for foreign/old daemons. Add the + spec'd amber "Collectibles tracking is turned off" page with a copyable + `zslpindex=1` config line + "Copy line" button (NATIVE_NFT_GUIDE §2.3 Page-2). +- [ ] **Mint permanence/public-ledger warning.** The mint "honest" label + (nftmintdialog.cpp:102) correctly says the FILE stays private, but nothing warns that the + name + collection + fingerprint are written to a **permanent, public** ledger and cannot + be edited or removed (no burn primitive exists, G-PARITY). Add one line above Create: + *"The name, collection and fingerprint go on the public ledger permanently — they can't be + edited or removed later."* +- [ ] **Pending / no-bytes / 0-conf explainers.** The states are NAMED honestly but never + EXPLAINED. Add a What's-This on the no-bytes badge (`applyNoBytesBadge`, + nftdetaildialog.cpp:302) clarifying that for privacy the wallet never downloads images + automatically (ties to the §A Attach button so the instruction is honest), and on the + 0-conf "Just arrived — confirming…" line (nftdetaildialog.cpp:197-199) explaining a held + NFT is real once it confirms. +- [ ] **Quiet experimental-status line.** Nothing on the NFT surface tells the user this is + a new non-consensus overlay. Add a single calm line in the Collections header + (mainwindow.cpp `setupNFTTab` sub-head) or the mint footer: *"Collectibles are a new + feature — please use small amounts while we harden it."* Honest, non-alarming. +- [ ] **Surface provenance (chain-of-custody).** The detail dialog advertises "provenance" + in its header comment but never calls `zslp_listtransfers` (no GUI caller anywhere). Add a + `RPC::nftTransfers` wrapper over `zslp_listtransfers` and a compact timeline in the detail + dialog; also display the `zslp_gettoken` Set/Creator that `nftProvenance` already reads + but the back-fill stub discards (nftdetaildialog.cpp:326-337). + +--- + +## (C) SELL — Buy/Sell UI (per NFT_SELL_DESIGN.md) + +GUI-greenfield, but the daemon side is DONE — the `nft_*` offer RPCs are **built + regtest-proven** +(`src/rpc/nftoffer.cpp`: `nft_makeoffer`, `nft_verifyoffer`, `nft_takeoffer`, `nft_listoffers`, +`nft_canceloffer`, `nft_requestbuy` — see NFT_SELL_DESIGN.md §6), so the GUI work is **UNBLOCKED**. +The GUI target is the do-not-make-me-think flow from §5: **Sell ~3 taps, Buy ~3 taps.** Build the +RPC wrappers + dialogs against the existing daemon RPCs. + +- [ ] **`RPC::nftMakeOffer` / `nftVerifyOffer` / `nftTakeOffer` / `nftListOffers` / + `nftCancelOffer` wrappers** in `rpc.{h,cpp}`, each with a test-injection seam (like §P0). +- [ ] **`NFTSellDialog`** on an owned NFT card (reuse `NFTSendDialog` patterns): one *Price + in ZCL* field + *Expires in [7 days ▾]*; "List" → `nft_makeoffer` (locks the NFT outpoint) + → "Offer ready — Copy / Save / Show QR. Expires in 7d." The card then shows a "Listed" + badge + **Cancel** (→ `nft_canceloffer`, confirmed "voids the listing, frees your NFT"). +- [ ] **`NFTBuyDialog`** (Paste / Open `*.znftoffer` file / Scan QR): auto-run + `nft_verifyoffer`, render the offer card (image via ContentEngine, name, **price**, + "Expires in 6d", green check or amber reason). Confirmation sheet: *You pay **P ZCL** + (+ ~fee F). You receive: * plus the **mandatory honest privacy line**: *"This + trade settles publicly on-chain — price and addresses are visible. Only negotiation can be + private."* "Buy" → silent pre-sized funding UTXO (§2.5) → `nft_takeoffer` → spinner → + "NFT received." Never expose vout indices / ANYONECANPAY / templates. +- [ ] **Offer-blob (de)serializer + `*.znftoffer` file association** (NFT_SELL_DESIGN.md §4): + base64 of the compact-binary `ZNFTOFFER1` header + raw hex; Copy/Paste/QR; **no web + service**. The GUI registers the extension. +- [ ] **MISMATCH + expiry honesty in Buy.** Reuse the existing verifyState==2 red-mismatch + pattern (nftsenddialog.cpp:41-47): a verify-failed or expired offer renders amber/red with + the reason and **disables Buy**. + +--- + +## (D) SHIELD — private-send / private-NFT toggle + +The daemon datachannel RPCs are now **BUILT** (`z_senddatafile` / `z_listdatatransfers` / +`z_getdatatransfer`, gated behind `-datachannel` default-OFF, `z_senddatafile` requires +`acknowledge_permanent=true`). But the GUI has **zero affordance**: `isPrivateMintWired()` +hard-returns `false` (rpc.h:225), every NFT dialog shows "Private — coming in this release" +(nftmintdialog.cpp:96, nftsenddialog.cpp:77), and the binary-safe memo-read sniff is not +applied. `z_revealkey` + `zslp_mint_private` are still absent daemon-side. + +- [ ] **Apply the GUI binary-safe memo read fix** (rpc.cpp ~756): sniff the ZDC1 magic + `0x5A,0x44,0x43,0x31` on the raw 512-byte `QByteArray` **before** any `QString` + conversion, and route binary frames to a data-channel inbox instead of mangling them. + (Documented bug-fix; not applied.) +- [ ] **`RPC::sendDataFile` / `listDataTransfers` / `getDataTransfer` wrappers** over the + built daemon RPCs, each handling the `-32601` "feature not present" latch the GUI already + understands (so a daemon with `-datachannel` off degrades honestly). +- [ ] **Flip `isPrivateMintWired()` to a real probe** (call the daemon once; true only if a + datachannel RPC does not return `-32601`) instead of the hard `false`. Until then keep the + honest "coming soon" gate. +- [ ] **Private-send toggle in `NFTSendDialog`** — once wired: a "Send privately" option that + accepts a `zs…` recipient (lift the current hard t-addr-only reject at nftsenddialog.cpp:129) + and routes through the datachannel path with the **`acknowledge_permanent=true`** consent + surfaced honestly ("the encrypted bytes are permanent and public on-chain"). Keep the + consensus-limit copy: ownership stays a public ZSLP UTXO; key-possession is not DRM. +- [ ] **Private-mint** (blocked on daemon `zslp_mint_private`): once it lands, add the + encrypt-the-asset path to `NftMintDialog` (document_hash = ciphertext fingerprint) behind + the same wired probe. + +--- + +## (E) Tests to add (per untested path) + +All L1 tests are gated behind `ZCL_WIDGET_TEST` + the offscreen QPA and live in +`tests/widget/tst_widget.cpp`; L0 unit tests live in `tests/tst_logic.cpp`. The mint/send +success/failure tests are **blocked on the §P0 RPC test seam**. + +### MINT (L1 — `NftMintDialog` is never constructed today) +- [ ] **Create-button gating** (`refreshCreateEnabled`, nftmintdialog.cpp:207-213): construct + `NftMintDialog(engine, rpc)`, find `nftMintCreateButton` — assert DISABLED with empty + name/no file, ENABLED only after a name is typed AND a fingerprint arrives, DISABLED again + while `m_hashing`/`m_inFlight`, and DISABLED after `m_succeeded`. +- [ ] **Fingerprint streaming UI** (`setPickedFile`/`onDescriptorReady`, nftmintdialog.cpp:162-205): + feed a real temp file, pump the loop, assert `m_fpLabel` reaches "Fingerprint ready …" + (nftmintdialog.cpp:199) with the anchor's first 8 hex chars. Plus an **L0** unit for + `anchorHexFor` returning `merkleRoot` when `chunkCount>1` else `sha256Whole`. +- [ ] **Privacy-drop reject** (`dropEvent`/`setPickedFile`, nftmintdialog.cpp:142-167): feed a + non-local `QUrl` (or an `http` path to `setPickedFile`); assert `m_fpLabel` shows "drop a + local file — not a web link" (nftmintdialog.cpp:149) in red and `m_srcPath` stays empty + (Create stays disabled). +- [ ] **0-conf success terminal + in-flight lock** (`onCreate`/`closeEvent`, + nftmintdialog.cpp:215-294): with the §P0 seam — in-flight → Create reads "Creating…" + + `closeEvent`/[X] swallowed; success → Create retires to "Done", result line contains + "confirm", dialog NOT yet `accept()`ed, `ContentEngine::cachePut` called; failure → "Try + again" + the honest daemon `errStr`. + +### VIEW (L1/L0) +- [ ] **Detail VERIFIED/MISMATCH badge copy** (`applyVerifyBadge`, nftdetaildialog.cpp:279): + construct `NFTDetailDialog` with a cached/local image whose anchor MATCHES (assert + `nftDetailVerifyLine` contains "matches its on-chain fingerprint") and one that MISMATCHES + (assert "does NOT match" + red), driving `onPosterReady` via the real engine. The only + honesty-critical VIEW copy currently unverified. +- [ ] **Undecodable-image branch** (`onPosterReady` `img.isNull()`, nftdetaildialog.cpp:257-266): + one L1 case for an image present but undecodable (distinct from the tested no-bytes branch). +- [ ] **Attach-local-bytes happy + mismatch path** (§A, once built): attach a matching file → + badge flips to VERIFIED; attach a non-matching file → red "does NOT match", not cached. +- [ ] **0-conf "Received" line** (`backfillReceived`, nftdetaildialog.cpp:340-364): drive + `txReceivedDate` via the §P0 seam — assert "confirming" for `confs < kFinalConfs` and an + ISO date for `confs >= kFinalConfs`. +- [ ] **Delegate paint (optional, rendering not logic)** — grab/paint test for the neutral + dash badge + "Image not on this device" placeholder (nftgallerydelegate.cpp:150-192); only + `sizeHint` is covered today (`nftDelegateSizeHintStable`). +- [ ] **Honesty-copy presence (G-HELP, once added)** — L1 asserting the verify-badge tooltip + rejects "genuine"/"official" framing and the mint dialog mentions permanence; per the + honesty rule, untested honest copy can regress silently. + +### SELL (GUI L1/L0 — none exist yet; the daemon side HAS 6 gtests + the nft-sell regtest) +- [ ] `NFTSendDialog` recipient validation (the closest existing surface): construct + `NFTSendDialog(item, rpc)`, type each of {empty, garbage, valid t-addr, valid zs-addr} + into the recipient field (`onRecipientChanged`, nftsenddialog.cpp:106) — assert the status + copy and that `nftSendButton` is ENABLED only for the valid t-addr (zs-addr REJECTED with + "Private gifts coming soon"). +- [ ] **Send mismatch guard** (the strongest honesty guarantee, ZERO coverage): construct + `NFTSendDialog` with `NFTItem{verifyState=2}` — assert the red mismatch warning is present + (nftsenddialog.cpp:41-47) AND `nftSendButton` stays disabled even with a valid t-addr + (`notMismatch`, nftsenddialog.cpp:130); a sibling with `verifyState=1` must allow the send. +- [ ] **Send success/failure terminal** (`onSendClicked`, nftsenddialog.cpp:134): with the + §P0 `sendNFT` seam — success → "on its way — confirming" copy; failure → honest `errStr`. +- [ ] Sell/Buy dialog tests (once §C lands): offer build/verify/take happy paths + the + verify-failed/expired amber-disable path. + +### SHIELD (L1, once §D wired) +- [ ] Binary-safe memo sniff unit (L0): a 512-byte buffer starting `5A 44 43 31` is routed to + the data-channel path, not converted to `QString`. +- [ ] Private-send toggle: a `zs…` recipient is accepted only when the wired probe is true, + with the `acknowledge_permanent` consent surfaced. + +--- + +## Cross-cutting + +- [ ] **`getExplorerTxURL` unit test** (L0): pin the URL scheme + the testnet `''` sentinel + (settings.cpp:418); no test covers it today and the explorer reveal is honesty-gated. +- [ ] **Keep the no-browser invariant** — no `QtWebEngine`/`QtMultimedia`; `document_url` is + never auto-fetched. Add a guard/grep test if feasible so a regression is caught. +- [ ] **C++14 discipline** — no `std::optional`/`std::string_view`; declare header-signature + types' includes in the header (see `gui-cpp14-constraint`). + +--- + +*Companion to `NFT_FEATURE_CHECKLIST.md`. Read-only audit basis: the landed (uncommitted) +GUI tree + the L0/L1 test files. Daemon RPC line refs are against `feature/zslp-nft-indexer`.* diff --git a/doc/nft/NFT_SELL_DESIGN.md b/doc/nft/NFT_SELL_DESIGN.md new file mode 100644 index 00000000000..20a63774e22 --- /dev/null +++ b/doc/nft/NFT_SELL_DESIGN.md @@ -0,0 +1,548 @@ +# Selling a ZClassic NFT for ZCL — non-consensus sell/trade design + +**Status:** design + **BUILT** — the daemon SELL RPCs have landed (atomic swap +regtest-proven; see the status table at the end). Evidence-driven; supersedes the SELL +sections of `doc/nft/ONCHAIN_TRADES.md` (which is partly wrong about the layout — see §0). +Every load-bearing claim cites `file:line` from this tree. Constraint: **no +consensus change.** Old, unmodified miners and relay nodes (IsStandard / +RequireStandard) must accept and mine every transaction this design produces; we +add only off-consensus wallet tooling + RPC. + +Honesty legend (same as ONCHAIN_TRADES.md): +- **trustless** — settlement enforced by consensus; nobody can cheat. +- **trust-minimized** — the *coin* movement is consensus-atomic, but the *NFT + token semantics* are an off-consensus indexer convention every honest node + recomputes; a cheat can be *mined* but credits nobody, and is detectable. +- **trusted** — relies on a third party (escrow arbiter) behaving. + +--- + +## (0) The correction that drives this whole design + +`ONCHAIN_TRADES.md §2.2` proposes this layout for the atomic swap: + +``` +vin[0] = seller NFT dust (signed SINGLE|ANYONECANPAY) +vout[0] = seller ZCL payout (pinned by SINGLE to vin[0]) ← WRONG +vout[1] = buyer NFT dust +vout[2] = OP_RETURN ZSLP SEND +``` + +**That transaction is not a valid ZSLP transfer and the buyer would receive +nothing.** Two facts from the live indexer make it impossible: + +1. **The ZSLP message is parsed from `vout[0]` ONLY.** `CZSLPIndexer::ParseTx` + reads `tx.vout[0].scriptPubKey` and requires it to parse as an SLP message; + anything at `vout[0]` that is not a valid SLP OP_RETURN means *the tx has no + SLP message* (`src/zslp/zslpindexer.cpp:205-234`, esp. 229-234). If `vout[0]` + is the seller's P2PKH payout, the tx is a non-SLP tx — the SEND never + happens, and the NFT input the tx spends is simply **burned** + (`zslpstore.cpp:437-446`: every spent token UTXO is consumed/burned + regardless of whether a message is present). + +2. **SEND credits the token positionally to `vout[1+j]`.** The new owner's dust + must sit at `vout[1]` (for a single-output SEND), `vout[1+j]` in general + (`zslpstore.cpp:577-585`). So the OP_RETURN must be `vout[0]` and the buyer's + NFT dust must be `vout[1]`. + +3. **The indexer is now UTXO-bound conservation, not credit-only.** It reads + `tx.vin`, burns spent token UTXOs, and a SEND is valid only if + `availIn >= requiredOut` for that token (`zslpstore.cpp:548-588`; + `WouldBeValid` mirror at `zslpstore.cpp:703-748`). ONCHAIN_TRADES.md's claim + that ZSLP "never reads `tx.vin`" and "only credits" (its lines 40-45, + appendix 230-232) is **stale** — the working tree's indexer DOES debit the + spent NFT input and DOES enforce conservation. Good: that makes the NFT leg + *trust-minimized* (a forged SEND that doesn't actually spend the live NFT + UTXO credits nobody), not merely advisory. + +Now combine with the sighash rule: + +- `SIGHASH_SINGLE` commits the input being signed at index `k` to **only the + output at the same index `k`** (`src/script/interpreter.cpp:1087-1095`: + `hashOutputs` = hash of `txTo.vout[nIn]` alone). + +So if the seller spends the NFT at `vin[0]` and signs `SINGLE`, the only output +the seller pins is `vout[0]` — which **must be the OP_RETURN** (value 0, no +address, not a payout). The seller therefore **cannot pin their ZCL payout with +`SIGHASH_SINGLE` while also producing a layout the ZSLP indexer accepts.** The +index that SINGLE forces the seller to pin is occupied by the protocol-mandated +OP_RETURN. + +**Conclusion:** the per-output `SIGHASH_SINGLE|ANYONECANPAY` "open template" +trick does not fit ZSLP. We need a mechanism where the seller commits to the +*entire* output set (so OP_RETURN@0, buyer-NFT@1, seller-payout@k are all fixed), +and leaves only the *inputs* open for the buyer to fund. That is exactly what +`SIGHASH_ALL|ANYONECANPAY` does. This doc's primary mechanism is built on that, +not on SINGLE. + +--- + +## (1) Mechanisms evaluated + +| Mechanism | Fits ZSLP layout? | Atomic? | Trust | Verdict | +|---|---|---|---|---| +| **A. SINGLE\|ANYONECANPAY open-output swap** (ONCHAIN_TRADES.md) | **NO** — SINGLE pins vout[0]=OP_RETURN, can't pin payout | n/a | — | **rejected** (breaks ZSLP, §0) | +| **A′. ALL\|ANYONECANPAY fixed-template swap** (this doc) | **YES** — seller fixes the whole output set incl. OP_RETURN | **yes (coin legs)** | trust-minimized | **PRIMARY** | +| **B. 2-of-3 P2SH arbiter escrow** | yes (settlement is an ordinary SEND) | no (multi-tx) | **trusted** | **FALLBACK** (high-value / disputed) | +| **C. Shielded-pool private sale** (ZDC1) | n/a | **no** (privacy ⇒ no atomicity, proven) | trusted (sequential) | **not for settlement**; use for private negotiation only | + +--- + +## (2) PRIMARY mechanism — A′: fixed-template `SIGHASH_ALL|ANYONECANPAY` offer + +### 2.1 The idea + +`ANYONECANPAY` zeroes `hashPrevouts` and `hashSequence` +(`interpreter.cpp:1077-1085`), so a buyer can **append funding inputs** without +invalidating the seller's signature. Plain `ALL` (the `SINGLE`/`NONE`-free base +type) commits to the hash of **all outputs** (`interpreter.cpp:1087-1089`). So +with `ALL|ANYONECANPAY` the seller binds the **complete, exact output set** — +OP_RETURN, buyer's NFT dust, and the seller's payout — and leaves only the input +side open. The buyer may add inputs (and **only** inputs), then sign their own +inputs and broadcast. + +This is the inverse of the SINGLE trick: SINGLE fixes one output and opens the +rest; ALL fixes *all* outputs and opens the inputs. ZSLP needs all three outputs +fixed at known indices, so ALL is the correct base type. The cost: the buyer +cannot add a change output to themselves *in this transaction* (any new output +breaks the seller's `ALL` signature). The buyer must instead bring an input (or +inputs) whose value equals payout + buyer-dust + fee *exactly*, or accept that +the remainder becomes fee. This is the central UX problem and §2.5 solves it. + +### 2.2 Exact transaction template (the only valid one) + +``` +nVersion = 4 (Sapling), nVersionGroupId = 0x892F2085 (CreateNewContextualCMutableTransaction) +nExpiryHeight = E (offer deadline; see §2.6) +nLockTime = 0 + +vin[0] = SELLER's NFT-bearing dust UTXO ← seller signs, ALL|ANYONECANPAY +vin[1..]= BUYER's funding input(s) ← appended + signed by buyer, ALL|ANYONECANPAY + (buyer also uses ANYONECANPAY so each + party signs only their own inputs) + +vout[0] = OP_RETURN ZSLP SEND { tokenId, [1] } ← value 0; credits qty 1 to vout[1] +vout[1] = BUYER's new NFT dust (P2PKH buyerAddr) ← D sat (fee-rate-derived dust floor); ZSLP new owner +vout[2] = SELLER's ZCL payout (P2PKH sellerAddr) ← the asking price, in zatoshi +``` + +**`D` is FEE-RATE-DERIVED, not a hardcoded 546.** Compute the dust floor at build +time from the live relay fee rate — `CTxOut::GetDustThreshold = 3 * minRelayTxFee. +GetFee(serializeSize+148)` (`transaction.h:452-467`), ≈ 54 sat at the default +`-minrelaytxfee`. The builder uses a safe multiple of the *current* floor (the +`SLP_TOKEN_DUST = 546` constant is the conventional default and is what the wallet +mint/send path uses today, but if `-minrelaytxfee` is raised network-wide, recompute +`D` dynamically rather than trusting a literal so the output never falls below dust +and gets the tx rejected). Both legs (`vout[1]` and the seller's spent `Vin0`) use +the same derived floor so the fee math (below) nets out. + +Three outputs, fixed order, fixed values — **all** committed by the seller's +`ALL` signature. The buyer's only freedom is which inputs to add. This is the +template both wallets hard-code; users never see indices. + +Why each position: +- `vout[0]` OP_RETURN: mandatory for ZSLP parse (`zslpindexer.cpp:229-234`). +- `vout[1]` buyer NFT dust: SEND `output_quantities=[1]` credits qty 1 to + `vout[1+0]=vout[1]` (`zslpstore.cpp:581-585`). Conservation holds: availIn = 1 + (the NFT UTXO at `vin[0]`), requiredOut = 1, `availIn == requiredOut` + (`WouldBeValid` 735-746 demands exact conservation). +- `vout[2]` seller payout: the price. Plain P2PKH to the seller. + +**Fee math (no buyer change output).** Let `P` = price (zatoshi), `D` = the +fee-rate-derived dust floor (buyer NFT dust, computed at build time; ≈ 546 with the +default relay fee), `Vin0` = the NFT dust value (the seller's spent NFT UTXO, also at +the dust floor). Total output value = `0 + D + P`. The buyer's appended inputs +must sum to `S` where: + +``` +fee = (Vin0 + S) − (D + P) +``` + +The buyer picks inputs so `S` covers `P + D + fee − Vin0` with the surplus +becoming fee. Because the buyer cannot add a change output without breaking the +seller's `ALL` sig, **any overshoot is donated to miners**. §2.5 is entirely +about not overpaying. + +### 2.3 Who signs what — offer → fill + +1. **Seller makes the offer (`nft_makeoffer`).** Wallet locates the live NFT + dust UTXO (`ZSLPFindWalletTokenUtxos`, `zslpwallet.cpp:137-193`). **Precondition: + the NFT UTXO must be CONFIRMED** — `nft_makeoffer` re-runs the ZSLP self-validate + (`WouldBeValid`) on the partial, and that reads the **confirmed** indexer store + (`zslpstore.cpp:703-748`; the indexer is `ChainTip`-only, no mempool/0-conf path), + so an unconfirmed NFT cannot be validated as the live token; refuse with a clear + "your NFT is still confirming" reason. The wallet hand-builds the 3-output + template above (createrawtransaction **cannot** emit the OP_RETURN — + `rawtransaction.cpp:554-571` only does address outputs — so the tooling assembles + `vout[0]` from the existing SLP SEND encoder `ZSLPBuildSendOpReturn`/`slp.h`), + sets `nExpiryHeight = E`, then signs **only `vin[0]`** with + `signrawtransaction(hex, [nftPrevTx], [], "ALL|ANYONECANPAY")` (the RPC signs only + inputs it has keys for and leaves the rest — `rawtransaction.cpp:953-979`). + **Result: `complete:true`.** A single-input `ALL|ANYONECANPAY` signature over a + COMPLETE, fixed 3-output template is itself complete — `vin[0]` is the only input + the seller contributes and it is fully signed (there are no other seller inputs + waiting on a signature). `complete:true` here is correct and expected — it does + **not** mean the tx is broadcast-ready (it has no funding inputs yet); it means + the seller's half is finished. **That hex IS the offer.** The wallet also + **locks the NFT outpoint** against coin selection (`LockCoin`, mirroring + `zslpwallet.cpp:42-47`) so it can't be spent as fee or double-offered. + +2. **Offer shared (§4).** The hex + a small metadata header travels as a file, + clipboard string, or QR — **never** a web service. + +3. **Buyer verifies (`nft_verifyoffer`, mandatory).** Decodes the hex + (`decoderawtransaction`), checks: `vout[0]` parses as a SEND for `tokenId` + with `[1]` to `vout[1]`; `vin[0]` is the **live** token UTXO for `tokenId` + (`zslp_gettoken` + the UTXO is unspent in the live view); `vout[2]` price == + advertised price; not expired (`E > tip+3`); and re-runs the ZSLP + `WouldBeValid` conservation check on the (still-incomplete) tx so the UI can + promise "you will own this." Surfaces a reason string on any failure. + +4. **Buyer fills (`nft_takeoffer`).** The buyer does **NOT** touch any output — + `vout[1]` (the buyer's NFT-dust address) is already baked into the seller's + `ALL`-signed output set and **cannot be rewritten** (any output edit invalidates + `vin[0]`). The offer was **sealed to the buyer's address** at make-offer time via + the buyer-address handshake (§2.4): the buyer handed the seller a fresh receive + address (`nft_requestbuy`/copy-paste), the seller baked `vout[1] = buyerNftAddr` + into the signed template, and `nft_verifyoffer` already confirmed it matches the + buyer's own key. The buyer therefore only **appends funding input(s) `vin[1..]`** + chosen by the exact-input selector (§2.5; these funding inputs MUST EXCLUDE all + ZSLP-protected outpoints — see §2.5/§6 anti-burn), signs the buyer's own inputs + with `ALL|ANYONECANPAY`, lets `CombineSignatures` (`rawtransaction.cpp:970`) merge + the seller's pre-existing `vin[0]` scriptSig, then `sendrawtransaction`. (For a + truly-open listing with no pre-agreed buyer address, use the §2.4 ALT 2-message + seller-signs-last flow instead — there is no way to keep the offer one-sided AND + let the buyer choose `vout[1]`, because ZSLP positional crediting forces the + buyer's address into the seller's signed set.) + +5. **Settlement.** The tx confirms wholly or not at all. The seller's NFT UTXO + is spent **iff** the seller's payout and the buyer's NFT dust exist in the + same tx (all co-committed by the seller's `ALL` sig). On block connect the + indexer parses `vout[0]`, burns `vin[0]`, and credits qty 1 to `vout[1]` = + the buyer (`zslpstore.cpp:548-588`). `zslp_listmytokens` now shows the NFT + under the buyer. + +### 2.4 The vout[1] ownership detail — who names the buyer? + +`vout[1]` (buyer's NFT dust address) is part of the seller's `ALL`-signed output +set, so it **cannot be a placeholder the buyer rewrites** — rewriting it would +break the seller's signature. Two correct options: + +- **(Chosen) Offer is buyer-specific.** The offer is created *for a specific + buyer address*: `nft_makeoffer { ..., buyerNftAddr }`. The seller bakes + `vout[1] = buyerNftAddr` into the signed template. The offer can then only be + filled by whoever controls funds and wants the NFT at that address. For a + public listing the buyer first tells the seller (over the §4 channel or a + one-RPC handshake) a fresh receive address; the seller returns a sealed offer. + This is a 1-round-trip negotiation, not a fully one-sided post — an honest + trade-off forced by ZSLP's positional crediting. + +- **(Alt) Open offer via a re-sign round.** Seller posts an *unsigned* template + + price; buyer fills `vout[1]` with their address and their inputs; buyer + sends it back; seller signs `vin[0]` last. This is fully general but is a + 2-message protocol and the seller signs last (so the seller could withhold — + no worse than not trading). Use only if a buyer-agnostic listing is required. + +The chosen path keeps the seller's commitment one-sided (post-and-forget for a +known buyer address) and avoids a second seller signature. The do-not-make-me- +think flow (§5) hides the address handshake behind "Buy" → wallet auto-sends a +fresh address → seller wallet auto-seals. + +### 2.5 Solving "buyer can't add change" — the exact-input problem + +Because no buyer change output is allowed (it would break `ALL`), the buyer must +fund with inputs whose total minus payout minus dust equals the fee they're +willing to pay. Three layered tactics, all client-side, all standard: + +1. **Pre-sized funding UTXO.** The wallet, when the user taps Buy, first does a + tiny ordinary self-send creating one UTXO of value exactly + `P + D + fee` (it knows `Vin0 == D`, the derived dust floor). Then `vin[1]` is + that single UTXO and the swap has **zero waste**. Cost: one cheap prep tx + (confirmed or 0-conf; 0-conf is fine since the buyer is spending their own + output). This is the default. (The prep-tx self-send must also exclude any + ZSLP-protected outpoints from its own coin selection — never fund the pre-size + UTXO with a token UTXO.) +2. **Accept-overshoot.** If the user wants one-tap with no prep tx, pick the + smallest input combination ≥ target and **donate the remainder to fee**, + showing the user the (usually tiny) overshoot explicitly: "network fee ~X + (incl. Y rounding)." Honest, never silent. +3. **Seller-funded dust.** The buyer dust at `vout[1]` (value `D`, the derived dust + floor) is paid from the seller's NFT input value (`Vin0 == D`), so it nets out and + the buyer only funds `P + fee`. Encoded in the fee math (§2.2). + +The pre-sized-UTXO tactic makes A′ as clean as the rejected SINGLE design while +staying ZSLP-correct. **`fundrawtransaction` is forbidden here** for two +reasons: it inserts a change output at a random vout index +(`src/wallet/wallet.cpp:3697-3698`, `GetRandInt`) which breaks the seller's +`ALL` sig, and it adds an output at all (forbidden under `ALL`). The buyer +selector must hand-pick inputs only. + +### 2.6 Expiry, cancel, conservation, fee — recap of the guards + +- **Expiry.** Set `nExpiryHeight = E` deliberately far out (e.g. +N×1440 + blocks). `createrawtransaction` validates `E ≥ tip + TX_EXPIRING_SOON_THRESHOLD` + and `E < TX_EXPIRY_HEIGHT_THRESHOLD = 5e8` (`rawtransaction.cpp:514-518`; + `consensus.h:31`). The default wallet delta (~20 blocks) is far too short, so + the offer builder sets E explicitly. Offer card shows "Expires in Xd." +- **Cancel = self-spend the NFT UTXO.** `nft_canceloffer` does a 1-output + self-send of the NFT (a valid ZSLP SEND to the seller's own fresh address), + which spends `vin[0]`'s prevout. Any outstanding offer referencing it is now + unfillable: `signrawtransaction`/`sendrawtransaction` will fail "Input not + found or already spent" (`rawtransaction.cpp:956-958`). Costs one cheap tx. +- **Conservation is enforced by the template + self-validate.** availIn=1, + requiredOut=1; `WouldBeValid` requires `availIn == requiredOut` + (`zslpstore.cpp:743-746`), so the NFT is never burned. Both `nft_makeoffer` + (on the partial) and `nft_takeoffer` (on the final, before broadcast) + re-run the real parse + `WouldBeValid` — the same self-validate seam + `BuildAndCommitZSLP` already uses (`zslpwallet.cpp:417-467`). +- **Fee = buyer.** Seller's NFT input value flows to dust; buyer's inputs cover + payout + dust + fee (§2.2). +- **Anti-burn (both sides).** Seller side: the NFT dust is locked while an offer is + live; ordinary coin selection already excludes confirmed token UTXOs via + `fExcludeZSLPTokens` / `ZSLPIsProtectedTokenOutpoint` (`wallet.cpp` anti-burn; + `zslpwallet.cpp:100-135`). **Buyer side (REQUIRED): the funding inputs the buyer + appends in `nft_takeoffer` MUST EXCLUDE every ZSLP-protected outpoint** — re-apply + the same `ZSLPIsProtectedTokenOutpoint` filter to the buyer's funding selection. If + the buyer accidentally funds the swap with one of their own token UTXOs, the live + indexer's `ApplyTransaction` debits that spent token input as a burn (the SEND + declares only the seller's NFT, so the buyer's accidentally-spent token is consumed + for nothing). The fill builder must hand-pick funding inputs and reject any + protected outpoint before signing. + +### 2.7 Trust statement for A′ + +- **Trustless for the coin leg:** the seller's `ALL` signature co-commits "spend + my NFT" with "pay me exactly P at vout[2] and give the buyer the NFT at + vout[1]." The buyer cannot take the NFT without paying P, and cannot reduce P + or redirect the NFT, because any output edit invalidates `vin[0]`'s signature. + The seller cannot take the buyer's money without releasing the NFT, because + both are the same atomic tx. +- **Trust-minimized for the token leg:** ZSLP attribution is off-consensus. + But the indexer now debits `vin[0]` and enforces conservation, so a *forged* + offer (one whose `vin[0]` is not the live NFT) credits the buyer nothing and + is caught by `nft_verifyoffer` *before* the buyer ever signs. The residual + trust is only "all honest nodes run the same indexer rules" — which is the + same assumption the whole NFT feature already rests on. +- **NOT trustless:** the buyer-address handshake (§2.4) and price discovery are + off-chain; nothing about the *negotiation* is enforced. Only the *final + settlement* is atomic. + +--- + +## (3) FALLBACK mechanism — B: 2-of-3 P2SH arbiter escrow (trusted) + +For high-value or disputed sales where a buyer-specific atomic offer is +insufficient (e.g. cross-party trust is low and an external dispute path is +wanted), use a 2-of-3 P2SH escrow. Primitives confirmed present and tested: +`TX_MULTISIG` + `GetScriptForMultisig` (`src/script/standard.cpp:53,310-317`), +`OP_CHECKMULTISIG` (script-tested `multisig_tests.cpp:65-66`), P2SH always-on +(`main.cpp:2610`). + +``` +redeem = OP_2 OP_3 OP_CHECKMULTISIG +``` + +Flow: buyer funds the P2SH with the price; seller delivers the NFT (an ordinary +ZSLP SEND to the buyer); buyer + seller co-sign the payout release (no arbiter +needed in the happy path). On dispute the **arbiter** co-signs with the honest +party. This is **trusted** — the arbiter can collude — and is **not atomic** +(funding, delivery, and release are separate txs). Offer it only as an opt-in +for off-spec trades, never the default. (Cross-chain HTLCs are out of scope: +CSV/relative timelocks are absent — `script.h:163` OP_NOP3 inert, +`interpreter.cpp:222` — so only absolute-CLTV HTLCs are buildable, which the NFT +sell flow does not need.) + +--- + +## (4) Offer encoding — file / clipboard / QR, no web service + +An offer is `{ header || rawHex }`. Header is a tiny, versioned, self-describing +blob so a wallet can render the card *before* trusting the hex: + +``` +ZNFTOFFER1 (magic) +tokenId : 32-byte hex +priceZat : varint +sellerPayout : address string +buyerNftAddr : address string (the offer is sealed to this; §2.4) +expiryHeight : uint32 +offerHex : the partial ALL|ANYONECANPAY tx hex +``` + +Serialized as base64 of a CBOR/compact-binary struct, prefixed `znftoffer:` for +URI/clipboard handling. Transport options (all offline, none a server): +- **File** `*.znftoffer` — email/airdrop/USB; the GUI registers the extension. +- **Clipboard** — "Copy offer" / "Paste offer" buttons. +- **QR** — the base64 string in a QR for phone-to-desktop; chunked if > QR + capacity (multi-frame animated QR, standard). +- **Shielded memo (optional, private negotiation only)** — the offer (or just + a pointer) can ride a 512-byte Sapling memo via the ZDC1 channel + (`src/datachannel/zdc.{h,cpp}`, currently not yet RPC-wired) so price + discovery and the address handshake stay private; **settlement is still the + public tx of §2.** This is mechanism C used correctly (private path, public + atomic settle) — never as a private settlement, which is provably impossible + (ONCHAIN_TRADES.md §4: shielded notes carry no script, binding sig is + single-party whole-tx). + +The wallet keeps a **local** offer store (sent + received) for `nft_listoffers`. +There is deliberately no central order book; a community relay could gossip +offer blobs, but it is untrusted plumbing — every wallet re-verifies via +`nft_verifyoffer` and the blob can never move funds on its own. + +--- + +## (5) GUI flow — do-not-make-me-think + +**Sell (on an owned NFT card):** +1. "Sell" → sheet: *Price in ZCL* (one field), *Expires in [7 days ▾]*. +2. (If selling to a known buyer) paste/scan the buyer's "request to buy" code; + else "Create open listing" (uses the §2.4 alt re-sign flow). +3. Tap **List** → wallet calls `nft_makeoffer`, locks the NFT dust, produces the + offer blob. Shows: "Offer ready — Copy / Save / Show QR. Expires in 7d." +4. The card shows a "Listed" badge + a **Cancel** button (→ `nft_canceloffer`, + confirmed as "This voids the listing and frees your NFT"). + +**Buy (from an offer blob):** +1. "Buy an NFT" → Paste / Open file / Scan QR. +2. Wallet auto-runs `nft_verifyoffer`. Card renders: image (from the ZSLP + document hash via ContentEngine), name, **price**, **"Expires in 6d,"** and a + green check (verify passed) or amber warning (with the reason). +3. Confirmation sheet: *You pay **P ZCL** (+ ~fee F). You receive: .* + plus the **honest privacy line**: *"This trade settles publicly on-chain — + price and addresses are visible. Only negotiation can be private."* +4. Tap **Buy** → wallet (a) creates a pre-sized funding UTXO (§2.5) silently, + (b) calls `nft_takeoffer`, (c) shows a spinner → **"NFT received"** when the + indexer credits it. No mention of vout indices, ANYONECANPAY, or templates. + +Reuses existing GUI infra: `doRPCWithDefaultErrorHandling` (rpc.cpp), the +ContentEngine/nftImgCache image pipeline, `Settings::getExplorerTxURL` for the +settlement-tx link, and the L0/L1 offscreen harness for tests. Every sell RPC is +new and must get tst_logic + tst_widget coverage (no test exists today — GAP). + +--- + +## (6) RPC API + +All non-consensus tooling; each emits a **standard transparent tx** old nodes +relay+mine. Each new RPC needs an entry in `src/rpc/client.cpp` arg-conversion +(numeric/object params) or args arrive as strings — currently only `zslp_*` have +entries (`client.cpp:138-145`), none for sell. + +- **`nft_makeoffer { tokenId, priceZat, payoutAddr?, buyerNftAddr, expiryHeight? }` + → `{ offerBlob, offerId, nftOutpoint }`** + Finds the live NFT UTXO (**must be CONFIRMED** — the self-validate below reads the + confirmed indexer store; refuse if still confirming, §2.3 step 1); hand-builds the + §2.2 template (OP_RETURN SEND@0, buyer dust@1 at the fee-rate-derived dust floor + `D` sealed to `buyerNftAddr`, payout@2); sets `nExpiryHeight`; signs **only vin[0]** + with `ALL|ANYONECANPAY` — a single-input `ALL|ANYONECANPAY` sign over the complete + 3-output template returns **`complete:true`** (the seller's only input is fully + signed; "complete" does not mean broadcast-ready — there are no funding inputs yet); + locks `nftOutpoint`; returns the base64 offer blob. Re-runs `WouldBeValid` on the + partial before returning. (`payoutAddr` defaults to a fresh wallet address; + `expiryHeight` defaults to tip + ~7d of blocks.) + +- **`nft_verifyoffer { offerBlob }` + → `{ ok, tokenId, priceZat, payoutAddr, buyerNftAddr, expiryHeight, reasons[] }`** + Mandatory safety check. Decodes; confirms `vout[0]` is a SEND of `tokenId` + crediting `vout[1]`; confirms `vin[0]` is the **live** token UTXO (via + `zslp_gettoken` + unspent check); confirms `vout[2]` price; confirms not + expired and NFT not already spent; runs ZSLP `WouldBeValid`. Surfaces every + failure reason. Read-only — never signs or broadcasts. + +- **`nft_takeoffer { offerBlob, fundingInputs?, changeAddr? }` + → `{ txid }`** + Calls `nft_verifyoffer` first (refuses if not ok). Selects exact funding + inputs (§2.5; if none given, auto pre-size). **REQUIRED anti-burn: every funding + input the buyer appends MUST be filtered through `ZSLPIsProtectedTokenOutpoint` + and rejected if it is a ZSLP token/baton UTXO** — otherwise `ApplyTransaction` + burns any token UTXO the buyer accidentally funds with (the SEND only declares the + seller's NFT). Appends `vin[1..]` only (no new outputs — the buyer touches no + output; `vout[1]` is sealed to the buyer's address in the seller-signed set, §2.3 + step 4); signs buyer inputs `ALL|ANYONECANPAY`; `CombineSignatures` merges the + seller's `vin[0]`; final self-validate (real parse + `WouldBeValid`); + `sendrawtransaction`. (`changeAddr` is used only by the optional pre-size prep tx, + whose own coin selection must also exclude protected outpoints; never a swap + output.) + +- **`nft_listoffers { mine? }` → `[ { offerId, tokenId, priceZat, expiryHeight, status } ]`** + Reads the local offer store (sent and received). Status: open / filled / + expired / canceled, recomputed against the live UTXO set. + +- **`nft_canceloffer { offerId }` → `{ txid }`** + Self-spends the NFT UTXO (a 1-output ZSLP SEND to a fresh own address), + invalidating any offer that referenced it; unlocks the outpoint. + +- **`nft_requestbuy { offerId? | tokenId }` → `{ buyerNftAddr, requestBlob }`** + Produces the buyer's fresh receive address + a small request blob for the + §2.4 buyer-address handshake (so the seller can seal a buyer-specific offer). + Optional convenience; the handshake can also be plain address copy/paste. + +*(reused as-is)* `zslp_gettoken`, `zslp_listmytokens`, `zslp_listtransfers` +(ownership/provenance), `decoderawtransaction`, `signrawtransaction`, +`sendrawtransaction`. The only genuinely new daemon plumbing is (a) the OP_RETURN +SEND carrier — already present as the ZSLP SEND encoder used by +`BuildAndCommitZSLP`, so reuse it; `createrawtransaction` still can't emit +OP_RETURN (`rawtransaction.cpp:554-571`) so the offer builder assembles `vout[0]` +directly — and (b) the offer-blob (de)serializer + local store. + +--- + +## (7) Attack surface and mitigations + +| Attack | What it tries | Mitigation | +|---|---|---| +| **Fake / forged offer** (vin[0] isn't the live NFT, or SEND malformed) | Get the buyer to pay for an NFT they won't receive | `nft_verifyoffer` is **mandatory** and re-runs the real indexer parse + `WouldBeValid` (`zslpstore.cpp:703-748`) + a live-UTXO check on `vin[0]` *before the buyer signs*. A forged SEND credits the buyer nothing; verify catches it pre-payment. | +| **Price tampering** | Buyer (or relayer) lowers `vout[2]`, or seller raises it after posting | Impossible without breaking the seller's `ALL` sig: `vout[2]` value+address are in `hashOutputs` (`interpreter.cpp:1087-1089`). Any edit ⇒ `VerifyScript` fails (`rawtransaction.cpp:976`); tx is invalid. | +| **NFT redirect** | Buyer makes `vout[1]` pay someone else / themselves at a different addr | Same: `vout[1]` is in the seller's `ALL`-signed output set. The offer is sealed to `buyerNftAddr` (§2.4); editing it invalidates `vin[0]`. | +| **Adding a buyer change output** | Buyer keeps the overpay | Any extra output breaks `ALL`. By design there is no change output; the exact-input selector (§2.5) or honest-overshoot disclosure handles value. | +| **Front-running / offer theft** | A watcher sees the broadcast fill and races a competing tx | The fill spends the seller's specific NFT UTXO (`vin[0]`); a racer would need that same prevout (can't — only the seller signed it) so cannot steal the NFT. A racer *could* try to be the one who fills a **public open** offer (§2.4 alt) first; mitigate by sealing offers to a specific `buyerNftAddr` (the chosen path), which makes the offer fillable only by that buyer. | +| **Double-spend of the NFT before sale** | Seller spends/sells the NFT elsewhere while an offer is live | (a) The wallet **locks** the NFT outpoint while an offer is live; (b) if the seller spends it anyway (e.g. another wallet), the offer's `vin[0]` becomes a spent prevout and the fill fails "already spent" (`rawtransaction.cpp:956-958`); the buyer loses nothing (they never paid). Re-verify at fill time closes the TOCTOU window. | +| **Replay of an old offer** | Re-broadcast a stale offer to re-trigger a sale | The offer's `vin[0]` is a specific UTXO; once filled it is spent and cannot be respent (consensus). `nExpiryHeight` also bounds the window. A second broadcast is a double-spend that consensus rejects. | +| **Free-option / stale-price griefing** | Buyer sits on a long-lived offer as a free option while price moves | Keep `nExpiryHeight` tight relative to volatility; seller can `nft_canceloffer` anytime (cheap self-spend) (§2.6). | +| **Accidental NFT burn (coin selection)** | Wallet spends the NFT dust as ordinary fee/change | Anti-burn already excludes token UTXOs from selection (`fExcludeZSLPTokens` / `ZSLPIsProtectedTokenOutpoint`, `zslpwallet.cpp:100-135`); the offer additionally `LockCoin`s it. | +| **Buyer underpays via input games** | Buyer adds inputs that don't cover payout | Consensus: a tx whose outputs exceed inputs is invalid (it would have negative fee) — `sendrawtransaction` rejects it. Plus `nft_takeoffer` computes funding to cover `P + D + fee`. | +| **Malleated buyer scriptSig** | Third party mauls the broadcast tx | The settlement is a single broadcast; standard malleability concerns apply but cannot change outputs (seller `ALL` sig). Buyer can re-broadcast from their own copy if needed. | +| **Indexer divergence** | A node runs different ZSLP rules and disagrees on ownership | This is the residual trust-minimized assumption. `WouldBeValid`/`ApplyTransaction` are the single shared rule set (`zslpstore.cpp`), pinned by the R-VECTORS gtest corpus; divergence is a node bug, not a protocol cheat. Promoting ZSLP to consensus is the only way to remove it (out of scope, ONCHAIN_TRADES.md §6). | + +--- + +## (8) What is NOT trustless — explicit + +1. **The token leg is trust-minimized, not trustless.** ZSLP is off-consensus. + The coin movement is atomic; the *meaning* "buyer now owns the NFT" holds + because every honest node recomputes the same indexer result and the + indexer now enforces UTXO conservation — but the chain itself does not + validate it. A forgery is detectable (and credits nobody), not chain-blocked. +2. **Negotiation is not enforced.** Price discovery and the buyer-address + handshake (§2.4) happen off-chain. Either party can walk away before + settlement. +3. **Privacy and atomicity are mutually exclusive** (proven in + ONCHAIN_TRADES.md §4: no script on z-notes, single-party whole-tx binding + sig). The shielded pool can carry **private negotiation**, but settlement is + a **public transparent tx** — price, both addresses, and the asset are + visible. Sequential "pay shielded, then deliver NFT" is plain counterparty + trust; we will never label it atomic. +4. **Escrow (mechanism B) is trusted** — the arbiter can collude. Opt-in only. + +--- + +## (9) Build status / honesty ledger + +| Piece | Status | +|---|---| +| Sighash `ALL\|ANYONECANPAY` masking | **present + tested** (`interpreter.cpp:1077-1089`; sighash/transaction tests) | +| `signrawtransaction` partial-sign + sighashtype + CombineSignatures | **present + tested** (`rawtransaction.cpp:911-979`; `rpc_tests.cpp`) | +| ZSLP SEND OP_RETURN encoder (reused for vout[0]) | **present** (`slp.h`, used by `BuildAndCommitZSLP`) | +| ZSLP UTXO-bound conservation + `WouldBeValid` self-validate seam | **present + tested** (`zslpstore.cpp:413-748`; ~103 ZSLP gtests) | +| Anti-burn coin-lock | **present** (`zslpwallet.cpp:100-135`; `wallet.cpp` `fExcludeZSLPTokens`) | +| `createrawtransaction` OP_RETURN | **absent** — builder hand-assembles vout[0] (GAP, but worked around with the existing encoder) | +| `nft_makeoffer/verifyoffer/takeoffer/listoffers/canceloffer/requestbuy` | **built** (`src/rpc/nftoffer.cpp:1180-1186`; regtest `qa/zslp/nft-sell-regtest.sh`, 6 gtests) | +| Offer blob (de)serializer + local store | **built** (`src/rpc/nftoffer.cpp`) | +| GUI sell/buy flow + L0/L1 tests | **unbuilt** (this design); reuses ContentEngine + RPC wrapper + explorer URL | +| Escrow (mechanism B) | **primitives present + tested; flow unbuilt** | + +**Bottom line:** A trust-minimized, coin-atomic NFT→ZCL sale is buildable today +on existing consensus with **`SIGHASH_ALL|ANYONECANPAY` over the fixed ZSLP +3-output template** — *not* the SINGLE|ANYONECANPAY design in ONCHAIN_TRADES.md, +which produces a tx the ZSLP indexer rejects (§0). Reconcile ONCHAIN_TRADES.md +to this doc before any implementation. diff --git a/doc/nft/ONCHAIN_TRADES.md b/doc/nft/ONCHAIN_TRADES.md new file mode 100644 index 00000000000..e796fc5a61a --- /dev/null +++ b/doc/nft/ONCHAIN_TRADES.md @@ -0,0 +1,265 @@ +> ## ⚠️ SUPERSEDED by `NFT_SELL_DESIGN.md` +> The `SIGHASH_SINGLE|ANYONECANPAY` design described below is **WRONG and +> funds-losing** — it pins the seller's payout to `vout[0]`, but `vout[0]` MUST be +> the ZSLP OP_RETURN, so the seller's price is never bound and the seller's NFT +> input is **burned** while the buyer pays nothing back. The correct, build-ready +> design is `doc/nft/NFT_SELL_DESIGN.md` (a fixed-template +> `SIGHASH_ALL|ANYONECANPAY` offer: OP_RETURN@vout[0] / buyer-NFT@vout[1] / +> seller-payout@vout[2]). **Do not implement anything from this file.** It is kept +> only for history. Several factual claims below (notably "ZSLP never reads +> `tx.vin` / only credits") are also **stale** — the live indexer debits spent +> token inputs and enforces conservation (`availIn == requiredOut`). See +> `NFT_SELL_DESIGN.md §0`. + +# On-chain NFT ↔ ZCL trades on ZClassic — what's possible and how + +**Status:** ⚠️ **SUPERSEDED — see banner above and `NFT_SELL_DESIGN.md`.** This was +a design doc, evidence-driven. Every capability claim below cites `file:line` from this repo (ZClassic = Zcash 2.x fork, Bitcoin Core 0.11–0.12 lineage + Overwinter + Sapling, Equihash PoW). Where a thing is **not** in the code, it is marked *unavailable* / *needs-consensus-change* — we do **not** assume Bitcoin/Zcash upstream behavior survived into ZClassic unless it was read in-tree. + +Legend for honesty about trust: +- **trustless** — settlement enforced by consensus; no party can cheat. +- **trust-minimized** — settlement of the *value* is consensus-enforced, but one layer (here: ZSLP token semantics) relies on every node running the same off-consensus indexer rules; a counterparty can be *caught* but the chain itself does not enforce it. +- **trusted** — depends on a third party (e.g. an escrow arbiter) behaving. + +--- + +## (1) Bottom line — what trades are possible + +| Trade | Verdict | Mechanism | Trust | +|---|---|---|---| +| **Transparent NFT ↔ transparent ZCL**, atomic single tx | **available** (script layer) | `SIGHASH_SINGLE\|ANYONECANPAY` signed-offer; seller signs only their NFT input + fixed payout, buyer funds the rest | **trust-minimized** — coin legs are consensus-atomic; ZSLP token attribution is indexer-convention, not consensus | +| **Cross-chain NFT ↔ external coin** | **partial** | P2SH HTLC: hashlock + **absolute** CLTV refund | trust-minimized (standard cross-chain free-option risk; **no** relative-timelock because CSV is absent) | +| **Escrowed / disputed sale** | **available** | 2-of-3 P2SH multisig with a human arbiter | **trusted** (the arbiter) | +| **Any leg shielded** (private ZCL or private NFT), atomic | **unavailable** | — | impossible in-codebase | +| **Fully-private programmable asset trade** | **needs-consensus-change** | ZSA-style shielded assets | not present | + +**The one honest sentence:** A trustless, atomic NFT↔ZCL trade is achievable **today** only in the **fully transparent** domain, as a single `SIGHASH_SINGLE|ANYONECANPAY` partial-signed transaction; the *coin movement* is consensus-atomic but the *NFT token semantics* are an off-consensus indexer convention (so it's trust-**minimized**, not chain-enforced), and **anything touching the shielded pool cannot be made atomic** because shielded notes carry no script and the Sapling binding signature is built by a single party over the whole transaction. + +### Why these verdicts (the five hypotheses, confirmed against source) + +1. **SINGLE|ANYONECANPAY masking is preserved on mainnet — CONFIRMED.** ZClassic runs the ZIP-243 (Sapling) sighash. In `SignatureHash()` (`src/script/interpreter.cpp:1069-1156`): `ANYONECANPAY` zeroes `hashPrevouts` (1077-1079) and `hashSequence` (1081-1085) so other parties may add inputs; `SINGLE` commits `hashOutputs` to **only** `vout[nIn]` (1087-1095); and the signed input's own prevout + scriptCode + amount + nSequence are always re-committed (1145-1153) so the signed input and its amount stay bound. `signrawtransaction` accepts `"SINGLE|ANYONECANPAY"` (`src/rpc/rawtransaction.cpp:920`), only signs SINGLE inputs that have a matching output index (965: `i < mergedTx.vout.size()`), merges counterparty sigs via `CombineSignatures` (970), and returns `{hex, complete}` even when incomplete (983-984). Overwinter+Sapling are active at mainnet height 476969 (`src/chainparams.cpp:107-110`). + +2. **P2SH + CLTV available; CSV absent — CONFIRMED.** `ConnectBlock` sets script flags `SCRIPT_VERIFY_P2SH | SCRIPT_VERIFY_CHECKLOCKTIMEVERIFY | SCRIPT_VERIFY_CHECKDATASIG_SIGOPS` unconditionally — no height gate (`src/main.cpp:2610`); DERSIG/BIP66 is always enforced (2612). `OP_CHECKLOCKTIMEVERIFY` is fully implemented and gated on its flag (`src/script/interpreter.cpp:180-219`). But `OP_NOP3` — the opcode CSV would occupy — is **not** aliased to `OP_CHECKSEQUENCEVERIFY` (`src/script/script.h:163`) and falls into the inert generic-NOP case (`src/script/interpreter.cpp:222-228`). A repo-wide grep for `CHECKSEQUENCEVERIFY` / `CheckSequence` / `SequenceLocks` / `SCRIPT_VERIFY_CHECKSEQUENCEVERIFY` returns **nothing**. ⇒ Absolute timelocks work; **relative** timelocks (BIP68/BIP112) do not exist. + +3. **Shielded notes have no script — CONFIRMED.** `SpendDescription` is `{cv, anchor, nullifier, rk, zkproof, spendAuthSig}` with **no** `CScript`/`scriptSig`/`scriptPubKey` field (`src/primitives/transaction.h:43-85`); spend authority is a zk proof plus a randomized-key signature only. ⇒ No hashlock, timelock, or covenant can be attached to a z-note. + +4. **Sapling binding sig is single-party over the whole tx — CONFIRMED.** `transaction_builder.cpp` issues exactly one `librustzcash_sapling_binding_sig(ctx, mtx.valueBalance, dataToBeSigned, …)` (295-299) from one accumulating proving context, and `dataToBeSigned = SignatureHash(scriptCode, mtx, NOT_AN_INPUT, SigHashType(), 0, …)` (281) — i.e. **SIGHASH_ALL over the whole transaction** (`SigHashType()` defaults to ALL). There is no API to merge a second party's spend description / value-commitment randomness. ⇒ Two mutually-distrusting parties cannot jointly assemble one shielded bundle, and any counterparty edit invalidates the binding/spend-auth signatures. + +5. **Fully-private atomic trades ⇒ needs-consensus-change — follows from (3)+(4).** No script on notes + single-party whole-tx binding sig means privacy and atomicity cannot be combined here. Programmable shielded assets (ZSA-style) are absent from the tree. + +### The ZSLP reality check (this changes the framing of "NFT") + +The task premise — *"owning the NFT = controlling that dust UTXO; transfer = a ZSLP SEND"* — is the **intended** model. **⚠️ STALE: the paragraphs below described an +early credit-only indexer that NO LONGER matches the code.** The live indexer is +now UTXO-bound: it **reads `tx.vin`**, **debits** spent token UTXOs, and **enforces +conservation** (`availIn == requiredOut`) — see `zslpstore.cpp:548-588` and the +`WouldBeValid` mirror at `zslpstore.cpp:703-748`, and `NFT_SELL_DESIGN.md §0`. The +write path (`zslp_genesis` / `zslp_send` / `zslp_mint`) and a wallet anti-burn +fence also now exist. The original (now-incorrect) claims, kept for history: + +- ~~The indexer reads only `tx.vout` … It never reads `tx.vin`.~~ **WRONG today** — + it reads `tx.vin` and debits spent token UTXOs. +- ~~`ApplySend` only credits the recipient … never debits a sender, never checks + the sender ever held the token, enforces no conservation.~~ **WRONG today** — a + SEND is valid only if `availIn >= requiredOut` for the token; surplus is token- + change, and a forged SEND that doesn't spend the live NFT credits nobody. +- ~~The RPC surface is read-only … there is no `zslp_send` / mint / transfer + builder.~~ **WRONG today** — `zslp_genesis`, `zslp_send`, and `zslp_mint` exist + (`src/rpc/zslp.cpp`) and `-zslpindex` defaults **ON** (`init.cpp:3272`). +- ZSLP is referenced **nowhere** in `src/main.cpp` or `src/consensus/` (still true — + ZSLP remains non-consensus). + +**Consequence (still true):** an "NFT" here is an off-consensus ledger fact every +honest node recomputes, not a script-custodied consensus asset. The atomic-swap +mechanism below makes the **ZCL payment leg** consensus-atomic and binds it to the +**spending of a specific dust UTXO**; what it cannot do by itself is make the +token's *meaning* consensus-truth. Because the indexer now enforces conservation, +the token leg is **trust-minimized** (a forgery credits nobody), not merely +advisory. + +--- + +## (2) The transparent atomic swap — `SIGHASH_SINGLE|ANYONECANPAY` signed-offer marketplace + +This is the recommended, available-today path. The NFT is modeled as **control of one specific transparent dust UTXO** that the ZSLP indexer currently attributes the token to. The seller publishes a *signed half-transaction* (an "offer"); any buyer can complete and broadcast it. + +### 2.1 How masking makes a one-sided signed offer possible + +Because `ANYONECANPAY` zeroes `hashPrevouts`/`hashSequence` (interpreter.cpp:1077-1085), a buyer may **append funding inputs** without invalidating the seller's signature. Because `SINGLE` commits to **only `vout[nIn]`** (interpreter.cpp:1090-1094), a buyer may **append additional outputs** (the NFT-recipient dust, change) after the one output the seller pinned. The seller's signed input still binds its own prevout + amount (1149-1151), so the seller's NFT UTXO cannot be swapped out and its value cannot be changed. **Net: the seller commits exactly two things — "I spend *this* NFT UTXO" and "I receive *this exact* payout at the same index" — and leaves everything else open.** + +### 2.2 Exact transaction layout + +`SIGHASH_SINGLE` pins the signed input at index *k* to the output at index *k*. So the seller's NFT input and the seller's payout output must share an index. Use this layout: + +``` +vin[0] = seller's NFT-bearing dust UTXO ← seller signs THIS, SINGLE|ANYONECANPAY +vin[1..]= buyer's funding inputs (appended by buyer) + +vout[0] = seller's ZCL payout (P2PKH to seller) ← pinned by SINGLE to vin[0]; the price +vout[1] = buyer's new NFT dust output (P2PKH) (appended by buyer; ZSLP credits the token here) +vout[2] = OP_RETURN ZSLP SEND message (appended by buyer/tooling) +vout[3] = buyer's change (appended by buyer) +``` + +Index alignment is the whole game: **seller input index == seller payout index == 0**. The OP_RETURN can sit at any index for *script* purposes, **but** the ZSLP SEND convention credits `outputQuantities[j]` to `vout[1+j]` (`zslpindexer.cpp:181-182`) — so the buyer's NFT dust must land at the vout index the SEND message names (here `vout[1]`, the first credited output). The marketplace tooling owns this alignment; users never see it. + +Two structural facts that the tooling must respect: +- **`createrawtransaction` cannot emit an OP_RETURN.** It only accepts valid address outputs via `GetScriptForDestination` (`src/rpc/rawtransaction.cpp:554-571`). The OP_RETURN/SEND carrier must be hand-assembled (see §7 / port `op_return_push.h`) or added by a thin new RPC. +- **Only one OP_RETURN per relayable tx**, ≤ 223 bytes (`TX_NULL_DATA` once: `src/script/standard.cpp:197`; `MAX_OP_RETURN_RELAY = 223`: `src/script/standard.h:34`). A ZSLP SEND fits easily. + +### 2.3 Who signs what — the offer → fill flow + +1. **Seller makes the offer.** Seller builds a skeleton with `vin[0]` = their NFT UTXO and `vout[0]` = a P2PKH paying *themselves* the asking price. Seller calls `signrawtransaction` with `sighashtype = "SINGLE|ANYONECANPAY"`, signing only that one input (the RPC signs only inputs whose coins/keys it has and skips the rest: `rawtransaction.cpp:953-966`). Result: partially-signed hex, `complete:false` (983-984). **This hex *is* the offer.** It is a self-contained, transferable, take-it-or-leave-it order: anyone who can read it can fill it, and no one can alter the seller's price or asset. + +2. **Offer published.** The hex (plus metadata: tokenId, price, sellerPayout index) is posted anywhere — a relay, a gossip topic, a file, the shielded memo channel (§5). No on-chain footprint until filled. + +3. **Buyer fills.** Buyer **hand-assembles** funding: appends their inputs (`vin[1..]`), appends `vout[1]` = NFT dust to themselves, appends the OP_RETURN SEND, appends change. Buyer signs **their** inputs with `SIGHASH_ALL` (the default). `CombineSignatures` (`rawtransaction.cpp:970`) merges the seller's pre-existing scriptSig with the buyer's. Buyer `sendrawtransaction`. + +4. **Settlement.** The tx either confirms wholly or not at all. The seller's NFT UTXO is consumed **iff** the seller's payout exists in the same tx, because both are co-committed under the seller's one signature. Atomic for the coin legs. + +5. **Indexer recognizes the transfer.** On block connect the ZSLP `ValidationInterface` observer parses the OP_RETURN SEND and credits the token to `vout[1]`'s address (`zslpindexer.cpp:174-187`) — i.e. the buyer. Buyer wallet now shows the NFT via `zslp_listmytokens`. + +### 2.4 Griefing, partial-fill, fee, and expiry considerations (and mitigations) + +- **Do NOT use `fundrawtransaction` on the buyer side.** It inserts change at a **random** vout position (`src/wallet/wallet.cpp:3679-3682`), which would shove the seller's pinned `vout[0]` out of index 0 and **break the SINGLE signature**. The buyer must **hand-place** outputs to keep the seller's payout at its committed index. (Stated explicitly because it is the #1 footgun.) +- **Offer lifetime is bounded, not infinite.** A raw tx carries `nExpiryHeight`. `createrawtransaction` accepts an `expiryheight` arg but rejects "expiring soon" (`nextBlockHeight + TX_EXPIRING_SOON_THRESHOLD`, where the threshold is 3: `src/main.h:71`, check at `rawtransaction.cpp:518`) and caps it below `TX_EXPIRY_HEIGHT_THRESHOLD = 500000000` (`src/consensus/consensus.h:31`, check at 514). The wallet's default delta is only ~20 blocks (`DEFAULT_PRE_BUTTERCUP_TX_EXPIRY_DELTA`, `src/main.h:68`; post-Buttercup scaled, 69) — far too short for a posted offer. **Mitigation:** the offer builder must set a deliberately distant `expiryheight` (e.g. weeks/months of blocks out) and the offer card must show the expiry. After it lapses the seller re-issues. There is no "never-expires" offer via this RPC. +- **Free-option / stale-price griefing.** A posted, long-lived signed offer is a free option to the buyer: the seller is committed at a fixed price until expiry while the market moves. **Mitigation:** keep expiry tight relative to volatility; let the seller cancel by *self-spending the NFT UTXO* (which invalidates the offer, since the offer's `vin[0]` is then a spent prevout — `signrawtransaction` would mark it "Input not found or already spent": `rawtransaction.cpp:956-958`). Cancellation costs one cheap self-send. +- **No partial fills.** An NFT is qty-1 indivisible (ZSLP NFT = decimals 0 / qty 1 / no baton: `src/zslp/slp.h:49-50`), so partial fill is not a concern for single NFTs. For fungible-token lots you would issue separate offers per lot size; SINGLE pins exactly one payout output, so a single offer is one all-or-nothing price. +- **Fee responsibility.** The buyer adds inputs and change, so the **buyer pays the fee** naturally (seller's input value all flows to the pinned payout; buyer's inputs cover payout + dust + fee + change). Tooling computes fee on the buyer side. +- **Accidental NFT burn.** The wallet has **zero ZSLP awareness** (no ZSLP references in `src/wallet/`), so ordinary coin-selection can spend an NFT-bearing dust UTXO as plain coin and the indexer would silently strand the token. **Mitigation:** the marketplace wallet must mark NFT dust outpoints as **locked / non-spendable for normal selection**, and only release them through the offer builder. +- **Token validity is buyer-verified, not chain-verified.** Because `ApplySend` never validates inputs, a malicious seller could publish a tx whose ZSLP SEND is malformed or whose `vin[0]` does not actually carry the live token. **Mitigation (mandatory):** before counter-signing, the buyer verifies via `zslp_gettoken` / `zslp_listtransfers` (+ a UTXO check) that `vin[0]` is the live token-bearing UTXO and that the SEND vout mapping is well-formed. A verifier RPC (§7) should do this so the UX is one click. + +--- + +## (3) Cross-chain + escrow — only the opcodes that audited as available + +### 3.1 HTLC (hashlock + **absolute** CLTV refund) — **partial / available with the CSV caveat** + +P2SH is mandatory and always-on (`src/main.cpp:2610`; `IsPayToScriptHash` BIP16 pattern at `src/script/script.cpp:230-234`). CLTV is implemented and consensus-active (interpreter.cpp:180-219; `CheckLockTime` at 1210-1244). Hashlock primitives are present (`OP_SHA256`, `OP_HASH160`, `OP_HASH256`, `OP_EQUALVERIFY` in `script.h`). So this redeem script works **wrapped in P2SH**: + +``` +OP_IF + OP_SHA256 OP_EQUALVERIFY + OP_CHECKSIG +OP_ELSE + OP_CHECKLOCKTIMEVERIFY OP_DROP + OP_CHECKSIG +OP_ENDIF +``` + +- **Claim branch:** recipient reveals `preimage`, satisfying the hashlock + their signature. +- **Refund branch:** after `refundLockHeight`, the funder reclaims. The refund spend must set tx `nLockTime >= refundLockHeight` and a non-final `nSequence` on the input (CLTV's anti-bypass requires the input be non-final; `CheckLockTime` at interpreter.cpp:1210-1244). `createrawtransaction` lets you set both `nLockTime` and per-input `nSequence` (`rawtransaction.cpp:504-509, 542-547`). + +**Hard caveat — no relative timelocks.** `OP_CHECKSEQUENCEVERIFY`/BIP112 is absent (§1, hypothesis 2). HTLC timeouts must be **absolute block heights**, not "N blocks after funding." This is workable for cross-chain atomic swaps (you pick concrete heights per chain) but rules out Lightning-style relative-locktime channels and any construction that needs a relative refund timer. + +**Trust level:** trust-minimized. The preimage reveal links the two legs across chains; standard cross-chain **free-option / premium risk** applies (the party who moves second has an option). Not perfectly trustless, but no third party. + +### 3.2 2-of-3 arbiter escrow — **available, but *trusted*** + +`TX_MULTISIG` template (`m OP_PUBKEYS n OP_CHECKMULTISIG`) and `GetScriptForMultisig` exist (`src/script/standard.cpp:53`), `OP_CHECKMULTISIG` supports up to 20 keys (interpreter.cpp:739-834), and 2-of-3 is relay-standard. Redeem script (P2SH-wrapped): + +``` +OP_2 OP_3 OP_CHECKMULTISIG +``` + +Either buyer+seller cooperate (no arbiter needed) or, on dispute, the **arbiter** co-signs with the honest party. **This is the right tool for human-mediated disputes, and it is explicitly *trusted***: the arbiter is a third party who can collude. It is not trustless; offer it as an opt-in for high-value or off-spec trades, never as the default. + +`OP_CHECKDATASIG`/`OP_CHECKDATASIGVERIFY` are also implemented (interpreter.cpp:692-737; opcodes `script.h:173-174`; flag in `main.cpp:2610`), enabling **oracle-gated** script branches (a branch that requires a signature over an external message). This is a bonus primitive — useful for oracle-resolved escrow conditions — but it introduces an oracle trust assumption and is out of scope for the core swap. + +--- + +## (4) The privacy wall — why any shielded leg breaks trustless atomicity + +There are **two independent, code-confirmed reasons** a shielded leg cannot be part of a trustless atomic swap. Either alone is fatal. + +**Reason A — shielded notes have no script.** `SpendDescription` = `{cv, anchor, nullifier, rk, zkproof, spendAuthSig}` (`src/primitives/transaction.h:43-85`); `OutputDescription` is `{cv, cm, ephemeralKey, encCiphertext, …, zkproof}`; `SaplingNote`/`SproutNote` are pure `{value, keys, randomness}` structs (`src/zcash/Note.hpp:25-51`). **No `CScript` anywhere.** There is no place to attach a hashlock, a timelock, or a covenant to a z-note — so you cannot build an HTLC or a SINGLE-pinned payout on the shielded side at all. + +**Reason B — the binding signature is single-party over the whole tx.** The Sapling binding key is the sum of per-spend/per-output value-commitment randomness, all generated **locally inside one builder** (`transaction_builder.cpp:21` generates each `alpha`/`rcv`; the one ctx accumulates them, 187/204/245). The bundle is closed by exactly one `librustzcash_sapling_binding_sig(ctx, mtx.valueBalance, dataToBeSigned, …)` call (295-299), and `dataToBeSigned` is `SignatureHash(…, NOT_AN_INPUT, SigHashType(), …)` = **SIGHASH_ALL over the entire transaction** (281). There is **no API to import a counterparty's spend description or randomness**, and **no `ANYONECANPAY`/`SINGLE` analogue for shielded** sigs — the whole shielded bundle commits to the whole tx, so *any* edit by a counterparty invalidates it. The Sprout/joinsplit path is likewise single-party (one ephemeral ed25519 key signs the whole joinsplit: `src/wallet/asyncrpcoperation_sendmany.cpp:543`). + +Combined: a single party must build the entire shielded bundle, signing over the entire transaction — which is the exact opposite of the two-party "each signs only their part" pattern that makes the transparent SINGLE|ANYONECANPAY swap work. **Therefore a mixed trade (e.g. transparent NFT for shielded ZCL) cannot be made atomic in a single transaction**, and no HTLC/covenant can live on a z-note. Privacy + atomicity are mutually exclusive in this codebase. + +**Honest downgrade path (not atomic):** you *can* do a **sequential, trust-required** flow — buyer sends shielded ZCL, then seller sends the NFT — but that is plain counterparty trust (whoever moves first can be cheated), not an atomic swap. Don't dress it up as trustless. + +--- + +## (5) The hybrid — private negotiation, public atomic settlement + +You cannot make settlement private and atomic. But you **can** keep *negotiation and price discovery* private while settlement is public and atomic. This is the realistic "privacy-respecting marketplace." + +**What can be private (off-chain or shielded-memo channel):** +- Discovery, bids, counter-bids, and the eventual price. The shielded **memo field** (the 512-byte encrypted memo carried in an `OutputDescription`'s `encCiphertext`) is a sender→recipient private channel: a buyer can send a tiny shielded note to the seller's z-address carrying an encrypted "I'll take offer X at price P" memo. This reveals only that *some* shielded note moved (standard Sapling metadata), not the parties' identities or the price to the public. +- The matching of buyer↔seller. Off-chain, the parties can exchange the **partial offer hex** privately (over the memo channel, a private relay, or any side channel). Until broadcast, there is **no on-chain trace**. + +**What is unavoidably public at settlement:** +- The settlement transaction is **fully transparent** (the swap is transparent-only, §2). On-chain you will see: the seller's NFT UTXO spent, the seller's payout address + amount (the price), the buyer's funding inputs/change, the buyer's new NFT dust, and the ZSLP SEND OP_RETURN. **Price, both parties' transparent addresses, and the asset are public.** +- You **cannot** hide the price by routing the payout through the shielded pool *in the same tx*, because that shielded leg would re-break atomicity (§4). + +**The realistic hybrid recipe:** negotiate privately (memo channel / off-chain), agree on a price, exchange the signed partial offer privately, then broadcast one transparent atomic settlement. **Private price discovery + private matchmaking + public, trust-minimized atomic settlement.** Be explicit to users that the *final trade* (price, addresses, asset) is public; only the *path to it* was private. + +--- + +## (6) What would need a consensus change (and why it's out of scope) + +To get a **fully-private, atomic** NFT↔ZCL trade you need **programmable shielded assets (ZSA-style)** at the consensus layer. Concretely, the codebase would need: + +1. **A predicate/script (or asset-type + spend condition) on shielded notes** — to attach a hashlock/timelock/covenant to a private note (Reason A, §4 — `SpendDescription` has no script today). +2. **A multi-party shielded bundle assembly + a partial/maskable shielded sighash** — so two distrusting parties can each contribute spends/outputs and the binding signature can be assembled jointly (Reason B, §4 — today it's one `librustzcash_sapling_binding_sig` call over a whole-tx SIGHASH_ALL message by a single party). +3. **A consensus-enforced shielded asset type** (ZSA) so the NFT itself is a private, conserved, chain-validated asset rather than a transparent dust UTXO annotated by an off-consensus indexer. + +Each requires new circuits, new transaction fields, a network upgrade with an activation height (the `vUpgrades` machinery exists — `src/chainparams.cpp:107-117` — but defining a new upgrade is a hard fork), and an audited Rust crypto backend. **Out of scope for this design**, which is deliberately *no-consensus-change, ship-today*. Separately, even for the transparent path, making the **NFT leg trust-minimized rather than trusted** would benefit from a **UTXO-bound ZSLP rule** (debit-by-spent-input, conservation checked) — that can be done as a stricter non-consensus indexer rule first, and only later promoted to consensus. + +--- + +## (7) API sketch + native UX + +### 7.1 RPCs — thin wrappers over existing primitives (DRY: reuse `createrawtransaction` + `signrawtransaction` + `sendrawtransaction`) + +The only genuinely new plumbing is (a) an OP_RETURN/data carrier (createrawtransaction lacks one — `rawtransaction.cpp:554-571`) and (b) a ZSLP conservation **verifier** (the indexer doesn't check — `zslpstore.cpp:348-390`). Everything else composes existing RPCs. + +- **`nft_makeoffer { tokenId, nftOutpoint, priceZat, payoutAddr, expiryHeight }` → `{ offerHex, offerId }`** + Builds the skeleton (`vin[0]` = `nftOutpoint`, `vout[0]` = P2PKH `payoutAddr` for `priceZat`), sets `nExpiryHeight = expiryHeight` (validated: ≥ `tip+3`, < 5e8 — §2.4), then internally calls `signrawtransaction(hex, [prevtx], [], "SINGLE|ANYONECANPAY")` to sign only `vin[0]`. Returns the partial hex. Locks `nftOutpoint` against the wallet's coin-selection (anti-burn, §2.4). + +- **`nft_takeoffer { offerHex, fundingInputs?, changeAddr, buyerNftAddr }` → `{ txid }`** + Verifies the offer (calls `nft_verifyoffer` first), **hand-assembles** funding (appends inputs, `vout[1]` = dust to `buyerNftAddr`, the OP_RETURN ZSLP SEND crediting `vout[1]`, change to `changeAddr` — **never** `fundrawtransaction`, §2.4), signs the buyer's inputs SIGHASH_ALL via `signrawtransaction`, then `sendrawtransaction`. Returns txid. + +- **`nft_verifyoffer { offerHex }` → `{ ok, tokenId, price, payoutAddr, expiry, reasons[] }`** + The mandatory safety RPC: confirms `vin[0]` is the live token-bearing UTXO via `zslp_gettoken`/`zslp_listtransfers` + UTXO existence, that the SINGLE-pinned `vout[0]` price matches, that the SEND mapping will credit the buyer's intended output, and that the offer hasn't expired / the NFT UTXO isn't already spent. Surfaces *why* if not ok. + +- **`nft_listoffers` / `nft_canceloffer { offerId }`** + `listoffers` reads the local/relayed offer store. `canceloffer` self-spends the NFT dust UTXO (one cheap tx) to invalidate any outstanding offer referencing it (§2.4). + +- *(reused as-is)* `zslp_gettoken`, `zslp_listmytokens`, `zslp_listtransfers` for ownership/provenance display; `decoderawtransaction` for offer inspection. + +All of the above are non-consensus tooling: they emit standard transparent transactions and never touch validation, PoW, or the shielded builder. + +### 7.2 Native UX — don't-make-me-think + +- **"List for sale"** button on any owned NFT card → modal: *price in ZCL*, *expires in [7 days ▾]*. One tap calls `nft_makeoffer`, locks the dust UTXO, and shows the offer card. No mention of SINGLE, vout indices, or expiry heights. +- **Offer cards** (a marketplace grid): NFT image (the image-hash is the ZSLP document hash), name, **price**, **"Expires in 6d"**, and a green **"Buy"** button. A small shield/check badge = `nft_verifyoffer` passed; an amber badge = "verify before buying." +- **"Buy"** → confirmation sheet: *You pay X ZCL + ~fee. You receive: .* One tap calls `nft_takeoffer` (which verifies first, then funds + signs + broadcasts). Spinner → "NFT received" when the indexer credits it. +- **Honest privacy line** in the buy/sell sheets: *"This trade settles publicly on-chain (price and addresses are visible). Negotiation can be private."* — so we never imply a transparent swap is private. +- **Cancel** on your own offer card → `nft_canceloffer`, confirmed as "This frees the NFT and voids the listing." +- **Anti-burn guardrail** in the wallet: NFT dust UTXOs are visually flagged and excluded from ordinary send coin-selection; sending one requires going through the marketplace flow. + +--- + +## Appendix — verdict per capability (with evidence) + +| Capability | Verdict | Key evidence | +|---|---|---| +| P2SH / BIP16 | available (consensus, always-on) | `main.cpp:2610`; `script.cpp:230-234` | +| OP_CHECKLOCKTIMEVERIFY / BIP65 | available (consensus, always-on) | `interpreter.cpp:180-219`, `1210-1244`; `main.cpp:2610` | +| OP_CHECKSEQUENCEVERIFY / BIP112 (CSV) | **unavailable** | `script.h:163` (no alias); `interpreter.cpp:222-228` (inert NOP); grep returns nothing | +| Hashlocks (SHA256/HASH160/EQUALVERIFY) | available | `interpreter.cpp:634-658, 499-525` | +| OP_CHECKMULTISIG (≤20 keys, 2-of-3 std) | available | `interpreter.cpp:739-834`; `standard.cpp:53` | +| OP_CHECKDATASIG (oracle branches) | available | `interpreter.cpp:692-737`; `script.h:173-174` | +| Disabled opcodes (CAT/SUBSTR/MUL/… /CODESEPARATOR) | hard-fail | `interpreter.cpp:118-134` | +| ZIP-243 SINGLE\|ANYONECANPAY masking | available (live mainnet) | `interpreter.cpp:1069-1156`; `chainparams.cpp:107-110` | +| signrawtransaction partial-sign + sighashtype | available | `rawtransaction.cpp:911-927, 953-990` | +| createrawtransaction OP_RETURN carrier | **unavailable** (needs new arg/RPC) | `rawtransaction.cpp:554-571` | +| createrawtransaction locktime + expiryheight | available (expiry bounded) | `rawtransaction.cpp:504-527`; `consensus.h:31`; `main.h:71` | +| Script on shielded notes | **unavailable** | `transaction.h:43-85`; `Note.hpp:25-51` | +| Multi-party shielded bundle assembly | **unavailable** | `transaction_builder.cpp:281, 295-299` | +| ZSLP token = consensus-enforced asset | **no** (off-consensus, credit-only) | `zslpstore.cpp:348-390`; `zslpindexer.cpp:160-187`; no refs in `main.cpp`/`consensus/` | +| ZSLP transfer builder RPC | **unavailable** (read-only RPCs) | `rpc/zslp.cpp` | +| Wallet ZSLP/NFT-burn awareness | **unavailable** (anti-burn must be added) | no ZSLP refs in `src/wallet/` | diff --git a/doc/nft/PRIVACY.md b/doc/nft/PRIVACY.md new file mode 100644 index 00000000000..a96e2fe4290 --- /dev/null +++ b/doc/nft/PRIVACY.md @@ -0,0 +1,542 @@ +# ZClassic Private NFTs & Shielded Data Channel + +> **READ FIRST — honesty banner (2026-06-06).** This doc predates the build and oversells in places. As-built truth: (1) **NFT ownership is ALWAYS transparent/public** — the token rides a transparent dust UTXO, so who holds an NFT is on-chain and visible. "Private" here means only the data-channel **file contents** are confidential (encrypted), and even those are stored **permanently as public ciphertext on every full node**. There is **no "shielded ownership."** (2) The single-tx file cap is **40000 bytes** (not 64 KB). (3) **"Seal-then-reveal", `z_revealkey`, and `zslp_mint_private` are NOT built.** (4) Cross-wallet/cross-node receive does **not** work yet (the per-transfer key is sender-session-local — see task #117). Authoritative as-built contract: `NATIVE_NFT_GUIDE.md §3.3`; whole-feature status: `NFT_FINAL_REVIEW.md`. + +**Status:** Codec BUILT + TESTED **and compiled into the daemon** (`src/Makefile.am:247,294`; +25 daemon gtests in `test_zdc.cpp`). The daemon RPCs `z_senddatafile` / `z_listdatatransfers` +/ `z_getdatatransfer` are **BUILT** (`src/rpc/datachannel.cpp:597-599`), **default-OFF** behind +`-datachannel` (the current daemon gate; `-experimentalfeatures` is NOT required by the as-built code — adding that second gate is a logged hardening option). The GUI binary-safe read path and native UX are still +DESIGNED here, not yet wired. Ships **default-OFF / opt-in** on its own experimental track, +decoupled from the beta release pipeline. *(NOTE: the as-built RPC surface differs from the +original design in §3 — there is no `z_revealkey`/`keymode` and no single `zslp_mint_private` +RPC; the as-built daemon always includes the KEY frame and returns the per-transfer key to the +sender, and the as-built per-file cap is 40000 bytes. The authoritative as-built contract is +`NATIVE_NFT_GUIDE.md §3.3`. §3 below is kept as the original design.)* + +**Rides UNCHANGED consensus.** Every byte below sits on top of the Sapling +shielded pool and 512-byte encrypted memos that ZClassic consensus *already* +enforces. No soft fork, no hard fork, no new opcode, no builder change. The send +path already accepts a raw binary memo as hex (`get_memo_from_hex_string`, +`src/wallet/asyncrpcoperation_sendmany.cpp:1321`); we only choose what bytes go +in it. + +This is the single canonical design. It supersedes and consolidates the four +working notes (`PRIVACY_STACK.md`, `ZDC1_CODEC_SPEC.md`, `PRIVACY_UX.md`, and the +API-surface design). Where those differ, **this document is authoritative**. + +--- + +## Table of contents + +1. [What privacy tech we enable — the 4-layer stack](#1-what-privacy-tech-we-enable) +2. [The ZDC1 codec spec](#2-the-zdc1-codec-spec) +3. [API — codec, daemon RPCs, GUI binary-safe memo path](#3-api) +4. [Native privacy UX](#4-native-privacy-ux) +5. [Honest privacy limits](#5-honest-privacy-limits) +6. [Build/test plan + implementation order](#6-buildtest-plan--implementation-order) + +--- + +## 1. What privacy tech we enable + +### 1.1 Plain language + +ZClassic already moves money privately: a shielded (Sapling) transaction hides +**who sent**, **who received**, and **how much**, and it can carry a small +**private note** (a "memo") that **only the recipient can read**. + +This feature turns that private-note capability into a general-purpose +**confidential delivery system**: + +- **Private messages** — send a note up to ~4 KB in one transaction; only the + recipient can decrypt it. +- **Private files** — send a file up to ~64 KB on-chain; bigger files go + off-chain with a tiny on-chain encrypted fingerprint so the recipient can + prove they got the right bytes. +- **Seal now, reveal later** — publish the encrypted content today and send the + decryption key whenever you choose. Until you reveal the key, **even the + recipient cannot open it**. +- **Private NFTs** — a publicly-trackable 1-of-1 ZSLP token whose *image/asset + bytes are encrypted and delivered privately*. Anyone can see the token exists + and changes hands; only a key-holder can see what it actually is. + +Honest framing, in every screen: **confidential, not undetectable.** Observers +can still see *that* a private transfer happened, roughly *when*, and roughly +*how big*. And it is **permanent** — every full node stores every memo forever. + +### 1.2 The 4-layer stack (technical) + +The privacy is a stack. The bottom two layers are consensus-enforced and free; +the top two are the new codec (`src/datachannel/zdc.{h,cpp}`). + +| Layer | What | Where | Enforced by | +|------|------|-------|-------------| +| **L0** | Sapling shielded pool — zk-SNARKs hide sender/recipient/amount | consensus | ZClassic consensus (NOT this code) | +| **L1** | Per-output 512-byte memo, ChaCha20-Poly1305-encrypted to recipient ivk | consensus | ZClassic consensus (NOT this code) | +| **L2** | **ZDC1 transport** — framing + chunking + reassembly across many memos | `zdc.{h,cpp}` | wallet/app policy | +| **L3** | **ZDC1 application AEAD** — independent ChaCha20-Poly1305 IETF under a per-transfer 32-byte key, per-chunk Poly1305 tag | `zdc.{h,cpp}` | wallet/app policy | + +**Why two encryption layers (L1 and L3) and not one?** Three concrete wins: + +1. **Seal-then-reveal.** L1 always encrypts to the recipient's ivk, so an ivk + holder could open the memo the instant it lands. L3 adds an *independent* + per-transfer key so the content stays sealed until the **KEY frame** (or an + out-of-band key) arrives — even from the recipient. +2. **Layer isolation.** A break or key-compromise in one layer does not cascade + into the other. +3. **One ciphertext, N recipients.** The same L3 ciphertext can be opened by + several recipients, each handed the key via a separate KEY frame / channel, + without re-encrypting the payload. + +### 1.3 User capabilities (mapped to the stack) + +- **Private message ≤ 4 KB, 1 tx** — L0+L1 carry the frames; L2 chunks; L3 seals. +- **Private file ≤ 64 KB on-chain** (responsible default cap; structural ceiling + ~29 MB but DO NOT approach it — see §5). Larger = off-chain ciphertext + an + on-chain encrypted fingerprint (`ciphertext_fingerprint`) per + `CONTENT_MODEL.md`. +- **Seal-then-reveal-key** — encode with `include_key_frame=false`; broadcast or + hand over the KEY frame later. +- **Private NFT** — encrypted asset bytes over ZDC1; ownership shielded; the + public ZSLP 1-of-1 token commits to the private bytes via + `document_hash = ciphertext_fingerprint(frames)` (verify-before-decrypt). +- **Selective disclosure** — share the Sapling **incoming viewing key** to let a + third party prove contents/receipt **without spend authority**. + +--- + +## 2. The ZDC1 codec spec + +The codec is **pure logic**: libsodium + C++11 standard library only. No daemon, +no chain, no Qt, no globals, no exceptions across the boundary (only compile-time +`static_assert`s against libsodium's constants). It compiles standalone for unit +tests *and* into the daemon (`-std=c++11 -noext`, `-lsodium` already linked; +`configure.ac:68,783`). + +> **One frame = one Sapling memo = 512 bytes.** Chain many tiny (0.00001 ZCL) +> shielded outputs, one frame each, to move an arbitrary encrypted byte stream. + +### 2.1 Frame layout (frozen wire format) + +512 bytes total = **32-byte header + 480-byte payload**. All multi-byte fields +are **big-endian**. + +| Off | Size | Field | Notes | +|----:|-----:|-------|-------| +| 0 | 4 | `magic` | `0x5A444331` = "ZDC1" | +| 4 | 1 | `version` | `0x01` | +| 5 | 1 | `type` | `START=0x01`, `DATA=0x02`, `END=0x03`, `KEY=0x04` | +| 6 | 1 | `flags` | bit0 `FL_CIPHERTEXT` = payload is L3 ciphertext | +| 7 | 1 | `cipher_id` | `0=NONE`, `1=ChaCha20-Poly1305-IETF` | +| 8 | 8 | `transfer_id` | u64; random or = ZSLP token_id | +| 16 | 4 | `seq` | u32; DATA index 0..N-1; END seq = chunk_count | +| 20 | 4 | `chunk_count` | u32; authoritative count of DATA frames | +| 24 | 2 | `payload_len` | u16, 0..480 | +| 26 | 4 | `crc32` | over the full 480-byte payload field — **TRANSPORT ONLY, NOT security** | +| 30 | 2 | `reserved` | must be 0 | + +Constants (from `zdc.h`, asserted against sodium in `zdc.cpp`): +`MEMO_SIZE=512`, `HEADER_SIZE=32`, `FRAME_PAYLOAD=480`, +`AEAD_KEYBYTES=32`, `AEAD_NPUBBYTES=12`, `AEAD_ABYTES=16` (tag), +`CONTENT_HASH_LEN=32`. + +**Usable plaintext per DATA frame** = `480 − 16 (tag)` = **464 bytes** +(`DATA_PLAINTEXT_PER_FRAME`). + +### 2.2 Frame types & ordering + +Order: `START, DATA*N, END[, KEY]`. + +- **START** (`type=0x01`, `seq=0`): encrypted `TransferMeta` blob + `{u64 total_plaintext_size, u32 chunk_count, u16 filename_len, filename, + u16 content_type_len, content_type}` — must fit in 464 B. +- **DATA** (`type=0x02`, `seq=i`): one AEAD-sealed plaintext chunk, ≤464 B. + Each chunk is sealed **independently** (no chaining) so out-of-order, + partial, and per-chunk verification all work. +- **END** (`type=0x03`, `seq=chunk_count`): encrypted **SHA-256 of the full + plaintext** — verified after reassembly (binds the stream). +- **KEY** (`type=0x04`, `seq=SEQ_KEY=0xFFFFFFFF`): the **raw 32-byte key**, + `cipher_id=NONE`. *Not* L3-encrypted — its on-chain confidentiality is the L1 + Sapling-to-ivk encryption. A distinct type makes seal-then-reveal first-class + and the key matters only at `assemble()` time. + +`chunk_count = ceil(plaintext_len / 464)`. A zero-length payload is valid (0 +DATA frames; START+END only). + +### 2.3 AEAD, key, nonce, AAD — the security core + +**This is the part that "tested" matters for.** + +**Cipher:** `crypto_aead_chacha20poly1305_ietf` (combined mode, +ciphertext = plaintext ‖ 16-byte tag). + +**Key:** 32 bytes from `randombytes_buf()`, **fresh per transfer**, never reused +across transfers, never logged. + +**Nonce (12 B) — the non-negotiable.** +``` +nonce = transfer_id(8 BE) ‖ nonce_ctr(4 BE) +``` +`nonce_ctr` is a **per-frame counter unique within the transfer**, and it is +**NOT the wire `seq`**: + +| frame | nonce_ctr | +|-------|-----------| +| DATA chunk *i* | `i` (0 .. chunk_count-1, ≤ 65534) | +| START | `0xFFFFFFFF` (`NONCE_CTR_START`) | +| END | `0xFFFFFFFE` (`NONCE_CTR_END`) | +| KEY | (not L3-encrypted; consumes no counter) | + +> **Why not `seq`?** A catastrophic nonce-reuse bug existed in an early draft: +> START used `seq=0` and DATA[0] used `seq=0`, so deriving the nonce from `seq` +> made them **share `(key, nonce)`** under one key — ChaCha20-Poly1305 nonce +> reuse leaks the plaintext XOR and breaks Poly1305 one-time authentication. The +> fix is the reserved-counter band above. Because the key is fresh per transfer +> and every counter within a transfer is distinct, **every `(key, nonce)` pair +> is unique by construction.** This is locked by `test_nonce_uniqueness` — keep +> that test as a **permanent CI regression gate** on any protocol change. + +**AAD:** the 32-byte header **with `crc32` and `payload_len` zeroed**. It binds +`magic/version/type/flags/cipher_id/transfer_id/seq/chunk_count`, so a +reordered, retyped, or cross-transfer-grafted frame fails the Poly1305 check. + +**Three distinct integrity mechanisms — do not conflate them:** + +1. **Per-chunk Poly1305 tag** — the actual **security** check (tamper / wrong + key / wrong AAD ⇒ `ERR_AEAD_FAIL`). +2. **Content binding** — END plaintext SHA-256 (internal, over **plaintext**) + *and* `ciphertext_fingerprint` (on-chain anchor, over **ciphertext**). These + are **two different hashes** for two different jobs (see §2.6). +3. **`crc32`** — **transport corruption detection only**, attacker-forgeable; + any caller treating it as integrity is wrong. Tests prove this: a tampered + ciphertext byte **with the crc re-fixed** still fails AEAD. + +### 2.4 Reassembly (Decoder) + +One `Decoder` == one transfer. Caller routes frames by `(zaddr, transfer_id)`. + +- `add_frame()` accepts frames in **any order**; duplicates are ignored + (first-wins). Non-ZDC1 memos return `ERR_BAD_MAGIC` so the caller treats them + as ordinary text memos. The first valid frame **locks `transfer_id`**; a + foreign id returns `ERR_BAD_STATE`. +- DATA frames stored in `map`. +- `is_complete()` ⇔ START + END + all DATA seqs in `[0, chunk_count)`. **Does not + require the key** — you can be *complete-but-sealed*. +- `assemble()` requires complete **and** key. It decrypts START meta, concatenates + and decrypts DATA in seq order, and verifies the END SHA-256 == sha256(plaintext) + and the size cross-check. +- The codec holds **no clock and does no GC**. The **caller must** TTL-expire + incomplete `(zaddr, transfer_id)` entries and impose a per-sender quota. + +### 2.5 Size caps + +- `MAX_CHUNK_COUNT = 65535` (`parse_header` rejects above ⇒ `ERR_OVERSIZE`). +- `MAX_TRANSFER_BYTES = 65535 × 464 ≈ 29 MB` — a **structural ceiling only** to + stop a hostile START from allocating forever. **Callers MUST set a far tighter + policy cap (64 KB default).** Do not raise the policy cap toward the ceiling + without revisiting the all-node-permanent-storage governance question (§5). + +### 2.6 NFT fingerprint anchor (`ciphertext_fingerprint`) + +`ciphertext_fingerprint(frames, out32)` = SHA-256 over the concatenated **DATA +ciphertext** payloads (payload_len bytes each, in seq order). This is the +on-chain ZSLP `document_hash` anchor, per `CONTENT_MODEL.md §2.6`: + +- **Over ciphertext, not plaintext**, so it is **key-independent** and stable + before/after key reveal. +- Lets any node **verify-before-decrypt**: prove the received frames are exactly + the committed bytes *without* possessing the key or revealing the plaintext. +- **Implementers must use `ciphertext_fingerprint()` for the ZSLP + `document_hash`** — NOT the END plaintext hash. They are different by design; + the END hash re-verifies plaintext integrity *after* key reveal. + +### 2.7 Error taxonomy + +14 stable codes via `status_str()` (never logs key material): +`OK=0`, `ERR_TRUNCATED`, `ERR_BAD_MAGIC`, `ERR_BAD_VERSION`, `ERR_BAD_TYPE`, +`ERR_BAD_PAYLOAD_LEN`, `ERR_BAD_CRC`, `ERR_BAD_CIPHER`, `ERR_AEAD_FAIL`, +`ERR_OVERSIZE`, `ERR_INCOMPLETE`, `ERR_HASH_MISMATCH`, `ERR_NO_KEY`, +`ERR_BAD_STATE`, `ERR_INTERNAL`. + +--- + +## 3. API + +Three layers, one composable core. The codec is the foundation that the daemon +RPCs, the GUI read path, and the private-NFT mint all call. + +### 3.1 Codec public API (`src/datachannel/zdc.h`, namespace `zdc`) + +Exact signatures from the live header: + +```cpp +// --- L3 AEAD (low level, public + testable) --- +ZdcAead::generate_key(std::vector& key /*out 32B*/) -> Status; +ZdcAead::derive_nonce(uint64_t transfer_id, uint32_t nonce_ctr, + uint8_t out_nonce[12]); +ZdcAead::encrypt(key, transfer_id, seq, aad, aad_len, plaintext, ct&) -> Status; +ZdcAead::decrypt(key, transfer_id, seq, aad, aad_len, ct, plaintext&) -> Status; +ZdcAead::sha256(const uint8_t* data, size_t len, uint8_t out[32]) -> Status; + +// --- header / transport --- +crc32(const uint8_t* data, size_t len) -> uint32_t; // transport only +serialize_header(const FrameHeader&, uint8_t out[32]); +parse_header(const uint8_t in[32], FrameHeader& out) -> Status; + +// --- encode (bytes + meta -> frames) --- +Encoder::encode(uint64_t transfer_id, const std::vector& key, + const std::vector& plaintext, const TransferMeta& meta, + bool include_key_frame, + std::vector>& frames_out) -> Status; +Encoder::encode_key_frame(uint64_t transfer_id, const std::vector& key, + uint32_t chunk_count, + std::vector& frame_out) -> Status; + +// --- decode / reassemble (one Decoder == one transfer) --- +Decoder::add_frame(const uint8_t* memo, size_t len) -> Status; // any order, dup-safe +Decoder::add_frame(const std::vector& memo) -> Status; +Decoder::set_key(const std::vector& key) -> Status; // out-of-band key +Decoder::is_complete() const -> bool; // START+END+all DATA, key NOT required +Decoder::have_start()/have_end()/have_key() const -> bool; +Decoder::transfer_id()/chunk_count()/received_chunks() const; +Decoder::missing_chunks() const -> std::vector; +Decoder::assemble(std::vector& out, TransferMeta& meta) const -> Status; + +// --- on-chain NFT anchor --- +ciphertext_fingerprint(const std::vector>& frames, + uint8_t out[32]) -> Status; // = ZSLP document_hash +status_str(Status) -> const char*; +``` + +**Seal-then-reveal contract:** `include_key_frame=false` ⇒ `is_complete()` can be +true while `assemble()` returns `ERR_NO_KEY` until a KEY frame is accumulated or +`set_key()` supplies the key out-of-band. + +### 3.2 Daemon RPCs (designed; default-OFF behind an opt-in flag) + +These ride the existing `z_sendmany` binary-memo path **unchanged** — each +`encode()` frame becomes one shielded output's memo (0.00001 ZCL dust), +batched at the per-tx output budget. Keys are generated and held in the daemon; +the GUI never links libsodium. + +- **`z_senddatafile(fromaddr, toaddr, datahex|filepath, opts)`** + → `{transfer_id, key(hex — only for out-of-band/reveal-later), opids[]}`. + `opts = {keymode: "inband"|"reveal-later"|"out-of-band", cap, metafilename, + metamime}`. Validates `from` is shielded (sender privacy), `size ≤ cap` + (default 64 KB; hard typed-override ceiling 256 KB), generates key via + `ZdcAead::generate_key` and a CSPRNG `transfer_id`, calls `Encoder::encode`, + maps frames → `z_sendmany` memos. +- **`z_revealkey(fromaddr, toaddr, transfer_id, keyhex)`** → `{opid}`. + Builds one `Encoder::encode_key_frame` and sends it as a final memo — the + seal-then-reveal trigger. +- **`z_listdatatransfers(zaddr?)`** → `[{transfer_id, complete, sealed, + received/chunk_count, filename, mime, firstseen}]`. Scans decrypted memos + (`z_listreceivedbyaddress`, on-demand decrypt), routes by + `(zaddr, transfer_id)` into one `Decoder` each, reports + `is_complete()`/`have_key()`/`missing_chunks().size()`. +- **`z_getdatafile(transfer_id, outpath?, keyhex?)`** → `{bytes|written, sha256, + filename, mime}` via `Decoder::assemble`. `keyhex` for out-of-band mode. + +**Private-NFT RPCs (ZSLP + ZDC1, combined):** + +- **`zslp_mint_private({name, ticker, decimals:0, quantity:1, + asset:datahex|filepath, documenturl?})`** — encrypts the asset, sets ZSLP + genesis `document_hash = ciphertext_fingerprint(frames)`, broadcasts the ZSLP + genesis `OP_RETURN` tx (`slp_build_genesis`, **unchanged**) **plus** a ZDC1 + transfer with `transfer_id = token_id`. Ownership = the ZSLP UTXO + (public/transferable); content access = key possession (revealed on transfer). + Validates: encoded script ≤ 223 B, `document_hash` exactly 32 B, asset ≤ cap. + +Every send RPC enforces: shielded-funding (don't deanonymize the sender via a +transparent fee input), the 64 KB policy cap, a local rate limit, and TTL GC of +incomplete inbound transfers. + +### 3.3 GUI binary-safe memo read path (design only) + +**Confirmed bug** at `zcl-qt-wallet/src/rpc.cpp:755-758`: + +```cpp +QString memo(QByteArray::fromHex( + QByteArray::fromStdString(i["memo"].get()))); +``` + +Routing raw memo bytes through `QString` UTF-8-coerces them and **destroys any +non-UTF-8 / binary ZDC1 frame** (the `f600` empty-marker is the only special +case today). + +**Fix (additive, binary-lossless, C++14-safe — no `std::optional`/`string_view`, +GUI is `c++14`-constrained):** + +```cpp +QByteArray raw = QByteArray::fromHex( + QByteArray::fromStdString(i["memo"].get())); +// Sniff the 4-byte magic on RAW bytes, BEFORE any QString conversion. +static const char ZDC1_MAGIC[4] = {0x5A, 0x44, 0x43, 0x31}; +if (raw.size() == 512 && memcmp(raw.constData(), ZDC1_MAGIC, 4) == 0) { + // route raw bytes to the data-channel handler (feeds the daemon RPCs above) +} else { + // existing text path, on a copy — never trim()/toUtf8 the binary branch + if (!QString::fromStdString(i["memo"].get()) + .startsWith("f600")) { /* ... unchanged ... */ } +} +``` + +**Recommended split (Option A): keys stay in the daemon.** The GUI only *detects* +the magic and routes; reassembly/decryption happens in the daemon via the §3.2 +RPCs, so the GUI links no libsodium and never holds key material. The **text +inbox and the data-channel inbox stay separate surfaces.** + +--- + +## 4. Native privacy UX + +Don't-make-me-think, vocabulary-locked to `NATIVE_UX.md` ("sealed", "reveal the +key", "note" — never "memo"/"ivk"/"z-addr"). Reuse the existing delegate, the +green **Private** pill, and the image-match badge — **add no new visual +vocabulary.** + +### 4.1 Send a private file / message + +1. Pick recipient → attach file or type message. +2. A **live consequence table** maps size → outputs: + `notes = ceil(size / 464) + 2` (START + DATA*N + END) → flat fee → + balance-after. +3. Choose delivery: **Send the key with it** (inband) or **Seal it — reveal the + key later** (reveal-later) or **I'll share the key myself** (out-of-band). +4. **The one non-negotiable honesty line, verbatim on the send screen:** + > *Hidden: who it's from, who it's to, the amount, and the contents. + > Visible: that a private transfer happened, roughly when, and roughly how + > big. It is permanent.* + +### 4.2 Receive + +Three calm states, driven by `is_complete()` + `have_key()`: + +- **Arriving** — frames still landing (`received/chunk_count`, + `missing_chunks()`). +- **Waiting for the key** — complete but sealed (`ERR_NO_KEY` is the *calm* face + of seal-then-reveal, never an error tone). +- **Ready** — `assemble()` succeeded; show file / message / NFT. + +### 4.3 Reveal the key + +One tap → `z_revealkey` → `encode_key_frame` → one tiny output. Plus an **"I have +a key"** paste field → `set_key` for out-of-band delivery. + +### 4.4 Private NFT in the gallery & private mint + +- **Gallery:** same green **Private** pill; same image-match badge via a local + `assemble()` + the existing content engine. The fingerprint anchors the + **ciphertext** (`CONTENT_MODEL.md`); the END frame binds the **plaintext** hash + after key reveal. +- **Mint (NATIVE_UX tile A):** `transfer_id = ZSLP token_id`, so the public token + commits to the sealed bytes. Default key delivery = **reveal-later or + out-of-band** for anything sensitive. + +### 4.5 Consent gate + +Everything is **default-OFF**. First use shows a **one-time permanence + +metadata-leak consent dialog** stating §5 in plain words. No DRM, no anti-copy, +no consensus enforcement — say so. + +--- + +## 5. Honest privacy limits + +State these everywhere; never oversell. The codec **cannot** fix any of them. + +- **Metadata leaks.** The **number of outputs ≈ transfer size**; the **timing** + of the burst is a signature; the **existence of a shielded tx** is observable. + An all-max-memo output run hints at a data channel. This is a + **confidentiality** channel, **not steganographic / not undetectable**. +- **Sender deanonymization via fees.** A transparent fee input can deanonymize + the sender even when the recipient is shielded → RPCs **enforce shielded + funding**. +- **Permanence.** Every memo is stored by **every full node forever**, + encrypted-but-undeletable. Size caps are about *responsibility*, not just + performance. +- **KEY-frame caveat.** The in-band KEY frame ships the raw 32-byte key in + cleartext at L3; its on-chain confidentiality relies **entirely on L1 Sapling + encryption to the recipient's ivk**. A compromised ivk exposes the key, and + in-band reveal commits the key on-chain at send time. **Out-of-band / + reveal-later is the safer default** for sensitive content. +- **`crc32` is not security.** Transport corruption detection only, + attacker-forgeable. +- **"Private" ≠ undetectable; no consensus enforces any of this.** It is all + wallet/application policy, default-OFF, opt-in. +- **Abuse / governance surface.** Arbitrary encrypted bytes stored by every node + forever is a real governance question. Caps + opt-in mitigate but do **not** + remove it. + +--- + +## 6. Build/test plan + implementation order + +### 6.1 Current state (DONE) + +- `src/datachannel/zdc.h` (337 lines — API + full security-model header doc). +- `src/datachannel/zdc.cpp` (C++11, libsodium only). +- `src/datachannel/test/zdc_test.cpp` — one canonical self-contained harness + (no gtest dependency, so it runs anywhere). + +**Reproduce the green build (no proot, no zclbuild, no daemon build):** + +```sh +g++ -std=c++11 -pedantic -Wall -Wextra \ + src/datachannel/zdc.cpp src/datachannel/test/zdc_test.cpp -lsodium \ + -o /tmp/zdc_test && /tmp/zdc_test +# => 260 checks, 0 failures RESULT: PASS +``` + +Also clean under `-pedantic-errors -Wshadow -Wconversion` (daemon-mode object) +and under `-fsanitize=address,undefined`. + +**Test coverage:** header round-trip + endianness + rejects; CRC vector +`0xCBF43926` for `"123456789"`; AEAD round-trip; **nonce uniqueness** (incl. +START vs DATA[0] domain separation and the empty-transfer case); AAD +reorder/retype/cross-transfer rejection; per-byte tamper of +ciphertext/tag/AAD/key (crc re-fixed, proving crc ≠ security); truncation; +duplicate/reorder/missing reassembly; foreign-transfer-id rejection; empty and +maximal payloads; seal-then-reveal (`ERR_NO_KEY` then KEY frame); out-of-band +key; wrong-key; non-ZDC1/`f600` passthrough; size caps; +`ciphertext_fingerprint` determinism / order-independence / tamper-visibility. + +### 6.2 Implementation order + +1. **Land + CI-gate the codec — DONE.** `src/datachannel/*` is in `src/Makefile.am` + (`:247,294`); daemon gtests in `test_zdc.cpp`. **Keep + `test_nonce_uniqueness` as a mandatory CI gate** — it catches the single most + dangerous failure mode and it already regressed once. +2. **Daemon send path — DONE.** `z_senddatafile` is built (`src/rpc/datachannel.cpp`), + feeding `Encoder` frames into Sapling memos (no builder change); enforces shielded + funding + the 40000-byte per-file cap + acknowledge_permanent. *(There is no + `z_revealkey` in the as-built surface — the daemon always includes the KEY frame and + returns the per-transfer key to the sender; seal-then-reveal is a designed, not-built + option.)* +3. **Daemon receive path — DONE.** `z_listdatatransfers` + `z_getdatatransfer` are built, + feeding the on-chain memos into `zdc::Decoder` with verify-before-decrypt; inflight GC + via the 72h TTL + `ZDC_MAX_INFLIGHT=256` cap (the codec does no GC). +4. **GUI binary-safe read path** (§3.3) — sniff the 4-byte magic on the raw + `QByteArray` before any `QString` conversion; route to a separate + data-channel inbox; leave the text inbox untouched. Do this **after** the + in-flight GUI build settles. C++14-safe. +5. **Native UX** (§4) — Send-Private-File, Private-Files inbox, reveal-key, + private-NFT gallery + mint, reusing existing delegate/pill/badge. +6. **Private NFTs** — set ZSLP genesis + `document_hash = ciphertext_fingerprint(frames)`; default to out-of-band / + reveal-later key delivery. +7. **Consent gate** — default-OFF behind a one-time permanence + metadata-leak + consent dialog. + +### 6.3 Guardrails + +- **Do NOT raise the policy cap toward the 29 MB structural ceiling** without + revisiting the all-node permanent-storage governance question. **Do not enable + multi-MB transfers.** +- Keep the feature on its own experimental track, **decoupled from the beta + release pipeline**. +- **Do NOT edit `src/zslp/*` or any GUI source while the content-engine build is + in flight** — those edits in `git status` are pre-existing, untouched here. + Only `src/datachannel/*` and `doc/nft/*` are owned by this work. +- Keep the honest framing (§5) in **all** UI copy: confidential, not + undetectable; permanent; no DRM; no consensus enforcement. diff --git a/doc/nft/PRIVACY_STACK.md b/doc/nft/PRIVACY_STACK.md new file mode 100644 index 00000000000..9430094e5a5 --- /dev/null +++ b/doc/nft/PRIVACY_STACK.md @@ -0,0 +1,308 @@ +# ZClassic Private NFTs — The Privacy Stack (ZDC1) and What We Are Enabling + +**Status:** the codec (`src/datachannel/zdc.{h,cpp}`) is built, unit-tested, **and compiled into +the daemon** (`src/Makefile.am:247,294`). The daemon RPCs (`z_senddatafile` / +`z_listdatatransfers` / `z_getdatatransfer`) are **built, default-OFF** behind `-datachannel` +(`src/rpc/datachannel.cpp:597-599`); only the native GUI wiring is later. NON-consensus, +default-OFF, experimental track. Rides UNCHANGED consensus: it uses only the existing Sapling +shielded pool and the 512-byte encrypted memo that ZClassic already supports. No opcode, no fork, +no builder change. *(The as-built RPC contract is `NATIVE_NFT_GUIDE.md §3.3`; where this doc's +RPC sketch differs, the guide wins.)* + +> Companion docs: `ZDC1_CODEC_SPEC.md` (the codec wire spec), `CONTENT_MODEL.md` +> (content-addressing / fingerprint anchor for any-size files). + +--- + +## 1. Plain answer: what privacy technology are we enabling? + +We are turning ZClassic's shielded payments into a **private data channel**, and on top of +that a **private NFT**. Concretely, a user can: + +- **Send a private message** that only the recipient can read (`<= ~4 KB`, one transaction). +- **Send a private file** — an image, a document, a contract (`<= ~64 KB` on-chain is the + practical, responsible ceiling; larger files keep the bytes off-chain and put only an + encrypted fingerprint on-chain). +- **"Seal now, reveal the key later"** — publish the encrypted content immediately, then + reveal the decryption key whenever you choose. Until you do, *not even the recipient* can + open it. This is a first-class, on-chain timelock-by-choice. +- **Mint a private NFT** — a 1-of-1 token whose asset bytes are encrypted and whose + ownership is shielded. "Owning" it means holding the key (and, for public provenance, the + ZSLP token that commits to the encrypted bytes). +- **Selectively disclose** — because the Sapling layer is keyed to a *viewing key*, the owner + can hand an auditor/buyer a viewing key to prove receipt/contents without giving up spend + authority. + +The honest one-liner: **this is a confidentiality channel, not an invisibility cloak.** The +*contents* and the *recipient* are well hidden; the *fact that a shielded transfer happened*, +its *approximate size*, and its *timing* are not. See Section 6. + +--- + +## 2. The 4-layer stack + +``` + L3 PRIVATE NFT / FILE / MESSAGE (application) <- this codec + daemon RPC + GUI + per-transfer 32B key, per-chunk AEAD, content-hash anchor, seal-then-reveal + ───────────────────────────────────────────────────────────────────────── + L2 ZDC1 TRANSPORT (framing + reassembly) <- this codec src/datachannel/zdc.* + 512B memo = one ZDC1 frame (32B header + 480B payload); START/DATA/END/KEY; + out-of-order / dup / missing tolerant; size caps + ───────────────────────────────────────────────────────────────────────── + L1 SAPLING ENCRYPTED MEMO (consensus base) <- already in ZClassic + 512B memo per shielded output, ChaCha20-Poly1305-IETF to recipient's ivk; + only the incoming-viewing-key holder can read the memo AT ALL + ───────────────────────────────────────────────────────────────────────── + L0 SAPLING SHIELDED POOL (consensus base) <- already in ZClassic + zk-SNARKs hide sender, recipient, and amount +``` + +- **L0 + L1 are consensus-enforced and free.** Every shielded output already carries a + 512-byte memo encrypted to the recipient's incoming viewing key (`ivk`). Only that + recipient can decrypt it. This is the privacy foundation; the codec never bypasses it. +- **L2 (ZDC1 transport)** chains many tiny (e.g. 0.00001 ZCL) shielded outputs, each memo + carrying one **frame**, to move an arbitrary byte stream across the 512-byte limit. +- **L3 (application AEAD)** adds an *independent* libsodium ChaCha20-Poly1305 layer under a + per-transfer symmetric key, so that (a) you can publish ciphertext now and reveal the key + later, (b) a break in L1 does not cascade into L3, and (c) one ciphertext can be opened by + N recipients (one KEY frame each). + +The codec implements **L2 and L3**. L0/L1 belong to the daemon's existing Sapling path; the +codec output (a list of 512-byte memos) is fed straight into `z_sendmany`. + +--- + +## 3. The wire format (ZDC1 frame) + +Each Sapling memo = exactly one frame: **32-byte header + 480-byte payload**, all multi-byte +fields big-endian. (Constants verified against `src/zcash/Zcash.h:17` `ZC_MEMO_SIZE=512`.) + +``` +off len field notes +0 4 magic 0x5A444331 "ZDC1" +4 1 version 0x01 +5 1 type 0x01 START | 0x02 DATA | 0x03 END | 0x04 KEY +6 1 flags bit0 = payload is L3 ciphertext +7 1 cipher_id 0x00 none | 0x01 ChaCha20-Poly1305 +8 8 transfer_id random 64-bit (or a ZSLP token_id); separates concurrent transfers +16 4 seq DATA chunk index 0-based; START=0; END=chunk_count +20 4 chunk_count total DATA chunks (authoritative in START) +24 2 payload_len 0..480 valid bytes in this frame +26 4 crc32 CRC-32 over the 480B payload field — TRANSPORT integrity ONLY +30 2 reserved 0 +32 480 payload L3 ciphertext (DATA/START/END) or raw key (KEY) +``` + +**Frame roles:** +- **START** — payload = AEAD-encrypted `TransferMeta` `{filename, content_type, + total_plaintext_size, chunk_count}`. Carries the authoritative `chunk_count`. +- **DATA** — payload = AEAD-encrypted plaintext chunk. **464 plaintext bytes/frame** + (480 payload − 16-byte Poly1305 tag). +- **END** — payload = AEAD-encrypted SHA-256 of the *full plaintext*. The content-hash anchor. +- **KEY** — payload = the raw 32-byte per-transfer key. Distinct frame so reveal-later is + first-class; its on-chain confidentiality is the Sapling L1 encryption to the recipient. + +**On-chain efficiency** (from real constants, `ZDC1_CODEC_SPEC.md`): 948 on-chain +bytes per 480-byte frame (≈1.975× expansion); the **200 KB block** (`consensus.h:27`) is the +binding throughput limit, ~210 outputs/block, ~0.5 MB/hr best case. **This is why we cap.** + +--- + +## 4. Security model (why "tested" matters) + +All security properties below are exercised by `src/datachannel/test/zdc_test.cpp` +(260 checks, all passing, no gtest). Build + run: + +``` +g++ -std=c++11 src/datachannel/zdc.cpp src/datachannel/test/zdc_test.cpp -lsodium -o /tmp/zdc_test && /tmp/zdc_test +``` + +### 4.1 AEAD nonce uniqueness (the catastrophic case) +ChaCha20-Poly1305-IETF nonce = 12 bytes; reuse under one key is catastrophic. We make reuse +**impossible by construction**: + +- **Key** is fresh per transfer (32 bytes from `randombytes_buf`). So uniqueness reduces to: + every L3-encrypted frame in ONE transfer must use a distinct 32-bit counter. +- **Nonce = `transfer_id`(8 BE) || `nonce_ctr`(4 BE)`**, where `nonce_ctr` is a per-frame + **counter**, NOT the wire `seq`: + - `DATA[i]` -> `i` (`0 .. chunk_count-1`, `<= 65534`) + - `START` -> `0xFFFFFFFF` + - `END` -> `0xFFFFFFFE` + - `KEY` is not L3-encrypted, so it consumes no counter. + +> **Why not just use `seq`?** START carries `seq=0` and DATA chunk 0 also carries `seq=0`; using +> the raw wire field would collide their nonces under the same key — catastrophic. The reserved +> high-counter band for the singleton control frames removes the collision deterministically, +> proven for the multi-chunk AND the empty-transfer (`chunk_count==0`) case. + +### 4.2 Key handling +Per-transfer 32-byte key from a CSPRNG; never reused across transfers; never logged +(`status_str()` never emits key bytes). The KEY frame carries it, or it travels out-of-band. + +### 4.3 Integrity — three distinct mechanisms, do not conflate them +- **Per-chunk Poly1305 tag (SECURITY).** Any flipped byte in ciphertext, tag, or AAD fails + decryption with `ERR_AEAD_FAIL`. Tested over every byte position. +- **Overall PLAINTEXT hash (SECURITY).** After reassembly+decrypt we recompute SHA-256 over + the plaintext and `sodium_memcmp` it against the END frame's value. A grafted-but-validly- + encrypted chunk from another transfer is caught here (`ERR_HASH_MISMATCH`). +- **CIPHERTEXT fingerprint (NFT ANCHOR, verify-before-decrypt).** `ciphertext_fingerprint()` + computes SHA-256 over the concatenated DATA-frame ciphertext payloads in seq order + (order-independent over the input frame vector). This is what the MINT path puts on-chain as + the ZSLP `document_hash` (`CONTENT_MODEL.md` §2.6 — "the anchor is over CIPHERTEXT"), so the + public token cryptographically commits to the private bytes, and ANY node can verify the + received frames match the on-chain anchor BEFORE possessing the key. A flipped ciphertext + byte changes the anchor (tamper visible pre-decrypt; tested). +- **Header CRC-32 (TRANSPORT ONLY).** Detects corruption / foreign data so we can cheaply + ignore non-ZDC1 memos. **It is NOT security** — an attacker who edits a payload simply + recomputes the CRC. The test deliberately fixes the CRC after tampering and confirms the + AEAD layer still rejects it. + +### 4.4 AAD binds the frame's role +The AEAD associated data = the 32-byte header with `crc32` and `payload_len` zeroed (so +encoder and decoder compute it identically). This binds version/type/transfer_id/seq/ +chunk_count/flags/cipher. A reordered or relabelled frame (e.g. DATA seq 0 rewritten to claim +seq 1) decrypts under the wrong nonce-counter + wrong AAD and fails (`ERR_AEAD_FAIL`, tested). + +### 4.5 Key-reveal ordering (seal then reveal) +A transfer can be *structurally complete but sealed*: `is_complete()==true` while +`have_key()==false`. `assemble()` returns `ERR_NO_KEY` and never yields plaintext until the +KEY frame arrives (or `set_key()` is called out-of-band). A wrong key fails AEAD, never +returns garbage. All tested. + +### 4.6 Reassembly robustness (the chain is unordered) +`mapWallet` iterates by txid hash, not block order, so the decoder is seq-keyed and order- +independent. Tested: out-of-order delivery, duplicates (first wins), missing chunks +(`missing_chunks()` + `ERR_INCOMPLETE`), truncated buffers (`ERR_TRUNCATED`), and ordinary +text / `0xF6` memos (`ERR_BAD_MAGIC` = "not for me, ignore"). + +### 4.7 Size caps (responsibility, not just performance) +`MAX_CHUNK_COUNT = 65535` (≈29 MB structural ceiling) bounds a hostile START's allocation; +oversize transfers are rejected with `ERR_OVERSIZE`. Callers MUST impose far tighter limits +(the as-built daemon caps a transfer at 40000 bytes; see `NATIVE_NFT_GUIDE.md §3.3`). Every memo is stored by every full node +**forever** — the cap is about not bloating a 200 KB-block chain. + +--- + +## 5. The codec API (`src/datachannel/zdc.h`) + +Pure logic. Depends ONLY on libsodium + the C++11 std lib. No daemon, chain, Qt, or globals. +Builds standalone for tests AND inside the daemon (C++11 `-noext`, libsodium already linked — +`configure.ac:68,783`). + +```cpp +// --- L3 AEAD primitives --- +zdc::ZdcAead::generate_key(key); // 32 CSPRNG bytes, per transfer +zdc::ZdcAead::sha256(data, len, out32); // plaintext content hash + +// --- NFT on-chain anchor (verify-before-decrypt; over CIPHERTEXT) --- +zdc::ciphertext_fingerprint(frames, out32); // ZSLP document_hash = this + +// --- encode: plaintext -> a list of 512-byte memo frames --- +zdc::Encoder::encode(transfer_id, key, plaintext, meta, + include_key_frame, frames_out); // START,DATA*,END[,KEY] +zdc::Encoder::encode_key_frame(transfer_id, key, chunk_count, frame_out); // reveal-later + +// --- decode: feed memos in any order, then assemble --- +zdc::Decoder d; +for (each decrypted 512B memo) d.add_frame(memo); // BAD_MAGIC => ordinary memo, skip +d.is_complete(); // START+END+all DATA (key not required) +d.have_key(); // sealed vs openable +d.set_key(key_out_of_band); // OR an on-chain KEY frame supplies it +d.assemble(out_plaintext, out_meta); // AEAD + content-hash verified +``` + +Status codes are explicit (`zdc::Status`, `status_str()`); `OK==0`, all failures negative. + +--- + +## 6. Honest privacy limits (do not oversell) + +Hidden (strong, consensus-backed): +- **Memo contents** — Sapling-encrypted to `ivk` (L1) AND app-AEAD'd (L3). +- **Sender, recipient, amount** — zk-SNARK shielded (L0). +- **File metadata** (name, type, size, hashes) — inside the encrypted START/END frames. + +Observable (the leakage — state it plainly): +- **Number of outputs/txs => approximate transfer size.** A burst of 21 tiny shielded txs ≈ + "~1 MB transfer." Large transfers are conspicuous (many outputs). +- **Timing** — a burst of tiny shielded txs across consecutive blocks is a distinct signature. +- **Existence** — "a shielded tx occurred" and "these outputs all carry max-size memos" is + visible; an all-max-memo pattern hints "data channel" vs ordinary payment. +- **Fee-source linkage** — funding from a transparent address deanonymizes the *sender*. Fund + from shielded inputs; use a single-use recipient address per transfer. + +Permanence: **every memo is stored by every full node forever.** Encrypted, but undeletable. +This is a governance/liability surface (illicit-content storage, unbounded growth on a small +chain). Hence: default-OFF, opt-in consent, hard 64 KB default cap, local rate limit. + +**No consensus enforces any of this.** It is wallet/application policy. `"Private"` means +*confidential*, not *undetectable*. + +--- + +## 7. Daemon + GUI wiring (designed; not implemented here) + +**Daemon (`/home/rhett/github/zclassic`):** +- The send path already accepts raw binary memos as hex — `get_memo_from_hex_string` + (`src/wallet/asyncrpcoperation_sendmany.cpp:1321-1343`) copies bytes verbatim; no builder + change. Each `zdc::Encoder` frame becomes one recipient `{address, 0.00001, memo=hex(frame)}`. +- `z_listreceivedbyaddress` already emits the full memo as a hex string + (`HexStr(...)`, `src/wallet/rpcwallet.cpp:3374,3388`). The receive path hex-decodes each + memo to 512 raw bytes and calls `zdc::Decoder::add_frame`. `ERR_BAD_MAGIC` => ordinary memo. +- New RPCs (separate task): `z_senddatafile` / `z_listdatatransfers` / + `z_getdatatransfer` / `z_receivedatafile`, enforcing caps + rate limit. + +**GUI (`/home/rhett/github/zcl-qt-wallet`) — the binary-safe read path (design only):** +The current code at `src/rpc.cpp` (~756-790) is **lossy** for ZDC1: it does +`QString::fromStdString(memo_hex)`, then for non-`f600` memos +`QString(QByteArray::fromHex(...))`. Routing binary frame bytes through `QString` corrupts +them (invalid UTF-8 => replacement chars), and the `startsWith("f600")` guard also drops the +empty-memo marker. **Design:** keep the decoded bytes as a `QByteArray` and branch BEFORE any +`QString` conversion: + +```cpp +QByteArray raw = QByteArray::fromHex(QByteArray::fromStdString(i["memo"].get())); +if (raw.size() == 512 && (quint8)raw[0]==0x5A && (quint8)raw[1]==0x44 + && (quint8)raw[2]==0x43 && (quint8)raw[3]==0x31) { // "ZDC1" + // binary-safe path: hand raw.constData()/raw.size() to the ZDC1 receive handler. + routeDataChannelFrame(zaddr, raw); +} else { + // existing text path, unchanged: + QString memo(raw); + if (!memo.trimmed().isEmpty()) memos[zaddr + txid] = memo; +} +``` + +This adds a parallel binary path without touching the text inbox behaviour. (C++14-only +features are banned in the GUI — `gui-cpp14-constraint`; the snippet uses none.) + +--- + +## 8. Private NFT, end to end + +1. **Mint:** pick a `transfer_id` (a random 64-bit id, or the ZSLP `token_id`). `generate_key`. + `Encoder::encode(...)` the asset bytes -> frames. Set the ZSLP genesis `document_hash` = + `ciphertext_fingerprint(frames)` so the public token commits to the private CIPHERTEXT + (verify-before-decrypt anchor; `CONTENT_MODEL.md` §2.6). Broadcast frames as shielded + outputs to the owner zaddr (`z_sendmany`). +2. **Hold/own:** ownership = holding the 32-byte key (+ the ZSLP UTXO for public provenance). + The recipient `Decoder`s the memos; `assemble()` verifies AEAD + content hash before any + bytes are trusted. +3. **Seal-then-reveal:** mint with `include_key_frame=false`; the asset is on-chain but + unopenable until you broadcast the KEY frame (or hand the key over out-of-band). +4. **Selective disclosure:** share the Sapling *incoming viewing key* to let an auditor read + the memos (prove contents/receipt) without granting spend authority. +5. **Transfer:** re-deliver the key to the new owner (out-of-band = instant/free; or a single + KEY-frame tx). Honest limit: pure key-possession cannot stop a prior holder keeping a copy + — the chain proves the fingerprint and (via ZSLP UTXO conservation) who holds the 1-of-1 + token, never the pixels. **No DRM, no anti-copy.** State this in the UI. + +--- + +## 9. Files + +- `src/datachannel/zdc.h` — codec API + full security-model header doc. +- `src/datachannel/zdc.cpp` — implementation (C++11, libsodium only). +- `src/datachannel/test/zdc_test.cpp` — standalone harness, 260 checks (no gtest). +- `doc/nft/ZDC1_CODEC_SPEC.md` — codec wire + crypto spec. +- `doc/nft/CONTENT_MODEL.md` — content-addressing / fingerprint anchor for any-size files. diff --git a/doc/nft/PRIVACY_UX.md b/doc/nft/PRIVACY_UX.md new file mode 100644 index 00000000000..21c2e4081de --- /dev/null +++ b/doc/nft/PRIVACY_UX.md @@ -0,0 +1,383 @@ +# ZClassic Private NFTs & Shielded Data Channel — Native Privacy UX + +**Status:** Design spec + shipped codec. Grounds on the working ZDC1 codec at +`src/datachannel/zdc.{h,cpp}` (tested: `src/datachannel/test/`, 800+ checks, 0 +failures) and on the UX contracts in `doc/nft/NATIVE_UX.md`. +**Repos:** daemon `/home/rhett/github/zclassic`; GUI `/home/rhett/github/zcl-qt-wallet`. +**Constraint (codec):** C++11, libsodium-only, no daemon/chain/Qt deps — builds +standalone for tests AND into the daemon unchanged. +**Constraint (GUI):** C++14 only (`zcl-qt-wallet.pro`), 100% native Qt +(`QPainter`/delegates), no QtWebEngine. Reuse `dark.qss` tokens only. +**Consensus:** UNCHANGED. Rides the existing Sapling shielded pool + 512-byte +encrypted memos. No fork, no new opcode, default-OFF / opt-in. + +This document is the privacy-UX companion to NATIVE_UX.md. Where NATIVE_UX.md +defines the gallery/detail/mint/send screens, this defines the **private** +behaviours layered on them: send a private file/message, receive private items, +reveal the key, the private pill in the gallery, and private mint — all in the +"don't make me think" voice, honest about metadata leakage and permanence. + +--- + +## 0. The product in one breath + +You pick a person (a private/shielded address), attach a file or type a message, +and the wallet shows — before you commit — exactly how big it is, how many notes +it becomes, the flat fee, and one honest line about what stays hidden and what +leaks. You press **Send privately**. On the other side, private items appear in +**Activity**; they open the moment the key is present, or sit calmly in a +**"Waiting for the key"** state until the sender reveals it. Revealing is one +tap. A private NFT shows the same green **Private** pill and the same image-match +check as any other — it just decrypts locally first. + +The whole thing reuses the vocabulary the user already learned on the gallery +(NATIVE_UX P9). Nothing here teaches a new visual word. + +--- + +## 1. Vocabulary lock (extends NATIVE_UX §1, P1) + +Banned protocol terms stay banned. The data channel adds these plain words, used +identically everywhere: + +| Plain word (UI) | Means (engineering) | Never say | +|---|---|---| +| **sealed** | AEAD-encrypted under the per-transfer key | "encrypted memo", "AEAD" | +| **the key** | the 32-byte per-transfer symmetric key | "symmetric key", "K_file" | +| **reveal the key** / **hand over the key** | deliver the KEY frame / key out-of-band | "send the KEY frame" | +| **note** / **message** | a memo's contents | "memo", "512-byte field" | +| **private (shielded) address** | a z-addr | "z-addr", "ivk" | +| **piece / item / file** | the content stream | "transfer", "ZDC1 frames" | +| **a few small notes** | the chained shielded outputs | "outputs", "chunks" | +| **permanent on the network** | stored by every node forever | "chain bloat", "unprunable" | + +The word **"private"** in this app means **hidden from the public, not +one-of-a-kind** (NATIVE_UX P10). Copy never implies a sealed file can't be +copied once opened. + +--- + +## 2. The one honesty line (non-negotiable, appears on every send) + +Every private-send surface shows ONE calm sentence (12pt `#9aa0a6`, no red unless +something is actually wrong) that tells the truth about a shielded data transfer: + +> **"Hidden: who it's from, who it's to, the amount, and the contents. Visible: +> that a private transfer happened, roughly when, and about how big. It stays on +> the network permanently."** + +This is the load-bearing honesty. It is shown, not hidden behind a tooltip, +because the size→#notes mapping genuinely leaks an approximate size (more notes = +bigger file) and we refuse to oversell "private" as "undetectable". For a tiny +message it shrinks to: + +> **"Only the person you choose can read this. The fact that you sent something +> private is still visible, and it stays on the network permanently."** + +--- + +## 3. SEND A PRIVATE FILE / MESSAGE + +New `NFTSendPrivateDialog` (or a "Private" mode of the existing send tab), +modeled on `memodialog.ui` + `confirm.ui`. Fixed width 560. Built programmatically +(C++14-trivial, no `.ui` churn). One bright green primary at all times (P2). + +### 3.1 Anatomy (top to bottom) + +1. **Who gets it.** Title "Send to". The wallet's `AddressCombo`. One reserved- + height live status line (debounced local validation, no per-keystroke RPC): + - valid private → green "Looks good — a private (shielded) address" + - valid public → amber "A private send needs a private (shielded) address — + paste one above." (and the primary stays disabled, P4: reason+fix inline) + - invalid → red "That doesn't look like a ZClassic address." + +2. **What you're sending.** A tab-pair: **Message** (a `QPlainTextEdit`, live + "0 / 4 KB", soft amber past 3.5 KB) or **File** (a 528×140 dropzone identical + to the mint dropzone — "Drop a file here", "Up to 64 KB on the network", a + "Choose a file…" button). After a file: a 528×72 loaded row — 56×56 icon, + filename (elided middle), "1.8 KB · image/png". + +3. **The consequence table (rewrites live, P6).** The instant a file/message is + present, an inset (`#1d2027`/radius 8) fills in — NO daemon round-trip: + + ``` + Size 1.8 KB + Becomes 5 small private notes <- ceil(size / 464) + 2 (start/end) + Network fee 0.0001 ZCL <- one fee per tx; ~107 notes/tx + After this 5.2340 ZCL + ``` + + The "Becomes N small private notes" row is computed purely from the codec + constants: `DATA_PLAINTEXT_PER_FRAME = 464`, plus START + END frames, plus an + optional KEY frame. This is the same number an observer can count on-chain, + which is *why* we surface it — it is the honest size signal. + +4. **Who can open it, and when (the seal-then-reveal choice).** Two rows, shown + only after a valid private recipient (animated `setVisible`): + - **Send it sealed, with the key (default)** — "They can open it the moment it + arrives." (= `include_key_frame = true`) + - **Send it sealed now, reveal the key later** — "It arrives locked. You + unlock it for them anytime from Activity — good for a surprise on a certain + day." (= `include_key_frame = false`; a "Reveal the key" action is recorded) + Footnote: "Either way, only they can ever open it." + +5. **The honesty line** (§2), full-width inset, always visible. + +### 3.2 Footer + states + +Footer: the fee line + [Cancel] + a green primary whose **label states the +outcome** (NATIVE_UX payoff): send-with-key → **"Send privately"**; reveal-later +→ **"Send sealed"**. Disabled until valid private recipient AND non-empty +content AND not in flight. + +States: ready · file-too-big (inline amber "That file is larger than 64 KB. +Private on-chain transfers are for small files — pick a smaller one, or share it +another way.", primary disabled — we cap, we never silently truncate) · +sealing/sending (primary → spinner "Sending…", Cancel → "Close (keeps sending)") +· sent-with-key ("Sent privately. They can open it now.") · sent-sealed ("Sent +sealed. Unlock it for them anytime from Activity." + an Activity entry carrying a +"Reveal the key" action) · error (inline red + the daemon's plain reason + "Try +again"; nothing sent) · low-balance ("Not enough ZCL for the network fee."). + +--- + +## 4. RECEIVE — private items appear, decrypt when the key is present + +Private items surface in **Activity** (and, for NFTs, the gallery). The receive +engine is the codec's `Decoder` driven by the binary-safe read path (§7). + +### 4.1 The three receive states (drive the row's right-side status) + +| Codec state | UI state | Row status | Action | +|---|---|---|---| +| not `is_complete()` | **Arriving** | "Arriving… 3 of 5 notes" (live count from `ChunksReceived`/`ChunksExpected`) | — (greyed) | +| `is_complete() && !have_key()` | **Waiting for the key** | amber dot + "Waiting for the key" | "Ask sender" (copies a short note) — never a dead end (P4) | +| `is_complete() && have_key()` | **Ready** | green dot + "Private message" / "Private file — aurora.png" | **Open** / **Save…** | + +The **"Waiting for the key"** state is the visible face of seal-then-reveal: the +bytes are fully here and verified-complete, but `assemble()` returns `ERR_NO_KEY` +until the key arrives (in-band KEY frame, or the recipient pastes a key they were +given out-of-band). The row never errors and never disappears — it waits calmly. + +### 4.2 Open / Save + +**Open** runs `Decoder::assemble()`. On `OK` the content + manifest are in hand; +a message shows inline, a file offers **Save…** (`QFileDialog` defaulting to the +manifest filename, writes the exact bytes). On `ERR_AEAD_FAIL` / `ERR_HASH_MISMATCH` +the row turns red: "This file failed its security check — it may be damaged or +wasn't meant for this wallet." (honest, never a crash). `ERR_INCOMPLETE` cannot +happen here because Open is only offered in **Ready**. + +### 4.3 DoS / hygiene (carry from PRIVACY_STACK §5, wallet-side only) + +- Incomplete `(zaddr, transfer_id)` transfers expire after a TTL (default 7 days) + so START-spam can't grow memory unbounded. UI: an arriving item older than the + TTL with gaps shows "This didn't finish arriving." + a quiet "Dismiss". +- A per-sender concurrent-transfer cap; over it, new STARTs are dropped (logged, + not shown). The codec already gates oversize at `parse_header` (`ERR_OVERSIZE`) + and bounds `chunk_count <= MAX_CHUNK_COUNT`. + +--- + +## 5. REVEAL KEY — one clear action + +For an item the user sent with **reveal-later**, Activity shows a green +**"Reveal the key"** button on that entry. One tap: + +1. Confirms once, plainly: "Unlock this for {recipient}? After this they can open + it. You can't take it back." [Cancel] [Reveal the key] +2. Builds the KEY frame with `Encoder::encode_key_frame(transfer_id, key, + chunk_count, frame)` and sends it as a single tiny shielded output to the same + recipient (one note, one fee). +3. On success the entry flips to "Key revealed — they can open it now." + +There is exactly one reveal action and one confirm. The key the wallet holds for +a pending reveal is stored encrypted at rest with the rest of the wallet secrets; +it is **never** shown to the user as hex (P1) and never logged (the codec's +`status_str` never includes key material). + +**Out-of-band reveal (most private):** for the truly sensitive case the recipient +side also accepts a key the sender handed over by other means. The receive row in +**Waiting for the key** offers a quiet "I have a key" → a single paste field → +`Decoder::set_key()`. Wrong key → calm red "That key doesn't open this item." +(= `ERR_AEAD_FAIL`), try again. Nothing on-chain in this path. + +--- + +## 6. PRIVATE NFT in the gallery + PRIVATE MINT + +### 6.1 Private pill + the content engine (reuse, don't fork) + +A private NFT is, on the wire, a ZDC1 transfer whose plaintext is the asset bytes +(small) or an off-chain pointer (large, per CONTENT_MODEL.md), plus the ZSLP +ownership record. In the gallery it is rendered by the **same** delegate: + +- **Private pill** (green, NATIVE_UX §2.3): "Only you can see this. Its ownership + is shielded." A private item NEVER offers an explorer link or any remote fetch + (P8). +- **Verify badge** (NATIVE_UX §2.2): the wallet decrypts the asset bytes locally + (via `Decoder::assemble`), then runs the existing content engine + (`nftimagecache` streaming SHA-256) and shows the same green check / red x / + amber question. For private NFTs the on-chain fingerprint anchors the + **ciphertext** (CONTENT_MODEL §"PRIVATE NFT"), and the codec's END frame + additionally binds the **plaintext** SHA-256 — so the gallery can show "matches + its on-chain fingerprint" with the exact same sentence and zero new vocabulary. + +A private NFT that is structurally complete but key-not-yet-present shows the +**"Waiting for the key"** treatment on its card (amber question badge + the pill +still green) instead of a broken image — honest, never a dead end. + +### 6.2 Private mint (NATIVE_UX §3.3 "Who can see it" tile A, now wired) + +Tile **A — Private (only people you choose)** is the default-selected, green +option. Its body copy (already in NATIVE_UX): *"The image and details are sealed. +Stored encrypted on the ledger; only someone you give the key to can open it. +Your balance and addresses stay shielded."* + +Mint pipeline (all wallet-side, no consensus change): +1. Hash the chosen image with the existing content engine → the fingerprint. +2. `ZdcAead::generate_key()` → a fresh per-mint key (never reused). +3. `Encoder::encode(token_id, key, assetBytes, meta, include_key_frame, frames)` + with `transfer_id = the ZSLP token_id` so the public token cryptographically + commits to the private bytes (the END ciphertext/ plaintext hash ties them). +4. The ZSLP genesis carries `document_hash` over the ciphertext (CONTENT_MODEL). +5. Frames go out as chained tiny shielded outputs to the owner's own private + address (self-custody of the sealed bytes). + +Review card (NATIVE_UX §3.3 card 4) gains, for Private, the §3.3 consequence +table ("Stays private" → right column "Nothing") plus the **"Becomes N small +private notes"** row and the §2 honesty line. The fee row is the real per-tx fee +× the number of txs (`ceil(frames / 107)`). + +--- + +## 7. The binary-safe GUI read path (design; GUI edit deferred — build in flight) + +**The bug** (`zcl-qt-wallet/src/rpc.cpp` ~756–760): a memo arrives as a hex +string in JSON; the code does +`QString(QByteArray::fromHex(...))` and then `.trimmed().isEmpty()`. Constructing +a `QString` from raw bytes runs them through the local 8-bit/UTF-8 codec, which +**mangles every non-text byte** — exactly the ZDC1 frame bytes. `f600` (the empty +marker) is special-cased, but a binary frame is silently corrupted into the text +inbox or dropped. + +**The fix (a PARALLEL path, text path untouched):** + +``` +// memoHex is the raw JSON memo string (hex). Decode to BYTES, never QString. +QByteArray memoBytes = QByteArray::fromHex( + QByteArray::fromStdString(i["memo"].get())); + +if (memoBytes.size() == 512 && isZdc1Frame(memoBytes)) { + // Route to the data-channel receive engine. Do NOT touch the text inbox. + dataChannel->ingest(zaddr, txid, + reinterpret_cast(memoBytes.constData()), + memoBytes.size()); +} else { + // EXISTING text path, unchanged: + QString memo(memoBytes); // text memos only reach here + if (!memo.startsWith("f600") && !memo.trimmed().isEmpty()) + memos[zaddr + txid] = memo; +} +``` + +`isZdc1Frame()` is a 5-byte check (magic + version) — it must NOT pull in +libsodium on the GUI side for the *detection* step. Two clean options: + +- **(A, preferred) thin daemon RPCs.** The daemon owns the codec and exposes + `z_listdatatransfers` / `z_getdatatransfer(transfer_id)` / + `z_receivedatafile`. The GUI calls them and renders §4 states from the JSON. + The GUI never links libsodium for this; the daemon already does. This keeps the + key material in the daemon/wallet, never in the GUI process. +- **(B) GUI-side codec.** Compile `src/datachannel/zdc.{h,cpp}` into the GUI and + call `zdc::PeekFrame`/`Decoder` directly. Simpler to prototype, but puts the + per-transfer key in the GUI process; only acceptable if the GUI already holds + spending keys. + +**Decision: ship (A).** Keys stay where the spend authority already is; the GUI +stays a thin renderer; the codec lives in exactly one place. The detection-only +`isZdc1Frame()` (magic+version, no crypto) can live GUI-side either way so the +text inbox is fixed even before the RPCs land. + +--- + +## 8. API surface (what the daemon/GUI call) + +The codec (`src/datachannel/zdc.h`) is the whole library surface. Send side: + +``` +zdc::ZdcAead::generate_key(key); // 32B CSPRNG per transfer +zdc::Encoder::encode(transfer_id, key, plaintext, meta, + include_key_frame, frames); // -> N x 512-byte memos +zdc::Encoder::encode_key_frame(transfer_id, key, chunk_count, frame); // reveal later +``` + +Receive side: + +``` +zdc::Decoder d; +for (each 512-byte memo) d.add_frame(memo); // any order, dups OK +d.is_complete(); // structurally here? +d.have_key(); // key present? +d.set_key(key); // out-of-band reveal +d.assemble(out_plaintext, out_meta); // OK / ERR_NO_KEY / ERR_* +``` + +These map 1:1 onto the daemon RPCs in §7(A). Recommended RPC shapes: + +- `z_senddatamemo(fromaddr, toaddr, hexpayload, {filename, content_type, + reveal_later})` → `{transfer_id, opids[]}`. Enforces the 64 KB default cap and + ≤107 outputs/tx batching. +- `z_listdatatransfers()` → array of `{transfer_id, direction, state + (arriving|waiting_key|ready|failed), chunks_received, chunks_expected, + filename, size}`. +- `z_getdatatransfer(transfer_id, [outpath])` → on Ready, returns the bytes (or + writes to `outpath`); else the state. Never returns the key. +- `z_revealdatakey(transfer_id, toaddr)` → sends the KEY frame; `{opid}`. + +--- + +## 9. Honest limits (must appear in the one-time consent + the §2 line) + +Default-OFF, opt-in, behind a one-time consent dialog: + +> **"Private files on the network — read this once."** +> "ZClassic can send a sealed file or message that only the person you choose can +> open. The network hides who it's from, who it's to, the amount, and the +> contents. It does **not** hide that a private transfer happened, roughly when, +> or about how big it was. Everything you send this way is stored by every node +> **permanently** and cannot be deleted. Keep files small. Don't send anything +> you couldn't live with being stored forever, even sealed." +> [Not now] [I understand — turn it on] + +What we never claim: untraceable, deletable, one-of-a-kind, DRM-protected, or +that sending a public-source fee hides the sender (fund from shielded inputs). +The green check means **bytes match the on-chain fingerprint** — nothing about +genuine/official/who-made-it (NATIVE_UX §2.2 honesty rule, carried verbatim). + +--- + +## 10. Why this design (security rationale, one place) + +- **Nonce uniqueness is structural, not lucky.** The 12-byte AEAD nonce = + `transfer_id(8) || counter(4)`; the key is fresh per transfer, and each frame + gets a distinct counter by ROLE (DATA→chunk index, START→0xFFFFFFFF, + END→0xFFFFFFFE; KEY isn't encrypted). The naive "counter = wire seq" would make + START(0) and DATA-chunk-0(0) share a nonce — catastrophic — so the codec maps + roles to a reserved counter band instead. Verified: 13 encrypted frames → 13 + unique nonces; START vs DATA-0 never collide. +- **Header is AAD.** version/type/transfer_id/seq/chunk_count are authenticated, + so a reordered/retyped/cross-spliced frame fails to decrypt. +- **Two integrity layers.** Per-frame Poly1305 tag (security; catches any flipped + byte — verified 80/80 single-bit flips caught with the CRC repaired) + an END + SHA-256 over the whole plaintext (binds the NFT fingerprint; catches a re-sealed + substitute chunk → `ERR_HASH_MISMATCH`). The header CRC32 is transport-only and + explicitly NOT a security control. +- **Seal-then-reveal is first-class.** A complete-but-keyless transfer returns + `ERR_NO_KEY`; the recipient genuinely cannot open it until the key arrives. + +Codec status: `src/datachannel/zdc.{h,cpp}` compiles standalone and under the +daemon's strict `-std=c++11 -pedantic-errors -Wall -Wextra` with zero warnings; +tests in `src/datachannel/test/` pass (800+ checks, 0 failures) plus an +independent adversarial verification (nonce uniqueness, tamper sweep, +seal/reveal, wrong-key, out-of-order/dup) all green. diff --git a/doc/nft/README.md b/doc/nft/README.md new file mode 100644 index 00000000000..9f489ec8b33 --- /dev/null +++ b/doc/nft/README.md @@ -0,0 +1,121 @@ +# ZClassic Native NFTs — Documentation Index + +This folder specifies **native, no-browser NFTs on ZClassic**: minting any file/image/video, +holding and verifying it, gifting and atomically trading it, and — uniquely — **private** +NFTs over the shielded pool. Everything here is designed to ride **unchanged network +consensus** (old, unmodified nodes relay and mine these transactions; no consensus change is +ever required). Security comes from a **non-consensus overlay that every honest wallet +re-validates deterministically** — not from the chain rejecting bad transactions. + +> One-line model: **a forgery can be mined, but it credits nobody** — uniqueness/ownership are +> a deterministic function of the confirmed chain that every correct implementation computes +> identically. + +> **Build status (dev/testnet):** MINT + VIEW = built (daemon RPCs + native GUI). SHIELD +> (private files/NFTs over the shielded data channel) and SELL (NFT⇄ZCL atomic swap) = now +> built in the daemon (CLI, regtest-proven; SHIELD default-OFF behind `-datachannel`); the +> native GUI for SHIELD/SELL is next. See **NATIVE_NFT_GUIDE §1** for file:line ground truth. + +--- + +## ⭐ Start here + +**[NATIVE_NFT_GUIDE.md](NATIVE_NFT_GUIDE.md)** — the single, canonical, build-ready guide. +It answers "what can I do" (works-now / building-now / next), gives the per-screen native-Qt UI +spec (gallery, detail, mint-from-file, send/gift, private NFT, later trade — exact widget trees, +states, copy, and `zslp_genesis`/`zslp_send` RPC calls), the privacy stack + as-built RPC API +(`z_senddatafile`/`z_listdatatransfers`/`z_getdatatransfer`, with selective disclosure via the +returned per-transfer key / `z_exportviewingkey`), and the non-negotiables. **Read this first. +Everything below is reference depth.** + +## Canonical reference docs (cited by the guide) + +After the guide, these remain authoritative for their own domain: + +1. **[SECURITY_MODEL.md](SECURITY_MODEL.md)** — **the canonical security spec.** The + non-consensus model, the bit-exact canonical validation rules (R-1…R-25), the threat table, + the wallet anti-burn suite (R-WALLET-1…11), the honest uniqueness statement, and the two + closure criteria (R-VECTORS test corpus + R-DIFF second-implementation differential test). + **If anything elsewhere conflicts with this doc, this doc wins.** +2. **[MINT_TRANSFER_SPEC.md](MINT_TRANSFER_SPEC.md)** — the **write path**: the shared + OP_RETURN tx builder (mint genesis + conservation-valid transfer), self-validation before + broadcast, dust/fee/anti-burn funding, and the proof that unchanged nodes relay+mine it. + **Authoritative for the `zslp_genesis`/`zslp_send` contract.** +3. **[NATIVE_UI_CONSOLIDATED_SPEC.md](NATIVE_UI_CONSOLIDATED_SPEC.md)** — the deep, file:line- + grounded native-Qt widget-tree reference the guide's §2 summarizes. +4. **[CONTENT_MODEL.md](CONTENT_MODEL.md)** — **any file/image/video → NFT** via + content-addressing: on-chain fingerprint (SHA-256 + Merkle root for large files), off-chain + bytes, streaming verification (a multi-GB video hashes in bounded memory). +5. **[ZDC1_CODEC_SPEC.md](ZDC1_CODEC_SPEC.md)** — the shielded data-channel codec reference + (frame/reassemble/AEAD/seal-then-reveal/ciphertext fingerprint). +6. **[NFT_SELL_DESIGN.md](NFT_SELL_DESIGN.md)** — **authoritative for trades.** Selling an NFT + for ZCL via a fixed-template `SIGHASH_ALL|ANYONECANPAY` signed offer (OP_RETURN ZSLP + SEND@vout[0] / buyer NFT dust@vout[1] / seller ZCL payout@vout[2]); the mandatory + `nft_verifyoffer` check; why any shielded leg cannot be trustlessly atomic; the + private-negotiation / public-settlement hybrid. + +## Superseded (kept for traceability, do not delete) + +- **[ONCHAIN_TRADES.md](ONCHAIN_TRADES.md)** — the early transparent-trade sketch; + **SUPERSEDED by NFT_SELL_DESIGN.md.** Its `SIGHASH_SINGLE|ANYONECANPAY` layout is WRONG and + funds-losing (SINGLE pins vout[0]=OP_RETURN, not the payout → burns the seller NFT), and + several of its ZSLP claims are stale; kept for history only — do not implement from it. + +These fed the guide and are superseded by it for the role noted; kept for depth/history: + +- **[NATIVE_UX.md](NATIVE_UX.md)**, **[NATIVE_UI_BUILD_PLAN.md](NATIVE_UI_BUILD_PLAN.md)** — + the two source UI docs; **superseded by NATIVE_NFT_GUIDE.md §2 + NATIVE_UI_CONSOLIDATED_SPEC.md.** +- **[PRIVACY_STACK.md](PRIVACY_STACK.md)**, **[PRIVACY.md](PRIVACY.md)**, + **[PRIVACY_UX.md](PRIVACY_UX.md)** — the privacy normative/UX sources; + **superseded by NATIVE_NFT_GUIDE.md §3** (ZDC1_CODEC_SPEC.md kept as the codec reference). +- **[CAPABILITY_MAP.md](CAPABILITY_MAP.md)** — the code-verified status map; + **folded into NATIVE_NFT_GUIDE.md §1** (kept for its file:line citations). +- **[ENABLEMENT.md](ENABLEMENT.md)** — early why/aspirational matrix; **superseded for status by + NATIVE_NFT_GUIDE.md §1.** Its stale lines (gallery "fed by fixtures / 0 zslp_* calls" — + `refreshNFTs` already calls the real RPCs; "no `src/datachannel/`" — the codec exists at + `src/datachannel/zdc.{h,cpp}`) are corrected in the guide; trust the guide and the code. + +--- + +## Supporting analyses (fed the canonical docs above) + +These are the per-topic threat models and requirement lists the synthesis drew from. Useful for +depth and traceability; **`SECURITY_MODEL.md` is the normative summary of all of them.** + +| Topic | Threat model | Requirements / spec | +|---|---|---| +| Forgery & conservation | [zslp-forgery-conservation-threat-model.md](zslp-forgery-conservation-threat-model.md) | folded into SECURITY_MODEL R-10…R-19 | +| Cross-impl determinism | — | [zslp-determinism-spec.md](zslp-determinism-spec.md), [CANONICAL_VALIDATION_SPEC.md](CANONICAL_VALIDATION_SPEC.md), [zslp-canonical-validation-conformance-checklist.md](zslp-canonical-validation-conformance-checklist.md) | +| Holder anti-burn | [holder-anti-burn-threat-model.md](holder-anti-burn-threat-model.md) | [holder-anti-burn-requirements.md](holder-anti-burn-requirements.md), [zslp-wallet-antiburn-ux-honesty.md](zslp-wallet-antiburn-ux-honesty.md) | +| Reorg / confirmations | [REORG_CONFIRMATION_SAFETY.md](REORG_CONFIRMATION_SAFETY.md) | [REORG_CONFIRMATION_REQUIREMENTS.md](REORG_CONFIRMATION_REQUIREMENTS.md) | +| Impersonation & uniqueness | [IMPERSONATION_UNIQUENESS.md](IMPERSONATION_UNIQUENESS.md) | folded into SECURITY_MODEL R-UX-1…R-UX-9 | +| DoS / spam / griefing | [THREATS_DOS_SPAM_GRIEF.md](THREATS_DOS_SPAM_GRIEF.md) | [REQUIREMENTS_DOS_SPAM_GRIEF.md](REQUIREMENTS_DOS_SPAM_GRIEF.md) | +| Short overview | [zslp-security-model.md](zslp-security-model.md) (superseded by SECURITY_MODEL.md) | — | + +*Cleanup note:* the supporting docs overlap by design (independent reviewers). A later pass may +fold them entirely into the canonical set; until then, cite `SECURITY_MODEL.md` as normative. + +--- + +## Implementation status (code, not just docs) + +The single, code-verified status table now lives in **[NATIVE_NFT_GUIDE.md §1](NATIVE_NFT_GUIDE.md)** +(works-now / building-now / next, tied to file:line ground truth). It is maintained in one place so +build-status never drifts across docs again. **See the guide §1.** + +**Run the journey (regtest).** End-to-end, runnable proof of the write/sell paths lives in +`qa/zslp/` — `zslp-nft-regtest.sh` (mint → inspect → transfer → anti-burn → list) and +`nft-sell-regtest.sh` (makeoffer → verifyoffer → takeoffer, plus forged/tampered-offer +rejection). See `qa/zslp/README.md`. + +--- + +## Non-negotiables (apply to every doc and every line of code here) + +- **Never change consensus.** If a feature needs a new consensus rule, it is out of scope. +- **Honest UX.** The image-match badge means *"these bytes match the on-chain fingerprint"* — + never "genuine/official/original." Issuer trust is social (signed attestation / verified + list), not a network badge. Ownership is *pending* until ~10 confirmations. +- **Privacy.** Never auto-fetch a remote `documenturl`; bytes come from local cache or an + explicit user action. No `QtWebEngine`/browser anywhere. +- **Holder safety.** An ordinary send must never spend/burn an NFT's carrier UTXO. diff --git a/doc/nft/REORG_CONFIRMATION_REQUIREMENTS.md b/doc/nft/REORG_CONFIRMATION_REQUIREMENTS.md new file mode 100644 index 00000000000..f3730cbfbce --- /dev/null +++ b/doc/nft/REORG_CONFIRMATION_REQUIREMENTS.md @@ -0,0 +1,148 @@ +# ZSLP Reorg/Confirmation — Requirements Checklist + +Flat, testable checklist for the `reorg-confirmation` threat class. Companion to +`REORG_CONFIRMATION_SAFETY.md` (the security model + canonical spec). Each item has a +**verification method** so "done" is observable, not asserted. + +Legend: **[INDEXER]** = the conservation rewrite (`src/zslp/*`); **[WALLET]** = +`src/wallet/*`; **[GUI]** = `zcl-qt-wallet`; **[RPC]** = `src/rpc/zslp.cpp`; +**[TEST]** = test/vector deliverable. This doc edits nothing under `src/`. + +--- + +## A. Confirmed-block-only invariant (no 0-conf into the ledger) + +- **R1 [INDEXER]** The store is mutated **only** from `ChainTip` connect/disconnect. + The indexer MUST NOT override `SyncTransaction` or read the mempool. *(Today: only + `ChainTip` is overridden, `src/zslp/zslpindexer.h:49-51`; `ChainTip` fires only on + confirmed blocks, `src/main.cpp:3440`/`:3049`.)* + **Verify:** grep the rewrite for `SyncTransaction`, `mempool`, `mapTx` → none in + the store/indexer write path; unit test that a tx only in the mempool produces no + store record. + +## B. Deterministic, byte-exact reorg/undo + +- **R2 [INDEXER][TEST]** Every connect-side mutation appends a paired undo op, and + `DisconnectBlock` restores the **byte-identical** pre-connect DB. + **Verify:** connect a block, dump the full DB; connect, then disconnect, dump + again; assert the dumps are identical (covers token/utxo/balance/transfer/undo/tip + records). Add a same-block create-then-consume case + (`src/zslp/zslpstore.cpp:675-682`) and a multi-delta-same-address case. +- **R3 [INDEXER]** Crash-resume + re-delivery idempotence preserved: connect skips a + block already at the stored tip (`src/zslp/zslpindexer.cpp:180-183`); CatchUp + resumes one past stored tip (`:99-126`); version stamp written before reindex + (`:82-84`). + **Verify:** deliver the same connect twice → no double count; kill mid-reindex, + restart → resumes, final ledger equals replay-from-scratch. +- **R20 [TEST]** Reorg round-trip + 2-block reorg converge to one ledger + (incremental undo/redo == replay-from-scratch). + **Verify:** scripted regtest reorg; compare full DB dumps. + +## C. Canonical parse/apply determinism (close the ledger-fork class) + +- **R4 [INDEXER]** **SLP message is read from `vout[0]` ONLY.** If `vout[0]` is not a + parseable SLP `OP_RETURN`, the tx carries no SLP message (inputs still burn). + *(Today DIVERGES: scans all vouts, `src/zslp/zslpindexer.cpp:211-279`.)* + **Verify:** vector E1 — tx with junk vout[0] + valid SLP at vout[5] ⇒ not SLP. +- **R5 [INDEXER][TEST]** Pin ONE push-encoding rule (strict-minimal recommended) and + freeze it with vectors. `read_push` is currently lenient + (`src/zslp/op_return_push.h:24-46`). + **Verify:** vector E2 — minimal vs non-minimal encoding of the same field decide + SLP-or-not identically on every impl. +- **R6 [TEST]** Frozen vectors for E1–E13 (table in `REORG_CONFIRMATION_SAFETY.md` + §3.7): vout placement, zero-qty slot positionality, output-index-out-of-range + burn, Σ-overflow INVALID, under-funded INVALID, non-token input = 0, MINT w/o + baton, GENESIS voutCount≤1, baton bounds, duplicate genesis, >19 outputs. + **Verify:** corpus runs green; documented expected ledger delta per vector. +- **R9 [INDEXER][TEST]** uint64→int64 boundary is defined. SLP quantities are uint64 + on the wire (`src/zslp/slp.h:51,55,60`) but stored/summed as int64 + (`src/zslp/zslpstore.cpp:497-550`). A wire qty ≥ 2^63 becomes a negative int64. + Define the canonical outcome (recommend: treat qty ≥ 2^63 as INVALID at parse, or + clamp deterministically) and lock it. + **Verify:** vector E11 — qty = 2^63 and 2^64-1; identical outcome everywhere; no + signed overflow UB. +- **R10 [INDEXER]** Single SEND-output cap. Parser caps at 19 + (`src/zslp/slp.c:151`); indexer/store clamp at 20 + (`src/zslp/zslpindexer.cpp:265-268`, `src/zslp/zslpstore.cpp:540-542`). Unify to 19 + everywhere. + **Verify:** vector E13 — exactly 19 and an attempted 20th decode identically. +- **R19 [TEST]** Ship the vector corpus as the canonical oracle + a multi-tx in-block + ordering case (tx2 spends tx1's created output within one block) and a full + GENESIS→MINT→SEND→burn lifecycle. +- **R21 [TEST]** Fuzz `slp_parse`/`read_push`: deterministic SLP-or-not, no OOB read + (bounds check `src/zslp/op_return_push.h:43`), no crash. + +## D. Confirmation-depth gate (don't show "final" too early) + +- **R12 [RPC]** Read RPCs expose `confirmations = chainActive.Height() - height + 1` + per UTXO/transfer/token (read-side, under `cs_main`; ledger unchanged). Today no + depth is surfaced (no `confirm`/`depth` refs in `src/zslp/*` or + `src/rpc/zslp.cpp`). + **Verify:** RPC returns a `confirmations` field that increments per block. +- **R13 [GUI]** Ownership/authenticity shown **pending** until **N confirmations**, + then **final**. Default `N = DEFAULT_MAX_REORG_DEPTH = 10` (`src/main.h:116`) so UI + "final" == node finalization point. Single named constant. + **Verify:** GUI snapshot at 1..9 conf = pending; at ≥10 = final. +- **R14 [GUI]** A mempool-only transfer is never "received/owned." Any pending-send + indicator is labeled *unconfirmed / not yet final*. + **Verify:** broadcast a token send; recipient GUI shows nothing owned until + confirmed. +- **R15 [GUI]** Reorg demotion: GUI reads live store state on each ChainTip and never + caches an "owned" flag across blocks; an orphaned transfer disappears. + **Verify:** regtest reorg that orphans a transfer → GUI demotes within one tip. +- **R8 [INDEXER]** Undo is bounded: the node never applies a reorg deeper than + `MAX_REORG_LENGTH = 99` (`src/main.h:58`, shutdown at `src/main.cpp:3643-3654`), so + DisconnectBlock is never asked to undo more than that in one pass. + **Verify:** documented invariant; reorg-depth test stays within bound. + +## E. Wallet anti-burn (holder protection — token UTXOs must not be spent as fee/change) + +The wallet has **ZERO** ZSLP awareness today (verified: no zslp refs in +`src/wallet/`). An ordinary send can spend a token-carrying dust UTXO as +fee/change and **BURN** the token/NFT (§3.1: any token UTXO a tx spends is consumed, +and a non-SLP tx reassigns nothing → permanent burn). + +- **R22 [WALLET]** Coin selection MUST identify token-carrying UTXOs (via the store: + `GetUtxo(txid, vout)`, `src/zslp/zslpstore.h:357`) and **exclude** them from normal + fee/change selection so a routine ZCL send can never consume a token UTXO. + **Verify:** with a token UTXO in the wallet, repeatedly `sendtoaddress` ZCL until + change is forced; assert the token UTXO is never selected (store UtxoCount + unchanged; `zslp_listmytokens` balance unchanged). +- **R23 [WALLET]** A token UTXO is spendable **only** through an explicit token-send + path (or explicit coin-control opt-in), never implicitly. + **Verify:** no implicit code path reaches a token UTXO; only the typed send / coin + control can. +- **R24 [WALLET/GUI]** Token UTXOs are surfaced in coin-control, labeled as + token-bearing (so a user who *does* select one is warned it carries a token). + **Verify:** coin-control lists the UTXO with a token tag + a burn warning. +- **R25 [WALLET]** Burn is acknowledged, never silent: any path that *would* spend a + token UTXO outside a token-send requires explicit confirmation describing the burn. + **Verify:** attempting it raises a confirmation that names the token + "will be + destroyed." + +## F. Honest impersonation/uniqueness UX + +- **R16 [GUI]** Token-id (genesis txid) is shown as the identity; ticker/name never + presented as unique. RPC already returns `tokenid` (`src/rpc/zslp.cpp:43`). + **Verify:** UI shows a genesis-txid fingerprint alongside the name. +- **R17 [GUI]** `documentHash` framed as "matches the issuer's committed hash," not + "genuine/original." (`src/zslp/zslpindexer.cpp:238-246`, RPC `:47-48`.) + **Verify:** copy review; no "genuine"/"the original" wording. +- **R18 [GUI/DOC]** No claim that consensus enforces token uniqueness; impersonation + is a social/identity layer (consistent with `doc/nft/ENABLEMENT.md` honest ceiling). + **Verify:** doc/UI copy review. +- **R7 / R11 [GUI]** NFT classification (baton-less GENESIS, decimals==0, qty==1) is + computed on the read side from token fields; the store does not special-case NFTs. + **Verify:** an NFT genesis renders as 1-of-1; a decimals>0 / qty>1 token does not. + +--- + +## G. What this checklist deliberately does NOT promise + +- It does **not** make consensus reject forgeries — impossible by the hard constraint + (§1 of the model). Forgeries appear on-chain and are neutralized by *interpretation* + (credit nobody / burn), not by rejection. +- It does **not** prevent a *different* token reusing a name/image — only the token-id + is unique; the rest is honest UX (Section F). +- It does **not** protect a counterparty who acts on **< N** confirmations — that is + exactly why R13 exists; sub-finalization receipts are inherently reorg-revocable. diff --git a/doc/nft/REORG_CONFIRMATION_SAFETY.md b/doc/nft/REORG_CONFIRMATION_SAFETY.md new file mode 100644 index 00000000000..df85cb7fa21 --- /dev/null +++ b/doc/nft/REORG_CONFIRMATION_SAFETY.md @@ -0,0 +1,416 @@ +# ZSLP Reorg & Confirmation Safety — Security Model + Canonical Validation Spec + +**Threat class:** `reorg-confirmation` — token ownership undone by a chain reorg; an +apparent receipt at 0-conf that later vanishes; cross-implementation ledger forks +that let an attacker show conflicting "ownership" to two parties. + +**Status:** security model + requirements checklist. This document does **not** edit +`src/zslp/*`. A concurrent workflow owns the UTXO-bound conservation rewrite; this +spec is the contract that rewrite (and the wallet + GUI) must satisfy. + +**Scope of authority:** every claim that touches behavior cites `file:line` in this +tree (ZClassic = Zcash 2.x fork, Bitcoin Core 0.11–0.12 lineage). Where the code is +**already correct**, it is credited so the rewrite does not regress it. Where it is +**wrong or missing**, it is a numbered requirement. + +--- + +## 0. The one-sentence threat + +> Base consensus relays and mines **any** standard transaction, including an +> `OP_RETURN` that encodes a **forged** token SEND or a token transfer that a reorg +> will later orphan. We can never make consensus reject these. Therefore token +> safety is **not** "the chain refuses the bad tx" — it is **"every honest observer +> deterministically computes the identical ledger over the consensus-ordered, +> confirmed block history, and never shows ownership as final before it is +> reorg-safe."** + +Two distinct failure modes live in this threat class, and they have different +defenses: + +| Failure | What the attacker does | The defense layer | +|---|---|---| +| **Reorg undo** | Get a token tx confirmed, let the counterparty act, then have the block orphaned (natural reorg or a self-mined short fork). | Deterministic byte-exact undo + a **confirmation-depth gate** before the GUI says "final". | +| **0-conf vanish** | Show a payee an unconfirmed token receipt that never confirms (double-spend the dust, or the OP_RETURN never makes a block). | Index **confirmed blocks only**; GUI shows **pending until N confirmations**. | +| **Ledger fork** | Craft an edge-case tx (push-encoding, vout placement, overflow) that implementation A reads as a valid transfer and implementation B reads as invalid/burn → present each party "their" version of ownership. | **One canonical parse + apply spec, bit-exact, with cross-impl test vectors.** | + +The first two are *time* problems (don't trust too early). The third is an +*agreement* problem (everyone must compute the same thing). This document hardens +all three. + +--- + +## 1. What base consensus does and does NOT do (the hard constraint) + +`-zslpindex` is a pure **observer**. It registers on the validation signal bus only +to *read* connected/disconnected blocks; it never votes on validity, never affects +PoW, mempool acceptance, or wallet spends. Disabling it changes nothing about +consensus. (`src/zslp/zslpindexer.h:1-9`, init default-on at +`src/init.cpp:3271-3273`, help text `src/init.cpp:526`.) + +Consequences that **cannot** be engineered away: + +- A miner can include a tx whose `OP_RETURN` claims to SEND a token the sender does + not own. Consensus mines it. **Defense:** the overlay interprets it as crediting + **nobody** and **burning** any token UTXOs it actually spent — see §3 SEND rule. + The forgery is on-chain but moves no honest observer's ledger. +- A reorg can orphan a block that carried a valid token transfer. Consensus *will* + do this (up to the node's reorg bound). **Defense:** deterministic undo (§4) + + confirmation-depth gate (§5). +- Anyone can mint a *different* token reusing a name/ticker/image. **Defense:** none + at the protocol layer — uniqueness is at the token-id (genesis txid) level only; + impersonation is a social/identity problem, surfaced honestly in the GUI (§6). + +--- + +## 2. The existing reorg machinery this spec ties into (do NOT re-derive it) + +The indexer is **already** correctly bound to confirmed blocks, and the store +**already** has a byte-exact undo log. This section credits the existing work so the +conservation rewrite preserves it; the requirements in §3–§6 sit *on top* of it. + +### 2.1 Confirmed-block binding (already correct) + +`CZSLPIndexer` overrides **only** `ChainTip(...)` and nothing else +(`src/zslp/zslpindexer.h:49-51`; the class subscribes to no other signal). It does +**not** override `SyncTransaction` — so it **never sees mempool / 0-conf +transactions at all**. `added=true` → `ConnectBlock`, `added=false` → +`DisconnectBlock` (`src/zslp/zslpindexer.cpp:157-166`). + +`ChainTip` is emitted **only** from two sites, both under `cs_main`, both on a +*confirmed* block: + +- connect: `src/main.cpp:3440`, inside `ConnectTip`, **after** `view.Flush()` + (`:3412`) and `FlushStateToDisk` (`:3417`) — i.e. after the chainstate is durable. +- disconnect: `src/main.cpp:3049`, inside `DisconnectTip`, after `UpdateTip`. + +**Property A (load-bearing): the ZSLP ledger is a deterministic function of the +consensus-ordered, confirmed block history. There is no 0-conf code path into the +store.** The rewrite MUST NOT add one (no `SyncTransaction` override, no mempool +peeking). See requirement R1. + +### 2.2 Byte-exact undo log (already present; the model to preserve) + +Per block, every mutation appends a typed undo op under key `'r' + blockHash + +BE(seq)` (schema `src/zslp/zslpstore.h:226`, kinds `:249-257`). `ConnectBlockBegin` +resets the running seq (`src/zslp/zslpstore.cpp:407-411`); `DisconnectBlock` reads +the block's ops in ascending seq and **replays them in reverse** +(`src/zslp/zslpstore.cpp:591-731`), restoring consumed UTXOs, erasing created ones, +reversing balance / `totalMinted` / baton changes, deleting transfer + token rows, +then moving the tip marker back (`:727-728`). + +The undo replay is written to be **idempotent across same-block sibling ops**: it +accumulates per-token / per-balance / per-utxo changes in memory and writes each +record exactly once (`:622-720`), because `readToken`/`readBalance`/`readUtxo` see +only committed data, not the pending batch (comment `:615-621`). A same-block +create-then-consume nets to *erase* because reverse-order replay sees CONSUME first +(stages a write) then CREATE (stages an erase), last-writer-per-key wins, and CREATE +has the lower seq so it is processed later in the reverse loop (comment `:675-682`). + +**Property B (load-bearing): DisconnectBlock yields the byte-identical pre-connect +store state.** This is asserted by the design but is **only as true as its tests**. +See requirement R2 (round-trip determinism test is mandatory, not optional). + +### 2.3 Crash-resume / idempotent re-delivery (already present) + +`ConnectBlock` skips a block whose hash already equals the stored tip +(`src/zslp/zslpindexer.cpp:180-183`) so a re-delivered connect for the current tip +cannot double-count. `CatchUp()` resumes one past the stored tip +(`src/zslp/zslpindexer.cpp:99-126`). The version stamp is written **before** +reindexing so a crash mid-reindex resumes from the per-block tip rather than +re-wiping (`src/zslp/zslpindexer.cpp:82-84`). These must be preserved (R3). + +### 2.4 The node's own reorg bounds (free finalization the gate can lean on) + +Consensus already bounds how deep a reorg the node will follow: + +- Auto-finalization depth `DEFAULT_MAX_REORG_DEPTH = 10` (`src/main.h:116`); a block + ~10 deep is finalized and a reorg before it is rejected (`FindBlockToFinalize` + `src/main.cpp:3206-3234`, `IsBlockFinalized` gate at `:1985`, `:3072`). +- Hard reorg ceiling `MAX_REORG_LENGTH = COINBASE_MATURITY - 1 = 99` + (`src/main.h:58`, `src/consensus/consensus.h:29`); a reorg deeper than that **shuts + the node down** rather than reorging (`src/main.cpp:3643-3654`). + +**Implication for the confirmation gate (§5):** the deepest reorg the *local node +will ever apply on its own* is bounded, so the ZSLP DisconnectBlock path will never +be asked to undo more than `MAX_REORG_LENGTH` blocks in one ActivateBestChain pass. +This does **not** make a transfer safe at <10 confirmations (a 9-deep reorg is +allowed and *will* fire DisconnectBlock); it means a finite, testable upper bound +exists for undo, and that a confirmation gate of N≥`DEFAULT_MAX_REORG_DEPTH` aligns +the GUI's "final" with the node's own finalization point. See R8. + +--- + +## 3. CANONICAL VALIDATION SPEC (the agreement contract — bit-exact) + +This is THE spec every compatible implementation (this daemon, any wallet, any +explorer) must compute identically. **Any divergence forks the ledger.** Each rule +is normative; the "current code" note flags where the in-tree code already matches or +**diverges** (a divergence is a numbered requirement). + +### 3.1 Per-transaction order of operations (normative) + +For each tx, in **block order**, the observer MUST: + +1. **Consume inputs first, unconditionally.** For every `vin[k].prevout` that is a + recognized token UTXO, remove it and (for a quantity UTXO) debit its derived + balance. This happens for **every** tx, SLP or not — a non-SLP tx that spends a + token dust UTXO **burns** it. *(Current code: correct — + `src/zslp/zslpstore.cpp:432-446`; gathered for every tx at + `src/zslp/zslpindexer.cpp:199-203`.)* +2. **Parse at most one SLP message** from the transaction (§3.2). +3. **Apply the message's creates** only as far as the consumed inputs (or, for + GENESIS, the genesis itself) permit (§3.4–3.6). + +Order matters for determinism: consume-before-create lets a later tx in the same +block spend a UTXO an earlier tx created (per-tx batch commit, +`src/zslp/zslpstore.cpp:421-424`), and makes "spent-but-not-reassigned ⇒ burned" +fall out automatically. + +### 3.2 Which output carries the SLP message — **CANONICAL: vout[0] ONLY** + +**Normative rule:** the SLP `OP_RETURN` MUST be at **`vout[0]`**. If `vout[0]` is not +a parseable SLP message, the transaction **is not an SLP transaction** (it still +burns any token inputs it spends, per §3.1.1). An `OP_RETURN` at any other index is +**ignored**. + +This matches the canonical SLP specification and the in-tree header comment +(`src/zslp/slp.h:5` "Tokens are encoded in OP_RETURN outputs (vout[0])"). + +> **DIVERGENCE — must fix (R4, determinism-critical).** The current indexer scans +> **every** vout for the *first* parseable `OP_RETURN` and accepts it: +> `for (vo = 0; vo < tx.vout.size(); ++vo) { ... if (msgPresent) break; }` +> (`src/zslp/zslpindexer.cpp:211-279`, especially the loop start `:211` and the +> "first valid OP_RETURN wins" break `:277-278`). A tx with a junk/non-SLP `vout[0]` +> and a valid SLP `OP_RETURN` at `vout[5]` would be treated as SLP by this code but +> as **not-SLP** by any strict-vout[0] implementation → **ledger fork**. The +> conservation rewrite MUST restrict the scan to `vout[0]` only (parse +> `tx.vout[0].scriptPubKey`; if it is not `TX_NULL_DATA` or `slp_parse` fails, treat +> the tx as carrying no SLP message). The order of token-id byte conversion +> (`TokenIdToUint256`, `src/zslp/zslpindexer.cpp:147-153`) is unaffected. + +### 3.3 Push-encoding canonicalization — **must be pinned (R5)** + +`slp_parse` reads pushes via `read_push` (`src/zslp/op_return_push.h:24-46`), which +accepts direct pushes `0x01..0x4b`, `OP_PUSHDATA1` (`0x4c`), and `OP_PUSHDATA2` +(`0x4d`); it rejects anything else (`:39-41`). Canonical SLP additionally requires: + +- **Field 0 (lokad)** = exactly `"SLP\0"`, 4 bytes (`src/zslp/slp.c:48-51`). ✓ +- **token_type** = 1, encoded in 1–2 bytes (`src/zslp/slp.c:53-57`). ✓ +- A token_type push of length 0 or >2 ⇒ not SLP. ✓ (the `len < 1 || len > 2` guard). + +**Open determinism question to pin in tests (R5):** canonical SLP (BCH) mandates +*minimal* push encoding and treats a non-minimal push (e.g. value pushed via +`OP_PUSHDATA1` when a direct push would do) as **making the whole tx invalid SLP**. +The current `read_push` is **lenient** — it accepts `OP_PUSHDATA1`/`2` for any +length, including lengths that a minimal encoder would have done with a direct push. +Two valid options, but the choice MUST be made once and locked with vectors: + +- **(a) Strict-minimal (recommended, matches BCH SLP):** reject non-minimal pushes + in the parse path used by the indexer. Lowest fork risk against external SLP + tooling. +- **(b) Lenient (current behavior):** document that ZSLP intentionally accepts + non-minimal pushes. Acceptable *only if* it is the single canonical rule and every + compatible implementation copies `read_push` byte-for-byte. + +Either way: **the same bytes must decide SLP-or-not on every implementation.** R5 +requires a decision + a frozen vector set (§7) covering minimal vs. non-minimal +encodings of each field. + +### 3.4 GENESIS (normative) + +- token_id := **the genesis txid** (canonical; globally unique because consensus + txids are unique). *(Current: `parsed.tokenId = txid`, + `src/zslp/zslpindexer.cpp:229`, `:453`.)* ✓ +- **First-genesis-wins:** if a token row for that id already exists, do not overwrite. + *(Current: `if (... && !readToken(tokenId, existing))`, + `src/zslp/zslpstore.cpp:457`.)* ✓ — note this can only collide on a real txid + reuse, which consensus forbids, so it is belt-and-suspenders, but it MUST stay for + determinism under reindex. +- `decimals` MUST be 0–9 (`src/zslp/slp.c:99-101`). ✓ **For an NFT: decimals == 0.** +- `mint_baton_vout`: emitted only when ≥ 2; 0/1 mean no baton (`src/zslp/slp.c:103-109`). + A baton is *issued* only if `2 ≤ mint_baton_vout < voutCount` + (`src/zslp/zslpstore.cpp:463-466`). ✓ +- Initial quantity is created at **`vout[1]`** only if `initialQuantity > 0 && + voutCount > 1` (`src/zslp/zslpstore.cpp:474-477`). ✓ If `voutCount <= 1` the + quantity is **not created** (effectively burned at genesis). This edge MUST be in + the vector set (R6). +- **NFT definition (normative):** baton-less GENESIS, `decimals == 0`, + `initialQuantity == 1`. The store does not special-case NFTs; an NFT is just this + parameterization. The GUI/wallet classify by reading these fields (R7, R11). + +### 3.5 MINT (normative) + +- Valid **iff** a mint baton for that exact token_id was on a spent input + (`batonInputPresent[tokenId]`, `src/zslp/zslpstore.cpp:492-494`). No baton ⇒ create + nothing; consumed inputs stay burned. ✓ +- MINT of an unknown token ⇒ invalid, no outputs (`readToken` fails ⇒ break, + `src/zslp/zslpstore.cpp:489-491`). ✓ +- `totalMinted += additionalQuantity`, overflow-guarded against + `int64_t` max (`src/zslp/zslpstore.cpp:497-506`). ✓ — see R9 on the uint64↔int64 + boundary. +- New quantity at `vout[1]`; baton continues at its declared vout iff + `2 ≤ mint_baton_vout < voutCount` (`src/zslp/zslpstore.cpp:508-528`). ✓ + +### 3.6 SEND (normative) — the conservation core + +- `tokenId` := the message's token_id (display-order bytes reversed to internal, + `TokenIdToUint256`). ✓ +- `availIn` := Σ amounts of spent input UTXOs of **that token_id only** + (`availByToken[tokenId]`, `src/zslp/zslpstore.cpp:533-535`). Batons contribute 0 + (they carry no quantity, invariant `src/zslp/zslpstore.h:117-119`, + `src/zslp/zslpstore.cpp:441-444`). ✓ +- `requiredOut` := Σ `outputQuantities[j]`, computed with **overflow → INVALID** and + **any negative qty → INVALID** (`src/zslp/zslpstore.cpp:537-550`). ✓ +- **Validity:** `(!overflow && availIn >= requiredOut)`. If INVALID (under-funded or + overflow): **create nothing**; all that-token inputs already burned in §3.1.1 + (`src/zslp/zslpstore.cpp:552`, `:567-569`). ✓ — this is the anti-forgery property: + a forged/over-budget SEND credits nobody. +- **Positional mapping (normative):** `outputQuantities[j]` → **`vout[1+j]`**. A + zero-qty output **consumes a slot but creates nothing** (the positional mapping is + preserved across zero-qty outputs — `qty <= 0 ⇒ continue` *without* shifting the + index, `src/zslp/zslpstore.cpp:553-558`). ✓ This is determinism-critical: an + implementation that *skips* zero-qty slots instead of preserving position would + assign later quantities to the wrong vout → ledger fork. Lock with a vector (R6). +- **Output index out of range:** if `1+j >= voutCount`, that quantity is **burned** + (the create is skipped, `src/zslp/zslpstore.cpp:559-561`). ✓ +- **More outputQuantities than the spec cap:** the parser caps at 19 SEND outputs + (`src/zslp/slp.c:151` `while (num_outputs < 19)`); the message struct holds 20 + (`src/zslp/zslpmsg.h:47`); the indexer re-clamps to 20 + (`src/zslp/zslpindexer.cpp:265-268`) and the store re-clamps `n` to `[0,20]` + (`src/zslp/zslpstore.cpp:540-542`). The **two clamp limits differ (19 vs 20)** — + harmless today because the parser never emits >19, but it is a latent + divergence-by-construction. R10: pin a single cap (19, per parser) everywhere and + test the boundary. +- **"Input not a recognized token UTXO ⇒ contributes ZERO":** `readUtxo` miss ⇒ + `continue` (`src/zslp/zslpstore.cpp:438-440`). ✓ A SEND that lists a token_id for + which no input UTXO exists has `availIn == 0`, so any positive `requiredOut` is + INVALID. ✓ + +### 3.7 Determinism-critical edge cases — the checklist this spec freezes + +Every row MUST behave identically across implementations and MUST have a frozen test +vector (R6): + +| # | Edge case | Canonical outcome | Current code | +|---|---|---|---| +| E1 | OP_RETURN not at vout[0] | tx is **not SLP** (inputs still burned) | **DIVERGES — R4** (scans all vouts) | +| E2 | Non-minimal push encoding | one rule, locked (§3.3) | **UNPINNED — R5** (lenient) | +| E3 | SEND output index ≥ voutCount | that qty burned | ✓ `:559-561` | +| E4 | SEND zero-qty output | slot consumed, nothing created, position preserved | ✓ `:553-558` | +| E5 | Σ outputQuantities overflows int64 | INVALID (create nothing) | ✓ `:546-550` | +| E6 | SEND availIn < requiredOut | INVALID (inputs burned) | ✓ `:552,567` | +| E7 | input not a token UTXO | contributes 0 | ✓ `:438-440` | +| E8 | MINT without baton input | create nothing | ✓ `:492-494` | +| E9 | GENESIS with voutCount ≤ 1 | quantity burned (not created) | ✓ `:474` (guard) | +| E10 | baton vout ≥ voutCount or < 2 | no baton issued | ✓ `:463-466,508-509` | +| E11 | qty parsed as uint64 ≥ 2^63 | see R9 (uint64→int64 boundary) | **NEEDS PROOF — R9** | +| E12 | duplicate genesis txid (reindex) | first-genesis-wins | ✓ `:457` | +| E13 | >19 SEND outputs | capped consistently | **two caps 19/20 — R10** | + +--- + +## 4. Reorg / undo requirements (tie to existing machinery, don't rewrite it) + +The undo log (§2.2) is the right design. The hardening is to make its **byte-exact** +claim **proven**, and to make the conservation rewrite preserve it. + +- The store records, per block, *exactly* the UTXO creates/consumes + derived deltas + applied, so DisconnectBlock reverses them precisely + (`src/zslp/zslpstore.h:236-353`, impl `:591-731`). +- The reverse-replay nets same-block sibling ops in memory and writes each record + once (`src/zslp/zslpstore.cpp:615-720`). + +The **risk** is not the algorithm; it is that the conservation rewrite touches the +same mutation sites (`createUtxo`/`consumeUtxo`/`recordBalanceDelta`/`flushBalances`, +`src/zslp/zslpstore.cpp:289-403`) and could add a mutation that has **no matching +undo op** — silently breaking byte-exactness in a way only a reorg reveals. Hence R2 +(every mutation site MUST append a paired undo op, asserted by a connect→disconnect +round-trip test that compares the **full DB dump** before/after). + +--- + +## 5. Confirmation-depth gate (the missing layer) — requirements + +There is **no confirmation/depth concept anywhere in the ZSLP code today** (verified: +no `confirm`/`depth`/`mature` references in `src/zslp/*` or `src/rpc/zslp.cpp`). The +store records `height` per UTXO/transfer/token, so depth is derivable as +`chainActive.Height() - record.height + 1`, but nothing surfaces it and nothing gates +on it. + +Because the indexer is confirmed-block-only (§2.1), a record in the store is always +≥ 1 confirmation. But **1 confirmation is not reorg-safe**: the node itself will apply +reorgs up to `MAX_REORG_LENGTH = 99` and only auto-finalizes at depth +`DEFAULT_MAX_REORG_DEPTH = 10` (§2.4). So a token "received" at 1–9 confirmations can +still be undone by DisconnectBlock. + +**Requirements:** + +- **R12 (depth is a first-class, read-side field):** the read RPCs MUST expose the + confirmation depth of each token UTXO / transfer (`confirmations = + tipHeight - height + 1`) so the wallet/GUI can gate on it. This is a read-side + computation against `chainActive.Height()` under `cs_main`; it does **not** change + the stored ledger. +- **R13 (GUI "pending until N"):** the GUI MUST show ownership/authenticity as + **pending** until a record reaches **N confirmations**, and only then render it as + **final**. Recommended default **N = `DEFAULT_MAX_REORG_DEPTH` (10)** so "final" in + the UI aligns with the node's own finalization point; the value MUST be a single + named constant, not scattered literals. High-value transfers MAY use a larger N. +- **R14 (0-conf is never "received"):** a token transfer that is only in the mempool + MUST never be shown as received/owned. (The indexer structurally cannot show it — + it has no mempool path — but the GUI MUST NOT invent a 0-conf view by reading the + mempool separately. If a "pending send broadcast" indicator is desired, it MUST be + labeled *unconfirmed / not yet final*, visually distinct from owned.) +- **R15 (reorg demotion is visible):** if a reorg orphans a transfer, the GUI MUST + demote it from "owned" back to absent/pending on the next ChainTip — i.e. the GUI + reads live store state each tip, never caches an "owned" flag across blocks. + +--- + +## 6. Honest impersonation / uniqueness presentation — requirements + +Uniqueness is **only** at the token-id (genesis txid) level. Anyone can mint a +*different* token reusing a name/ticker/image-hash. This is unforgeable to *confuse a +careful verifier* (the token-id differs) but trivial to *fool a casual viewer*. + +- **R16 (token-id is the identity, shown):** any UI that renders a token MUST surface + its token-id (genesis txid) and MUST NOT present ticker/name as if unique. The RPC + already returns `tokenid` (`src/rpc/zslp.cpp:43`); the GUI must show a + fingerprint, not just the name. +- **R17 (image authenticity ≠ uniqueness):** `documentHash` proves the bytes match + what genesis committed to (`src/zslp/zslpindexer.cpp:238-246`, + RPC `src/rpc/zslp.cpp:47-48`), **not** that this is "the" token. The GUI MUST phrase + authenticity as "matches the issuer's committed hash," never "genuine/the original." +- **R18 (no consensus-uniqueness claim):** docs and UI MUST NOT imply the chain + enforces token uniqueness. Impersonation is defeated socially (issuer identity / + genesis-txid fingerprint / signed attestations), as already stated in the + enablement doc's honest ceiling (`doc/nft/ENABLEMENT.md`). + +--- + +## 7. Cross-implementation test-vector requirement (the agreement guarantee) + +Bit-exact agreement is THE security property; there is no consensus fallback. So the +spec is only real if it ships with **frozen vectors** that any implementation can run. + +- **R19 (vector corpus):** a committed corpus of `(raw OP_RETURN bytes, tx shape) → + expected ledger delta` covering every row of §3.7 (E1–E13), plus a multi-tx + in-block ordering case (tx2 spends tx1's output) and a GENESIS→MINT→SEND→burn + chain. The corpus is the canonical oracle; a second implementation passes iff it + reproduces every delta. +- **R20 (reorg round-trip vector):** for at least one non-trivial block, a + connect→disconnect cycle MUST restore a **byte-identical full DB dump** (proves + Property B). And a 2-block reorg (disconnect B, disconnect A, connect A', connect + B') MUST converge to the same ledger two ways: replay-from-scratch vs. + incremental-undo/redo. +- **R21 (parse-determinism fuzz):** fuzz `slp_parse` / `read_push` against the chosen + push-encoding rule (R5); every input must yield a single deterministic + SLP-or-not + identical field decode. No input may crash or read out of bounds + (note `read_push` bounds-checks `p + *len > end`, `src/zslp/op_return_push.h:43`). + +--- + +## 8. Requirements checklist (consolidated, testable) + +See `REORG_CONFIRMATION_REQUIREMENTS.md` for the flat checklist the conservation +rewrite + wallet + GUI must satisfy, each mapped to a verification method. diff --git a/doc/nft/REQUIREMENTS_DOS_SPAM_GRIEF.md b/doc/nft/REQUIREMENTS_DOS_SPAM_GRIEF.md new file mode 100644 index 00000000000..a4dd7500eee --- /dev/null +++ b/doc/nft/REQUIREMENTS_DOS_SPAM_GRIEF.md @@ -0,0 +1,137 @@ +# Requirements Checklist — DoS / Spam / Grief + +Concrete, testable acceptance bar for the conservation rewrite (`src/zslp/*`) and +the wallet/GUI work. Each item is verifiable by a gtest, an RPC probe, or a +manual GUI check. IDs map to threats in `THREATS_DOS_SPAM_GRIEF.md` and rules in +`CANONICAL_VALIDATION_SPEC.md`. "MUST" = blocking; "SHOULD" = strong. + +## A. Parse determinism (blocking — these are the ledger-fork class) + +- **A1 (T1/R1, MUST):** The indexer parses the SLP message from `tx.vout[0]` + ONLY. A tx with a valid SLP OP_RETURN at vout[1] (vout[0] a normal payment) is + treated as non-SLP. Replace the `for (vo ...)` scan at `zslpindexer.cpp:211`. + *Test:* gtest — build a tx with payment@vout0 + SLP-SEND@vout1; assert no token + UTXO created and the spent token input is burned. +- **A2 (T2/R1, MUST):** A mined tx with two OP_RETURNs (vout0 + vout1, both + parseable SLP, different messages) produces the result of vout[0] alone. + *Test:* gtest with two OP_RETURNs; assert vout[1]'s message has zero effect. +- **A3 (R5, MUST):** The output-count cap is a SINGLE constant used by parser, + bridge, store, and the `outputQuantities` array bound. Reconcile `slp.c:151` + (19) with `zslpindexer.cpp:267` / `zslpstore.cpp:542` / `zslpstore.h:207` (20). + *Test:* a SEND with the maximum + one extra 8-byte push parses/applies + identically at every layer. +- **A4 (R5, MUST):** Output quantity ≥ 2^63 ⇒ SEND INVALID (nothing created, + inputs burned). *Test:* gtest with `outputQuantities[0] = 0x8000...0`. +- **A5 (R5, MUST):** Output-sum overflow ⇒ SEND INVALID. *Test:* two outputs each + near INT64_MAX. (Extends existing arithmetic; assert burn + no outputs.) +- **A6 (R5, MUST):** Output index past `voutCount` burns only that quantity; lower + in-range outputs are still created. *Test:* SEND with 3 quantities but + voutCount=2 → vout1 created, the rest burned, SEND otherwise valid. +- **A7 (R5/R7, MUST):** Input that is not a recognized token UTXO contributes 0; + a SEND whose `availIn` (from real token inputs) < `requiredOut` is INVALID and + burns its token inputs. *(Covered by `OverSendBurnsInputs`/`ForgeSend...`; keep + them green under the rewrite.)* +- **A8 (R2, MUST):** Non-canonical pushes and a push that overruns the script are + non-SLP. *Test:* truncated PUSHDATA2, unknown opcode after OP_RETURN. +- **A9 (R3, MUST):** decimals outside 0–9, or a baton vout < 2, make the GENESIS + non-SLP; a metadata field longer than its buffer is dropped to empty + deterministically. *Test:* per-field gtest. +- **A10 (R6, MUST):** token_id byte-order round-trips: a MINT/SEND quoting a + genesis-txid in display order resolves to the same token the GENESIS created. + *Test:* genesis then SEND quoting its txid; assert the SEND finds the token. +- **A11 (R12, MUST):** A versioned `input→ledger` test-vector file covering + A1–A10 is committed in-tree and run in CI, declared the interoperability + contract for any second implementation. + +## B. Resource / amplification bounds (blocking where noted) + +- **B1 (T4/R8, MUST):** `ListTransfers` peak memory/CPU is O(count+from), NOT + O(total transfers for the token). Re-implement `zslpstore.cpp:777-799` to + early-stop; do not materialize the full set. *Test:* insert >ZSLP_LIST_MAX + transfers for one token; assert the call allocates/returns ≤ count and stops + early (instrument or bound-check). +- **B2 (T4/R8, MUST):** Every list RPC clamps `count` to `ZSLP_LIST_MAX` at the + store boundary (not just the RPC layer) so a direct store caller is also + bounded. *(RPC clamp present `rpc/zslp.cpp:124,162`; push the bound into + `ListTokens`/`ListTransfers` themselves — `ListTokens` already does + `zslpstore.cpp:741`; make `ListTransfers` match.)* +- **B3 (T4/R8, SHOULD):** `zslp_listmytokens` / `GetTokensForAddress` cannot be + driven to an unbounded full-table scan per call. Add an address-keyed view OR a + documented scan cap + the existing `ZSLP_LIST_MAX` response cap + (`rpc/zslp.cpp:242`). *Test:* many wallet keys × a large 'b' table returns + within a bounded record-scan budget. +- **B4 (T4, MUST):** The index remains fully derivable and disposable: a version + bump wipes + rebuilds (`zslpindexer.cpp:74-85`); no path makes index size + super-linear in chain size. *Test:* catch-up over a block stuffed with many + genesis/dust-send txs completes in O(total outputs) with no quadratic blowup. +- **B5 (T8, MUST):** A reorg's disconnect cost is O(undo ops for the block) and + yields byte-identical pre-state. *(Keep `ReorgGenesisRoundTrip`/`ReorgMint- + RoundTrip`; add a multi-tx-block reorg case.)* +- **B6 (T9, MUST):** A re-delivered connect for the current tip is a no-op (no + double-credit). *(Guard at `zslpindexer.cpp:180-183`; add a regression test + that re-delivers the tip and asserts balances unchanged.)* + +## C. Wallet anti-burn (blocking — prevents user/asset loss) + +- **C1 (T6/R9, MUST):** Wallet coin selection EXCLUDES UTXOs the index reports as + token-bearing (`GetUtxo`). *Test:* fund a wallet with a token dust UTXO + normal + coins; a plain ZCL send must not select the token UTXO. (Wallet has 0 zslp refs + today — `grep -rin zslp src/wallet/` = 0; this is net-new.) +- **C2 (T6/R9, MUST):** Token UTXOs are visible/selectable in coin-control so a + user can spend them only deliberately. +- **C3 (T6/R9, MUST):** Fail CLOSED — if the index is not synced past a UTXO's + height, treat that UTXO as possibly-token and warn before spending it in a + non-SLP tx. *Test:* with `-zslpindex` behind tip, a send touching unindexed + dust warns. +- **C4 (T6/R5, MUST):** A deliberate token transfer emits the canonical SEND + OP_RETURN at vout[0] with correct positional quantities (R5); otherwise the UI + blocks/loudly warns "this will burn the token". +- **C5 (T6, SHOULD):** Auto-shield / consolidation / sweep features never sweep a + token-bearing UTXO without explicit consent. (Cross-check the auto-shield path + noted in repo memory.) + +## D. UX honesty / anti-grief presentation (blocking for the grief class) + +- **D1 (T5/R10, MUST):** Unsolicited / unverified tokens are hidden by default; + user opts in per token or per issuer (allowlist). +- **D2 (T5/R10, MUST):** `document_url` and any media are NEVER auto-fetched or + auto-rendered; resolving a URL requires explicit user action + a warning. +- **D3 (T5/R10, MUST):** `ticker`/`name` rendered as plain text — no markup/HTML, + control chars stripped, length-clamped — and always shown with the genesis-txid + fingerprint. +- **D4 (T7/R10, MUST):** Token identity in the GUI is the genesis-txid, never the + name alone; impersonation is surfaced (e.g. "N other tokens use this name"), + not hidden behind a false uniqueness claim. +- **D5 (honesty, MUST):** The GUI states plainly that on-chain bytes (spam, abusive + names, unsolicited tokens) are permanent and only hideable, and that no chain + rule enforces a "real" issuer. (See `THREATS...` §5.) + +## E. Non-negotiable invariants (regression guards) + +- **E1 (MUST):** The overlay NEVER touches consensus/validation/PoW/mempool + acceptance. *Check:* `src/zslp/*` includes no validation mutation; indexer only + reads the validation-signal bus (`zslpindexer.cpp` `RegisterValidationInterface` + observer). Keep it behind `-zslpindex`. +- **E2 (MUST):** Forgery remains impossible at the ledger: a SEND with no funding + input, an over-send, a baton-less MINT, and a duplicate-NFT GENESIS all credit + nobody / burn. *(Keep `ForgeSendWithoutInputCreditsNobody`, + `OverSendBurnsInputsNoOutputs`, `MintWithoutBatonRejected`, + `NftCannotBeDuplicated` green.)* +- **E3 (MUST):** All determinism rules (R1–R12) have at least one gtest each, and + the A11 test-vector file is the canonical agreement contract. + +--- + +### Quick verification map (file:line cited above) + +- vout-scan bug to fix: `src/zslp/zslpindexer.cpp:211` +- output-cap mismatch: `src/zslp/slp.c:151` vs `zslpindexer.cpp:267` / + `zslpstore.cpp:542` / `zslpstore.h:207` +- SEND arithmetic / validity: `src/zslp/zslpstore.cpp:531-569` +- burn-on-spend (all txs): `src/zslp/zslpstore.cpp:432-446` +- unbounded ListTransfers: `src/zslp/zslpstore.cpp:777-799` +- full-scan listmytokens: `src/zslp/zslpstore.cpp:810-822`, `src/rpc/zslp.cpp:222` +- ZSLP_LIST_MAX: `src/zslp/zslpstore.h:49` +- wallet has zero ZSLP awareness: `grep -rin zslp src/wallet/` → 0 +- consensus can't help: `src/script/standard.cpp:65-72`, `src/main.cpp:758-779`, + `src/script/standard.h:34` diff --git a/doc/nft/SECURITY_MODEL.md b/doc/nft/SECURITY_MODEL.md new file mode 100644 index 00000000000..6b28d5faf8c --- /dev/null +++ b/doc/nft/SECURITY_MODEL.md @@ -0,0 +1,307 @@ +# ZSLP NFT — Non-Consensus Security Model & Canonical Validation Spec + +Status: NORMATIVE. Spec version `ZSLP_SPEC_VERSION = 1` (see §8). +Scope: the ZSLP (SLP Token Type 1) overlay for ZClassic — `src/zslp/{slp.h,slp.c,op_return_push.h,zslpmsg.*,zslpstore.*,zslpindexer.*}`, `src/rpc/zslp.cpp`, plus the wallet anti-burn and GUI honesty requirements in `src/wallet/` and the Qt wallet. +This document defines the SECURITY MODEL and the BIT-EXACT CANONICAL VALIDATION RULES. It edits no source. The concurrent UTXO-conservation rewrite of the store+indexer, the wallet, and the GUI MUST satisfy the requirements checklist in §7. + +This is a synthesis of six independent threat reviews (determinism-fork, forgery-conservation, holder-anti-burn, impersonation-uniqueness, reorg-confirmation, dos-spam-grief). Every code citation below was re-verified against the working tree on branch `feature/zslp-nft-indexer` (the branch that carries the ZSLP source the R-* rules cite). + +--- + +## 1. The Security Model In One Page + +### 1.1 The hard constraint + +We CANNOT change ZClassic consensus. Minters and users run EXISTING, unchanged consensus nodes. **Base consensus does not know about ZSLP/SLP at all.** A push-only `OP_RETURN` is classified `TX_NULL_DATA` by `Solver()` (`src/script/standard.cpp`) and relayed/mined like any standard data-carrier output. Consensus will therefore relay and mine an `OP_RETURN` that encodes a **forged** token GENESIS, MINT, or SEND. We can never make consensus reject an invalid token transaction. + +Corollary: **token uniqueness and ownership cannot be enforced by the chain refusing forgeries.** They must be enforced some other way. + +### 1.2 What actually secures tokens: deterministic re-validation + +The token ledger is a **pure, deterministic function** computed by an observer (our `-zslpindex` indexer `CZSLPIndexer`/`CZSLPStore`, and any compatible wallet or explorer) over the **consensus-ordered, confirmed** block history: + +``` +ledger = F( [block_0, block_1, ..., block_tip] ) // confirmed blocks, in consensus order +``` + +A transaction's token effect is VALID only if it follows the overlay rules. An on-chain transaction that breaks them is interpreted as **crediting nobody and/or burning its token inputs**. A forgery can therefore land on-chain yet change no honest observer's ledger. + +> **The precise statement: forgery on-chain != forgery in the ledger.** +> An attacker can place any bytes on-chain. They cannot make `F` credit a forgery. `F` is computed independently by every observer, so the only thing the attacker controls is the input to `F` (the confirmed history), never `F` itself. + +### 1.3 Why agreement is the whole game + +Because there is no consensus over the overlay, the SOLE security property is: + +> **SECURITY = DETERMINISM + AGREEMENT.** +> Every honest observer running the same canonical rules MUST compute the BIT-IDENTICAL ledger from the same confirmed history. + +If two compatible implementations disagree on ANY edge case, the token ledger **forks**: the attacker presents one "ownership truth" to victim A (running implementation X) and a contradicting one to victim B (running implementation Y), with no tiebreaker — there is no consensus to fall back on. Cross-implementation bit-exact agreement is therefore not a nice-to-have; it IS the security property. Every divergence in §3 is a real exploit, not a style nit. + +### 1.4 The trust boundary, stated honestly + +The overlay guarantees, over confirmed history: + +- **Token-id uniqueness** — `tokenId == genesis txid`, globally unique because txids are unique under consensus. +- **Conservation** — the single NFT unit (and any token quantity) cannot be inflated or duplicated; a forged SEND/MINT credits nobody. +- **Determinism** — every observer computes the same ledger (IFF the canonical rules below are followed bit-for-bit). + +The overlay does NOT and CANNOT guarantee: + +- **Name/ticker/image uniqueness** — anyone can mint a DIFFERENT token (new genesis txid) reusing any metadata. Defeated socially, not on-chain (§5). +- **Issuer identity** — GENESIS has no input requirement and no signed issuer field. Identity is an out-of-band attestation layer (§5). +- **That a holder's own wallet won't burn the token** — a token rides ordinary transparent dust; consensus lets any wallet spend it. Prevented wallet-side only (§4). +- **Finality below the node's reorg horizon** — see §6. + +### 1.5 The overlay's place in the system + +`CZSLPIndexer` is behind `-zslpindex`, derived and disposable. It MUST NEVER touch consensus, validation, PoW, or mempool acceptance. Verified: the indexer overrides only `ChainTip` (`src/zslp/zslpindexer.h:49`) and never `SyncTransaction`, so there is **no mempool / 0-conf path** into the ledger — a record exists only after a confirmed block connect under `cs_main` post-flush. This is a structural strength and MUST be preserved (R-1). + +--- + +## 2. Canonical Validation Rules (bit-exact) + +These rules are NORMATIVE. Any implementation that follows them computes the identical ledger. Each rule is keyed `R--` and cross-referenced from the checklist in §7. Code citations mark whether the current tree already complies (OK) or requires a change (FIX). + +### 2.0 Integer model (governs all of §2) + +- **R-INT-1.** On-chain quantity fields are 8-byte big-endian **uint64**. The canonical internal arithmetic model is **signed int64 with explicit overflow guards** (matches the current store, which decodes uint64 into int64). Any quantity whose uint64 value has the high bit set (`>= 2^63`) is **INVALID for the entire message** (creates nothing; still burns inputs). Applied identically to GENESIS initial_quantity, MINT additional_quantity, and EVERY SEND output quantity. + - Status: SEND guards this (`if (q < 0)` after the int64 cast, `zslpstore.cpp` SEND branch). GENESIS/MINT cast `(int64_t)msg.initialQuantity` / `additionalQuantity` with **no high-bit check** — FIX. + - This removes the signed/unsigned boundary at `2^63` as a fork surface and prevents a negative-amount UTXO from corrupting derived balances. +- **R-INT-2.** `be_to_u64` is the canonical big-endian decoder. Quantity pushes MUST be EXACTLY 8 bytes; the genesis/mint/send field readers already enforce `len != 8 => reject`. The 1..8-byte leniency inside `be_to_u64` (`slp.c:16`) is unreachable for quantities because the callers length-gate first; it MUST stay that way (no caller may pass a non-8-byte quantity). Status: OK, pin by test. + +### 2.1 Message location and gating (parse) + +- **R-PARSE-1 (BLOCKER).** The SLP message is parsed from **`tx.vout[0].scriptPubKey` ONLY**. A tx is an SLP candidate IFF `vout[0]` begins `OP_RETURN` and parses as a valid SLP message. No other vout is ever examined for a message. + - Status: **FIX (CRITICAL).** `zslpindexer.cpp:211` loops `for (size_t vo = 0; vo < tx.vout.size(); ++vo)` and breaks on the "first valid OP_RETURN wins" (the `break` after `msgPresent`). This contradicts the file's own header (`slp.h:5`: "OP_RETURN outputs (vout[0])"). Replace the all-vout scan with a single `vout[0]` check. + - Rationale: this is the single largest live ledger-fork bug. A vout[0]-strict wallet says "not SLP, inputs burned"; this indexer credits a SEND placed at vout[1]. Attacker shows each victim a different ledger. +- **R-PARSE-2.** If `vout[0]` is not a valid SLP message, the tx has **no SLP message** (`msgPresent = false`). Token inputs the tx spends are STILL burned (R-BURN-1). Additional `OP_RETURN`s at vout >= 1 are irrelevant by construction (defeats the multi-OP_RETURN fork, even for self-mined non-standard txs). +- **R-PARSE-3.** The coinbase transaction (`vtx[0]`) is SKIPPED for SLP message parsing. A coinbase whose vout[0] begins `OP_RETURN` is ignored. +- **R-PARSE-4 (no policy dependence).** Ledger validity depends ONLY on confirmed transaction bytes. NEVER consult `-datacarrier`/`-datacarriersize`, relay policy, mempool, wallet state, or the clock. The parser already operates on raw script and does not read these globals (OK); MUST stay decoupled — in particular, do NOT gate SLP parsing behind `TX_NULL_DATA`'s datacarrier size check. + +### 2.2 Push grammar (script) + +- **R-SCRIPT-1.** ZSLP fields use ONLY: direct push `0x01..0x4b`, `OP_PUSHDATA1` (`0x4c`), and `OP_PUSHDATA2` (`0x4d`). The reader MUST reject `OP_PUSHDATA4` (`0x4e`), `OP_0`, `OP_1NEGATE`, `OP_RESERVED`, and `OP_1..OP_16`. + - Status: OK. `read_push` (`op_return_push.h:31-42`) accepts exactly `{0x01..0x4b, 0x4c, 0x4d}` and returns NULL otherwise. Do NOT gate solely on `TX_NULL_DATA`/`IsPushOnly`, which is looser (accepts `OP_0..OP_16`, `OP_PUSHDATA4`) and would fork the ledger if relied upon. +- **R-SCRIPT-2 (dual-encoding accepted, frozen).** A short value pushed via direct push and the same value via `OP_PUSHDATA1`/`OP_PUSHDATA2` MUST parse to the IDENTICAL message. There is NO minimal-push requirement in this canonical spec; the current lenient behavior is FROZEN as canonical (this differs from BCH SLP's minimal-push rule — deliberate, and pinned here so no implementation re-introduces a minimality check and forks). Status: OK, freeze by test vector. +- **R-SCRIPT-3 (Lokad + type).** Lokad prefix MUST be exactly `"SLP\x00"` and token_type exactly `1`. Any deviation => not SLP. Status: OK. +- **R-SCRIPT-4 (empty-field encoding).** Zero-length text fields (`ticker`/`name`/`document_url`) are accepted when pushed as a zero-length push. Oversize text (`len >= sizeof(buffer)`) is deterministically dropped to empty WITHOUT rejecting the message (current behavior, frozen). Status: OK (`slp.c:69,77,85`), freeze by test. +- **R-SCRIPT-5 (FIX — no trailing data for GENESIS/MINT).** After the last required field of GENESIS and MINT, the parser MUST verify the script is fully consumed (`p == end`); any trailing push => reject the entire message. + - Status: FIX. `slp.c` returns `true` immediately after `initial_quantity`/`additional_quantity` and ignores trailing bytes. A strict parser rejects; this one accepts => fork. +- **R-SCRIPT-6 (field-length validity).** + - `document_hash` push length MUST be exactly `0` or exactly `32`. Any other length => **reject the whole GENESIS**. Status: FIX — `slp.c:91-96` sets `has_document_hash` only at len==32 and otherwise silently treats it as "no hash" without rejecting. + - `decimals` MUST be a 1-byte push with value `0..9`. Status: OK (`slp.c:101`). + - `mint_baton_vout` push MUST be length 0 (no baton), OR length 1 with value `>= 2`. Length 1 with value `0/1` => reject (OK). **Length > 1 => reject** the whole message. Status: FIX — `slp.c:104-109` only special-cases `len == 1` and silently ignores `len > 1`. + - Quantities MUST be exactly 8 bytes (R-INT-2). `token_id` MUST be exactly 32 bytes. Status: OK. + +### 2.3 Token-id endianness + +- **R-ID-1.** `tokenId` is the genesis txid as the node's internal `uint256` (little-endian internal bytes). GENESIS stores `tokenId = tx.GetHash()` directly (`zslpindexer.cpp` GENESIS branch). MINT/SEND read the 32-byte on-chain `token_id` field in display byte order and **byte-reverse** it via `TokenIdToUint256` so that a MINT/SEND quoting a genesis txid's display-hex resolves to the GENESIS's UTXOs. These MUST coincide (they do, because `GetHex()` prints reversed internal bytes). Status: OK but invariant is silent — pin with a GENESIS->MINT/SEND round-trip test. + +### 2.4 GENESIS validity + +- **R-GEN-1.** `tokenId == genesis txid`. The token row is inserted ONLY if absent (first-genesis-wins, idempotent under reorg-replay). Status: OK (`readToken` miss => `writeTokenBatch`). +- **R-GEN-2.** Initial quantity (R-INT-1 valid) is created as a token UTXO at **`vout[1]` only**, and only if `vout[1]` exists (`voutCount > 1`). Status: OK. +- **R-GEN-3 (FIX).** `totalMinted` counts ONLY quantity ACTUALLY created as a UTXO (overflow-guarded), NOT declared-but-uncreated. Status: FIX — GENESIS sets `token.totalMinted = msg->initialQuantity` UNCONDITIONALLY, before the `voutCount > 1` creation gate. A GENESIS with no vout[1] then reports a non-zero supply that an impl counting created UTXOs reports as zero => RPC supply fork. +- **R-GEN-4.** A mint baton is issued IFF `mint_baton_vout >= 2 AND mint_baton_vout < voutCount`, as a baton UTXO (amount 0, isBaton true) at that vout. Out-of-range baton vout => no baton. Status: OK. + +### 2.5 MINT validity + +- **R-MINT-1.** MINT is valid IFF the token is known (`readToken` hit) AND a live mint baton UTXO of that tokenId was on a spent input (`batonInputPresent`). Otherwise: create nothing; consumed inputs stay burned. Status: OK. +- **R-MINT-2.** Additional quantity (R-INT-1 valid) is created at `vout[1]` only, if it exists. Baton continues IFF a new `mint_baton_vout >= 2 AND < voutCount` is declared; not re-declaring the baton permanently ends minting for that token. Status: OK. +- **R-MINT-3 (FIX).** `totalMinted += additionalQuantity` only for quantity ACTUALLY created (R-GEN-3 logic). Overflow-guarded (OK). Status: align with R-GEN-3. + +### 2.6 SEND validity, conservation, and transitive validity + +- **R-SEND-1.** SEND quantity list MUST be **exactly 1..19** entries, each an exactly-8-byte push. A 20th 8-byte push makes the message **INVALID** (reject) — NOT "first 19 win". + - Status: FIX (cap mismatch). Parser caps at 19 (`slp.c:151 while (num_outputs < 19)`, then `num_outputs < 1 => reject`), but the bridge/store clamp to 20 (`zslpindexer.cpp` `if (n > 20) n = 20` and `zslpstore.cpp` SEND branch `if (n > 20) n = 20`). Reconcile to a SINGLE constant `ZSLP_SEND_MAX_OUTPUTS = 19` used by parser, bridge, store, AND the `outputQuantities`/array bounds; `> 19` quantities => INVALID, not truncated. +- **R-SEND-2 (transitive validity / availIn).** `availIn(T)` = sum of the **token UTXO amounts of tokenId T** consumed on this tx's inputs. An input that is NOT a recognized token UTXO of T contributes **ZERO**. Batons contribute 0 to availIn. This is the transitive-validity anchor: a token UTXO only enters availIn because it is an actual spent prevout the store recognizes, which transitively traces back to a GENESIS over confirmed blocks; consensus' own scriptSig check guarantees the spender actually owns the carrying dust. Status: OK (`readUtxo` per input; unknown => `continue`). +- **R-SEND-3 (budget + overflow).** `requiredOut` = sum of output quantities with an explicit int64 overflow guard; overflow => SEND INVALID. SEND is valid IFF `availIn(T) >= requiredOut`; surplus (`availIn - requiredOut`) is BURNED (never created). Status: OK. +- **R-SEND-4 (output-index bounds — PIN to "burn-that-quantity").** Output quantity `j` maps positionally to `vout[1 + j]`; a zero-quantity output consumes a slot but creates nothing. **The budget check (R-SEND-3) runs FIRST over all declared quantities.** Then, when creating, a positive quantity whose target `vout[1+j] >= voutCount` is BURNED (that quantity only); in-range outputs are still created. + - **NOTE — this is the one rule where the six reviews split.** Two readings exist: + - **Reading A (current code, PINNED CANONICAL):** out-of-range positive quantity burns only that quantity; other outputs apply. (`zslpstore.cpp` SEND branch: `if (voutIdx >= voutCount) continue;`.) + - **Reading B (rejected):** any positive quantity to a nonexistent vout makes the WHOLE SEND invalid. + - **Decision: PIN Reading A** (matches the current store and the dos-spam-grief review's R5; the determinism/forgery reviews proposed B but Reading A is equally deterministic, is already implemented, and never inflates — it only ever burns). Both are safe; what matters is that ALL implementations pick the SAME one. This spec freezes **Reading A** at version 1. The published test vector (§8) makes the choice unambiguous. Status: OK under Reading A. +- **R-SEND-5 (NFT non-duplication, derived).** Because availIn for a qty-1 NFT is at most 1, any SEND requesting > 1 (or by a non-holder, availIn 0) is INVALID and creates nothing (over-claim burns the single UTXO). A baton-less GENESIS means no MINT can ever add a second unit. Status: OK (gtest `NftCannotBeDuplicated`). + +### 2.7 Burn rules (apply to EVERY tx) + +- **R-BURN-1.** Every transaction — SLP or not, valid or not — CONSUMES every spent live token UTXO. Any consumed token UTXO not validly re-created by a valid SLP message of its tokenId is thereby BURNED. Status: OK (consume step (a) runs for every tx before dispatch). +- **R-BURN-2.** A spent prevout that is not a recognized token UTXO contributes nothing (no effect). Consume happens BEFORE create within a tx. Status: OK. +- **R-BURN-3 (intra-block ordering).** Transactions are applied strictly in `block.vtx` order, each tx's writes visible to the next tx's reads (per-tx batch commit). A GENESIS in tx1 and a SEND spending it in tx2 of the SAME block both apply correctly. Status: OK. + +### 2.8 Read/RPC ordering and balances (observable surface) + +- **R-RPC-1.** `zslp_listtokens` is ordered by raw-byte `tokenId` key order (leveldb key order). NORMATIVE so an alternate-store impl reproduces it. +- **R-RPC-2.** `zslp_listtransfers` is height-ascending then reversed to newest-first, tie-broken by txid then BE(vout). NORMATIVE. +- **R-RPC-3.** `balance(token, addr) == sum over live non-baton token UTXOs of that token at that address`. This invariant MUST hold after every block (checked by an after-each-block invariant test). +- **R-RPC-4 (confirmations).** Read-side RPC exposes `confirmations = chainActive.Height() - record.height + 1` per token/UTXO/transfer (computed under `cs_main`; ledger unchanged). No depth concept exists in the store today — this is read-side only. + +### 2.9 Reorg / determinism over reorgs + +- **R-REORG-1.** State is mutated ONLY from `ChainTip` connect/disconnect, never from the mempool/write path. Status: OK. +- **R-REORG-2.** Every connect-side mutation appends a paired typed undo op; `DisconnectBlock` replays the undo log in reverse to restore **byte-identical** pre-connect state (incl. same-block create-then-consume netting). Status: OK by design; MUST be locked by a connect/dump vs connect+disconnect/dump equality test. +- **R-REORG-3.** Post-reorg incremental ledger EQUALS a from-scratch reindex of the winning chain. Crash-resume (resume one past stored tip) and a re-delivered connect for the current tip are no-ops. Status: OK (idempotence guard `zslpindexer.cpp:180-183`); lock by tests. +- **R-REORG-4.** Undo is bounded by consensus: no reorg deeper than `MAX_REORG_LENGTH = COINBASE_MATURITY - 1` (= 99; `main.h:58`, hard shutdown) is ever applied; auto-finalization at `DEFAULT_MAX_REORG_DEPTH = 10` (`main.h:116`). No superlinear undo cost. Status: OK. + +--- + +## 3. Threat Table + +Severity: CRITICAL (active ledger fork or guaranteed holder loss) / HIGH / MEDIUM / LOW / INFO (already neutralized). + +| # | Attack | Why on-chain unstoppable | Overlay defense (canonical rule) | Residual risk | Severity | +|---|--------|--------------------------|----------------------------------|---------------|----------| +| T1 | **Message-position fork**: payment at vout[0], SLP SEND at vout[1+]. This indexer credits it; a vout[0]-strict impl burns. Two ledgers. | `TX_NULL_DATA` has no positional constraint; multi-output tx with OP_RETURN at any index is standard and confirmable. | R-PARSE-1/2: parse vout[0] ONLY; non-vout[0] => not SLP, inputs still burned. | NONE once the all-vout scan (`zslpindexer.cpp:211`) is deleted and tested. **HIGHEST-severity live bug.** | CRITICAL | +| T2 | **Multi-OP_RETURN fork**: two SLP messages at low vouts; observers key on different ones. | `nDataOut>1` rejected only by relay policy; a self-mined block carrying it is consensus-valid. | R-PARSE-2: vout >= 1 OP_RETURNs are ignored by construction. | NONE once R-PARSE-1 lands. | CRITICAL | +| T3 | **Holder self-burn**: ordinary send/auto-shield/sweep picks a token dust UTXO as fee/change; the non-SLP tx burns the NFT. | Token rides ordinary t-dust; consensus sees only ZCL value and spends it. Dust threshold ~100 sats (`DEFAULT_MIN_RELAY_TX_FEE=100`, `main.h:64`) dwarfs a 1-sat NFT, so dust-to-fee fold burns it silently. | NOT a determinism issue — the burn is the deterministically-correct interpretation. Wallet-side only: R-WALLET-1..6. | HIGH until wallet coin-selection consults the token store. Document prominently. | CRITICAL | +| T4 | **Forged SEND / over-send / MINT-without-baton / unknown-token MINT / NFT-dup**. | Any OP_RETURN naming any token/quantity is a standard tx; consensus does no token math. | R-SEND-2/3 (availIn; unknown input = 0; under-funded => INVALID+burn), R-MINT-1 (baton input required), R-GEN-1 (id==txid). Tested: ForgedSendCreditsNobody, OverSendBurnsInputsNoOutputs, MintWithoutBatonRejected, NftCannotBeDuplicated. | None ECONOMICALLY, PROVIDED every impl computes availIn/requiredOut identically (depends on T5-T9). | INFO | +| T5 | **uint64 high-bit / overflow fork**: quantity `>= 2^63` (GENESIS/MINT cast int64 with no guard), or output-sum overflow. | 8-byte `0xFFFF...` is valid script data; consensus never sums. | R-INT-1 (high-bit => whole message INVALID, all three types), R-SEND-3 (sum overflow => INVALID). | GENESIS/MINT need the high-bit check added (SEND already has it). | HIGH | +| T6 | **Trailing-data / field-length fork**: extra push after GENESIS/MINT last field; non-{0,32} document_hash; baton push len>1. | Extra pushes keep the script push-only => still `TX_NULL_DATA`, confirms. | R-SCRIPT-5 (p==end for GENESIS/MINT), R-SCRIPT-6 (doc_hash {0,32}; baton len {0,1}). | Parser changes required (`slp.c`). | HIGH | +| T7 | **SEND cap fork**: 20th quantity; parser caps 19, store clamps 20. | Quantity-list length is independent of tx output count. | R-SEND-1: single `ZSLP_SEND_MAX_OUTPUTS=19`; > 19 => INVALID. | Reconcile parser/bridge/store to one constant. | HIGH | +| T8 | **Out-of-range output fork** (Reading A vs B). | Declared quantities independent of `tx.vout` count. | R-SEND-4: PIN Reading A (burn that quantity, apply in-range; budget-checked first). | NONE once the pinned reading is published as a test vector and both impls match. | HIGH | +| T9 | **totalMinted supply fork**: GENESIS/MINT with no vout[1] still bumps `totalMinted`. | A GENESIS/MINT tx with only the OP_RETURN output is confirmable. | R-GEN-3/R-MINT-3: count ONLY created quantity. | RPC supply diverges; FIX required in store. | MEDIUM | +| T10 | **RPC/list ordering fork** across alternate stores. | Ordering is observer presentation; consensus uninvolved. | R-RPC-1/2/3: orderings + balance invariant NORMATIVE. | Lower: divergence is presentation/pagination, not core UTXO truth. | MEDIUM | +| T11 | **Impersonation/clone token**: new genesis txid reusing victim's ticker/name/url/image-hash. | GENESIS imposes no metadata uniqueness/auth; consensus has no SLP notion. | NOT neutralizable on-chain — both are legitimately distinct tokens (different tokenIds). UX-honesty only: R-UX-1..5 + R-ATTEST-1/2. | Inherent and permanent; reduced to a social-trust problem. | MEDIUM (HIGH if GUI implies authenticity) | +| T12 | **Set/collection spoof**: children claiming a set's name with no cryptographic tie. | Set membership by name is unauthenticated. | Convert to a baton-input problem via NFT1 group/child (child spends a real group input) — REQUIRES adding the group/child rule to the spec with tests before any "verified set" UI claim. | Until specified+tested, membership is issuer-claimed and spoofable by name. | MEDIUM | +| T13 | **Confirmed-vs-unconfirmed / shallow-conf**: victim acts on an unconfirmed or 1-9-conf receipt; double-spend or reorg (depth < 10) orphans it. | Mempool/shallow txs are replaceable/reorgable; consensus promises nothing below finalization. | R-REORG-1 (no mempool path into ledger), R-RPC-4 (expose confirmations), R-UX-6/7 (pending-until-N, N=10; live read each ChainTip). | UI must enforce the split; high-value transfers may want N>10. | HIGH | +| T14 | **Reorg replay divergence**: two nodes compute different post-reorg ledgers. | Overlay keeps its own auxiliary store; a buggy disconnect could miss a paired undo. | R-REORG-2/3: byte-exact undo; incremental == from-scratch reindex. | Only as true as its tests; a new mutation site without a paired undo silently breaks — locked by the round-trip property test. | MEDIUM | +| T15 | **RPC DoS amplification**: spam thousands of tiny tokens/transfers; `ListTransfers` materializes the ENTIRE set then reverses (`zslpstore.cpp:777-799`); `GetTokensForAddress` full keyspace scan per wallet key. | Each genesis/send is a normal fee-paying standard tx. | Index is derived/disposable (bounded by chain size). FIX amplification: stream + early-stop `ListTransfers` at `from+count`; clamp `count` at the store boundary; address-keyed view or documented scan cap. | On-chain bloat itself is unpreventable (index mirrors chain); only the one-cheap-tx-to-expensive-RPC amplification is closeable. | HIGH | +| T16 | **Abusive/unsolicited token content**: offensive name/url airdropped to a victim; persists on-chain forever. | Paying an address is the chain's core function. | Presentation only: default-hide unsolicited/unverified; never auto-fetch document_url/media; render name/ticker as plain text (markup-stripped, length-clamped) with the genesis-txid fingerprint. | Bytes remain on-chain/in-index permanently; GUI can hide but not erase. State honestly. | MEDIUM | +| T17 | **Image-hash authenticity confusion**: a clone reusing the same image shows the same "match" badge; user reads it as "authentic". | `document_hash` proves bytes match SOME genesis, never WHICH is original; consensus records no issuer. | UX copy: badge means "matches THIS token's recorded fingerprint" only; never authentic/official/original. | Social engineering against users who ignore the tokenId; honest copy bounds but cannot eliminate. | HIGH (UX) | + +--- + +## 4. Holder Anti-Burn Requirement (wallet must lock token UTXOs) + +**Confirmed: the wallet has ZERO ZSLP awareness today** (`grep -ril zslp src/wallet/` is empty). A ZSLP token (and NFT) rides a transparent dust UTXO whose ownership lives only in the overlay ledger keyed by `(txid,vout)`. The single coin enumerator `CWallet::AvailableCoins` filters only spent/not-mine/locked/`nValue>0` — no token filter — so EVERY spend path (sendtoaddress, sendmany, z_sendmany `find_utxos`, z_shieldcoinbase, z_mergetoaddress, fundrawtransaction, GUI send/shield/send-max) can select a token UTXO. The sharpest edge is the dust-to-fee fold in `CreateTransaction` (`nFeeRet += nChange`, change dropped): with the ~100-sat dust threshold, a 1-sat NFT output is virtually always classified dust and folded into the miner fee — silently burning the token. + +The overlay can only RECORD the burn (it is forbidden to touch validation/mempool); it can NEVER prevent it. **Anti-burn is a wallet property.** The store already exposes the exact primitive needed: `CZSLPStore::GetUtxo(txid,vout,out)`. + +Requirements (see R-WALLET-* in §7): + +- **Identify** token UTXOs and mint batons via the local zslp store (a read-only RPC mapping `(txid,vout) -> {tokenId,amount,isBaton,decimals}`, delegating to `GetUtxo`; batch classification so `AvailableCoins` does one store traversal). +- **Exclude** them from ALL automatic coin selection (fee/change/normal send/auto-shield/`shield all`/merge/send-max/dust consolidation) by default, at the same chokepoint that honors `IsLockedCoin`. Belt-and-suspenders: assert no selected input is a token UTXO before signing, so the dust-to-fee fold can never consume one. +- **Surface** them in coin control and `listunspent`, labeled `{tokenId, ticker, amount, isBaton}` with a "spending outside a token SEND BURNS it" warning, listed but unchecked by default. +- **Spend deliberately only**: a token UTXO is spendable solely via an explicit token-transfer flow or explicit coin-control opt-in, behind an acknowledged warning naming the token and its irreversibility. +- **Conserving SEND builder**: places the canonical SLP OP_RETURN at vout[0], recipients at vout[1..], adds a token CHANGE output for surplus, includes only intended token inputs, and **self-validates the constructed tx against this canonical spec before broadcast**. +- **Baton protection**: treat a mint baton like a token UTXO; never auto-spend it; explicit MINT keeps it alive unless the user ends minting. +- **Fail-safe when `-zslpindex` is off/unreachable**: do NOT fail open into a burn — refuse to auto-spend sub-threshold transparent dust (or block with an "enable -zslpindex" message), and degrade send-max/sweep conservatively. A wallet that sources balances independently MUST use the SAME canonical rules as the daemon and pass the published test vectors. + +--- + +## 5. Impersonation Defense + the Honest Uniqueness Statement + +Uniqueness exists ONLY at the token-id (genesis-txid) level (`tokenId == genesis txid`, collision-free because txids are unique under consensus). Anyone can mint a DIFFERENT token (new genesis txid) reusing any ticker/name/document_url/document_hash — even pointing document_hash at the genuine image. Both are legitimately, deterministically distinct tokens. **This cannot be neutralized on-chain or by the overlay**; it is a valid-by-design use of an open protocol. + +Defense is layered and explicitly OFF-consensus: + +- **Identity = tokenId.** All value actions (send/buy/gift) resolve the target by tokenId, never by user-typed name. A name search may aid discovery but the user MUST confirm the tokenId before any value action. +- **Issuer attestation (optional, off-chain).** Define a canonical byte-exact attestation string binding `tokenId <-> address/pubkey`, signed with the genesis funding key or a published brand key via the existing `signmessage`/`verifymessage`. Verify path: `(tokenId, address/pubkey, signature) -> valid/invalid`. No consensus, no new on-chain data. +- **Verified-issuer list keyed BY TOKENID ONLY** (never name/ticker), versioned, provenance shown. The badge means "on a list maintained by ", never "protocol-guaranteed authenticity". +- **Image-hash badge** means ONLY "these bytes match THIS token's recorded fingerprint" — never authentic/official/original. +- **Lookalike indicator** fires when distinct tokenIds share a name/ticker/image-hash, instead of silently resolving to one. +- **Set membership** is shown as issuer-claimed until the NFT1 group/child rule (T12) is specified+tested. + +### The HONEST uniqueness statement for the GUI + +> **What "unique" / "authentic" actually means here.** This token is identified by its **genesis transaction id (tokenId)**, which is globally unique — there is exactly one genesis on the ZClassic chain with this id, and the overlay guarantees its quantity (for an NFT, the single 1-of-1 unit) can never be duplicated, inflated, or forged into someone else's wallet, as long as you and your counterparty compute the ledger by the same published rules. **What it does NOT mean:** it does NOT mean the name, ticker, image, or description is unique — anyone can create a *different* token (a different tokenId) that reuses this exact name and image. A matching image hash proves the picture's bytes match what this tokenId committed to; it does NOT prove this tokenId is "the original", "official", or made by any particular person. The chain does not record or verify who an issuer is. Trust an issuer only via their tokenId fingerprint and an out-of-band signed attestation (or a verified-issuer list naming its maintainer) — never via a name, a green check, or an image. And remember: **this is a non-consensus overlay** — ZClassic's consensus rules do not enforce any of this; safety comes from every honest wallet/explorer recomputing the identical ledger, not from the network rejecting bad token transactions. + +--- + +## 6. Reorg / Confirmation Safety + +Strong properties to PRESERVE (verified): (1) the indexer overrides only `ChainTip` and never `SyncTransaction` — no mempool/0-conf path; a record exists only after a confirmed block connect under `cs_main` post-flush. (2) A typed per-block undo log replays in reverse for byte-identical pre-state, with correct same-block create-then-consume netting. (3) Crash-resume + re-delivery idempotence. (4) Node bounds reorgs: auto-finalize at depth 10, hard shutdown at 99. + +Gaps to close: + +- **No confirmation-depth concept exists** anywhere in `src/zslp/*` or `src/rpc/zslp.cpp` (verified by grep). Records are always >= 1 conf, but **1-9 confs are NOT reorg-safe** (node applies reorgs up to 99 deep, finalizes at 10). The RPC MUST expose `confirmations = tipHeight - height + 1` (R-RPC-4); the GUI MUST show ownership/authenticity as **PENDING until N confirmations**, then FINAL, with `N = DEFAULT_MAX_REORG_DEPTH = 10` via a single named constant so UI-final aligns with node finalization (high-value transfers may warrant N > 10). +- The GUI MUST read live store state on every `ChainTip` and never cache an "owned" flag across blocks, so a reorg that orphans a transfer demotes it within one tip. +- A mempool-only transfer MUST NEVER be shown as received/owned; any pending-send indicator is explicitly labeled unconfirmed/not-final and visually distinct from owned. +- Lock byte-exactness: a connect/dump vs connect+disconnect/dump equality property test; a two-branch reorg vs full-reindex equality test; idempotent re-delivery and crash-resume tests; a multi-tx-in-block reorg case (tx2 spends tx1's created output). + +--- + +## 7. Requirements Checklist (each item testable) + +Legend: **BLOCKER** = must land before any token can be safely held/transferred. Status reflects the working tree. + +### Indexer / store (the conservation rewrite must satisfy) + +- [ ] **R-1 (BLOCKER)** Mutate the store ONLY from `ChainTip` connect/disconnect; never override `SyncTransaction` or read the mempool in the write path. *Test:* a mempool-only tx produces no store record. (Today: OK.) +- [ ] **R-2 (BLOCKER, FIX)** Parse the SLP message from `tx.vout[0]` ONLY; delete the all-vout scan at `zslpindexer.cpp:211` ("first valid OP_RETURN wins"). *Test:* payment at vout[0] + valid SLP SEND at vout[1] spending a token UTXO => create nothing, burn inputs. (R-PARSE-1/2) +- [ ] **R-3** Skip the coinbase for SLP parsing. *Test:* coinbase with OP_RETURN at vout[0] ignored. (R-PARSE-3) +- [ ] **R-4** Two parseable OP_RETURNs (vout0+vout1) yield vout[0]'s result alone. *Test:* vout[1] message has zero effect. (R-PARSE-2) +- [ ] **R-5** Push grammar = `{0x01..0x4b, 0x4c, 0x4d}` only; reject `0x4e/OP_0/OP_1NEGATE/OP_1..OP_16`; do not gate on `IsPushOnly`. *Test per case.* (R-SCRIPT-1) (Today: OK.) +- [ ] **R-6** Dual-encoding (direct vs PUSHDATA1/2) parses identically; lenient non-minimal pushes FROZEN as canonical. *Test:* token_type 1 via both encodings yields equal parse. (R-SCRIPT-2) +- [ ] **R-7 (FIX)** GENESIS/MINT require `p == end` after the last field; trailing push => reject. *Test:* GENESIS + one appended push => not SLP. (R-SCRIPT-5) +- [ ] **R-8 (FIX)** `document_hash` length exactly 0 or 32; baton push length 0 or (1 and value>=2), len>1 => reject; decimals 1 byte 0..9; quantities exactly 8 bytes; token_id exactly 32. *Tests:* 31-byte hash, baton len 2, baton value 1, decimals 10, 7-byte quantity => all not SLP. (R-SCRIPT-6, R-INT-2) +- [ ] **R-9** token_id endianness: a MINT/SEND quoting the genesis txid's display-hex resolves the GENESIS's UTXOs. *Test:* round-trip. (R-ID-1) +- [ ] **R-10 (FIX)** Any quantity with the high bit set (`>= 2^63`) => whole message INVALID for GENESIS, MINT, and every SEND output. *Tests:* `2^63` and `2^64-1` for all three types. (R-INT-1) +- [ ] **R-11** SEND `requiredOut` overflow-guarded => INVALID; valid IFF `availIn >= requiredOut`; surplus burned; unknown/non-token/baton inputs contribute 0. *Tests:* sum at int64-max valid, +1 invalid; forge-send credits nobody; over-send burns. (R-SEND-2/3) +- [ ] **R-12 (FIX)** Single constant `ZSLP_SEND_MAX_OUTPUTS = 19` in parser, bridge, store, and array bounds; > 19 quantities => INVALID (not truncated to 20); 0 quantities => INVALID. *Tests:* 0/19/20 outputs. (R-SEND-1) — reconciles `slp.c:151` (19) vs `zslpindexer.cpp`/`zslpstore.cpp` (clamp 20). +- [ ] **R-13** Out-of-range positive output index => that quantity burned, in-range outputs still created (PIN Reading A); budget-checked first. *Test:* 3 quantities on a 2-output tx => vout[1] created, the rest burned. (R-SEND-4) +- [ ] **R-14** Every tx burns spent token UTXOs not validly re-created; consume-before-create; non-token input = 0. *Tests:* non-SLP tx spending a token UTXO burns it; invalid SEND burns inputs. (R-BURN-1/2) +- [ ] **R-15** Block.vtx order respected; tx2 can spend tx1's created output in the same block. *Test:* same-block genesis + send. (R-BURN-3) +- [ ] **R-16** GENESIS: `tokenId == txid`; first-genesis-wins INSERT-only (idempotent). *Test:* re-applying the same genesis block is a no-op. (R-GEN-1) +- [ ] **R-17 (FIX)** `totalMinted` counts ONLY quantity actually created as a UTXO (overflow-guarded), GENESIS and MINT. *Test:* GENESIS/MINT with no vout[1] => totalMinted unchanged, no UTXO. (R-GEN-3/R-MINT-3) +- [ ] **R-18** Baton issued/continued IFF `2 <= mint_baton_vout < voutCount`; MINT valid IFF a live baton input of that token; not re-declaring the baton ends minting. *Tests:* baton vout >= voutCount => no baton; MINT without baton => create nothing; MINT not re-declaring baton ends it. (R-GEN-4, R-MINT-1/2) +- [ ] **R-19** Unknown-token MINT issues nothing. *Test:* MINT of a never-genesised id => no token, no UTXO. (R-MINT-1) +- [ ] **R-20** Reorg: connect/dump == connect+disconnect/dump (byte-identical), incl. same-block create-then-consume and multi-delta-same-address. (R-REORG-2) +- [ ] **R-21** Post-reorg incremental ledger == from-scratch reindex of the winning chain; re-delivered tip + crash-resume are no-ops. *Tests:* two-branch reorg vs reindex equality; idempotent re-delivery. (R-REORG-3) +- [ ] **R-22** Undo never asked to exceed `MAX_REORG_LENGTH = 99`. (R-REORG-4) +- [ ] **R-23** `balance(token,addr) == sum of live non-baton token UTXOs` — after-each-block invariant test. (R-RPC-3) +- [ ] **R-24 (FIX)** `ListTransfers` peak memory/CPU is O(from+count), not O(total); clamp `count` at the store boundary; bound `GetTokensForAddress`. *Test:* > `ZSLP_LIST_MAX` transfers for one token. (T15) +- [ ] **R-25** RPC list orderings normative (R-RPC-1/2); `confirmations` exposed (R-RPC-4). *Test:* ordering + a confirmations value matching `tipHeight - height + 1`. + +### Wallet (holder anti-burn) + +- [ ] **R-WALLET-1 (BLOCKER)** Read-only RPC + in-process batch classification mapping `(txid,vout) -> {tokenId,amount,isBaton,decimals}` via `GetUtxo`; no ledger logic in the wallet. *Test:* NFT outpoint reports qty 1/baton false; random outpoint not-a-token; 1000-UTXO wallet classified in one pass. +- [ ] **R-WALLET-2 (BLOCKER)** `AvailableCoins` excludes token UTXOs and batons from default selection (alongside `IsLockedCoin`) when `-zslpindex` is on. *Test:* 1000 randomized sends never select the NFT outpoint. +- [ ] **R-WALLET-3 (BLOCKER)** Dust-to-fee fold and change construction can never consume a token UTXO; assert no selected input is a token UTXO before signing. *Test:* a tx that would route an NFT to fee makes `CreateTransaction` fail with a token-protection error, not a burn. +- [ ] **R-WALLET-4** z_sendmany `find_utxos`, z_mergetoaddress, z_shieldcoinbase inherit the exclusion; shield/merge-ALL skip token UTXOs even on "*". *Test:* `z_shieldcoinbase "*"` / merge leave the NFT untouched. +- [ ] **R-WALLET-5** Send-max / empty-wallet compute spendable EXCLUDING token UTXO values. *Test:* NFT + 10 ZCL => send-max sends ~10 ZCL minus fee, NFT unspent. +- [ ] **R-WALLET-6** Fail-safe with `-zslpindex` off/unreachable: refuse/route-around sub-threshold dust with a warning; never fail open. *Test:* index off => sub-threshold-dust send blocked or routed around, no silent burn. +- [ ] **R-WALLET-7** `listunspent` + coin control annotate token UTXOs `{tokenId,amount,isBaton,ticker,name}` + "do not spend as fee" flag, unchecked by default. *Test:* token object emitted; coin-control row badged + unchecked. +- [ ] **R-WALLET-8** Spending a token UTXO requires explicit coin-control selection or the dedicated token-transfer flow, behind an acknowledged irreversibility warning naming the token. *Test:* spend outside the token flow rejected; inside it user sees name + confirm gate. +- [ ] **R-WALLET-9** Token SEND builder: OP_RETURN at vout[0], recipients vout[1..], token change output for surplus, only intended token inputs, and self-validates the built tx against this spec before broadcast. *Test:* NFT transfer yields SLP SEND at vout[0], qty 1 to recipient, supply unchanged, sender no longer owns it. +- [ ] **R-WALLET-10** Baton treated as a token UTXO for anti-burn; never auto-spent; explicit MINT keeps it alive unless the user ends minting. *Test:* ordinary send never selects the baton. +- [ ] **R-WALLET-11** CI anti-burn regression suite: ordinary send, dust-to-fee, shield/merge, send-max, index-off fail-safe, conserving transfer, baton protection. + +### GUI (honesty + confirmation safety) + +- [ ] **R-UX-1** Identify tokens primarily by tokenId (genesis txid); copyable. *Test:* two same-name tokens show distinct tokenIds + a "name not unique" cue. +- [ ] **R-UX-2** No on-chain-derived "verified" badge. Verified-issuer badge (if any) names its maintainer and is visually distinct from the image-match badge; absence reads "Unverified issuer", never "fake". *Test:* UI copy review. +- [ ] **R-UX-3** Image-match badge copy = "matches its on-chain fingerprint" ONLY; never authentic/official/original. *Test:* copy review; clone reusing the image is not implied genuine. +- [ ] **R-UX-4** Issuer identity only via out-of-band signed attestation (R-ATTEST) or a tokenId-keyed verified list; labeled social/external. *Test:* no "verified by network" wording. +- [ ] **R-UX-5** Lookalike/collision indicator when distinct tokenIds share a name/ticker/image-hash; value actions resolve by tokenId, confirmed before sending. *Test:* collision surfaced; name search requires id confirmation. +- [ ] **R-UX-6** Ownership/authenticity shown PENDING until N confirmations, then FINAL; `N = DEFAULT_MAX_REORG_DEPTH = 10` via one named constant. *Test:* a 5-conf receipt shows pending; a 10-conf shows final. +- [ ] **R-UX-7** Read live store state each ChainTip (no cached owned flag); a mempool-only transfer never shown as owned, any pending-send labeled not-final and visually distinct. *Test:* reorg demotes an orphaned item within one tip. +- [ ] **R-UX-8** Default-hide unsolicited/unverified tokens (per-token/per-issuer opt-in); never auto-fetch/render `document_url` or media; render ticker/name as plain text (markup-stripped, control-chars stripped, length-clamped) with the tokenId. *Test:* unsolicited token hidden; URL not fetched without explicit action. +- [ ] **R-UX-9** State plainly that on-chain bytes are permanent (hideable, not erasable), that consensus enforces no token rules, and that burn loss is irreversible (the overlay only records it). With `-zslpindex` off, provenance/verify degrade to a calm "can't verify — token index is off", never a false green or crash. *Test:* dialog/copy review. + +### Attestation + interop + +- [ ] **R-ATTEST-1** Canonical byte-exact attestation string binding `tokenId <-> address/pubkey` with sign (genesis funding key or published brand key via `signmessage`) and verify paths. No consensus, no new on-chain data. +- [ ] **R-ATTEST-2** Verified-issuer list keyed by tokenId only, versioned, provenance shown, source configurable/signed. +- [ ] **R-NFT1** (DEFERRED, before any "verified set" UI) NFT1 group/child membership rule (child proves membership by spending a real group baton/quantity input) added to this spec WITH determinism tests; until then membership is "issuer-claimed". + +### Closure criterion (the threat class stays OPEN until this passes) + +- [ ] **R-VECTORS** Publish a VERSIONED file `{raw_op_return_hex -> expected parse result}` and `{block_of_txs -> expected ledger snapshot (UTXO set / balances / token rows)}` covering EVERY adversarial vector above: vout[1] message, two OP_RETURNs, PUSHDATA2/4 + OP_N fields, dual encoding, trailing byte (GENESIS/MINT), 31-byte hash, baton len>1, decimals 10, `2^63`/`2^64-1` quantities (all three types), 19/20/0 SEND outputs, out-of-range output index, sum overflow, unknown-token MINT, MINT-without-baton, no-vout[1] GENESIS/MINT, same-block genesis+send, two-branch reorg, re-delivered tip, crash-resume. +- [ ] **R-DIFF** A SECOND independent implementation of `F`, fed the identical history including all of R-VECTORS, produces a BIT-IDENTICAL token ledger AND RPC output (verified via a read-only dump/validate RPC emitting the full token-UTXO set + balances at a height, diffable in CI). The threat class stays OPEN until this differential test exists and passes. + +--- + +## 8. Versioning Note + +Because security IS cross-implementation bit-exact agreement, the canonical rules are a contract that external wallets and explorers must implement identically. Therefore: + +- This document is the NORMATIVE spec at **`ZSLP_SPEC_VERSION = 1`**. It is published (committed in-tree under `doc/nft/`) so any third party can implement `F` and prove agreement. +- The published test-vector corpus (R-VECTORS) is part of the versioned contract and is the authoritative tiebreaker for any ambiguity in prose. +- ANY change to a frozen `R-*` rule changes the ledger function. Such a change MUST: (a) bump `ZSLP_SPEC_VERSION`; (b) bump the on-disk `ZSLP_INDEX_VERSION` (`zslpstore.h`), which already triggers a wipe + reindex; and (c) update the published test vectors. The two version numbers move together so a node never silently computes a different ledger than its stored index version implies. +- Until R-DIFF passes against a second implementation, treat the overlay as EXPERIMENTAL and gate any "uniqueness/authenticity" UX claim behind the honesty statement in §5. + +--- + +*Synthesized from six independent threat reviews (determinism-fork, forgery-conservation, holder-anti-burn, impersonation-uniqueness, reorg-confirmation, dos-spam-grief). All code citations re-verified against the working tree. No source under `src/` was edited by this workflow.* diff --git a/doc/nft/THREATS_DOS_SPAM_GRIEF.md b/doc/nft/THREATS_DOS_SPAM_GRIEF.md new file mode 100644 index 00000000000..9d5db96accd --- /dev/null +++ b/doc/nft/THREATS_DOS_SPAM_GRIEF.md @@ -0,0 +1,323 @@ +# ZSLP Threat Model — DoS / Spam / Griefing (non-consensus overlay) + +Threat class: **dos-spam-grief** +Scope: ZClassic ZSLP token overlay (`-zslpindex`, `CZSLPIndexer`, `CZSLPStore`), +RPC reads in `src/rpc/zslp.cpp`, and the (not-yet-written) wallet integration. +Status: ANALYSIS + SPEC. This document does **not** edit `src/zslp/*`. It is the +security model and acceptance bar the conservation rewrite + wallet work must hit. + +--- + +## 0. The hard constraint (why most of this can't be "fixed") + +We CANNOT change ZClassic consensus. Minters and users run **existing, unchanged** +nodes. Base consensus does not know ZSLP exists: it relays/mines any standard +transaction, including an OP_RETURN that encodes a forged or abusive token op. + +Verified in-tree: +- `src/script/standard.cpp:65-72` — a `TX_NULL_DATA` output is standard as long + as it starts with `OP_RETURN` and the rest is push-only. The chain neither + parses nor rejects ZSLP content. +- `src/script/standard.h:34` — `MAX_OP_RETURN_RELAY = 223` bytes; one OP_RETURN + per **relayed** tx is the policy ceiling. +- `src/main.cpp:758-779` — `IsStandardTx` rejects a tx with `nDataOut > 1` + ("multi-op-return") **for relay only**. A cooperating/self-mining party can + still place a non-standard, multi-OP_RETURN tx in a block, and once mined it is + a normal confirmed tx that every indexer must process. Standardness is a relay + policy, **not** a consensus rule. +- `src/init.cpp:552-553,1833` — `-datacarrier` / `-datacarriersize` are + node-local relay knobs; they do not bind miners or other nodes. + +**Consequence for this threat class:** we can never stop an attacker from getting +spam/dust/abusive-token bytes confirmed on-chain. Every defense below is +indexer-side (bound the damage to our resources + the canonical ledger) or +wallet-side (don't burn the user's tokens, don't auto-display hostile content, +tell the truth). Be honest in the GUI about what is unpreventable. + +--- + +## 1. What an attacker can put on-chain (the raw primitives) + +1. **OP_RETURN spam:** up to 223 bytes of arbitrary data per relayed output; via + self-mined non-standard txs, multiple OP_RETURNs and larger payloads per tx. + Each looks like a normal tx to consensus. +2. **Dust outputs:** ZSLP "rides" transparent dust — token quantity lives at a + real pay-to-address vout (`zslp_listmytokens` help text, `src/rpc/zslp.cpp:191`). + An attacker can pay 1 satoshi (or the dust floor) to **any** address and attach + a token to it. +3. **Unsolicited token sends:** a valid SEND whose output vout pays the victim's + address creates a real token UTXO the victim now "owns" in the index + (`CZSLPStore::ApplyTransaction` SEND branch, `zslpstore.cpp:531-569`). +4. **Abusive metadata:** GENESIS `ticker`/`name`/`document_url`/`document_hash` + are attacker-controlled free text/URL/hash (`slp.c:63-114`). `document_url` + can point at hostile content; the hash can claim to be any image. +5. **Fake collections / impersonation:** anyone can GENESIS a token reusing a + famous name/ticker/image. Uniqueness is only at token-id = genesis-txid level + (`zslpindexer.cpp:229`, `zslpstore.cpp:453`). + +--- + +## 2. The security property we are actually defending + +SECURITY = **DETERMINISM + AGREEMENT**. There is no consensus to fall back on, so +the only thing protecting "who owns what" is that every honest observer running +the canonical rules computes the **bit-identical** ledger. Therefore in this +threat class a *determinism divergence is itself a critical DoS/grief vector*: an +attacker who finds an input two implementations parse differently can present +conflicting ownership to a marketplace vs. a wallet (ledger fork). I treat parse +non-determinism as the most severe item here, above resource exhaustion. + +--- + +## 3. Threats (each: attack -> why consensus can't stop it -> overlay defense -> residual) + +### T1 — Parse non-determinism: OP_RETURN at non-zero vout (CRITICAL, ledger fork) + +**Attack.** Construct a tx whose vout[0] is a normal payment and whose vout[1] (or +later) is the SLP OP_RETURN. Canonical SLP requires the SLP message to be at +**vout[0]**; a tx with a non-zero-position OP_RETURN is simply "not SLP". An +implementation that scans *any* vout will credit/move tokens that a canonical +implementation ignores (and vice-versa) → two ledgers, conflicting ownership. + +**Why unchanged consensus can't stop it.** The tx is standard either way; the +chain has no opinion on which vout "should" carry the data. + +**Current code is WRONG here.** `zslpindexer.cpp:211` loops +`for (size_t vo = 0; vo < tx.vout.size(); ++vo)` and takes the **first vout that +parses as SLP**, not vout[0]. The header comment `slp.h:5` and `slp.h:7` already +*state* the canonical rule ("vout[0]"), so the implementation contradicts its own +spec. There is no gtest asserting vout-position behavior +(`src/gtest/test_zslp_indexer.cpp` has no nulldata-position test). + +**Overlay defense (required).** Pin ONE canonical rule and make every observer +obey it: the SLP message MUST be parsed from **vout[0] only**. If vout[0] is not a +parseable SLP `TX_NULL_DATA`, the tx is non-SLP (inputs still burn, nothing +created). No "scan for first match". See `CANONICAL_VALIDATION_SPEC.md` §R1. + +**Residual.** None once pinned + tested; this is fully closeable. + +--- + +### T2 — Parse non-determinism: multiple OP_RETURNs in one (mined) tx (CRITICAL, ledger fork) + +**Attack.** Self-mine a non-standard tx with two OP_RETURNs, both at low vouts, +each a *different* valid SLP message (e.g. vout[0] = junk-but-parseable, vout[1] = +the "real" SEND), betting that observer A keys on the first and observer B on +another. + +**Why unchanged consensus can't stop it.** "multi-op-return" is relay policy only +(`main.cpp:778`); a mined block carrying it is valid and must be indexed. + +**Overlay defense (required).** The canonical rule (vout[0]-only) already +disambiguates: only vout[0] is ever consulted, so additional OP_RETURNs at vout≥1 +are irrelevant by construction. Spec §R1 + a gtest with a two-OP_RETURN tx. + +**Residual.** None once §R1 is enforced and tested. + +--- + +### T3 — Quantity/index edge-case divergence (HIGH, ledger fork) + +**Attack.** Craft a SEND whose `outputQuantities` (a) sum-overflow int64/uint64, +(b) reference an output index beyond `tx.vout.size()`, (c) supply more quantities +than there are outputs, (d) include a quantity that overflows when added to the +running input total, or a GENESIS/MINT with a baton vout out of range. Each is a +spot where two implementations can silently disagree (one burns, one creates). + +**Why unchanged consensus can't stop it.** All such txs are standard bytes. + +**Overlay defense (mostly present, must be pinned + tested).** Current store +behavior to canonicalize: +- Output-sum overflow → whole SEND INVALID, inputs burned + (`zslpstore.cpp:543-550,567`). ✔ matches a sane spec. +- Output index ≥ voutCount → that quantity is **burned**, others still applied + (`zslpstore.cpp:560-561`). Must be the canonical rule, not an error. +- num_outputs clamp to ≤ 20 in two places (`slp.c:151` caps at 19 on parse; + `zslpindexer.cpp:267` and `zslpstore.cpp:542` clamp to 20). **The clamp value + must be ONE number across parser/bridge/store** or a 20th output diverges. +- "Input not a recognized token UTXO ⇒ contributes ZERO" — `readUtxo` miss is a + `continue` (`zslpstore.cpp:439`). ✔ canonical. +- uint64→int64 cast: amounts are parsed as uint64 (`slp.c:158`) then stored int64 + (`zslpstore.cpp:230,270`). A quantity with the high bit set becomes **negative** + int64; the SEND loop treats `q < 0` as overflow→INVALID (`zslpstore.cpp:545`). + This is *a* deterministic rule but it must be the **declared** one (see spec + §R5): "any output quantity ≥ 2^63 ⇒ SEND invalid". + +**Residual.** None if the spec fixes each rule and gtests assert them. Risk is +purely "second implementation guesses differently" — closed by a published spec + +test vectors. + +--- + +### T4 — Index resource exhaustion via cheap genesis/UTXO flooding (HIGH) + +**Attack.** Mint thousands of tiny tokens, or fan a token into thousands of dust +UTXOs, to bloat the `-zslpindex` LevelDB ('t', 'u', 'x', 'b' records) and slow +catch-up/reorg replay. Cost to attacker = on-chain fees only; cost to every +indexing node = unbounded disk + CPU at `CatchUp` and on each `ConnectBlock`. + +**Why unchanged consensus can't stop it.** Each genesis/send is a normal, +fee-paying tx. + +**Overlay defense (partial; needs explicit bounds).** +- The index is **derived and disposable**: behind `-zslpindex`, fully rebuildable, + wiped on version bump (`zslpindexer.cpp:74-85`). So worst case is bounded by the + chain's own size, not amplified. +- Per-tx work is O(vin + vout) with a small constant; reorg replay is O(undo ops + for the block). No superlinear blowup found. +- **Gaps:** (1) `TokenCount()`/`UtxoCount()` and `ListTransfers` build full + in-memory vectors (`zslpstore.cpp:255-279,777-799`) — `ListTransfers` gathers + **all** transfers for a token then reverses (`zslpstore.cpp:777,796`), + unbounded by `count`. A token spammed with millions of transfers makes one RPC + call allocate the whole set. (2) `GetTokensForAddress` scans the **entire** 'b' + keyspace for every address (`zslpstore.cpp:810-822`), and `zslp_listmytokens` + calls it once per wallet key (`rpc/zslp.cpp:222-236`) → O(keys × total_balances) + full-table scan per RPC. + +**Overlay defense (required).** Bound list RPCs at the **store** layer +(stream + early-stop at `count+from`, never materialize the full set); +the `ZSLP_LIST_MAX = 1000` cap (`zslpstore.h:49`) currently bounds the *returned* +slice but **not** the gathered set in `ListTransfers`. Make `GetTokensForAddress` +seek by an address-keyed view or accept that it is a full scan and rate-limit/cap +it. See spec §R8. + +**Residual.** On-chain bloat itself is unpreventable; the index merely mirrors the +chain. We close the *amplification* (one cheap tx → expensive RPC / OOM), not the +base growth. + +--- + +### T5 — Unsolicited / abusive token sends to a victim's address (MEDIUM, grief) + +**Attack.** Send a valid token (offensive name, or a "scam airdrop") to a +victim's t-address. The index correctly records the victim as owner +(`zslpstore.cpp:562`); `zslp_listmytokens` will surface it +(`rpc/zslp.cpp:185-261`). The victim cannot refuse receipt. + +**Why unchanged consensus can't stop it.** Paying someone is the chain's whole +purpose; a token-carrying dust payment is indistinguishable to consensus. + +**Overlay defense (wallet/GUI, required).** Cannot be neutralized at the ledger +(the tokens are genuinely there). Defense is **presentation**: +- Default-hide unsolicited / unverified tokens; require explicit "show" per token + or per issuer (allowlist). +- **Never auto-fetch or auto-render** `document_url` content or any media; never + resolve the URL without an explicit user click + warning. +- Treat `name`/`ticker` as untrusted text: no HTML/markup, length-clamp on + display, strip control chars; show the token-id fingerprint, not just the name. + +**Residual.** The bytes (including an abusive name) remain on-chain and in the +index forever; we can hide but not erase. Be explicit about this in the GUI. + +--- + +### T6 — Token-burn griefing via the ZSLP-unaware wallet (HIGH, user fund/asset loss) + +**Attack.** Not even an external attacker is needed: the **user's own wallet** +will destroy tokens. The wallet has ZERO ZSLP awareness today (verified: `grep +-rin zslp src/wallet/` = 0 hits). A token quantity rides a transparent dust UTXO; +ordinary coin selection will happily spend that dust as fee/change in a normal +ZCL send, and `ApplyTransaction` then **burns** it (a non-SLP tx consuming a token +UTXO creates nothing — `zslpstore.cpp:432-446`, gtest +`NonSlpSpendBurnsUtxo`). An NFT (qty 1) is gone permanently. An attacker can +*induce* this by sending the victim tokens on tiny dust the wallet will +opportunistically sweep. + +**Why unchanged consensus can't stop it.** The wallet builds a perfectly valid tx; +nothing on-chain marks the dust as "special". + +**Overlay defense (wallet, required).** +- The wallet MUST identify token-carrying UTXOs (query the index by + `(txid,vout)` → `GetUtxo`) and **exclude** them from automatic coin selection. +- Surface them in **coin-control** so the user can spend them only deliberately. +- A deliberate token spend must go through a ZSLP-aware path that emits the + correct SEND OP_RETURN at vout[0], or warn loudly that the token will burn. + +**Residual.** A user who force-spends a token via coin-control can still burn it +intentionally; that is acceptable with a clear warning. A wallet that hasn't yet +synced the index could mis-classify — must fail **closed** (treat unknown dust as +possibly-token and warn) per spec §R9. + +--- + +### T7 — Impersonation / fake-collection flooding (MEDIUM, social grief) + +**Attack.** GENESIS many tokens cloning a real project's `ticker`, `name`, +`document_url`, and `document_hash` to confuse buyers; flood `zslp_listtokens` +with look-alikes. + +**Why unchanged consensus can't stop it.** Names are free-text bytes; there is no +on-chain registry. + +**Overlay defense (presentation only).** Uniqueness exists **only** at token-id = +genesis-txid (`zslpstore.cpp:453`, `NftCannotBeDuplicated` gtest). The GUI must: +- Identify a token by its **genesis-txid fingerprint**, never by name alone. +- Mark everything unverified by default; verification is an out-of-band, + social/attestation layer (issuer-signed statements, curated allowlists), NOT a + chain guarantee. +- Make impersonation visible (e.g. "3 other tokens use this name") rather than + pretending uniqueness. + +**Residual.** Impersonation is **inherent** and unpreventable; honesty is the only +mitigation. State this plainly in the UX. + +--- + +### T8 — Reorg / disconnect amplification (LOW–MEDIUM) + +**Attack.** Drive deep reorgs (or feed a node many competing tips) so the indexer +replays large undo logs. + +**Why unchanged consensus can't stop it.** Reorgs are normal consensus behavior. + +**Overlay defense (present).** Disconnect is O(undo ops for the block), accumulates +per-record in memory, writes each once (`zslpstore.cpp:591-731`), and yields a +byte-identical pre-state (gtests `ReorgGenesisRoundTrip`, `ReorgMintRoundTrip`). +The undo log is bounded by the block's own ZSLP activity. No unbounded +amplification found. + +**Residual.** Bounded by chain reorg depth, which consensus already limits in +practice. Acceptable. + +--- + +### T9 — Catch-up / re-delivery double-count (LOW, integrity not DoS, noted) + +The connect path has an idempotence guard (`zslpindexer.cpp:180-183`) and +crash-resume via the tip marker. Relevant here only because a broken guard would +let a replay double-credit, which is a (self-inflicted) ledger divergence. Keep +the guard + a test; not an external DoS lever. + +--- + +## 4. Severity ranking (this threat class) + +| ID | Threat | Severity | Closeable on overlay? | +|----|--------|----------|------------------------| +| T1 | OP_RETURN non-vout[0] parse divergence | **critical** | Yes — pin vout[0]-only | +| T2 | Multiple-OP_RETURN parse divergence | **critical** | Yes — implied by T1 fix | +| T3 | Quantity/index edge-case divergence | high | Yes — spec + vectors | +| T6 | Wallet burns tokens (own + induced) | high | Yes — coin-control exclude | +| T4 | Index resource exhaustion / RPC OOM | high | Partly — bound RPCs; base growth inherent | +| T5 | Unsolicited / abusive sends | medium | No (ledger); hide in GUI | +| T7 | Impersonation / fake collections | medium | No; honesty only | +| T8 | Reorg amplification | low | Already bounded | +| T9 | Replay double-count | low | Guard present; keep tested | + +--- + +## 5. Honest "cannot be prevented" list (put this in the GUI, not the footnotes) + +- On-chain **bytes are permanent**: spam OP_RETURNs, abusive names, dust, and + unsolicited tokens cannot be deleted, only hidden in our views. +- **Anyone can clone** any token's name/ticker/image; only the genesis-txid is + unique. There is no chain-enforced "real" issuer. +- We cannot stop a user from being **sent** a token; we can only choose not to + surface it. +- A confirmed forged/abusive tx is a **valid** ZClassic tx; consensus will keep + relaying and mining such transactions. The overlay's only power is to credit + nobody / burn / hide — never to make the chain reject it. + +See `CANONICAL_VALIDATION_SPEC.md` for the exact rules and +`REQUIREMENTS_DOS_SPAM_GRIEF.md` for the testable acceptance checklist the +conservation rewrite + wallet work must satisfy. diff --git a/doc/nft/ZDC1_CODEC_SPEC.md b/doc/nft/ZDC1_CODEC_SPEC.md new file mode 100644 index 00000000000..304ce4f8219 --- /dev/null +++ b/doc/nft/ZDC1_CODEC_SPEC.md @@ -0,0 +1,357 @@ +# ZDC1 Codec — Implementation-Ready Specification + +**Scope:** the exact, byte-level format and crypto the implementer codes to for the +ZClassic Shielded Data Channel (ZDC1). This is the **codec** only: pure logic, no +daemon/chain/Qt dependency, depends only on **libsodium + C++11**. It lives at +`src/datachannel/zdc.{h,cpp}` and compiles both standalone (`g++ -std=c++11 +... -lsodium`) for unit tests and unchanged into the daemon (which already builds +`-std=c++11 -noext` and links `-lsodium`; `configure.ac:68,783`). + +Refines, does not contradict, `PRIVACY_STACK.md` (and `CONTENT_MODEL.md`). Read those for the +throughput/cost/governance analysis; this file is the wire + crypto contract. + +> **NON-CONSENSUS.** Rides the existing Sapling shielded pool + 512-byte encrypted +> memo (`ZC_MEMO_SIZE`, `src/zcash/Zcash.h:17`) with no validation, builder, or +> opcode change. The send path already accepts raw binary memo bytes +> (`get_memo_from_hex_string`, `asyncrpcoperation_sendmany.cpp:1321-1343`). + +--- + +## 0. Layer model (what is whose job) + +``` + L0 Sapling shielded pool consensus zk-SNARK privacy NOT this code + L1 Sapling per-output memo 512B, ChaCha20-Poly1305 to ivk NOT this code + L2 ZDC1 transport framing + reassembly + ordering this codec + L3 ZDC1 application AEAD per-transfer key, per-chunk Poly1305 this codec +``` + +L0/L1 already guarantee only the recipient (holder of the incoming viewing key) +can read a memo at all. ZDC1 adds an **independent** application-layer AEAD so that +(a) ciphertext can be published now and the key revealed later, (b) a break in one +layer does not cascade, (c) one ciphertext can be opened by N recipients. + +--- + +## 1. The frame — 32-byte header + 480-byte payload (512 = one memo) + +All multi-byte fields **big-endian** (network order). One memo = exactly one frame. + +``` + off len field notes + 0 4 magic 0x5A444331 "ZDC1" + 4 1 version 0x01 + 5 1 type 0x01 START | 0x02 DATA | 0x03 END | 0x04 KEY + 6 1 flags bit0 = payload_is_ciphertext (L3-AEAD); rest reserved 0 + 7 1 cipher_id 0x00 none | 0x01 ChaCha20-Poly1305-IETF + 8 8 transfer_id random 64-bit; separates concurrent transfers + 16 4 seq chunk index; START=0, DATA=0..N-1, END=chunk_count + 20 4 chunk_count total DATA chunks; authoritative in START and END + 24 2 payload_len 0..480 valid bytes in this frame's payload + 26 4 crc32 CRC-32/IEEE over the FULL 480-byte payload field + 30 2 reserved 0 (rejected if non-zero) + 32 480 payload data, zero-padded past payload_len +``` + +Usable payload = **480 B/frame**. After the L3 16-byte AEAD tag, usable +**plaintext per DATA chunk = 464 B** (`DATA_PLAINTEXT_PER_FRAME`). Header overhead += 32/512 = 6.25%; with the tag, plaintext efficiency = 464/512 = 90.6%. + +`crc32` is **transport integrity only** — corruption / foreign-data detection. It +is **NOT a MAC** and provides **no tamper-evidence**; the L3 Poly1305 tag is the +security check. The crc covers the whole padded 480-byte field so a flipped pad +byte is still caught at transport. + +### 1.1 Frame semantics + +| type | payload (plaintext, before L3) | seq | nonce_ctr | cipher | +|---|---|---|---|---| +| **START** 0x01 | `TransferMeta` blob (below) | 0 | `0xFFFFFFFF` | ChaCha20P | +| **DATA** 0x02 | up to 464 B of content | `i` (0..N-1) | `i` | ChaCha20P | +| **END** 0x03 | `SHA-256(full plaintext)` 32 B | `chunk_count` | `0xFFFFFFFE` | ChaCha20P | +| **KEY** 0x04 | the raw 32-byte transfer key | 0 | — | NONE (L1 protects it) | + +`TransferMeta` blob (the START plaintext, big-endian, bounds-checked parse): + +``` + off len field + 0 8 total_plaintext_size u64 (exact reassembled byte length) + 8 4 chunk_count u32 + 12 2 filename_len u16 + 14 ... filename (filename_len bytes, may be 0) + .. 2 content_type_len u16 + .. ... content_type (content_type_len bytes, may be 0) +``` +Total must fit `DATA_PLAINTEXT_PER_FRAME` (464 B) or `encode` returns `ERR_OVERSIZE`. + +--- + +## 2. Chunking + +`chunk_count = ceil(len / 464)`. A **zero-length** payload is valid: 0 DATA frames, +producing `START, END[, KEY]`. DATA chunk `i` carries `plaintext[i*464 .. )`, the +last possibly short. Each chunk is independently AEAD-sealed (no cross-chunk +chaining) so out-of-order arrival, partial fetch, and per-chunk verification all +work without holding the whole transfer. + +--- + +## 3. AEAD — ChaCha20-Poly1305 IETF (libsodium) + +- **Primitive:** `crypto_aead_chacha20poly1305_ietf_{encrypt,decrypt}`. Key 32 B, + nonce 12 B, tag 16 B. Ciphertext layout = `plaintext || tag` (combined mode). +- **Key:** per-transfer, 32 bytes from `randombytes_buf` (CSPRNG). + **Never reused across transfers. Never logged.** `ZdcAead::generate_key`. + +### 3.1 Nonce — guaranteed unique per chunk (the catastrophic-if-wrong part) + +ChaCha20-Poly1305 nonce reuse under one key is catastrophic: the keystream repeats +(XOR of two plaintexts leaks) **and** the one-time Poly1305 key repeats (forgery). +AAD does **not** change the keystream, so binding different AAD does **not** rescue +a reused (key, nonce). + +The key is fresh per transfer, so uniqueness reduces to: every L3-sealed frame in +one transfer uses a distinct 12-byte nonce. We derive it **deterministically**: + +``` + nonce[12] = transfer_id (8 B, big-endian) || nonce_ctr (4 B, big-endian) +``` + +`nonce_ctr` is a **per-frame counter unique within the transfer**, and is **NOT** +the wire `seq` (START seq 0 and DATA[0] seq 0 would collide). The role→counter map +reserves the top of the 32-bit range for the singleton control frames: + +``` + DATA chunk i -> nonce_ctr = i (0 .. chunk_count-1, <= 65534) + START -> nonce_ctr = 0xFFFFFFFF + END -> nonce_ctr = 0xFFFFFFFE + KEY -> (not L3-encrypted; consumes no counter) +``` + +Collision-free **by construction**: DATA counters are bounded by +`MAX_CHUNK_COUNT = 65535`, far below the reserved band. **Why deterministic and +not random per-chunk:** (a) saves no payload bytes carrying a nonce; (b) uniqueness +is *provable* and unit-testable rather than probabilistic; (c) a 96-bit random +nonce only has ~birthday-bound safety, unnecessary when we already have a unique +counter. Proven by `test_nonce_uniqueness` (across sizes incl. the 1-chunk case +where the naive `seq` scheme reuses counter 0). + +### 3.2 AAD — header binding + +AAD for every L3 op = the 32-byte serialized header **with `crc32` and `payload_len` +zeroed** (crc is computed after sealing; the AEAD tag already covers ciphertext +length). This binds `version | type | transfer_id | seq | chunk_count | flags | +cipher_id | reserved` — everything that defines the frame's **role** — so a +**reordered** (changed seq), **retyped** (DATA↔START), or **cross-transfer** +(changed transfer_id) frame fails the tag. Tested in `test_aead_tamper_and_aad`. + +### 3.3 Integrity, three independent checks + +1. **Per-chunk Poly1305 tag** (security) — any flipped ciphertext/tag/AAD byte ⇒ + `ERR_AEAD_FAIL`. Even if an attacker re-fixes the transport crc, the tag catches + it (`test_tamper_in_transit_detected`). +2. **Overall content hash** — END carries `SHA-256(full plaintext)`; after + reassembly+decrypt the decoder recomputes and compares ⇒ `ERR_HASH_MISMATCH` on + mismatch. Also cross-checks `total_plaintext_size`. +3. **Transport crc32** — corruption detection at the frame boundary, before any + crypto (`ERR_BAD_CRC`). NOT security. + +### 3.4 NFT fingerprint anchor (over CIPHERTEXT) + +`doc/nft/CONTENT_MODEL.md` commits the on-chain anchor to **ciphertext** (verify- +before-decrypt). The END plaintext hash above is an internal integrity check; the +**on-chain `document_hash`** is computed by `ciphertext_fingerprint(frames)` = +`SHA-256` over the concatenated DATA-frame ciphertext payloads, in seq order. This +lets the MINT path set `document_hash = ciphertext_fingerprint(...)` so the public +ZSLP token cryptographically commits to the private bytes, and lets any node verify +the committed bytes **without the key**. Tested in `test_ciphertext_fingerprint`. + +--- + +## 4. KEY frame + reveal modes + +The KEY frame's payload is the **raw 32-byte key**, `cipher_id = NONE`. Its on-chain +confidentiality is the **Sapling memo encryption (L1)** to the recipient's ivk — it +is not double-sealed under itself (that would be circular). It is a distinct frame +type so reveal-later is first-class and the decoder can show "key seen / sealed". + +- **(a) In-band, same/next tx** — KEY as another shielded output now. Atomic. +- **(b) Reveal-later** — broadcast START/DATA/END now; send KEY after N confirms. + Content is **undecryptable even by the recipient** until KEY arrives + (`assemble` returns `ERR_NO_KEY` while content-complete-but-sealed; + `test_seal_then_reveal`). +- **(c) Out-of-band** — deliver the key via Signal/QR/PGP; `Decoder::set_key`. + Nothing key-related on chain (`test_oob_key`). + +--- + +## 5. Reassembly, ordering, integrity (decoder) + +Frames arrive in **arbitrary order** — `mapWallet` iterates by txid hash, never +block/insertion order, so chain order is **never trusted**. The `Decoder`: + +1. `add_frame(memo, 512)` → `parse_header`. Non-ZDC1 ⇒ `ERR_BAD_MAGIC` (caller + routes it to the text inbox). Then verify transport crc (`ERR_BAD_CRC`). +2. **Transfer lock:** first valid frame fixes `transfer_id`; a differing + `transfer_id` ⇒ `ERR_BAD_STATE`. One `Decoder` == one transfer. +3. **Store by seq.** DATA in `map`. **Duplicate seq:** first wins, + dup ignored (replay / multi-address delivery). **DATA seq ≥ chunk_count** ⇒ + `ERR_BAD_STATE`. +4. **Complete** = START present AND END present AND every `seq ∈ [0, chunk_count)` + present. `missing_chunks()` lists gaps for UI. Complete does NOT require the key. +5. **`assemble`** (needs complete + key): decrypt START meta, decrypt DATA in seq + order, recompute + compare END hash, cross-check size. Returns the exact original + bytes + meta, or a precise error. Idempotent, side-effect-free on stored frames. +6. **GC (caller's job):** expire incomplete `(zaddr, transfer_id)` after a TTL + (default 7 days) to bound memory against START-spam. + +--- + +## 6. Size caps (responsibility, not just perf) + +- `MAX_CHUNK_COUNT = 65535` DATA frames (codec structural ceiling so a hostile + START cannot make the decoder allocate forever). `parse_header` rejects a + `chunk_count` above this ⇒ `ERR_OVERSIZE`. +- `MAX_TRANSFER_BYTES = 65535 * 464 ≈ 29 MB` absolute ceiling. **Callers MUST set a + far tighter policy cap** (the as-built daemon caps a transfer at 40000 bytes; see + `NATIVE_NFT_GUIDE.md §3.3`); the codec ceiling only prevents pathological allocation. +- **Honest permanence:** every memo is stored by every full node FOREVER. Large + transfers are conspicuous (many outputs ⇒ approximate size leaks). This is a + confidentiality channel, not a steganographic or scalable file-transfer one. + +--- + +## 7. Error taxonomy (`zdc::Status`) + +| code | value | meaning | who raises | +|---|---|---|---| +| `OK` | 0 | success | — | +| `ERR_TRUNCATED` | -1 | memo < 512 B | add_frame | +| `ERR_BAD_MAGIC` | -2 | not a ZDC1 frame (ordinary text memo) | parse_header | +| `ERR_BAD_VERSION` | -3 | magic ok, version unknown | parse_header | +| `ERR_BAD_TYPE` | -4 | type not START/DATA/END/KEY | parse_header | +| `ERR_BAD_PAYLOAD_LEN` | -5 | payload_len > 480 | parse_header | +| `ERR_BAD_CRC` | -6 | transport corruption | add_frame | +| `ERR_BAD_CIPHER` | -7 | unsupported cipher_id | (reserved) | +| `ERR_AEAD_FAIL` | -8 | Poly1305 fail: tamper / wrong key / wrong AAD | decrypt | +| `ERR_OVERSIZE` | -9 | chunk_count / transfer over cap; meta too big | encode/parse | +| `ERR_INCOMPLETE` | -10 | missing START/END/DATA seq | assemble | +| `ERR_HASH_MISMATCH` | -11 | reassembled hash/size ≠ END | assemble | +| `ERR_NO_KEY` | -12 | complete but sealed (no key yet) | assemble | +| `ERR_BAD_STATE` | -13 | protocol misuse / foreign transfer_id | add_frame | +| `ERR_INTERNAL` | -14 | libsodium / invariant failure | any | + +`status_str(Status)` returns a human string and **never logs key material**. + +--- + +## 8. Public API (`src/datachannel/zdc.h`) + +```cpp +namespace zdc { + // transport + uint32_t crc32(const uint8_t* data, size_t len); + void serialize_header(const FrameHeader& h, uint8_t* out /*>=32*/); + Status parse_header(const uint8_t* in /*>=512*/, FrameHeader& out); + + // L3 AEAD (also usable directly) + struct ZdcAead { + static Status generate_key(std::vector& key /*out 32B*/); + static void derive_nonce(uint64_t tid, uint32_t nonce_ctr, uint8_t out[12]); + static Status encrypt(key, tid, nonce_ctr, aad, aad_len, pt, ct /*out*/); + static Status decrypt(key, tid, nonce_ctr, aad, aad_len, ct, pt /*out*/); + static Status sha256(const uint8_t* d, size_t n, uint8_t out[32]); + }; + + // encode a whole transfer -> ordered 512B frames (START,DATA*,END[,KEY]) + struct Encoder { + static Status encode(uint64_t tid, const std::vector& key, + const std::vector& plaintext, + const TransferMeta& meta, bool include_key_frame, + std::vector>& frames_out); + static Status encode_key_frame(uint64_t tid, const std::vector& key, + uint32_t chunk_count, + std::vector& frame_out); + }; + + // stateful reassembly (one Decoder == one transfer) + class Decoder { + Status add_frame(const uint8_t* memo, size_t len); // any order, dups ok + Status add_frame(const std::vector& memo); + Status set_key(const std::vector& key); // out-of-band key + bool is_complete() const; // START+END+all DATA + bool have_key() const; + std::vector missing_chunks() const; + Status assemble(std::vector& out_pt, TransferMeta& out_meta) const; + uint64_t transfer_id() const; + }; + + // on-chain NFT anchor (SHA-256 over DATA ciphertext, key-independent) + Status ciphertext_fingerprint(const std::vector>& frames, + uint8_t out[32]); + const char* status_str(Status s); +} +``` + +### 8.1 Daemon integration points (no change to the codec) + +- **Send:** chunk the (already L3-sealed) frames; each frame's 512 bytes go to + `z_sendmany` as a memo via `get_memo_from_hex_string` (it already accepts raw + binary up to `ZC_MEMO_SIZE` — `asyncrpcoperation_sendmany.cpp:1321-1343`). Batch + ≤107 outputs/tx, ≤210/block. No builder/consensus change. +- **Receive:** the decrypted memo bytes from `z_listreceivedbyaddress` + (`rpcwallet.cpp:3338-3427`) feed `Decoder::add_frame`. NFT mint path sets ZSLP + `document_hash = ciphertext_fingerprint(frames)`. + +--- + +## 9. GUI binary-safe read path (design — DO NOT implement here) + +**The bug** (`zcl-qt-wallet/src/rpc.cpp ~756-790`): memos are read as +`QString memo(QByteArray::fromHex(hexMemo))`, which **UTF-8-decodes** the bytes and +**loses** any non-UTF-8 byte. ZDC1 frames are binary; this silently corrupts them. + +**Fix — a parallel binary-safe path, leaving the text inbox unchanged:** + +1. Keep the **raw** memo as a `QByteArray`, do not coerce to `QString`: + ```cpp + QByteArray rawMemo = QByteArray::fromHex( + QByteArray::fromStdString(i["memo"].get())); + ``` +2. **Sniff the magic on the raw bytes** before any text handling: + ```cpp + static const char ZDC1_MAGIC[4] = {0x5A,0x44,0x43,0x31}; // "ZDC1" + bool isZdc = rawMemo.size() == 512 && memcmp(rawMemo.constData(), ZDC1_MAGIC, 4) == 0; + ``` +3. **Route:** if `isZdc`, hand `rawMemo` (512 bytes) to the data-channel handler + (which feeds `zdc::Decoder::add_frame`) and do **not** add it to the text memo + map. Otherwise, keep today's behavior: skip `f600`, trim, store as text. +4. The data-channel handler keys `Decoder`s by `(zaddr, transfer_id)` (transfer_id + = header bytes 8..15, big-endian), surfaces `is_complete()/missing_chunks()` as + a progress UI, and gates "open" on `have_key()`. + +This is **additive and binary-lossless**: ordinary text memos are untouched, ZDC1 +frames are no longer mangled, and the codec (this spec) is the single source of +truth for parsing/decrypting them. The GUI is **C++14**; keep to that repo's +constraints (no `std::optional`/`string_view`; the codec header is C++11 so it links +into either). + +--- + +## 10. Build & test + +``` +g++ -std=c++11 -Wall -Wextra src/datachannel/zdc.cpp \ + src/datachannel/test/zdc_test.cpp -lsodium -o /tmp/zdc_test && /tmp/zdc_test +``` + +Tiny self-contained CHECK harness (no gtest needed). **260 checks, 0 failures**; +clean under `-pedantic-errors -Wshadow -Wconversion` and under +`-fsanitize=address,undefined`. Coverage: header round-trip + endianness + +rejects, CRC vectors, AEAD round-trip, **nonce uniqueness** (incl. the 1-chunk +collision case), AAD binding (reorder/retype/cross-transfer fail), tamper +(ciphertext/tag/AAD/key) detection, transport-crc rejection, truncation, dup + +reorder + missing reassembly, foreign-transfer-id rejection, empty + maximal +payloads, seal-then-reveal, out-of-band key, size caps, frame-size invariant, +and the ciphertext fingerprint (deterministic, order-independent, verify-before- +decrypt, tamper-visible). diff --git a/doc/nft/holder-anti-burn-requirements.md b/doc/nft/holder-anti-burn-requirements.md new file mode 100644 index 00000000000..e4f4145facc --- /dev/null +++ b/doc/nft/holder-anti-burn-requirements.md @@ -0,0 +1,215 @@ +# Holder Anti-Burn: Wallet Requirements + UX Honesty Checklist + +Companion to `holder-anti-burn-threat-model.md`. Concrete, testable requirements +the implementation MUST meet to close the holder-anti-burn threat class. All are +NON-consensus (wallet + one read-only RPC). No edits to `src/zslp/*` are made by +this workflow; the RPC surface below is the contract the conservation rewrite +must expose. + +Legend: [R#] requirement, each with an acceptance test. + +-------------------------------------------------------------------------------- +## A. Token-awareness plumbing (read-only) + +[R1] EXPOSE per-outpoint token status over RPC. +Add a read-only RPC (behind `-zslpindex`) that, given `(txid, vout)`, returns +whether it is a token UTXO and its `{tokenId, amount, isBaton, decimals}`. +It MUST delegate to `CZSLPStore::GetUtxo` (`src/zslp/zslpstore.h:356-357`) — the +source of truth — and add NO new ledger logic in the wallet. +TEST: mint an NFT; the RPC reports its dust outpoint as a token UTXO with qty 1, +baton false; a random non-token outpoint reports not-a-token. + +[R2] BATCH lookup for coin selection. +Provide a batched form (set of outpoints -> token status) so `AvailableCoins` +can classify all candidate coins in one pass without N RPC round-trips (in-process +call against the indexer, NOT an external RPC, when `-zslpindex` is on). +TEST: a wallet with 1000 UTXOs classifies all in a single store traversal; no +per-coin lock churn. + +-------------------------------------------------------------------------------- +## B. Exclude token UTXOs from normal spending (the core anti-burn) + +[R3] EXCLUDE token UTXOs from default `AvailableCoins`. +At `src/wallet/wallet.cpp:3151` `AvailableCoins`, add a token-exclusion check +alongside the existing `IsLockedCoin` check (line 3181). When `-zslpindex` is on, +a UTXO that is a token UTXO (or a mint baton) is NOT returned for default coin +selection. This single chokepoint protects sendtoaddress/sendmany/z_sendmany/ +shieldcoinbase/mergetoaddress/fundrawtransaction simultaneously, because they +all enumerate through `AvailableCoins`. +TEST: hold an NFT + ordinary funds; `sendtoaddress` to a third party never +selects the NFT outpoint as an input across 1000 randomized runs (defeats the +randomized subset-sum in `SelectCoinsMinConf`). + +[R4] NEVER fold a token UTXO into fee/change. +Independently of R3, guarantee the dust-to-fee path +(`src/wallet/wallet.cpp:3672-3675`, `nFeeRet += nChange`) and the change-output +construction can NEVER consume a token UTXO's value. Since R3 keeps token UTXOs +out of inputs, this is belt-and-suspenders: assert no selected input is a token +UTXO before signing. +TEST: construct a transaction by hand that would route an NFT's value to fee; +`CreateTransaction` refuses (returns false with a token-protection error) rather +than burning it. + +[R5] EXCLUDE token UTXOs from the shielded/merge sweeps. +`z_sendmany` `find_utxos` (`src/wallet/asyncrpcoperation_sendmany.cpp:988`), +`asyncrpcoperation_mergetoaddress`, and `asyncrpcoperation_shieldcoinbase` must +inherit the R3 exclusion (they call the same `AvailableCoins`). Additionally, +"shield/merge ALL transparent" operations MUST skip token UTXOs even when the +user expresses "all". +TEST: with an NFT held on a t-address, `z_shieldcoinbase "*"` and +`z_mergetoaddress` leave the NFT outpoint untouched; balances/notes reflect only +non-token funds. + +[R6] "Send max" / sweep-all UX never empties a token UTXO. +The GUI "send max" and any "empty wallet" path compute the spendable maximum +EXCLUDING token UTXO values, and never select them. +TEST: NFT + 10 ZCL; "send max" sends ~10 ZCL minus fee, NFT outpoint remains +unspent. + +-------------------------------------------------------------------------------- +## C. Surface token UTXOs (coin-control + listunspent) + +[R7] TAG token UTXOs in `listunspent` and coin-control. +`listunspent` (`src/wallet/rpcwallet.cpp:2335`) and the GUI coin-control picker +MUST annotate each token-carrying UTXO with `{tokenId, amount, isBaton, ticker, +name}` and a clear "TOKEN — do not spend as fee" flag. They are listed but +visually/structurally distinct and NOT selected by default. +TEST: `listunspent` output for an NFT outpoint includes a `token` object; the GUI +coin-control row shows the token badge and is unchecked by default. + +-------------------------------------------------------------------------------- +## D. Deliberate spend = explicit, warned, and CONSERVING + +[R8] FAIL-SAFE when the index is unavailable. +If `-zslpindex` is OFF or the store cannot be consulted, the wallet MUST NOT +silently treat dust as ordinary. It must either (a) refuse to auto-spend +low-value transparent dust (configurable threshold) and warn, or (b) refuse the +operation with a clear "token protection unavailable — enable -zslpindex" +message. NEVER fail OPEN into a burn. +TEST: with `-zslpindex` off, a send that would otherwise pick a sub-threshold +dust UTXO is blocked or routes around it with a warning; no silent burn. + +[R9] Deliberate token spend requires EXPLICIT opt-in + warning. +To spend a token UTXO at all (e.g. to transfer the NFT), the user must select it +explicitly via coin-control (`CCoinControl::Select`, `src/coincontrol.h:41`) or a +dedicated token-transfer RPC, AND acknowledge a warning naming the token and the +irreversibility of a mistaken spend. +TEST: spending a token outpoint without the explicit token-transfer flow is +rejected; with it, the user sees the token name + a confirm gate. + +[R10] Token transfer emits a CONSERVING SEND, not a bare spend. +A deliberate NFT/token transfer MUST construct a tx that (a) puts a canonical SLP +OP_RETURN at `vout[0]`, (b) recreates the token quantity at the correct output +vout for the recipient, and (c) conserves `sum(inputs) == sum(outputs)` for that +tokenId. A bare spend (no conserving OP_RETURN) is a BURN and must be refused +unless the user explicitly chose "BURN this token" with a separate, louder gate. +TEST: transferring an NFT yields a tx with SLP SEND at vout[0], qty 1 at the +recipient vout; the overlay ledger shows the recipient now owns the NFT and the +sender does not; supply unchanged. + +[R11] Baton protection. +A mint baton UTXO is treated like a token UTXO for anti-burn (R3-R10). Spending +it outside an explicit MINT flow (which must recreate the baton if continuation +is desired) warns that the mint capability will be destroyed. +TEST: an ordinary send never selects the baton outpoint; an explicit MINT keeps +the baton alive unless the user opts to end minting. + +-------------------------------------------------------------------------------- +## E. Determinism / canonical-spec requirements (the conservation rewrite MUST satisfy) + +These bind the indexer/store rewrite so wallet anti-burn rests on a ledger that +every implementation computes identically. Cross-implementation bit-exact +agreement is the security property; each rule needs a shared test vector. + +[R12] OP_RETURN at vout[0] ONLY (fix the confirmed divergence). +Replace the all-vout scan (`src/zslp/zslpindexer.cpp:205-224, 277-278`) with a +single `vout[0]` SLP check. If `vout[0]` is not a parseable SLP OP_RETURN, the tx +is non-SLP (and still burns any token inputs it spends). +TEST VECTOR: tx with payment at vout[0] and SLP-looking OP_RETURN at vout[3] => +classified non-SLP by BOTH our indexer and a reference canonical-SLP parser. + +[R13] SEND output cap = 19, too-many => INVALID. +Pin the SEND cap to 19 token outputs (vout[1..19]); a SEND with >19 quantities is +INVALID (token effect void, inputs burned), not truncated. Fix the 20-clamp +(`zslpindexer.cpp:266-267`) and the 20-sized arrays' off-by-one +(`slp.h:60`, `zslpmsg.h:47`) to a single agreed rule. +TEST VECTOR: a 20-quantity SEND => INVALID identically in both implementations. + +[R14] num_outputs > tx.vout count => INVALID. +Using `voutCount` (`zslpstore.h:333`), a SEND that references more outputs than +exist is INVALID (burn), not partial-credit. +TEST VECTOR: SEND with 3 quantities on a 2-output tx => INVALID in both. + +[R15] uint64 overflow on input/output sums => INVALID. +Explicit overflow detection when summing input token amounts and output +quantities; overflow => INVALID (burn). No wrapping. +TEST VECTOR: quantities chosen to overflow at the same byte boundary => INVALID +in both. + +[R16] Non-token / wrong-token inputs contribute ZERO. +A SEND's available amount is the sum over inputs that are recorded token UTXOs of +THE SAME tokenId (via `GetUtxo`, `zslpstore.h:356-357`). Other inputs (non-token, +or a different token) contribute zero; shortfall => INVALID/burn, never partial. +TEST VECTOR: SEND of token A spending one A-UTXO + one B-UTXO credits only A's +amount; if that is short of outputs => INVALID, identically in both. + +[R17] Quantity push length EXACTLY 8 bytes. +Enforce on the SEND path the same exact-8 rule genesis/mint already use +(`slp.c:113,134`); fix `be_to_u64`'s 1..8 leniency (`slp.c:16-21`) so a non-8 +quantity push is a parse failure. Identical classification in both. +TEST VECTOR: a 4-byte quantity push => non-SLP/INVALID in both. + +[R18] GENESIS/MINT/baton rules pinned. +Genesis qty at vout[1]; baton at `mint_baton_vout` (>=2), {0,1} = no baton +(`slp.h:50,76`); MINT requires spending the live baton UTXO; NFT = baton-less, +decimals 0, qty 1. MINT without a baton input => INVALID. +TEST VECTOR: MINT with no baton input => INVALID; genesis with mint_baton_vout=1 +=> no baton, identically in both. + +[R19] Lokad/token-type strictness pinned. +Lokad exactly `SLP\0` (`slp.h:23-24`), token type exactly 1; any deviation => +non-SLP. A tx classified SLP by one impl and non-SLP by the other (which can flip +a burn into a non-burn) is a fork. +TEST VECTOR: wrong Lokad / type 2 => non-SLP in both. + +[R20] Burn-on-spend is recorded deterministically. +For EVERY tx (SLP or not), token inputs it consumes are burned/transferred per +the rules (`ApplyTransaction` "Runs for EVERY tx", `zslpstore.h:322-325`); the +resulting UTXO set + balances are bit-identical across implementations and across +a disconnect/reconnect (reorg) round-trip. +TEST VECTOR: a cross-implementation golden ledger hash over a fixed block range +matches; a reorg replay yields the byte-identical pre-state +(`zslpstore.h:345-352`). + +-------------------------------------------------------------------------------- +## F. UX honesty (impersonation is social, never consensus-enforced) + +[R21] Token id is the fingerprint of identity. +The GUI MUST display the genesis-txid (tokenId) as the authoritative identity and +make clear that ticker/name/image are NOT unique and NOT verified by the chain. +TEST: two tokens with identical name/ticker show distinct tokenIds and a +"name not unique" cue; neither is presented as "verified". + +[R22] No false "verified" / "official" claims. +The wallet MUST NOT imply chain-level authenticity. Trust cues are limited to +issuer identity / genesis-txid match / (optional) signed attestations, each +labeled as social/external trust, not consensus. +TEST: UI copy review confirms no "verified by network" wording; impersonation +risk is surfaced. + +[R23] Burn/irreversibility honesty. +Any flow that can destroy a token (deliberate burn, baton spend) states plainly +that consensus cannot undo it and the overlay can only RECORD the loss. +TEST: burn/baton-spend confirm dialogs state irreversibility explicitly. + +-------------------------------------------------------------------------------- +## G. Regression guards + +[R24] Anti-burn regression suite. +Automated tests cover: ordinary send (R3), dust-to-fee (R4), shield/merge (R5), +send-max (R6), index-off fail-safe (R8), deliberate conserving transfer (R10), +and baton protection (R11). These run in CI on the wallet build. + +[R25] Cross-implementation determinism vectors. +The R12-R20 test vectors are stored as shared golden files so any compatible +implementation can prove bit-exact agreement; a divergence FAILS the build. diff --git a/doc/nft/holder-anti-burn-threat-model.md b/doc/nft/holder-anti-burn-threat-model.md new file mode 100644 index 00000000000..e6d4552699d --- /dev/null +++ b/doc/nft/holder-anti-burn-threat-model.md @@ -0,0 +1,230 @@ +# Holder Anti-Burn Threat Model (ZSLP / SLP overlay) + +Status: ANALYSIS — wallet has ZERO token awareness today. This document is the +security model + canonical validation requirements for the holder-anti-burn +threat class. It does NOT edit `src/zslp/*` (a concurrent workflow owns the +conservation rewrite); it specifies what that rewrite and the wallet must +satisfy. + +Scope: NON-consensus token overlay. We CANNOT change ZClassic consensus. Base +nodes relay/mine ANY standard tx, including an OP_RETURN that encodes a forged +token SEND, and including an ordinary payment that spends a token-carrying dust +UTXO as fee or change. Consensus offers NO protection. Holder safety is +therefore a WALLET property, and ledger integrity is a DETERMINISM property. + +-------------------------------------------------------------------------------- +## 1. The core hazard, grounded in code + +A ZSLP token (and an NFT in particular) "rides" a tiny transparent dust UTXO. +The token's existence and ownership live ONLY in the overlay ledger, keyed by +the UTXO outpoint `(txid, vout)` — see `src/zslp/zslpstore.h:114-128` +("Persisted token-carrying UTXO record — THE SOURCE OF TRUTH for ownership. +Keyed by (txid, vout)."). Consensus sees an ordinary, low-value t-output. + +The wallet has NO idea that UTXO is special. Verified: + +- No ZSLP/SLP reference exists anywhere under `src/wallet/` (grep returns + nothing). Coin selection, change, and fee logic are token-blind. + +- `CWallet::AvailableCoins` (`src/wallet/wallet.cpp:3151`) is the single + enumerator feeding ALL spend paths. Its only per-UTXO exclusions are: + `IsSpent`, not-mine, `IsLockedCoin`, and `nValue > 0` / zero-value flag + (line 3178-3183). There is NO token filter. A token dust UTXO with + `nValue > 0` is returned as ordinary spendable change material. + +- `CWallet::SelectCoins` / `SelectCoinsMinConf` + (`src/wallet/wallet.cpp:3235, 3336`) run a randomized subset-sum over those + coins. A token UTXO is eligible to be picked as an input to fund ANY + ordinary send. Once picked and not reproduced as an output, the overlay + treats its token as BURNED (inputs consumed, no conserving SEND output). + +- The change/dust path is the sharpest edge. In `CreateTransaction` + (`src/wallet/wallet.cpp:3471`), when computed change is below the dust + threshold the wallet FOLDS IT INTO THE FEE and drops the output: + `src/wallet/wallet.cpp:3672-3675` + ``` + if (newTxOut.IsDust(::minRelayTxFee)) + { + nFeeRet += nChange; // <-- token-carrying value silently burned to miners + reservekey.ReturnKey(); + ``` + An NFT rides a 1-sat (or similar) dust output. The dust threshold with the + default min-relay fee (`DEFAULT_MIN_RELAY_TX_FEE = 100`, + `src/main.h:64`; threshold = `3 * minRelayTxFee.GetFee(nSize)`, + `src/primitives/transaction.h:452-467`) is on the order of ~100 sats, FAR + above a 1-sat NFT dust output. So a token UTXO swept as change is essentially + guaranteed to be classified as dust and burned into the fee, with no output + and no warning. + +- The async (shielded) paths are equally blind. `z_sendmany`'s + `find_utxos` (`src/wallet/asyncrpcoperation_sendmany.cpp:988`) calls + `pwalletMain->AvailableCoins(vecOutputs, false, NULL, true, fAcceptCoinbase)` + with NO coin-control and NO token filter; `asyncrpcoperation_mergetoaddress` + and `asyncrpcoperation_shieldcoinbase` likewise sweep `AvailableCoins`. + A "shield all my transparent funds" or "merge" operation will hoover up token + dust into a z-address — token irrecoverably burned (z-outputs carry no + overlay SEND). + +NET: ANY of `sendtoaddress`, `sendmany`, `z_sendmany` (from t), `z_shieldcoinbase`, +`z_mergetoaddress`, fund-raw-transaction, and the GUI "send"/"shield"/"send max" +buttons can today destroy an NFT with a single ordinary action by the holder. + +-------------------------------------------------------------------------------- +## 2. Why base consensus cannot stop this + +Consensus validates scripts, PoW, supply, and standardness. It has no concept of +SLP/ZSLP — `src/zslp/*` lives entirely behind `-zslpindex` in `CZSLPIndexer` +and is forbidden to touch validation/mempool acceptance. A transaction that +spends a token dust UTXO as fee/change is a perfectly valid standard tx; +consensus relays and mines it. There is nothing to reject. The burn is real on +the only ledger that consensus enforces (the coin ledger); only the OVERLAY +ledger "knows" a token died, and it can only RECORD the burn, never prevent it. + +Therefore: anti-burn is enforced exclusively in the spending software (wallet), +and "ownership" is enforced exclusively by every honest observer computing the +SAME overlay ledger from the SAME confirmed history. + +-------------------------------------------------------------------------------- +## 3. Security model: determinism + agreement (no consensus fallback) + +The overlay ledger is a pure deterministic function of consensus-ordered, +confirmed block history. Security rests on TWO properties: + +1. DETERMINISM — given the same block history, an implementation always computes + the same ledger (same token UTXO set, same balances, same burns). +2. AGREEMENT — every honest implementation (our `-zslpindex`, any compatible + wallet/explorer) computes the BIT-IDENTICAL ledger. + +If two implementations disagree on ANY edge case, the ledger FORKS: an attacker +can show conflicting "ownership"/"validity" to different counterparties (e.g. +a marketplace that uses implementation A vs a buyer using implementation B). +There is no consensus to break the tie. Cross-implementation bit-exact agreement +IS the security property. + +A forged or malformed token tx is NOT "rejected" — it is INTERPRETED. The +canonical rule set must assign it ONE deterministic meaning (credit nobody / +burn inputs). The danger is not that forgeries land on-chain (they always can); +it is that two implementations interpret the SAME forgery differently. + +-------------------------------------------------------------------------------- +## 4. Determinism-critical edge cases (each is a potential ledger fork) + +These must be pinned to ONE canonical rule. Verified against current code where +cited; the conservation rewrite MUST satisfy these. + +### 4.1 OP_RETURN position — CONFIRMED DIVERGENCE (must fix) + +Canonical SLP requires the SLP OP_RETURN to be `vout[0]`. The repo's own header +states this: `src/zslp/slp.h:5` ("Tokens are encoded in OP_RETURN outputs +(vout[0])."). + +But the indexer SCANS ALL vouts and accepts the FIRST that parses: +`src/zslp/zslpindexer.cpp:205-224, 277-278` +``` +for (size_t vo = 0; vo < tx.vout.size(); ++vo) { + ... + if (!ZSLPParseScript(...)) continue; // not an SLP message; keep scanning other vouts + ... +} +... break; // one SLP message per tx (first valid OP_RETURN wins) +``` +This is a ledger fork against any canonical-SLP implementation: a tx whose +`vout[0]` is a payment and whose `vout[3]` is an SLP-looking OP_RETURN is +"not SLP" canonically but "SLP" here. CANONICAL RULE: an SLP message is +recognized ONLY at `vout[0]`; if `vout[0]` is not a parseable SLP OP_RETURN, the +tx is non-SLP (and still burns any token inputs it spends). The scan loop must +be replaced with a single `vout[0]` check. + +### 4.2 SEND output-quantity array bounds + +`output_quantities` is sized 20 in `slp.h:60` and `zslpmsg.h:47`, with a comment +"vout[1]..vout[19] + 1 extra". The indexer clamps `n` to `[0,20]` +(`zslpindexer.cpp:266-267`). Canonical SLP caps a SEND at 19 token outputs +(vout[1..19]); index 20 maps to no real vout. CANONICAL RULE: a SEND with more +than 19 output quantities is INVALID (entire token effect void, inputs burned), +not silently truncated. Pin the exact cap and the invalid-vs-truncate decision; +both implementations must agree. + +### 4.3 num_outputs greater than tx.vout count + +If a SEND lists more output quantities than the tx has real outputs, canonical +SLP makes the tx INVALID (inputs burned), it does NOT credit only the existing +outputs. The store is handed `voutCount` for exactly this bounds check +(`zslpstore.h:333, 341`). Pin: too-many-quantities => INVALID, deterministic. + +### 4.4 Output-sum overflow (uint64) + +Quantities are uint64 (`slp.h:60`). Summing output quantities or input token +amounts can overflow. CANONICAL RULE: any arithmetic overflow in summing +outputs (or inputs) makes the tx INVALID (burn), computed with explicit +overflow checks, NOT wrapping. Both implementations must detect overflow at the +same boundary. + +### 4.5 "Input not a recognized token UTXO contributes ZERO" + +A SEND's validity is `sum(input token UTXOs of tokenId) >= sum(outputQuantities)`. +Inputs that are not recorded token UTXOs of THIS tokenId contribute zero (they +do not borrow from another token, and a non-token input is just dust). The store +already keys truth by `(txid,vout)` and exposes `GetUtxo` (`zslpstore.h:356-357`). +Pin: only UTXOs recorded for the SAME tokenId count; mixing tokenIds does not +combine; shortfall => INVALID/burn (NOT partial credit). + +### 4.6 Quantity field length / encoding + +`be_to_u64` reads 1..8 big-endian bytes (`slp.c:16-21`) but canonical SLP +quantity pushes are EXACTLY 8 bytes. Pin: a quantity push whose length != 8 is +a parse failure => non-SLP (or INVALID where required), identically in both +implementations. Genesis/mint already require exactly 8 (`slp.c:113, 134`); +the SEND path must enforce the same. + +### 4.7 GENESIS quantity location and baton + +Genesis creates `initial_quantity` at `vout[1]`; the mint baton (if any) at +`mint_baton_vout` (>= 2). NFT = baton-less genesis, decimals 0, qty 1. Pin the +exact vout, the baton-present/absent decision, and that `mint_baton_vout` in +{0,1} means "no baton" (`slp.h:50,76`). MINT requires spending the live baton +UTXO as an input; no baton input => MINT INVALID. + +### 4.8 Lokad ID / token-type / parse strictness + +Lokad must be exactly `SLP\0` (`slp.h:23-24`) and token type exactly 1. Any +deviation => non-SLP. Pin every "is this an SLP message at all" gate so the two +implementations classify identically (a tx classified SLP by one and non-SLP by +the other forks the ledger AND can flip a burn into a non-burn). + +Each edge case above needs a NAMED canonical rule + a cross-implementation test +vector (see the requirements checklist). + +-------------------------------------------------------------------------------- +## 5. What "ownership" means, honestly (impersonation is social) + +Token id == genesis txid (`zslpindexer.cpp:229`), globally unique because txids +are unique under consensus. Uniqueness is at the TOKEN-ID level ONLY. Anyone can +mint a DIFFERENT token reusing a name, ticker, or image-hash. The overlay does +NOT and CANNOT prevent that — there is no consensus to reject a duplicate-named +genesis. Impersonation is defeated SOCIALLY: issuer identity, the genesis-txid +fingerprint, and (optionally) signed attestations — never by the chain. The GUI +MUST present this honestly (see UX-honesty requirements doc). + +-------------------------------------------------------------------------------- +## 6. The fix surface (minimal, non-consensus) + +The store already has the primitive the wallet needs: +`CZSLPStore::GetUtxo(txid, vout, out)` (`zslpstore.h:356-357`) answers +"is this outpoint a token UTXO, and what token/amount/baton is it?" — the SOURCE +OF TRUTH. Today NO RPC exposes it and the wallet never consults it. The minimal +safe design (detailed in the anti-burn requirements doc) is: + +1. Expose per-outpoint token status over RPC (read-only, behind `-zslpindex`). +2. Teach `AvailableCoins` to EXCLUDE token UTXOs from default coin selection + (the same chokepoint that already honors `IsLockedCoin`). +3. NEVER fold a token UTXO into fee/change; never let the dust-to-fee path + (`wallet.cpp:3672-3675`) touch one. +4. Surface token UTXOs in coin-control / `listunspent` with a token tag. +5. Require explicit opt-in + a clear warning to deliberately spend a token UTXO, + and emit a conserving SEND when transferring it (not a bare spend). + +This is wallet-only + one read-RPC. It touches no consensus, no validation, no +mempool, no PoW. It is fail-safe: if the index is unavailable, the wallet must +DEGRADE TO REFUSING to auto-spend low-value t-dust rather than risk a burn +(see requirements R8). diff --git a/doc/nft/zslp-canonical-validation-conformance-checklist.md b/doc/nft/zslp-canonical-validation-conformance-checklist.md new file mode 100644 index 00000000000..a7c4789561f --- /dev/null +++ b/doc/nft/zslp-canonical-validation-conformance-checklist.md @@ -0,0 +1,171 @@ +# ZSLP Canonical Validation — Conformance Checklist + +Testable requirements the UTXO-bound conservation rewrite (`src/zslp/*`) MUST +satisfy to close the forgery-conservation threat class. Each item is a +concrete, automatable assertion. Companion to +`zslp-forgery-conservation-threat-model.md` (§5 R-* rules). + +Legend: **[DONE]** verified present in current code (`src/zslp/*`, +`src/gtest/test_zslp*.cpp`); **[GAP]** not yet enforced / not yet tested; +file:line cites the relevant code. + +--- + +## A. Conservation core (economic) — mostly DONE + +- **C-1 [DONE]** Forged SEND with no token input of that token id credits + nobody and creates no UTXO. (`zslpstore.cpp:552,567`; test + `ForgeSendWithoutInputCreditsNobody`.) +- **C-2 [DONE]** SEND with `Σ outputs > availIn` is INVALID: creates nothing, + burns the spent inputs. (`zslpstore.cpp:552`; test + `OverSendBurnsInputsNoOutputs`.) +- **C-3 [DONE]** MINT without a baton input of that token id issues nothing; + spent inputs stay burned. (`zslpstore.cpp:493`; test + `MintWithoutBatonRejected`.) +- **C-4 [DONE]** MINT of an unknown token id issues nothing. + (`zslpstore.cpp:490`.) *Add an explicit test.* +- **C-5 [DONE]** An NFT (qty 1, decimals 0, no baton) cannot be duplicated by + a forged SEND. (test `NftCannotBeDuplicated`.) +- **C-6 [DONE]** A non-SLP tx that spends a token UTXO burns it (balance + drops, UTXO erased). (`zslpstore.cpp:449-450`; test `NonSlpSpendBurnsUtxo`.) +- **C-7 [DONE]** Unknown input contributes ZERO (readUtxo miss → continue). + (`zslpstore.cpp:439`.) *Add a test: SEND quoting tokenId X while spending a + non-token dust input asserts availIn==0 and nothing created.* +- **C-8 [DONE]** Output index out of range burns that quantity, creates no + UTXO. (`zslpstore.cpp:560-561`.) *Add a test: SEND with 3 amounts but tx has + only vout[0..1] — the amount(s) targeting vout≥voutCount are burned.* +- **C-9 [DONE]** Σ-output overflow ⇒ SEND INVALID. (`zslpstore.cpp:543-550`.) + *Add a test: two amounts each `0xFFFFFFFFFFFFFFFF` ⇒ create nothing, inputs + burned.* +- **C-10 [DONE]** totalMinted overflow guarded. (`zslpstore.cpp:497-499`.) + *Add a test.* +- **C-11 [DONE]** Genesis first-wins / replay idempotent. (`zslpstore.cpp:457`.) + *Add a test: re-deliver the same genesis tx (idempotence guard, + `zslpindexer.cpp:182`) and a second genesis attempt cannot overwrite metadata.* +- **C-12 [DONE]** Baton authority continuation: MINT moves the baton only if + `mint_baton_vout ∈ [2,voutCount)`; otherwise the baton ends (token becomes + fixed-supply). (`zslpstore.cpp:508-528`; tests `MintAccounting`, + `ReorgMintRoundTrip`.) +- **C-13 [DONE]** Baton bears no quantity (isMintBaton ⇒ amount 0, never in + availIn). (`zslpstore.cpp:303,315`, `zslpstore.h:118`.) + +## B. Parse determinism — the live fork risk — mostly GAP + +- **P-1 [GAP] (R-LOC-1, threat D1).** The SLP message MUST be taken from + **vout[0] only.** Current code scans every vout for the first parsable SLP + OP_RETURN (`zslpindexer.cpp:211-279`). + - **Test (fork-proof):** tx with a non-SLP `OP_RETURN` at vout[0] and a + valid SLP SEND `OP_RETURN` at vout[1], spending a token UTXO ⇒ result MUST + be "no SLP message": inputs burned, NO outputs created. (Today this + indexer would honor the vout[1] SEND.) + - **Test:** valid SLP at vout[0] ⇒ honored normally. + - **Test:** vout[0] OP_RETURN present but not SLP, vout[1] also not SLP ⇒ no + message, inputs burned. + +- **P-2 [GAP] (R-PARSE-2, threat D2).** Reject `OP_PUSHDATA4` (0x4e), `OP_0` + (0x00), `OP_1NEGATE`, `OP_RESERVED`, `OP_1`..`OP_16` as SLP fields. Do NOT + gate solely on `Solver==TX_NULL_DATA` (that accepts all of these via + `IsPushOnly`, `script/script.cpp:239`). + - **Test:** a script encoding the SLP grammar but with one field pushed via + `OP_PUSHDATA4` ⇒ parse fails (not SLP). `read_push` already rejects 0x4e + (`op_return_push.h:39`); add the assertion so a future "optimization" can't + regress it. + - **Test:** a field encoded as `OP_1` (0x51) where data was expected ⇒ not + SLP. + - **Test:** confirm the gate and parser AGREE: any script the gate + (`TX_NULL_DATA`) lets through but the parser rejects must end as "not SLP" + (it already does, via `continue`/`return false` — but with P-1's vout[0] + rule a vout[0] gate-pass/parse-fail MUST be "no message", not "scan on"). + +- **P-3 [GAP] (R-PARSE-3, threat D3).** Reject non-minimal pushes. A 4-byte + payload pushed as `OP_PUSHDATA1 0x04 ...` ⇒ not SLP. + - **Test:** build a valid SEND, re-encode one field non-minimally, assert + `slp_parse` returns false. (Current `read_push` ACCEPTS it, + `op_return_push.h:32-38` — this is a GAP that must be fixed or the rule + explicitly waived and frozen as repo-canonical with a published note.) + +- **P-4 [GAP] (R-PARSE-4, threat D2 note).** Pin the zero-length-field + encoding to exactly ONE byte sequence. Repo currently emits/accepts + `OP_PUSHDATA1 0x00` (`op_return_push.h:74`). + - **Test:** assert the canonical empty-field bytes round-trip and that the + *other* common form (`OP_0`) is treated per the frozen decision + (accept-as-equivalent OR reject — pick one, test it). + +- **P-5 [DONE] (R-PARSE-5).** Push length past end-of-script ⇒ not SLP. + (`op_return_push.h:43`; test `ParseTruncatedGenesis`.) *Add a targeted test + for each field boundary.* + +- **P-6 [GAP] (R-PARSE-6, threat D6).** Validity independent of + `-datacarrier`/`-datacarriersize`/mempool/wallet/clock. + - **Test:** index a block with the daemon configured at two different + `-datacarriersize` values ⇒ identical ledger. (Or a code-review assertion: + the parse path never reads these globals — it does not today.) + +- **P-7 [GAP] (R-SEND-1, threat D5).** Freeze SEND amount-list semantics: + each amount push exactly 8 bytes; pin whether a malformed amount push means + "honor the valid prefix" (repo, `slp.c:151-160`) or "whole tx not SLP". + - **Test:** SEND with two valid 8-byte amounts then a 7-byte push ⇒ assert + the frozen behavior exactly. (Today the repo honors the 2-amount prefix.) + - **Test:** SEND with a single 9-byte amount ⇒ frozen behavior. + - **Test:** max 19 amounts accepted, a 20th makes the result per the frozen + rule (parser caps at 19, `slp.c:151`). + +- **P-8 [GAP] (R-GEN-1).** GENESIS field-length validity: document_hash ∈ + {0,32}; decimals == 1 byte 0–9; mint_baton_vout ∈ {0-len, 1-byte ≥2}; + initial_qty == 8 bytes. (`slp.c:91-114` enforces these — add explicit + negative tests: decimals=10 ⇒ not SLP; baton_vout=1 ⇒ not SLP; + document_hash len=16 ⇒ field ignored vs not-SLP — **pin which**, the code + currently *ignores* a non-32 hash, `slp.c:93`, rather than failing.) + +## C. Token-id determinism + +- **I-1 [GAP] (R-ID-1, threat D4).** GENESIS↔MINT/SEND token-id coincidence. + - **Test (must add):** GENESIS a token (id = txid); then construct a MINT + and a SEND whose on-chain 32-byte token_id field is the **display-hex + bytes** of that txid; assert `TokenIdToUint256` of that field == the + GENESIS `tokenId`, and that the SEND resolves the GENESIS's vout[1] UTXO. + This is the one test that proves the two endianness paths + (`zslpindexer.cpp:229` direct vs `:256/:264` reversed) agree. + +## D. Ordering / reorg determinism — DONE, extend + +- **O-1 [DONE]** In-block ordering: a later tx spends an earlier tx's created + UTXO. (`zslpindexer.cpp:186`, `zslpstore.cpp:422`; test `IntraBlockSpend`.) +- **O-2 [DONE]** Disconnect restores byte-identical pre-state. (tests + `ReorgGenesisRoundTrip`, `ReorgMintRoundTrip`, `DisconnectEmptyBlock`.) + - **Extend:** a reorg that REPLACES a genesis+send chain with a different + chain ⇒ ledger equals fresh recompute over the new chain. +- **O-3 [DONE]** Connect idempotence guard prevents double-count on + re-delivered tip. (`zslpindexer.cpp:182`.) *Add a test that re-delivers a + connect for the current tip and asserts balances unchanged.* + +## E. Cross-implementation conformance (agreement is the property) + +- **X-1 [GAP]** Publish a **canonical test-vector file**: a list of + `{raw_op_return_hex, expected_parse_result}` and + `{block_of_txs, expected_ledger_snapshot}` pairs that ANY implementation + (wallet, explorer, alt indexer) must reproduce exactly. Include every D1–D6 + edge case above. This is the artifact that turns "we hope they agree" into + "they pass the same vectors." +- **X-2 [GAP]** A `zslp_validate`/dump RPC (read-only) that emits the full + `'u'` UTXO set + `'t'`/`'b'` snapshot for a given height, so two nodes can be + diffed for bit-exact agreement in CI. +- **X-3 [GAP]** Version-stamp coupling: any change to a frozen R-* rule MUST + bump `ZSLP_INDEX_VERSION` (`zslpstore.h:54`) AND the published test vectors, + because it changes the canonical ledger function. (Migration wipe+reindex is + already wired, `zslpindexer.cpp:74-85`.) + +--- + +## Priority for the conservation rewrite + +1. **P-1 (vout[0]-only)** — highest fork risk, smallest fix (gate on vout[0] + instead of scanning), directly named in the briefing. +2. **P-2 / P-3 (push opcode set + minimality)** — silent dual-encoding fork + risk; align parser to ONE canonical push rule and stop relying on + `TX_NULL_DATA`'s looser `IsPushOnly`. +3. **I-1 (token-id round-trip test)** — cheap, proves the endianness invariant. +4. **P-7 / P-8 (freeze field/amount edge semantics)** — write the rule down + and test the exact current behavior so a third party can match it. +5. **X-1 (canonical test vectors)** — the deliverable that makes agreement + verifiable rather than assumed. diff --git a/doc/nft/zslp-determinism-spec.md b/doc/nft/zslp-determinism-spec.md new file mode 100644 index 00000000000..f2f83445828 --- /dev/null +++ b/doc/nft/zslp-determinism-spec.md @@ -0,0 +1,386 @@ +# ZSLP Canonical Validation Spec (determinism-fork hardening) + +Status: SPEC. This document does not change `src/`. It is the single +normative reference the UTXO-bound conservation rewrite (zslpstore + indexer) +MUST satisfy bit-for-bit. Every compatible wallet / explorer / indexer that +computes a ZSLP ledger MUST produce the IDENTICAL result on the identical +consensus-ordered confirmed block history. Any divergence is a ledger fork. + +## 0. Security model (why determinism IS the security property) + +We cannot change ZClassic consensus. Existing, unchanged nodes relay and mine +ANY standard transaction, including an OP_RETURN that encodes a FORGED token +SEND/MINT/GENESIS. Consensus does not know SLP exists; it will never reject a +token forgery. Therefore: + +- Token ownership is NOT enforced by the chain. It is a DETERMINISTIC FUNCTION + `Ledger = f(confirmed, consensus-ordered block history)` computed by an + observer (`-zslpindex` / `CZSLPIndexer` / any compatible implementation). +- A forged or rule-breaking transaction can be confirmed on-chain yet credit + NOBODY: `f` interprets it as "creates nothing, burns its token inputs." +- SECURITY = DETERMINISM + AGREEMENT. If two implementations of `f` disagree on + ANY edge case, the ledger FORKS: the attacker shows ledger A ("I own NFT X") + to a buyer running implementation A, and the conflicting ledger B to a buyer + running implementation B. There is no consensus to break the tie. So + cross-implementation bit-exact agreement is the entire defense. + +Consequence for this spec: every rule below is written as a TOTAL function with +NO undefined / implementation-defined behavior. "Reject ambiguous, don't guess" +is the default. Where two readings exist, the spec PINS one and the other is an +explicit fork bug. + +## 1. Scope of the ledger function + +Inputs to `f`, in order: + +1. Confirmed blocks `B[0..tip]` in consensus order (`chainActive`). +2. Within a block, transactions in their in-block order `block.vtx[0..]` + (coinbase = `vtx[0]`). +3. Within a transaction, `tx.vin` order and `tx.vout` order as serialized. + +`f` MUST NOT depend on: mempool / unconfirmed state, wall-clock time, peer +order, local config, RPC call order, map iteration order of any in-memory +container that is not explicitly sorted by a spec-defined key, or floating +point. Output: per-(tokenId) token metadata, the live token-UTXO set +`(txid,vout) -> {tokenId, amount, isBaton}`, and the derived per-(token,address) +balances. + +ONLY confirmed history feeds the ledger. The wallet/GUI MAY show an +"unconfirmed/pending" view but it MUST be visually and semantically distinct +from confirmed ownership (see §10). Confirmed-vs-unconfirmed conflation is a +fork vector: two parties at different confirmation depths must never be shown +contradictory "confirmed" ownership. + +## 2. Transaction-level parse: which output carries the SLP message + +This is the single highest-risk determinism rule and TODAY THE CODE IS WRONG. + +`src/zslp/zslpindexer.cpp` IndexTransaction (~line 211) loops over ALL vouts and +takes the FIRST one that `Solver()` calls `TX_NULL_DATA` and that parses as SLP: + +```c +for (size_t vo = 0; vo < tx.vout.size(); ++vo) { ... first valid wins ... } +``` + +Canonical SLP requires the SLP OP_RETURN to be at **vout[0]**. Anything else is +"not an SLP transaction." The scan-any-vout behavior diverges from every +reference SLP implementation and from any wallet that follows the spec: an +attacker crafts a tx whose vout[0] is a normal payment and vout[1] is an SLP +SEND. A vout[0]-strict validator says "not SLP -> token inputs burned"; the +scan-any validator says "valid SEND." => ledger fork. + +CANONICAL RULE (R-PARSE-1): A transaction is an SLP-candidate IFF `tx.vout` +is non-empty AND `tx.vout[0].scriptPubKey` begins with `OP_RETURN` (0x6a) AND +the SLP parser (§3) accepts `tx.vout[0].scriptPubKey`. No other output is ever +examined for an SLP message. If vout[0] is not a valid SLP message, the +transaction has NO SLP message (msgPresent = false). Its spent token inputs are +still consumed/burned per §6. + +R-PARSE-2: At most one SLP message per transaction, and it is exactly the +vout[0] message. The "first valid OP_RETURN at any index wins" loop MUST be +deleted. + +R-PARSE-3: Coinbase transactions are never SLP. (Defensive: a coinbase vout[0] +that happens to start with OP_RETURN must still be ignored; the indexer should +skip `vtx[0]` for message parsing. It MUST still process coinbase inputs as +token inputs? No — coinbase has a single null prevout that can never reference a +token UTXO, so this is a no-op, but the skip must be explicit and tested.) + +## 3. Script -> SLP message parse (byte-exact) + +The parser is `slp_parse()` (`src/zslp/slp.c`) via `ZSLPParseScript`. Every +branch below is a TOTAL accept/reject decision. "reject" => not an SLP message. + +R-SCRIPT-1 (push grammar, the load-bearing one): The script after OP_RETURN is +a sequence of data pushes read by `read_push` (`op_return_push.h`). Canonical +SLP permits ONLY these push encodings for SLP fields: + - direct push 0x01..0x4b (length = opcode), and + - OP_PUSHDATA1 (0x4c) with an explicit 1-byte length. +SLP does NOT use OP_PUSHDATA2/4, and does NOT treat OP_0/OP_1NEGATE/OP_1..OP_16 +as data pushes. TODAY `read_push` ALSO accepts OP_PUSHDATA2 (0x4d). This is a +latent fork/availability risk: a field encoded with 0x4d would be parsed by this +indexer but rejected by a strict reference parser (which would say "not SLP"). +DECISION REQUIRED + PINNED: reject 0x4d (and anything not in {0x01..0x4c}) as +"not SLP." `read_push` MUST return NULL for opcode 0x4d when used for ZSLP. +(R-SCRIPT-1a) Also reject OP_0 (0x00) and OP_1..OP_16 (0x51..0x60) — they +already return NULL in `read_push`; KEEP that and pin it with a test. + +R-SCRIPT-2 (no minimal-push requirement, but pin it explicitly): SLP does NOT +require BIP62 minimal pushes. A 1-byte value MAY be pushed via 0x01 or via 0x4c +0x01. Both are valid and MUST parse identically. Pin with a test that the same +field via direct-push and via PUSHDATA1 yields the same parsed message. (This is +the one place where two encodings are deliberately allowed; everything else is +single-encoding.) + +R-SCRIPT-3 (lokad + version): vout[0] must be +`OP_RETURN <4: "SLP\0"> <1-2: token_type>`. token_type MUST equal 1 +(big-endian, 1 or 2 bytes). Any other token_type => not SLP (we implement only +Type 1; an unknown type is "not our ledger" and must credit nobody, NOT throw). + +R-SCRIPT-4 (transaction_type): next push is exactly the ASCII bytes "GENESIS" +(7), "MINT" (4), or "SEND" (4). Any other => reject. Case-sensitive, exact +length. + +R-SCRIPT-5 (trailing data): After the last field a tx-type requires, canonical +SLP requires NO trailing pushes for GENESIS/MINT (fixed field count). For SEND, +the trailing pushes ARE the output-quantity list (§5). Today GENESIS/MINT parse +`return true` immediately after the last required field and IGNORE any trailing +bytes; SLP treats trailing data after a fixed-arity message as INVALID (not +SLP). PIN: after reading the final required field of GENESIS/MINT, the parser +MUST verify `p == end` (script fully consumed); otherwise reject. Otherwise an +attacker appends a byte and one parser accepts while a strict one rejects. + +R-SCRIPT-6 (field-length rules, per SLP Type 1), all reject-on-violation: + - GENESIS ticker/name/document_url: any length 0..uint (PUSHDATA1 max 255); + purely metadata, never affects ledger arithmetic. Over-long is truncated + into the fixed buffers TODAY (`if (len > 0 && len < sizeof(...))`); that is + a metadata-display divergence, NOT a ledger fork (amounts unaffected), but + PIN it: store the FULL bytes (or a deterministic truncation) identically + across implementations, and never let ticker/name length change accept/ + reject. Recommended: do not truncate silently — store full bytes; if a + fixed buffer is kept, the truncation length is part of the spec and tested. + - document_hash: push length MUST be exactly 0 or exactly 32. Any other + length => reject the whole message (today only `len==32` sets the flag but a + non-{0,32} length does NOT reject — it silently means "no hash"; PIN to + reject so a 31-byte hash can't parse two ways). + - decimals: exactly 1 byte, value 0..9. Else reject. (NFT requires 0.) + - mint_baton_vout (GENESIS/MINT): push length 0 (no baton) or exactly 1 byte + with value >= 2. A 1-byte value 0 or 1 => reject the message. Any length >1 + => reject. (Matches current code; pin it.) + - initial_quantity (GENESIS) / additional_quantity (MINT): exactly 8 bytes, + big-endian uint64. Else reject. + +R-SCRIPT-7 (integer endianness): ALL multi-byte SLP integers are big-endian. +token_type is BE (1-2 bytes). Quantities are BE uint64 (exactly 8 bytes). +`be_to_u64` is the canonical decoder; pin it with vectors including 0, +0xFFFFFFFFFFFFFFFF. + +R-SCRIPT-8 (token_id byte order): On chain, token_id in MINT/SEND is 32 bytes +in DISPLAY (big-endian txid) order. The indexer reverses it to the daemon's +internal little-endian uint256 (`TokenIdToUint256`). GENESIS sets tokenId = +genesis txid directly. PIN: a MINT/SEND that names a token MUST resolve to the +SAME uint256 the GENESIS produced, verified by a round-trip test +(genesis txid -> on-chain BE bytes -> TokenIdToUint256 == genesis uint256). + +## 4. SLP quantity domain and overflow (determinism-critical) + +SLP quantities are unsigned 64-bit (uint64). The store uses int64_t internally. +The boundary 2^63..2^64-1 is a real fork surface. + +R-QTY-1 (domain): A parsed quantity is uint64 in [0, 2^64-1]. The store casts to +int64_t. A quantity with the high bit set (>= 2^63) becomes NEGATIVE int64_t. +Today: GENESIS/MINT cast `(int64_t)msg->initialQuantity`; SEND output qty is +checked `if (q < 0) overflow=true`. PIN one rule and apply it EVERYWHERE: + - DECISION: treat any quantity >= 2^63 as INVALID for the whole message + (because the rest of the pipeline is signed int64 and a negative amount is + nonsense). I.e. GENESIS initial_quantity, MINT additional_quantity, and + EVERY SEND output_quantity with the high bit set => the message creates + NOTHING (and for SEND/MINT/GENESIS still burns consumed inputs). + - This must be enforced at parse OR at apply, but identically; currently SEND + catches it (q<0) while GENESIS/MINT do NOT (a 2^63 initial_quantity would + be stored as a negative totalMinted and create a negative-amount UTXO). + => REQUIRED FIX: GENESIS/MINT must reject (create nothing) when the + quantity, read as uint64, has the high bit set. + +R-QTY-2 (SEND output-sum overflow): `requiredOut = Σ outputQuantities` must be +computed with an explicit overflow guard; on overflow the SEND is INVALID +(create nothing, burn inputs). Current code does this for int64 max. Because +R-QTY-1 already bans >=2^63 per-output, the sum guard is against int64 overflow +across up to 19 positive outputs — keep it, and pin with a vector that sums to +exactly int64 max and one that overflows by 1. + +R-QTY-3 (input availability): `availIn = Σ amount of spent token UTXOs of this +tokenId`. Batons contribute 0 (R-BATON). SEND is VALID iff `availIn >= +requiredOut` AND not overflow. Strict `>=` (equal is valid; equal-with-change-0 +is valid). The surplus `availIn - requiredOut` is BURNED (never re-created). + +R-QTY-4 (no implicit widening / no float): all comparisons in int64. No double. + +## 5. SEND output mapping (positional, deterministic) + +R-SEND-1: `output_quantities[j]` maps to `vout[1 + j]` (1-indexed; vout[0] is the +OP_RETURN). The mapping is POSITIONAL and preserved across zero-quantity +outputs (a zero-qty slot is consumed and creates nothing). Current code: correct +(`voutIdx = 1 + j`, zero-qty `continue` without skipping the index). + +R-SEND-2 (count bounds): The SEND quantity list is 1..19 entries. Today +`slp_parse` reads up to 19; the store clamps `n` to [0,20]. PIN to [1,19]: a +SEND with 0 entries => reject (already: `num_outputs < 1 => return false`). A +list of >19 pushes: the parser stops at 19 and (per R-SCRIPT-5) MUST then verify +the script is fully consumed; if there is a 20th 8-byte push, that is trailing +data => reject the message. PIN: a 20-quantity SEND is INVALID (not "first 19 +win"), because "stop at 19 and ignore the rest" vs "reject" is a fork. + +R-SEND-3 (more quantities than tx outputs): If `1 + j >= voutCount` (the named +output index does not exist in the tx), that quantity is BURNED (the output +can't receive tokens) BUT the SEND as a whole may still be valid for the outputs +that DO exist, PROVIDED conservation still holds over ALL declared quantities. +PIN the exact rule, because there are two defensible readings and they fork: + - READING A (current code): validity uses `requiredOut = Σ ALL declared + quantities` (including those pointing at nonexistent vouts); if availIn >= + that sum, create UTXOs only for existing vouts, burn the rest. Inputs are + fully covered; surplus burned. + - READING B (some references): a SEND that names a quantity for a + nonexistent output is INVALID as a whole. + - DECISION (PINNED): READING B — if any declared output_quantity j>0 maps to + a vout index `1+j >= voutCount`, the entire SEND is INVALID (create nothing, + burn inputs). Rationale: it is the strictest, removes the "partial-create" + ambiguity entirely, and matches "a message that can't be fully honored + credits nobody." THIS IS A REQUIRED CHANGE: current code uses Reading A + (it `continue`s past out-of-range vouts inside the valid branch). The store + must, BEFORE creating anything, verify every positive-qty output index is in + range; if not, treat the SEND as invalid. + - Zero-qty outputs pointing past voutCount are harmless (create nothing) and + do NOT invalidate, since they move no tokens. + +## 6. Input consumption / burn (runs for EVERY tx) + +R-BURN-1: For EVERY transaction (SLP or not, valid or not), every spent prevout +that is a live token UTXO is CONSUMED (removed from the UTXO set) and its +balance credit reversed. A non-SLP tx, an invalid SLP message, a SEND that +fails conservation, a MINT without a baton, a GENESIS — all still burn the token +UTXOs they spend that they do not validly re-create. Current code: correct +(consume loop in `ApplyTransaction` step (a) runs before dispatch). + +R-BURN-2 ("input not a recognized token UTXO contributes ZERO"): A spent prevout +that is not in the token-UTXO set contributes nothing and is a no-op. Current: +`readUtxo` miss => `continue`. Pin. + +R-BURN-3 (ordering within a tx): consume-then-create. A tx cannot "spend its own +output": prevouts always reference earlier txids/outputs, so the consume set is +disjoint from the create set within one tx. Pin that creates use this tx's txid +and consumes use prevout txids. + +R-BURN-4 (same-block dependency ordering): A later tx in a block may spend a +token UTXO an earlier tx in the SAME block created. Therefore txs MUST be +applied strictly in `block.vtx` order, and each tx's writes MUST be visible to +the next tx's reads. Current: each tx commits its own batch before the next +(documented in ApplyTransaction). Pin with a two-tx-in-one-block test +(genesis in tx1, send of it in tx2, same block). + +## 7. GENESIS rules + +R-GEN-1 (token id): tokenId == genesis txid (consensus-unique). Pin. + +R-GEN-2 (first-genesis-wins): A token row is INSERTED only if absent. Since +tokenId == txid and txids are unique under consensus, a duplicate tokenId is +impossible in honest history; the `!readToken` guard is belt-and-suspenders and +MUST remain (a reorg-replay must not double-insert). Pin: re-applying the same +genesis block is idempotent. + +R-GEN-3 (mint output): initial_quantity (if > 0 AND in-domain per R-QTY-1) is +created at vout[1] IFF vout[1] exists. If voutCount <= 1, the quantity is burned +(token row still created, totalMinted reflects declared initial_quantity?). +PIN the totalMinted semantics: totalMinted = sum of GENESIS + MINT DECLARED +quantities that were ACTUALLY CREATED, OR declared regardless of creation? + - DECISION (PINNED): totalMinted counts only quantity that was actually + created as a UTXO. If vout[1] doesn't exist, nothing is created and + totalMinted contribution is 0. Rationale: totalMinted should equal the sum + of live + burned token quantity that ever existed AS tokens; a quantity that + was never created never existed. CURRENT CODE sets + `token.totalMinted = msg->initialQuantity` unconditionally even if no UTXO + is created — REQUIRED FIX to make totalMinted == actually-created. + (This is a display value, but it is RPC-visible and therefore part of the + deterministic surface; two implementations disagreeing on totalMinted is a + fork of the observable ledger.) + +R-GEN-4 (baton): baton issued IFF mint_baton_vout >= 2 AND < voutCount. Baton +UTXO created at that vout with amount 0, isBaton true. The display mirror +`token.mintBatonVout` reflects the live baton. Pin: a baton vout that is >= +voutCount => no baton (decl ignored), token row still created. + +R-GEN-5 (NFT): NFT = baton-less GENESIS (mint_baton_vout absent), decimals == 0, +initial_quantity == 1. This is a CONVENTION over the same rules, not a separate +type. Uniqueness of the NFT is uniqueness of its tokenId (genesis txid). There +is no consensus-level "one of a kind"; see §9. + +## 8. MINT rules + +R-MINT-1: MINT of an unknown tokenId (no token row) => invalid, create nothing +(inputs still burned). Pin. + +R-MINT-2: MINT is VALID iff a live BATON UTXO of this tokenId was on a spent +input. No baton input => create nothing (inputs burned). Current: correct. + +R-MINT-3: additional_quantity (if >0 and in-domain) created at vout[1] iff +exists; totalMinted += actually-created amount (same R-GEN-3 fix: +overflow-guarded, and counts only created quantity). + +R-MINT-4 (baton continuation): new baton at mint_baton_vout iff >=2 and +=2^63), + matching SEND. (fork + negative-amount corruption) +6. R-SEND-3: PINNED Reading B — a SEND naming any positive quantity for a + nonexistent output index is INVALID as a whole. Pre-validate output indices + before creating. (fork risk; current code uses Reading A) +7. R-GEN-3/R-MINT-3: totalMinted counts only ACTUALLY-CREATED quantity, not + declared-but-burned. (observable-ledger fork) +8. R-PARSE-3: explicitly skip coinbase for message parsing. (defensive) +9. R-RPC-1: document list orderings as normative. + +Each item above MUST have a gtest vector that a second, independent +implementation could run to prove agreement. diff --git a/doc/nft/zslp-forgery-conservation-threat-model.md b/doc/nft/zslp-forgery-conservation-threat-model.md new file mode 100644 index 00000000000..75f97126d8c --- /dev/null +++ b/doc/nft/zslp-forgery-conservation-threat-model.md @@ -0,0 +1,359 @@ +# ZSLP Forgery / Conservation Threat Model + +Threat class: **forgery-conservation** — direct token forgery and inflation. + +Scope: the NON-consensus ZSLP (SLP Token Type 1) overlay in this repo +(`src/zslp/*`, `src/rpc/zslp.cpp`), behind `-zslpindex`, in `CZSLPIndexer` / +`CZSLPStore`. This document is the **security model + canonical validation +spec + requirements checklist**. It edits no source under `src/`. + +--- + +## 0. The unavoidable starting point + +We **cannot change ZClassic consensus.** Minters and users run existing, +unchanged consensus nodes. Base consensus does not know ZSLP/SLP exists: it +relays and mines ANY standard transaction, including an `OP_RETURN` that +encodes a *forged* token SEND with no token inputs, a MINT with no baton, a +SEND quoting a token id you have never owned, output indices past the end of +the tx, or quantities that sum past 2^64. + +Therefore **token validity is never enforced by the chain refusing the tx.** +Every forgery in this threat class CAN be mined and CAN sit in the confirmed +block history forever. The on-chain transaction is real. What is NOT real is +its *token effect*. + +Security here is exactly two properties: + +1. **DETERMINISM** — the token ledger is a pure function of the + consensus-ordered, confirmed block history. Given the same blocks, every + honest observer computes the identical ledger. +2. **AGREEMENT** — every compatible implementation (this `-zslpindex`, a + wallet, an explorer) computes that *identical* ledger, bit for bit, on + every edge case. There is no consensus to fall back on; if two + implementations disagree on one edge case, the ledger FORKS and an attacker + shows conflicting "ownership" to two parties. **Cross-implementation + bit-exact agreement IS the security property.** + +A forgery is neutralized when the canonical rules interpret it as +**crediting nobody** — i.e. it creates no token UTXO and changes no honest +observer's balances — and any token UTXOs it spent are **burned** (consumed, +not re-created). This is the design the store already implements +(`zslpstore.cpp` header comment, lines 8–20). + +--- + +## 1. The conservation model (what makes forgery a no-op) + +The ledger is **UTXO-bound**. The source of truth is a map + +``` +(txid, vout) -> { tokenId, amount, isMintBaton, address, height } +``` + +persisted under the `'u'` key (`CZSLPTokenUtxo`, `zslpstore.h:121`). Per- +address balances under `'b'` are a **derived view**, kept correct by signed +deltas (`zslpstore.cpp:367` `recordBalanceDelta`, `:386` `flushBalances`). +The token row under `'t'` (`CZSLPToken`) holds genesis metadata + issued +`totalMinted` + a baton display-mirror. + +Every transaction — SLP or not — runs `ApplyTransaction` +(`zslpstore.cpp:413`). The order is the whole defense: + +1. **CONSUME first.** For every `vin` prevout that is a known token UTXO, + the store erases the `'u'` record and reverses its balance credit + (`zslpstore.cpp:437-446`, `consumeUtxo` `:342`). Quantity inputs are summed + into `availByToken[tokenId]`; a baton input sets + `batonInputPresent[tokenId]`. **An input that is not a recognized token + UTXO contributes ZERO** (`readUtxo` miss → `continue`, `:439`). This is the + "unknown input = zero" rule, and it is the foundation of conservation. +2. **CREATE only as far as inputs permit.** Then dispatch on the parsed + message; create new `'u'` records only up to what step 1 made available. + Anything not re-assigned by a valid message of its token id is left + consumed — i.e. **burned**. + +This makes every member of the forgery-conservation class a no-op or a burn. +The exact rule each relies on is enumerated in §3. + +--- + +## 2. The trust chain: transitive validity back to genesis + +The store does NOT re-walk history per transaction; it keeps a *materialized* +UTXO set. But the materialized set is itself the fixed point of a transitive +rule, and the security argument is transitive: + +- A token UTXO exists in `'u'` **iff** some prior `ApplyTransaction` created + it via `createUtxo` (`zslpstore.cpp:295`), and `createUtxo` is reached only + from a **valid** GENESIS, MINT, or SEND branch. +- A SEND output exists iff `availIn >= requiredOut` for that token, and + `availIn` came only from consumed UTXOs that themselves existed by the same + rule — back to a GENESIS (`token id == genesis txid`) or a baton-authorized + MINT. +- Therefore every live token UTXO is **transitively valid back to a genesis + over confirmed blocks**. A forged SEND that quotes a token id for which the + spender holds no input UTXO finds `availIn = 0`, requires `> 0`, and creates + nothing. The "ownership" it claims never enters `'u'`, so no observer ever + sees it. + +**Pin (TR-1):** the canonical ledger is the unique fixed point of "consume +known inputs; create outputs only within consumed availability; unknown input += zero" applied in consensus tx order over confirmed blocks. Any +implementation that computes a *different* fixed point has forked. + +--- + +## 3. Forgery-conservation threats, neutralization, and the exact rule + +(Full structured detail is in the StructuredOutput payload. Summary table.) + +| # | Attack | Overlay defense | Exact rule (file:line) | +|---|--------|-----------------|------------------------| +| F1 | Forged SEND, no/insufficient token inputs | `availIn < requiredOut` → create nothing; inputs already burned | `zslpstore.cpp:552`, `:567` | +| F2 | SEND spending token UTXOs you don't control | a UTXO is only consumable as a real tx `vin`; spending requires the prevout's scriptPubKey to be satisfiable under consensus (you must own the dust). Indexer never credits a spender it can't prove spent the input. | `zslpindexer.cpp:200-204`, `zslpstore.cpp:437-446` | +| F3 | MINT without the baton | `!batonInputPresent.count(tokenId)` → issue nothing | `zslpstore.cpp:493` | +| F4 | MINT of an unknown token | `!readToken` → break, nothing issued | `zslpstore.cpp:490` | +| F5 | Output index out of range / more quantities than vouts | `voutIdx >= voutCount` → that qty burned, no UTXO | `zslpstore.cpp:560-561` | +| F6 | Sum overflow (Σ outputs > int64 max) | overflow flag → INVALID, create nothing, burn inputs | `zslpstore.cpp:543-550`, `:552` | +| F7 | totalMinted overflow | guarded add, skip on overflow | `zslpstore.cpp:497-499` | +| F8 | Genesis-txid replay (re-declare an existing token) | first-genesis-wins: token row INSERTed only if absent | `zslpstore.cpp:457` | +| F9 | NFT duplication via forged SEND | qty-1 UTXO is single; a SEND can't exceed availIn; over-claim burns | tests `NftCannotBeDuplicated`, `zslpstore.cpp:552` | +| F10 | Double-create at vout[1] for GENESIS+MINT in same tx | only one SLP message per tx is honored (first valid OP_RETURN wins) | `zslpindexer.cpp:277-278` | + +The economic-conservation half of this class (F1, F3–F9) is **already +implemented and unit-tested** (`src/gtest/test_zslp_indexer.cpp`: +`ForgeSendWithoutInputCreditsNobody`, `OverSendBurnsInputsNoOutputs`, +`MintWithoutBatonRejected`, `NftCannotBeDuplicated`, `NonSlpSpendBurnsUtxo`). +The residual risk in this class is **NOT economic — it is determinism**: two +implementations that PARSE the same tx differently will *create different +UTXOs*, which forks the very `'u'` map the conservation proof rests on. §4 is +where the live exposure is. + +--- + +## 4. Determinism exposures found in the current code (the real risk) + +These do not let an attacker mint free tokens against *this* node, but they +let an attacker craft a transaction that **this `-zslpindex` and a different +compatible implementation interpret differently**, forking the ledger and +defeating §1–§3. Each must be pinned to ONE canonical rule (§5). + +### D1 — "first OP_RETURN at ANY vout" vs canonical "SLP must be vout[0]" (HIGH) + +`zslpindexer.cpp:211` iterates **all** vouts looking for the first +`TX_NULL_DATA` that parses as SLP (`for (size_t vo = 0; vo < tx.vout.size(); +...)`, with `continue` on a non-SLP nulldata). Canonical SLP (BCH spec, echoed +in `slp.h:5` "vout[0]") requires the SLP `OP_RETURN` to be **scriptPubKey of +vout[0]**; if vout[0] is not a valid SLP message the transaction is "not SLP" +and contributes no message (its inputs are still burned). + +Attack: a minter puts a junk/non-SLP `OP_RETURN` at vout[0] and a valid SLP +SEND `OP_RETURN` at vout[1]. A canonical (vout[0]-only) implementation sees +"not SLP" → the spent token inputs are **burned, nothing created**. This +indexer scans on and **honors the vout[1] SEND → creates UTXOs**. The two +ledgers now disagree on who owns the tokens. **Ledger fork.** This is the +exact risk the briefing flagged. + +### D2 — push-opcode acceptance set disagrees between the gate and the parser (HIGH) + +The indexer gate is `Solver(spk, whichType, ...) == TX_NULL_DATA` +(`zslpindexer.cpp:215`). `TX_NULL_DATA` is `spk[0]==OP_RETURN && +spk.IsPushOnly(begin()+1)` (`script/standard.cpp:71`). `IsPushOnly` accepts +**every opcode `<= OP_16`** (`script/script.cpp:239-256`): that includes +`OP_0`/`OP_FALSE` (0x00), `OP_1NEGATE` (0x4f), `OP_1`..`OP_16` +(0x51..0x60), **and `OP_PUSHDATA4`** (0x4e). + +The raw parser `slp_parse` (`slp.c`) / `read_push` +(`op_return_push.h:24`) accepts **only** direct pushes 0x01–0x4b, +`OP_PUSHDATA1` (0x4c), `OP_PUSHDATA2` (0x4d). It rejects `OP_PUSHDATA4`, +`OP_0`, and `OP_1`..`OP_16` (returns NULL → parse fails → "not SLP"). + +So the gate and *this* parser already disagree with each other on what is a +"push", and a *different* implementation that uses `IsPushOnly` semantics (or +that, conversely, follows the strict canonical SLP rule below) will disagree +with this one. Canonical SLP is **stricter than both**: + +- The SLP field separators MUST be data pushes; `OP_0`/`OP_1`..`OP_16`/ + `OP_1NEGATE`/`OP_RESERVED` as a "field" make the tx **not SLP**. +- A zero-length field MUST be `OP_0` (0x4c 0x00 is NOT minimal in BCH SLP — + see D3). **NOTE:** this repo's `slp.c` emits empty fields as + `OP_PUSHDATA1 0x00` (`op_return_push.h:74` `push_empty`) and *parses* a + zero-length field only via `read_push` lengths (it never special-cases + `OP_0`). This is a self-consistent but **non-canonical** empty-field + encoding — pin it explicitly (§5 R-PARSE-4) or align to BCH SLP, but it + must be ONE rule everywhere. + +### D3 — non-minimal pushes are silently accepted (MEDIUM) + +`read_push` (`op_return_push.h:24`) accepts a length expressible in a shorter +form: e.g. a 4-byte payload pushed with `OP_PUSHDATA1 0x04` (2-byte prefix) +instead of the minimal `0x04`. Canonical SLP requires **minimal push +encoding**; a non-minimal push makes the tx "not SLP". An attacker can encode +the same logical SEND two ways; a minimal-only implementation rejects the +non-minimal form (burn, nothing created) while this parser honors it. +**Ledger fork.** Pin minimality (§5 R-PARSE-3). + +### D4 — GENESIS token-id endianness asymmetry is correct-but-unpinned (MEDIUM) + +GENESIS stores `tokenId = txid = tx.GetHash()` **directly** +(`zslpindexer.cpp:229,233`; `zslpstore.cpp:453`). MINT/SEND read the on-chain +32-byte `token_id` field — documented big-endian *display* order +(`zslpmsg.h:42`) — and **byte-reverse** it via `TokenIdToUint256` +(`zslpindexer.cpp:147-153,256,264`). These match **iff** a minter copying the +genesis txid's `GetHex()` display string into the SEND `token_id` field, then +reversed, equals `GetHash()`. It does (GetHex prints reversed internal +bytes), so this is **correct** — but it is a silent invariant. Any +implementation that gets the endianness of *either* path wrong will look up a +different `'t'`/`'u'` key and fork. Pin it (§5 R-ID-1) and add a +GENESIS→SEND round-trip test asserting the SEND finds the GENESIS UTXO by id. + +### D5 — quantity field width / `num_outputs` cap edge (LOW, pin anyway) + +`slp_parse` SEND loop reads pushes of **exactly 8 bytes**, 1..19 of them +(`slp.c:151-160`); a 0-byte or non-8-byte amount push ends the list. The +indexer then clamps `numOutputs` to `[0,20]` (`zslpindexer.cpp:265-268`) and +the store re-clamps to `[0,20]` (`zslpstore.cpp:540-542`). Canonical SLP +requires each amount to be **exactly 8 bytes** and at least one amount; a +SEND with a malformed amount push is "not SLP" entirely (burn inputs), NOT +"truncate at the bad push and honor the prefix". Confirm `slp.c`'s +"break on non-8-byte then require ≥1" matches the canonical "any malformed +amount ⇒ whole tx not SLP" rule, or pin this repo's prefix-honoring behavior +as canonical and write it down. Also pin the **maximum** output count: the +parser caps at 19 (`slp.c:151`), the structs hold 20 (`zslpmsg.h:47`, +`zslpstore.h:207`); the canonical max is 19 SLP amounts (vout[1..19]). + +### D6 — `-datacarriersize` / relay limits do not bound the *ledger* (INFO) + +`MAX_OP_RETURN_RELAY = 223` and `-datacarriersize` (`init.cpp:1833`) are +**relay/mine policy**, not consensus and not ledger rules. A miner can include +a larger OP_RETURN. The ledger function MUST depend ONLY on the confirmed tx +bytes, never on the local node's relay policy, or two nodes with different +`-datacarriersize` fork. The parser already operates on the raw script with no +relay-size dependence; keep it that way (§5 R-PARSE-6). Canonical SLP itself +imposes no 223-byte cap on validity. + +--- + +## 5. Canonical validation spec (the single rule each implementation must follow) + +This is the normative spec the conservation rewrite (and any wallet/explorer) +MUST match bit-for-bit. + +### Message location +- **R-LOC-1.** An SLP message is taken from **vout[0].scriptPubKey ONLY.** + If vout[0] does not parse as a valid SLP message, the transaction has **no + SLP message** (it may still burn token inputs). Do NOT scan other vouts. + +### Script / push decoding +- **R-PARSE-1.** scriptPubKey must begin with `OP_RETURN` (0x6a). +- **R-PARSE-2.** Every field after `OP_RETURN` must be a **data push** using + a direct push (0x01–0x4b), `OP_PUSHDATA1` (0x4c), or `OP_PUSHDATA2` (0x4d). + `OP_PUSHDATA4` (0x4e), `OP_0`, `OP_1NEGATE`, `OP_RESERVED`, and + `OP_1`..`OP_16` make the tx **not SLP**. (This is *stricter* than + consensus `IsPushOnly`; do not gate solely on `TX_NULL_DATA`.) +- **R-PARSE-3.** Pushes MUST be **minimal** (shortest opcode for the length). + A non-minimal push ⇒ not SLP. +- **R-PARSE-4.** Empty/zero-length field encoding MUST be ONE fixed form + across all implementations. Pin the repo's current `push_empty` = + `OP_PUSHDATA1 0x00` form **or** migrate to BCH SLP's `OP_0`; whichever is + chosen, parser and builder and every peer implementation use exactly that + byte sequence. (Resolve the D2 note before any third party implements.) +- **R-PARSE-5.** A push whose declared length runs past end-of-script ⇒ not + SLP (already enforced, `op_return_push.h:43`). +- **R-PARSE-6.** Validity depends ONLY on the confirmed transaction bytes. + Never consult `-datacarrier`, `-datacarriersize`, mempool state, wallet + state, or wall-clock. + +### Field grammar (Token Type 1) +- **R-FLD-1.** lokad_id field == exactly 4 bytes `53 4c 50 00` ("SLP\0"). +- **R-FLD-2.** token_type field decodes to **1** (1 or 2 bytes big-endian); + any other value ⇒ not SLP (Type-1 indexer). +- **R-FLD-3.** tx_type field ∈ {"GENESIS"(7B), "MINT"(4B), "SEND"(4B)}. + ("BURN" is implicit — under-spending — never an on-chain tx_type.) +- **R-GEN-1.** GENESIS: ticker/name/document_url are opaque label bytes, + length-bounded for *storage* but NEVER affect validity (truncation in + `slp.c:69` is display-only). document_hash field length ∈ {0, 32}; any other + length ⇒ not SLP. decimals field == 1 byte, value 0–9 + (`slp.c:98-101`). mint_baton_vout field length ∈ {0, 1}; if 1 byte, value + **≥ 2** (`slp.c:106-108`). initial_qty field == exactly 8 bytes BE. +- **R-MINT-1.** MINT: token_id == exactly 32 bytes. mint_baton_vout same rule + as R-GEN-1. additional_qty == exactly 8 bytes BE. +- **R-SEND-1.** SEND: token_id == exactly 32 bytes. Then 1..19 amount pushes, + **each exactly 8 bytes BE.** A malformed amount push terminates the list; + pin whether this means "honor the valid prefix" (repo behavior, + `slp.c:151-160`) or "whole tx not SLP" (stricter). **This is a live + fork-risk; choose one and freeze it.** + +### Token id +- **R-ID-1.** `tokenId` (internal, the `'t'`/`'u'`/`'b'` key) for a GENESIS + is `tx.GetHash()` (internal little-endian). For MINT/SEND it is the on-chain + 32-byte token_id field **byte-reversed** (`TokenIdToUint256`). These MUST + coincide for a token's own GENESIS; a conformance test MUST assert a SEND of + a just-minted token resolves to the GENESIS's UTXOs. + +### Conservation (the economic core — already implemented; pin it) +- **R-CONS-1 (unknown input = zero).** A tx input that is not a known token + UTXO contributes 0 to availIn and never a baton. (`zslpstore.cpp:439`) +- **R-CONS-2 (consume-then-create).** Burn every spent token UTXO first; then + create outputs only within consumed availability. Inputs not re-assigned by + a valid message of their token id remain burned. (`:437-446`) +- **R-CONS-3 (SEND budget).** Valid iff `Σ outputs (no overflow) ≤ availIn` + for the SEND's token id; else create nothing (inputs already burned). + (`:552`, `:567`) +- **R-CONS-4 (positional outputs).** outputQuantities[j] → vout[1+j]; a + zero-qty output consumes a slot and creates nothing; `voutIdx >= voutCount` + ⇒ that quantity is burned. (`:555-563`) +- **R-CONS-5 (MINT authority).** MINT valid iff the token is known AND a baton + UTXO of that token id was on a spent input; else issue nothing. New quantity + at vout[1]; baton continues only if `mint_baton_vout ∈ [2, voutCount)`. + (`:490`, `:493`, `:508-528`) +- **R-CONS-6 (GENESIS authority + replay).** First GENESIS for a txid wins; + the token row is INSERTed only if absent. Quantity at vout[1]; baton at its + declared vout iff `∈ [2, voutCount)`. Because token id == txid and txids are + unique under consensus, a *replay* of the same token id is impossible from a + different tx; a duplicate-id collision can only come from the same txid and + is idempotent. (`:457`, `:463-483`) +- **R-CONS-7 (overflow).** Σ outputs, totalMinted, and balance accumulation + are int64 overflow-guarded; an overflowing SEND is INVALID (burn), an + overflowing MINT/balance add is skipped. (`:497`, `:543-550`, `:395`) +- **R-CONS-8 (one message per tx).** Exactly the vout[0] message is applied; + no second OP_RETURN is ever honored. (Follows from R-LOC-1; replaces the + current "first valid wins across vouts", `zslpindexer.cpp:277`.) + +### Ordering / reorg determinism +- **R-ORD-1.** Transactions are applied in **block order, then in-block vtx + order** (`zslpindexer.cpp:186`). An earlier tx's created UTXO is visible to a + later tx in the same block (per-tx batch commit, `zslpstore.cpp:422-424`). +- **R-ORD-2.** A disconnect restores the store byte-for-byte to its + pre-connect state (undo log, `zslpstore.cpp:591`). The ledger after a reorg + equals the ledger computed fresh over the new confirmed chain. + +--- + +## 6. Why this closes the class (and what it does not) + +With R-* pinned and the conservation core as implemented, every +forgery-conservation attack reduces to **"creates nothing and/or burns the +inputs it touched."** No on-chain transaction can credit token value the +spender did not transitively receive from a genesis. The chain still *carries* +the forged bytes; honest observers simply compute a ledger in which those +bytes moved nothing. + +What this model **cannot** stop, by construction: +- The forged tx existing on-chain and being visible in a block explorer's raw + view. (Out of scope — not a ledger effect.) +- A holder **burning their own** token by spending its dust UTXO in an + ordinary send (the wallet has zero ZSLP awareness today — see the wallet + anti-burn requirements doc). That is self-inflicted, not forgery, but it is + a real loss and is addressed separately. +- **Impersonation by genesis reuse.** Anyone can GENESIS a *different* token + (different txid ⇒ different token id) with the same ticker/name/image. This + is not forgery of an existing token; it is a new token that lies socially. + Defeated by issuer identity / genesis-txid fingerprint / signed + attestations, surfaced honestly in the GUI — never claimed to be prevented + by consensus. + +See also: +- `doc/nft/zslp-canonical-validation-conformance-checklist.md` — the testable + checklist the conservation rewrite must pass. +- `doc/nft/zslp-wallet-antiburn-ux-honesty.md` — holder anti-burn + UX honesty. diff --git a/doc/nft/zslp-security-model.md b/doc/nft/zslp-security-model.md new file mode 100644 index 00000000000..a8a23651865 --- /dev/null +++ b/doc/nft/zslp-security-model.md @@ -0,0 +1,110 @@ +# ZSLP Security Model + Wallet Anti-Burn / UX-Honesty Requirements + +Companion to `zslp-determinism-spec.md`. This doc states the trust model, the +determinism-fork threat class, and the wallet/GUI requirements that keep honest +holders from destroying their own tokens and keep users from being socially +defrauded. + +## A. Trust model (one paragraph) + +ZClassic consensus is unchanged and SLP-unaware. It will confirm any standard +tx, including a token forgery. The token ledger is a pure function of confirmed, +consensus-ordered history computed identically by every honest observer. A +forgery confirms on-chain but credits nobody. The ONLY thing that can break this +model is two honest observers disagreeing (a determinism fork) — then the +attacker presents conflicting ownership to two victims with no tiebreaker. So +the security target is: bit-exact agreement of the ledger function across all +implementations, plus a wallet that (1) never accidentally burns tokens and +(2) never lies to the user about what on-chain data can and cannot prove. + +## B. What base consensus CANNOT do (and why the overlay must) + +- Cannot reject a forged SEND/MINT/GENESIS OP_RETURN. (It's a standard tx.) +- Cannot make token UTXOs unspendable-as-coins. The token rides a normal + transparent dust output; consensus sees only ZCL value and will happily let + any wallet spend it as fee/change. => the BURN risk (§C) is unavoidable at the + consensus layer and MUST be handled in the wallet. +- Cannot enforce token-id uniqueness beyond txid uniqueness. => impersonation is + a social problem (§D). + +The deterministic overlay's defense is uniform: a rule-breaking on-chain action +is interpreted as "creates nothing / burns inputs," so it changes no honest +observer's ledger — PROVIDED all observers apply the identical rules. + +## C. Holder anti-burn (wallet) — REQUIRED + +FACT (verified): `src/wallet/` has ZERO ZSLP awareness (`grep -rli zslp +src/wallet/` returns nothing). An ordinary `sendtoaddress` / `z_sendmany` / +fee/change selection can pick a token-carrying dust UTXO as an input and BURN +the token (per R-BURN-1 the indexer will dutifully record the burn — correctly, +but the user lost their NFT). + +R-WALLET-1 (exclude token UTXOs from automatic coin selection): The wallet MUST +identify token-carrying UTXOs (via the local `-zslpindex` store: a prevout that +`GetUtxo(txid,vout)` resolves to a token UTXO or baton) and EXCLUDE them from +all automatic input selection (fee, change, normal sends). This requires the +wallet to consult the ZSLP store (or an equivalent local view) during coin +selection. Default: token UTXOs are unspendable-by-accident. + +R-WALLET-2 (coin control surfacing): Token UTXOs (and batons) MUST be visible +and selectable in coin control, clearly labeled with tokenId/ticker/amount and a +"this output carries a token; spending it outside a token SEND BURNS it" +warning. Deliberate spend requires explicit selection. + +R-WALLET-3 (token SEND construction): When building a token SEND, the wallet +MUST include exactly the intended token input UTXO(s) of the target token, place +the SLP OP_RETURN at vout[0], place recipient(s) at vout[1..], and add a token +CHANGE output for any surplus (availIn - sent) so surplus is not burned (R-QTY-3 +burns surplus). The wallet's own constructed tx MUST validate under the §3-§8 +canonical rules BEFORE broadcast (self-check against the same parser/conservation +the indexer uses). + +R-WALLET-4 (no shielding of token dust): Token UTXOs are transparent. Auto-shield +/ "shield all" MUST exclude token UTXOs (shielding them = burning them and +leaking value into a z-addr). Tie into R-WALLET-1. + +R-WALLET-5 (dust/fee interaction): The token dust output's ZCL value is below +normal spend thresholds; the wallet must not "consolidate dust" across token +UTXOs. Exclusion (R-WALLET-1) covers this; pin it for the dust-consolidation +path specifically. + +## D. Impersonation / social honesty (GUI) — REQUIRED + +R-UX-1 (identify by tokenId): Every token is shown with its full tokenId +(genesis txid) as the canonical identifier; ticker/name/image are secondary and +explicitly attacker-controllable. Two tokens with the same ticker MUST be +visually distinguishable by tokenId/fingerprint. + +R-UX-2 (no false trust): The GUI MUST NOT render any "verified," "official," or +checkmark status derived solely from on-chain metadata. document_url and +document_hash are issuer claims, not proofs. + +R-UX-3 (image/hash honesty): If an NFT image is shown, the GUI MUST verify it +against document_hash when it has the bytes, and show "image matches on-chain +hash" vs "unverified" — never imply authenticity of the ISSUER from a matching +hash (anyone can mint a token pointing at someone else's image). + +R-UX-4 (issuer attestation is out-of-band): Any "this token is from issuer X" +claim MUST come from a signed attestation binding an identity to a genesis txid, +presented as a separate, clearly-sourced trust signal — never inferred from +on-chain strings. + +## E. Confirmed-vs-unconfirmed honesty — REQUIRED + +R-UX-5: Ownership/balance shown as "confirmed" MUST derive only from the +confirmed-history ledger (§ spec §1, §10). Unconfirmed token receipts/sends are +shown in a visually distinct "pending" state and are NEVER counted as owned for +the purpose of "I can prove I own this." This prevents an attacker from using a +just-broadcast (and later double-spent/reorged) tx to convince a victim of +ownership. + +## F. Determinism-fork threat class — the residual that this work closes + +The structured findings (returned to the orchestrator) enumerate each +nondeterminism source. The closure criterion for the whole class: a SECOND, +independent implementation of `f`, fed the identical block history including +adversarial edge-case txs (vout[1] SLP message, PUSHDATA2 field, trailing byte, +31-byte hash, 2^63 quantity, 20-quantity SEND, out-of-range output index, +same-block genesis+send, reorg), produces a BIT-IDENTICAL ledger and RPC output. +Until that cross-impl differential test exists and passes, the threat class is +OPEN regardless of how clean the single implementation looks. diff --git a/doc/nft/zslp-wallet-antiburn-ux-honesty.md b/doc/nft/zslp-wallet-antiburn-ux-honesty.md new file mode 100644 index 00000000000..505e1fd3162 --- /dev/null +++ b/doc/nft/zslp-wallet-antiburn-ux-honesty.md @@ -0,0 +1,114 @@ +# ZSLP Wallet Anti-Burn + UX-Honesty Requirements + +Companion to `zslp-forgery-conservation-threat-model.md`. The conservation +model makes *forgery* a no-op, but it does NOTHING to stop a holder from +**burning their own** token, and it does NOTHING to stop social +**impersonation**. Both are real losses/deceptions that the wallet (GUI, a +separate repo) and the daemon coin-selection must address. These are +requirements, not edits to `src/zslp/*`. + +--- + +## 1. The burn hazard (verified) + +ZSLP rides **transparent dust UTXOs**: a token quantity or a mint baton lives +at exactly one `(txid, vout)` whose scriptPubKey pays an ordinary t-address +(`CZSLPTokenUtxo.address`, `zslpstore.h:127`; created at `vout[1]` etc., +`zslpstore.cpp:474-483`). The amount on that output is plain dust (a few +zatoshi) — **the token value is metadata, invisible to consensus and to a +ZSLP-unaware wallet.** + +The wallet today has **ZERO ZSLP awareness** — verified: `grep -rli +"zslp\|slp" src/wallet/` returns nothing. So ordinary coin selection will +happily pick a token-carrying dust UTXO as a fee/change input in a normal +ZCL send. The moment that UTXO is spent by a non-SLP tx (or an SLP tx that +doesn't re-assign it), **R-CONS-2 burns the token** +(`zslpstore.cpp:449-450`; test `NonSlpSpendBurnsUtxo`). An NFT spent this way +is gone forever — there is no recovery, because token id == genesis txid and +the qty-1 UTXO that carried it is consumed. + +This is the single most likely way a user loses a token, and it is entirely +preventable in the wallet. + +## 2. Anti-burn requirements (daemon coin-selection + wallet) + +- **W-1.** The wallet MUST be able to identify token-carrying UTXOs. With + `-zslpindex` enabled, cross-reference each candidate `(txid, vout)` against + the token UTXO set. Expose a daemon read API the wallet can call cheaply: + `GetUtxo(txid, vout)` already exists (`zslpstore.cpp:230`); add a batch + "is-token-utxo" / "annotate my unspents" RPC so the wallet doesn't do N + round-trips. (Pure read; no consensus impact.) +- **W-2.** Normal coin selection (fee + change for ordinary sends, autoshield, + sweep, send-max) MUST **exclude** token-carrying UTXOs by default. A + token/baton UTXO is *never* auto-selected as an incidental input. +- **W-3.** Coin-control MUST **surface** token UTXOs distinctly (labeled with + ticker/name/qty/"mint baton"/"NFT") so a user can knowingly include one only + when they mean to (e.g. building an actual SLP SEND). +- **W-4.** If a user action WOULD spend a token UTXO without a valid SLP + message re-assigning it, the wallet MUST **block + warn** with an explicit + "this will permanently BURN " confirmation — never silent. +- **W-5.** Building an SLP SEND, the wallet MUST honor the positional output + mapping (R-CONS-4: amount[j] → vout[1+j]) and MUST place the SLP OP_RETURN + at **vout[0]** (R-LOC-1). It MUST select enough token inputs that + `Σ amounts ≤ availIn` (R-CONS-3) or the SEND burns. Change tokens MUST be an + explicit additional output, or they are burned implicitly + (`zslpstore.cpp:565`). +- **W-6.** Mint-baton handling: the wallet MUST treat the baton UTXO as + precious (losing it = token permanently fixed-supply, R-CONS-5) and never + auto-spend it; continuing the baton requires `mint_baton_vout ∈ [2, + voutCount)`. +- **W-7.** Dust-limit interaction: a token output is dust by ZCL value. + The wallet MUST NOT let a dust-consolidation / "clean up small UTXOs" or + "discard dust below threshold" feature sweep token UTXOs. Audit every place + the wallet filters by amount. + +## 3. UX-honesty requirements (impersonation is social, not consensus) + +Uniqueness is at the **token-id (genesis txid)** level only. Anyone can +GENESIS a *different* token (different txid ⇒ different `tokenId`) reusing a +ticker, name, document_url, or image hash. The overlay does NOT and CANNOT +prevent this (it's a new, valid token, not a forgery of an existing one). The +GUI must present this honestly: + +- **U-1.** NEVER present ticker/name/image as proof of identity. The + **genesis-txid (token id)** is the only unique identifier. Show a short, + copyable token-id fingerprint everywhere a token is named. +- **U-2.** Show the **document_hash** when present and let the user verify it + against the actual image bytes (the NFT image-hash binding, + `genesisMeta.documentHash`, `zslpindexer.cpp:238-246`). A matching hash + proves the *bytes* are the ones the genesis committed to — NOT that the + issuer is legitimate. +- **U-3.** Warn on look-alikes: if a token's ticker/name collides with a + previously-seen different token id, flag "another token uses this name — + verify the token id." +- **U-4.** Issuer trust is OUT of the protocol. Support (don't fake) social + attestation: issuer-published token-id lists, signed messages, known-issuer + registries. The GUI may *display* such attestations but MUST label them as + third-party claims, never as protocol guarantees. +- **U-5.** Never imply consensus enforces token rules. Token balances are + "as computed by the ZSLP index over confirmed blocks"; if `-zslpindex` is + off, the wallet shows no token data (it does not silently guess). +- **U-6.** Make burn risk legible: any screen that lists token UTXOs should + note they ride tiny transparent outputs that must not be spent as ordinary + funds. + +## 4. Determinism honesty (tie-back to the security model) + +- **U-7.** The wallet's token balances MUST come from the SAME canonical rules + as the daemon index (ideally by *reading the daemon index*, not by + re-parsing independently). If the wallet ever parses SLP itself, it MUST + pass the canonical test vectors (X-1 in the conformance checklist) — an + independently-parsing wallet that disagrees on a D1–D6 edge case shows the + user a balance no one else agrees with. Reading the daemon's `'u'`/`'b'` view + is the safe default. + +--- + +## Summary of what each property buys + +| Loss vector | Stopped by | Where | +|-------------|-----------|-------| +| Token forgery / inflation | conservation model (this is a no-op/burn) | `src/zslp/*` (threat model doc) | +| Holder burns own token via incidental spend | wallet anti-burn W-1..W-7 | wallet repo + daemon read RPC | +| Look-alike / name-reuse impersonation | UX honesty U-1..U-6 (social, not consensus) | wallet repo | +| Two implementations disagree → ledger fork | canonical spec + test vectors | conformance checklist doc | diff --git a/qa/zslp/README.md b/qa/zslp/README.md new file mode 100644 index 00000000000..768d5d43cf2 --- /dev/null +++ b/qa/zslp/README.md @@ -0,0 +1,59 @@ +# ZSLP NFT/token live regtest harness + +`zslp-nft-regtest.sh` is the committed, repeatable end-to-end test of the ZSLP +write path: the live `zslp_genesis` / `zslp_mint` / `zslp_send` RPCs -> +`BuildAndCommitZSLP` (coin selection -> sign -> CommitTransaction) -> confirm -> +re-read (`zslp_gettoken` / `zslp_listmytokens` / `zslp_listtransfers`). This is +the loop the gtest unit suite explicitly disclaims (it needs a live CWallet, +keystore, chain and mempool); the gtests cover the pure decision pieces, this +covers the real broadcast/confirm path. + +## What it proves (each step asserts on-chain evidence) + +- NFT genesis: `txid == tokenid`, `decimals 0`, `totalminted 1`, + `hasmintbaton false`. +- NFT transfer: the genesis token dust (`txid:1`) is pinned into the SEND `vin` + (decoded from `getrawtransaction`), and the holding address moves. +- Anti-burn vs `sendtoaddress`: the NFT token dust is absent from `listunspent` + and is never selected as a `vin` of an unrelated ZCL spend. +- Fungible genesis + mint baton, then re-mint (`100 -> 125`). +- 0-conf token-change protection: a concurrent `sendtoaddress` never selects an + unconfirmed token-change output. +- Self-validate refusals (over-send / unknown token / mint-without-baton), each + refused **without** broadcasting (mempool unchanged before/after). +- Anti-burn vs shielding (`z_sendmany`), when Sapling is active on the regtest. + +Coin ticker in all user-facing strings is **ZCL**. + +## Run it + +Plain (binaries default to `/src/zclassicd` and `/src/zclassic-cli`, +params from `~/.zcash-params`): + +``` +qa/zslp/zslp-nft-regtest.sh +# or with explicit binaries: +qa/zslp/zslp-nft-regtest.sh /path/to/zclassicd /path/to/zclassic-cli +``` + +## proot build env (the params gotcha) + +Under the unprivileged proot build env, `HOME` is `/root` and +`~/.zcash-params` is **not** auto-bound. Bind it (and `/tmp` for the unique +datadir) and point the script at the in-proot binary paths. + +`prun` launches the in-proot command via `env -i` (it wipes the environment to +a fixed whitelist), so `ZCLASSICD=…`-style vars set *outside* `prun` never reach +the script. Pass the binaries as **positional args** and inject +`ZCASH_PARAMS_DIR` *inside* proot with `prun env`: + +``` +EXTRA_BINDS="-b /home/rhett/.zcash-params:/root/.zcash-params -b /tmp:/tmp" \ + /home/rhett/zclbuild/prun env ZCASH_PARAMS_DIR=/root/.zcash-params \ + bash /src/daemon/qa/zslp/zslp-nft-regtest.sh \ + /build/daemon/src/zclassicd /build/daemon/src/zclassic-cli +``` + +The harness uses a unique datadir + port per run and always tears the daemon +down and removes the datadir on exit (EXIT/INT/TERM trap), so it is safely +re-runnable and never touches a real node. Exit code `0` = all green. diff --git a/qa/zslp/nft-sell-regtest.sh b/qa/zslp/nft-sell-regtest.sh new file mode 100755 index 00000000000..1c282917f3c --- /dev/null +++ b/qa/zslp/nft-sell-regtest.sh @@ -0,0 +1,656 @@ +#!/usr/bin/env bash +# ============================================================================ +# NFT SELL pillar end-to-end LIVE regtest harness (committed, repeatable). +# +# Proves the non-consensus atomic NFT->ZCL sale (mechanism A', +# doc/nft/NFT_SELL_DESIGN.md) on REAL consensus, end to end through the REAL +# zclassic-cli: nft_makeoffer -> nft_verifyoffer -> nft_takeoffer -> confirm, +# asserting with on-chain evidence that in ONE transaction: +# - the NFT moves to the buyer's address (vout[1]); the seller no longer holds it +# - the seller is paid the asking price at vout[2] +# - the final tx vin = [seller NFT input, buyer funding input(s)] and +# vout = [OP_RETURN, buyer NFT dust, seller payout] +# and proves TAMPER REJECTION: +# - editing vout[2] price -> the merged tx's vin[0] VerifyScript fails (relay +# rejects: mandatory-script-verify-flag / non-mandatory) +# - a forged offer whose vin[0] is NOT the live NFT -> nft_verifyoffer ok=false +# with a clear reason +# - nft_takeoffer refuses a !ok offer (no broadcast) +# and proves CANCEL: +# - nft_canceloffer self-spends the NFT; a later take on the stale blob fails +# +# A "buyer" here is a SECOND wallet address inside the same node funded by the +# seller; the swap is built, the buyer's funding input is appended + signed, and +# the single tx is broadcast + confirmed. (One node, two roles — sufficient to +# prove the atomic on-chain settlement; the offer blob is shared as a string.) +# +# ALWAYS tears the daemon down and removes the datadir on exit (trap). +# +# Usage: +# qa/zslp/nft-sell-regtest.sh [ZCLASSICD] [ZCLASSIC_CLI] +# Resolution: positional $1/$2, then env ZCLASSICD/ZCLASSIC_CLI, then +# /src/zclassicd and /src/zclassic-cli. params: env +# ZCASH_PARAMS_DIR, else ~/.zcash-params. +# +# proot/params GOTCHA (see qa/zslp/README.md): prun runs in-proot via `env -i`, +# so pass binaries POSITIONALLY and inject params with `prun env`: +# EXTRA_BINDS="-b /home/rhett/.zcash-params:/root/.zcash-params -b /tmp:/tmp" \ +# /home/rhett/zclbuild/prun env ZCASH_PARAMS_DIR=/root/.zcash-params \ +# bash /src/daemon/qa/zslp/nft-sell-regtest.sh \ +# /build/daemon/src/zclassicd /build/daemon/src/zclassic-cli +# +# Exit: 0 = all scenarios green; non-zero = a scenario failed. +# ============================================================================ +set -u + +# ---- Resolve binaries + params (repo-discoverable) ------------------------ +if SRCTOP=$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null); then + SRCDIR="$SRCTOP/src" +else + SRCDIR="$(cd "$(dirname "$0")/../../src" && pwd)" +fi +DAEMON="${1:-${ZCLASSICD:-$SRCDIR/zclassicd}}" +CLI="${2:-${ZCLASSIC_CLI:-$SRCDIR/zclassic-cli}}" +PARAMS="${ZCASH_PARAMS_DIR:-$HOME/.zcash-params}" + +PORT=$(( 19000 + (RANDOM % 800) )) +RPCPORT=$(( PORT + 1 )) +DATADIR=$(mktemp -d "${TMPDIR:-/tmp}/nft-sell-rt.XXXXXX") +RPCUSER=rt +RPCPASS=rt + +FAILS=0 +pass() { echo " PASS $*"; } +fail() { echo " FAIL $*"; FAILS=$((FAILS+1)); } +hdr() { echo; echo "================ $* ================"; } + +# Extract a top-level JSON string field by key from a value blob (quote-stripped). +jget() { echo "$1" | tr -d ' ",' | grep -m1 "$2:" | sed "s/.*$2://"; } + +echo "NFT SELL regtest harness" +echo " daemon = $DAEMON" +echo " cli = $CLI" +echo " params = $PARAMS" +[ -x "$DAEMON" ] || { echo "FATAL: zclassicd not executable at $DAEMON"; exit 2; } +[ -x "$CLI" ] || { echo "FATAL: zclassic-cli not executable at $CLI"; exit 2; } + +# ---- Daemon lifecycle ----------------------------------------------------- +DAEMON_PID="" +cleanup() { + echo; echo "---- teardown ----" + if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then + cli stop >/dev/null 2>&1 || true + for _ in $(seq 1 30); do kill -0 "$DAEMON_PID" 2>/dev/null || break; sleep 1; done + if kill -0 "$DAEMON_PID" 2>/dev/null; then + kill -TERM "$DAEMON_PID" 2>/dev/null || true; sleep 2 + kill -KILL "$DAEMON_PID" 2>/dev/null || true + fi + fi + pkill -KILL -f "zclassicd -regtest -zslpindex -datadir=$DATADIR" 2>/dev/null || true + rm -rf "$DATADIR" + echo "removed datadir $DATADIR"; echo "daemon stopped" +} +trap cleanup EXIT INT TERM + +cli() { + "$CLI" -regtest -datadir="$DATADIR" \ + -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$RPCPORT" "$@" +} + +# ---- Bring-up ------------------------------------------------------------- +hdr "(0) BRING-UP port=$PORT rpcport=$RPCPORT datadir=$DATADIR" +"$DAEMON" -regtest -zslpindex -datadir="$DATADIR" \ + -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$RPCPORT" \ + -port="$PORT" -listen=0 -txindex \ + -nuparams=5ba81b19:1 -nuparams=76b809bb:1 \ + > "$DATADIR/daemon.log" 2>&1 & +DAEMON_PID=$! + +UP=0 +for i in $(seq 1 90); do + if ! kill -0 "$DAEMON_PID" 2>/dev/null; then + echo " daemon died during warmup; log tail:"; tail -20 "$DATADIR/daemon.log" + fail "daemon did not stay up"; exit 1 + fi + h=$(cli getblockcount 2>/dev/null) + if [[ "$h" =~ ^[0-9]+$ ]]; then UP=1; pass "RPC up after ${i}s, height=$h"; break; fi + sleep 1 +done +[ "$UP" = 1 ] || { fail "RPC never came up"; tail -20 "$DATADIR/daemon.log"; exit 1; } + +cli generate 110 >/dev/null +H=$(cli getblockcount) +[ "$H" -ge 110 ] && pass "height=$H after generate 110" || fail "height=$H (<110)" + +# ============================================================================ +# (1) MINT an NFT (the seller's asset) +# ============================================================================ +hdr "(1) NFT GENESIS" +GEN=$(cli zslp_genesis '{"nft":true,"name":"SellMe #1","ticker":"SLM","document_hash":"00000000000000000000000000000000000000000000000000000000000000aa"}') +TOKEN=$(jget "$GEN" tokenid) +echo " tokenid=$TOKEN" +[ -n "$TOKEN" ] && pass "NFT minted ($TOKEN)" || { fail "genesis failed: $GEN"; exit 1; } +cli generate 1 >/dev/null + +# The seller's current NFT holding address. +MYT=$(cli zslp_listmytokens) +SELLER_NFT_ADDR=$(jget "$MYT" address) +echo " seller NFT holder = $SELLER_NFT_ADDR" + +# ============================================================================ +# (2) BUYER requests a fresh receive address (handshake) +# ============================================================================ +hdr "(2) BUYER nft_requestbuy" +REQ=$(cli nft_requestbuy "{\"tokenId\":\"$TOKEN\"}") +echo "$REQ" | sed 's/^/ /' +BUYER_ADDR=$(jget "$REQ" buyerNftAddr) +[ -n "$BUYER_ADDR" ] && pass "buyer fresh NFT addr = $BUYER_ADDR" || fail "requestbuy gave no address" +[ "$BUYER_ADDR" != "$SELLER_NFT_ADDR" ] && pass "buyer addr differs from seller holder" \ + || fail "buyer addr equals seller holder" + +# ============================================================================ +# (3) SELLER makes the offer (sealed to the buyer addr) +# ============================================================================ +hdr "(3) SELLER nft_makeoffer" +PRICE=300000000 # 3 ZCL in zatoshi +PAYOUT=$(cli getnewaddress) # seller's payout t-address (track its balance) +echo " price=$PRICE zat payout=$PAYOUT" +OFFER=$(cli nft_makeoffer "{\"tokenId\":\"$TOKEN\",\"priceZat\":\"$PRICE\",\"buyerNftAddr\":\"$BUYER_ADDR\",\"payoutAddr\":\"$PAYOUT\"}") +echo "$OFFER" | sed 's/^/ /' +OFFER_BLOB=$(jget "$OFFER" offerBlob) +OFFER_ID=$(jget "$OFFER" offerId) +NFT_OUTPOINT=$(jget "$OFFER" nftOutpoint) +[ -n "$OFFER_BLOB" ] && pass "offer blob produced (id=$OFFER_ID)" || { fail "makeoffer failed: $OFFER"; exit 1; } +echo " nftOutpoint=$NFT_OUTPOINT" + +# The NFT outpoint must now be LOCKED against coin selection. +LOCKED=$(cli listlockunspent) +echo "$LOCKED" | grep -q "$TOKEN" && pass "NFT outpoint is locked (listlockunspent)" \ + || fail "NFT outpoint NOT locked: $LOCKED" + +# ============================================================================ +# (4) BUYER verifies the offer (mandatory, read-only) +# ============================================================================ +hdr "(4) BUYER nft_verifyoffer" +VER=$(cli nft_verifyoffer "{\"offerBlob\":\"$OFFER_BLOB\"}") +echo "$VER" | sed 's/^/ /' +VOK=$(jget "$VER" ok) +VPRICE=$(jget "$VER" priceZat) +VBUYER=$(jget "$VER" buyerNftAddr) +VTOKEN=$(jget "$VER" tokenId) +[ "$VOK" = "true" ] && pass "verifyoffer ok=true" || fail "verifyoffer ok=$VOK" +[ "$VPRICE" = "$PRICE" ] && pass "verify price matches ($VPRICE)" || fail "verify price=$VPRICE" +[ "$VBUYER" = "$BUYER_ADDR" ] && pass "verify buyerNftAddr matches" || fail "verify buyer=$VBUYER" +[ "$VTOKEN" = "$TOKEN" ] && pass "verify tokenId matches" || fail "verify token=$VTOKEN" + +# ============================================================================ +# (5) TAMPER REJECTIONS (before any honest broadcast) +# ============================================================================ +hdr "(5) TAMPER REJECTIONS" + +# Reusable Python helpers (CompactSize read/write) to (de)construct an offer +# blob and re-wrap a tampered partial tx. The blob layout (from rpc/nftoffer.cpp +# CNftOfferBlob) is: magic(4) ver(1) tokenId(32) priceZat(8 LE) payout(str) +# buyer(str) expiry(4 LE) offerHex(str), each str = CompactSize len + bytes. +read_pyhelpers() { cat <<'PYEOF' +import sys,base64,binascii +def rd_cs(raw,i): + n=raw[i]; i+=1 + if n<253: return n,i + if n==253: return int.from_bytes(raw[i:i+2],"little"),i+2 + if n==254: return int.from_bytes(raw[i:i+4],"little"),i+4 + return int.from_bytes(raw[i:i+8],"little"),i+8 +def rd_str(raw,i): + n,i=rd_cs(raw,i); return raw[i:i+n],i+n +def wr_cs(n): + if n<253: return bytes([n]) + if n<0x10000: return b"\xfd"+n.to_bytes(2,"little") + if n<0x100000000: return b"\xfe"+n.to_bytes(4,"little") + return b"\xff"+n.to_bytes(8,"little") +def wr_str(b): return wr_cs(len(b))+b +def strip(b): + b=b.strip() + return b[len("znftoffer:"):] if b.startswith("znftoffer:") else b +PYEOF +} + +# (5a) Forged offer: re-point vin[0] at a NON-NFT live outpoint -> verify FAILS. +# Decode the partial, swap vin[0].prevout to a fresh ordinary UTXO, re-encode, +# wrap a fresh znftoffer blob (so verifyoffer re-derives every field + rejects). +OFFER_HEX=$( { read_pyhelpers; cat < $FORGE_TXID:$FORGE_VOUT (a plain non-NFT UTXO)" + +FORGED_HEX=$( { read_pyhelpers; cat <internal LE +tx[off+32:off+36]=int("$FORGE_VOUT").to_bytes(4,"little") +sys.stdout.write(binascii.hexlify(tx).decode()) +PYEOF +} | python3) + +# Re-wrap as a znftoffer blob (header advisory; verify re-derives from hex). +FORGED_BLOB=$( { read_pyhelpers; cat <&1) +FOK=$(jget "$FVER" ok) +echo " forged verify ok=$FOK" +echo "$FVER" | grep -qi 'reason' && echo "$FVER" | python3 -c ' +import sys,json +try: + d=json.load(sys.stdin) + for r in d.get("reasons",[]): print(" reason:",r) +except Exception: pass' 2>/dev/null +[ "$FOK" = "false" ] && pass "forged offer (vin[0] not the live NFT) -> verify ok=false" \ + || fail "forged offer was NOT rejected (ok=$FOK)" + +# (5b) takeoffer must REFUSE a !ok (forged) offer (no broadcast). +MEMPOOL_BEFORE=$(cli getrawmempool | tr -d ' \n[]"') +FTAKE=$(cli nft_takeoffer "{\"offerBlob\":\"$FORGED_BLOB\"}" 2>&1) +echo " forged take -> $FTAKE" | head -c 200; echo +echo "$FTAKE" | grep -qi 'verification' && pass "takeoffer refused the forged offer" \ + || fail "takeoffer did NOT refuse the forged offer: $FTAKE" +MEMPOOL_AFTER=$(cli getrawmempool | tr -d ' \n[]"') +[ "$MEMPOOL_BEFORE" = "$MEMPOOL_AFTER" ] && pass "no broadcast from the refused take (mempool unchanged)" \ + || fail "refused take broadcast something" + +# (5c) Price-edit: shave a satoshi off vout[2] in the partial. The buyer's +# MANDATORY nft_verifyoffer must reject it (re-derives price from vout[2] and +# finds it no longer matches the header/agreed price). Re-wrap with the header +# still claiming the original price (the on-the-wire attack). +TAMPER_HEX=$( { read_pyhelpers; cat </dev/null +PRICE_REASON=$(echo "$PVER" | grep -ci 'payout) value does not match priceZat') +{ [ "$POK" = "false" ] && [ "$PRICE_REASON" -ge 1 ]; } \ + && pass "price-edit (vout[2] shaved) -> verifyoffer ok=false (price mismatch)" \ + || fail "price-edit not caught by verifyoffer (ok=$POK reason=$PRICE_REASON)" + +# (5d) Belt-and-suspenders at the SCRIPT layer: the seller's EXISTING vin[0] +# scriptSig (an ALL signature over the original outputs) must FAIL VerifyScript +# once vout[2] is edited. signrawtransaction with an EMPTY key array uses ONLY a +# temp keystore (NOT the wallet), so it canNOT re-sign vin[0] (no key) — it just +# re-verifies the existing scriptSig and reports it in errors. We supply vin[0]'s +# prevout so the checker has the script+amount. +NFT_OP_TX=$(echo "$NFT_OUTPOINT" | cut -d: -f1) +NFT_OP_N=$(echo "$NFT_OUTPOINT" | cut -d: -f2) +TXO=$(cli gettxout "$NFT_OP_TX" "$NFT_OP_N") +PREV_SPK_HEX=$(echo "$TXO" | python3 -c 'import sys,json;print(json.load(sys.stdin)["scriptPubKey"]["hex"])') +PREV_AMT=$(echo "$TXO" | python3 -c 'import sys,json;print(json.load(sys.stdin)["value"])') +PREVTXS="[{\"txid\":\"$NFT_OP_TX\",\"vout\":$NFT_OP_N,\"scriptPubKey\":\"$PREV_SPK_HEX\",\"amount\":$PREV_AMT}]" +SIGN_RES=$(cli signrawtransaction "$TAMPER_HEX" "$PREVTXS" "[]" 2>&1) +SIGN_COMPLETE=$(echo "$SIGN_RES" | python3 -c ' +import sys,json +try: print(json.load(sys.stdin).get("complete")) +except: print("err")') +SIGN_VIN0_ERR=$(echo "$SIGN_RES" | python3 -c ' +import sys,json +try: + d=json.load(sys.stdin); errs=d.get("errors",[]) + print("YES" if any(e.get("vout")==int("'$NFT_OP_N'") for e in errs) else "NO") +except: print("NO")') +echo " signrawtransaction(empty-keys) complete=$SIGN_COMPLETE vin0_err=$SIGN_VIN0_ERR" +{ [ "$SIGN_COMPLETE" = "False" ] && [ "$SIGN_VIN0_ERR" = "YES" ]; } \ + && pass "price-edit breaks the seller's vin[0] signature (VerifyScript fails)" \ + || fail "seller sig still verified after price-edit (complete=$SIGN_COMPLETE vin0_err=$SIGN_VIN0_ERR)" + +# (5e) SIGNATURE-TAMPERED offer caught by nft_verifyoffer (the new VerifyScript +# backstop). Edit vout[2].nValue AND rewrite the blob header's priceZat to the +# SAME edited value so EVERY field re-derivation passes (vout[2].value==priceZat, +# addresses match, token matches, vin[0] live) — the ONLY thing now broken is the +# seller's ALL signature, which was made over the ORIGINAL value. Pre-fix this +# slipped past verifyoffer and was only caught at broadcast; now ok MUST be false +# with the VerifyScript reason. +SIGTAMPER_NEWPRICE=$((PRICE - 100000)) # 0.001 ZCL lower than signed value +SIGTAMPER_HEX=$( { read_pyhelpers; cat </dev/null +SIG_REASON=$(echo "$SVER" | grep -ci 'VerifyScript failed') +PRICEFIELD_REASON=$(echo "$SVER" | grep -ci 'payout) value does not match priceZat') +# fields must be consistent (no price-field mismatch) so we prove the NEW backstop +# fired, not the old field check. +{ [ "$SOK" = "false" ] && [ "$SIG_REASON" -ge 1 ] && [ "$PRICEFIELD_REASON" -eq 0 ]; } \ + && pass "sig-tampered offer (fields consistent) -> verifyoffer ok=false via VerifyScript" \ + || fail "sig-tamper not caught by the new VerifyScript backstop (ok=$SOK sigReason=$SIG_REASON priceField=$PRICEFIELD_REASON)" + +# takeoffer must also refuse the sig-tampered offer (no broadcast). +MEMPOOL_BEFORE_ST=$(cli getrawmempool | tr -d ' \n[]"') +STAKE=$(cli nft_takeoffer "{\"offerBlob\":\"$SIGTAMPER_BLOB\"}" 2>&1) +echo "$STAKE" | grep -qi 'verification' && pass "takeoffer refused the sig-tampered offer" \ + || fail "takeoffer did NOT refuse the sig-tampered offer: $STAKE" +MEMPOOL_AFTER_ST=$(cli getrawmempool | tr -d ' \n[]"') +[ "$MEMPOOL_BEFORE_ST" = "$MEMPOOL_AFTER_ST" ] && pass "no broadcast from the refused sig-tamper take" \ + || fail "refused sig-tamper take broadcast something" + +# (5f) ANTI-BURN funding guard: nft_takeoffer must REFUSE explicit fundingInputs +# that include a ZSLP-protected (token/baton) outpoint (nftoffer.cpp ~:789-791). +# The seller's own NFT outpoint ($NFT_OUTPOINT) is a live qty-1 token UTXO — feed +# it as a funding input and prove the buyer-side guard fires (exercising the +# explicit-fundingInputs path, not only the auto-selector). +MEMPOOL_BEFORE_AB=$(cli getrawmempool | tr -d ' \n[]"') +ABTAKE=$(cli nft_takeoffer "{\"offerBlob\":\"$OFFER_BLOB\",\"fundingInputs\":[\"$NFT_OUTPOINT\"]}" 2>&1) +echo " anti-burn take -> $(echo "$ABTAKE" | head -c 200)" +echo "$ABTAKE" | grep -qi 'anti-burn' && pass "takeoffer refused a ZSLP-token funding input (anti-burn fired)" \ + || fail "takeoffer did NOT refuse the token funding input: $ABTAKE" +MEMPOOL_AFTER_AB=$(cli getrawmempool | tr -d ' \n[]"') +[ "$MEMPOOL_BEFORE_AB" = "$MEMPOOL_AFTER_AB" ] && pass "no broadcast from the refused anti-burn take" \ + || fail "refused anti-burn take broadcast something" + +# (5g) OVERSHOOT consent (§2.5): supply ONE large explicit funding input so the +# overpay (funds in - price - dust - fee) far exceeds the dust threshold. Without +# acknowledge:true nft_takeoffer must REFUSE and NAME the overshoot (no broadcast). +BIG_UTXO=$(cli listunspent 1 | python3 -c ' +import sys,json +u=sorted(json.load(sys.stdin), key=lambda x:-x.get("amount",0)) +for x in u: + if x.get("spendable") and x.get("amount",0) > 5: # >5 ZCL => big overshoot + print(x["txid"], x["vout"]); break') +BIG_TXID=$(echo "$BIG_UTXO" | awk "{print \$1}") +BIG_VOUT=$(echo "$BIG_UTXO" | awk "{print \$2}") +if [ -n "$BIG_TXID" ]; then + echo " overshoot funding input -> $BIG_TXID:$BIG_VOUT" + MEMPOOL_BEFORE_OS=$(cli getrawmempool | tr -d ' \n[]"') + OSTAKE=$(cli nft_takeoffer "{\"offerBlob\":\"$OFFER_BLOB\",\"fundingInputs\":[\"$BIG_TXID:$BIG_VOUT\"]}" 2>&1) + echo " overshoot take (no ack) -> $(echo "$OSTAKE" | head -c 200)" + echo "$OSTAKE" | grep -qi 'overpay' && echo "$OSTAKE" | grep -qi 'acknowledge:true' \ + && pass "overshoot WITHOUT acknowledge -> refused, names the overpay" \ + || fail "overshoot not refused/named without acknowledge: $OSTAKE" + MEMPOOL_AFTER_OS=$(cli getrawmempool | tr -d ' \n[]"') + [ "$MEMPOOL_BEFORE_OS" = "$MEMPOOL_AFTER_OS" ] && pass "no broadcast from the refused overshoot take" \ + || fail "refused overshoot take broadcast something" +else + fail "could not find a large UTXO to drive the overshoot test" +fi + +# ============================================================================ +# (6) HONEST TAKE -> atomic swap confirms +# ============================================================================ +hdr "(6) BUYER nft_takeoffer (honest)" +# An honest buyer can rarely fund to the exact zat (no change output is possible +# under ALL), so the auto-selected fund overshoots to fee and the §2.5 guard +# requires explicit consent. The honest flow therefore passes acknowledge:true +# (the buyer has run nft_verifyoffer and accepts the small fee overshoot). This +# is the CLEAN swap and it still settles green. +PAYOUT_BAL_BEFORE=$(cli getreceivedbyaddress "$PAYOUT" 0) +TAKE=$(cli nft_takeoffer "{\"offerBlob\":\"$OFFER_BLOB\",\"acknowledge\":true}") +echo "$TAKE" | sed 's/^/ /' +SWAP_TXID=$(jget "$TAKE" txid) +[ -n "$SWAP_TXID" ] && pass "takeoffer broadcast swap tx ($SWAP_TXID)" || { fail "takeoffer failed: $TAKE"; exit 1; } + +# Decode the final swap tx: assert vin/vout shape + the seller NFT input present. +RAW=$(cli getrawtransaction "$SWAP_TXID" 1) +echo " --- decoded swap tx ---" +echo "$RAW" | python3 -c ' +import sys,json +tx=json.load(sys.stdin) +print(" vin count =",len(tx["vin"])) +for k,v in enumerate(tx["vin"]): + print(" vin[%d]= %s:%s"%(k, v.get("txid"), v.get("vout"))) +print(" vout count =",len(tx["vout"])) +for k,o in enumerate(tx["vout"]): + spk=o["scriptPubKey"] + addrs=spk.get("addresses",[]) + print(" vout[%d] val=%s type=%s addr=%s"%(k,o["value"],spk.get("type"),addrs))' + +# vin[0] is the seller's NFT outpoint. +NFT_TX=$(echo "$NFT_OUTPOINT" | cut -d: -f1) +NFT_N=$(echo "$NFT_OUTPOINT" | cut -d: -f2) +VIN0_OK=$(echo "$RAW" | python3 -c ' +import sys,json +tx=json.load(sys.stdin) +v=tx["vin"][0] +print("YES" if v.get("txid")=="'$NFT_TX'" and int(v.get("vout"))==int("'$NFT_N'") else "NO")') +[ "$VIN0_OK" = "YES" ] && pass "swap vin[0] is the seller NFT outpoint ($NFT_OUTPOINT)" \ + || fail "swap vin[0] is NOT the NFT outpoint" + +# >=2 inputs (seller NFT + buyer funding); exactly 3 outputs. +NIN=$(echo "$RAW" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)["vin"]))') +NOUT=$(echo "$RAW" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)["vout"]))') +[ "$NIN" -ge 2 ] && pass "swap has >=2 inputs (seller NFT + buyer funding) ($NIN)" || fail "swap inputs=$NIN" +[ "$NOUT" -eq 3 ] && pass "swap has exactly 3 outputs (OP_RETURN, NFT dust, payout)" || fail "swap outputs=$NOUT" + +# vout[1] pays the buyer addr; vout[2] pays the seller payout addr at the price. +V1_ADDR=$(echo "$RAW" | python3 -c ' +import sys,json;tx=json.load(sys.stdin) +print((tx["vout"][1]["scriptPubKey"].get("addresses") or [""])[0])') +V2_ADDR=$(echo "$RAW" | python3 -c ' +import sys,json;tx=json.load(sys.stdin) +print((tx["vout"][2]["scriptPubKey"].get("addresses") or [""])[0])') +V2_VAL=$(echo "$RAW" | python3 -c ' +import sys,json;tx=json.load(sys.stdin) +print(int(round(tx["vout"][2]["value"]*1e8)))') +[ "$V1_ADDR" = "$BUYER_ADDR" ] && pass "vout[1] pays the buyer NFT addr" || fail "vout[1] addr=$V1_ADDR" +[ "$V2_ADDR" = "$PAYOUT" ] && pass "vout[2] pays the seller payout addr" || fail "vout[2] addr=$V2_ADDR" +[ "$V2_VAL" = "$PRICE" ] && pass "vout[2] value == price ($PRICE)" || fail "vout[2] value=$V2_VAL" + +# Confirm + assert the LEDGER moved the NFT to the buyer addr. +cli generate 1 >/dev/null +NFT_BAL_BUYER=$(cli zslp_listmytokens | python3 -c ' +import sys,json +a=json.load(sys.stdin); t="'$TOKEN'"; b="'$BUYER_ADDR'" +for x in a: + if x["tokenid"]==t: + for ad in x.get("addresses",[]): + if ad["address"]==b: print(ad["balance"]); break + break +else: print("0")') +echo " NFT balance at buyer addr after confirm = ${NFT_BAL_BUYER:-0}" +[ "${NFT_BAL_BUYER:-0}" = "1" ] && pass "NFT now credited to the BUYER addr (qty 1)" \ + || fail "NFT not at buyer addr (bal=${NFT_BAL_BUYER:-0})" + +# The seller's old holding address no longer holds it. +SELLER_STILL=$(cli zslp_listmytokens | python3 -c ' +import sys,json +a=json.load(sys.stdin); t="'$TOKEN'"; s="'$SELLER_NFT_ADDR'" +for x in a: + if x["tokenid"]==t: + for ad in x.get("addresses",[]): + if ad["address"]==s: print(ad["balance"]); break + else: print("0") + break +else: print("0")') +[ "${SELLER_STILL:-0}" = "0" ] && pass "seller's old holder no longer holds the NFT" \ + || fail "seller still holds NFT (bal=$SELLER_STILL)" + +# The seller is paid: payout addr received >= price (confirmed). +PAYOUT_BAL_AFTER=$(cli getreceivedbyaddress "$PAYOUT" 1) +PAYOUT_ZAT=$(python3 -c "print(int(round(float('$PAYOUT_BAL_AFTER')*1e8)))") +echo " payout addr received = $PAYOUT_BAL_AFTER ($PAYOUT_ZAT zat)" +[ "$PAYOUT_ZAT" -ge "$PRICE" ] && pass "seller PAID: payout addr received >= price" \ + || fail "seller not paid (received $PAYOUT_ZAT < $PRICE)" + +# verifyoffer on the now-filled offer must report it's no longer live. +VER2=$(cli nft_verifyoffer "{\"offerBlob\":\"$OFFER_BLOB\"}") +VOK2=$(jget "$VER2" ok) +[ "$VOK2" = "false" ] && pass "verifyoffer on the FILLED offer -> ok=false (vin[0] spent)" \ + || fail "filled offer still verifies ok=$VOK2" + +# listoffers shows it filled. +LIST=$(cli nft_listoffers) +LSTAT=$(echo "$LIST" | python3 -c ' +import sys,json +a=json.load(sys.stdin); oid="'$OFFER_ID'" +for x in a: + if x["offerId"]==oid: print(x["status"]); break +else: print("missing")') +[ "$LSTAT" = "filled" ] && pass "listoffers status=filled" || fail "listoffers status=$LSTAT" + +# ============================================================================ +# (7) CANCEL: make a 2nd offer, cancel it, prove a stale take fails +# ============================================================================ +hdr "(7) CANCEL flow" +# Mint a 2nd NFT to sell, so the cancel test is independent of the filled one. +GEN2=$(cli zslp_genesis '{"nft":true,"name":"CancelMe #2","ticker":"CNL"}') +TOKEN2=$(jget "$GEN2" tokenid) +cli generate 1 >/dev/null +BUYER2=$(cli getnewaddress) +OFFER2=$(cli nft_makeoffer "{\"tokenId\":\"$TOKEN2\",\"priceZat\":\"100000000\",\"buyerNftAddr\":\"$BUYER2\"}") +OBLOB2=$(jget "$OFFER2" offerBlob) +OID2=$(jget "$OFFER2" offerId) +[ -n "$OBLOB2" ] && pass "2nd offer created (id=$OID2)" || fail "2nd makeoffer failed: $OFFER2" + +CANCEL=$(cli nft_canceloffer "{\"offerId\":\"$OID2\"}") +CTXID=$(jget "$CANCEL" txid) +[ -n "$CTXID" ] && pass "canceloffer self-spent the NFT ($CTXID)" || fail "cancel failed: $CANCEL" +cli generate 1 >/dev/null + +# A take on the stale blob must now fail (vin[0] already spent by the cancel). +STALE=$(cli nft_takeoffer "{\"offerBlob\":\"$OBLOB2\"}" 2>&1) +echo " stale take -> $(echo "$STALE" | head -c 160)" +echo "$STALE" | grep -qiE 'verification|spent|live' && pass "stale take after cancel is rejected" \ + || fail "stale take was NOT rejected: $STALE" + +# The cancelled offer's outpoint is unlocked again. +LOCKED2=$(cli listlockunspent) +echo "$LOCKED2" | grep -q "$(echo "$(jget "$OFFER2" nftOutpoint)" | cut -d: -f1)" \ + && fail "cancelled NFT outpoint still locked" \ + || pass "cancelled NFT outpoint is unlocked" + +# ============================================================================ +# (8) OVERSHOOT acknowledge:true PROCEEDS and surfaces overshootZat (§2.5) +# ============================================================================ +hdr "(8) OVERSHOOT acknowledge path" +# Fresh NFT + offer; fund with ONE big input + acknowledge:true -> the take MUST +# succeed AND report a large overshootZat (the donated miner fee). +GEN3=$(cli zslp_genesis '{"nft":true,"name":"AckMe #3","ticker":"ACK"}') +TOKEN3=$(jget "$GEN3" tokenid) +cli generate 1 >/dev/null +BUYER3=$(cli getnewaddress) +PAYOUT3=$(cli getnewaddress) +OFFER3=$(cli nft_makeoffer "{\"tokenId\":\"$TOKEN3\",\"priceZat\":\"100000000\",\"buyerNftAddr\":\"$BUYER3\",\"payoutAddr\":\"$PAYOUT3\"}") +OBLOB3=$(jget "$OFFER3" offerBlob) +[ -n "$OBLOB3" ] && pass "3rd offer created" || fail "3rd makeoffer failed: $OFFER3" + +BIG_UTXO3=$(cli listunspent 1 | python3 -c ' +import sys,json +u=sorted(json.load(sys.stdin), key=lambda x:-x.get("amount",0)) +for x in u: + if x.get("spendable") and x.get("amount",0) > 5: + print(x["txid"], x["vout"]); break') +BIG3_TXID=$(echo "$BIG_UTXO3" | awk "{print \$1}") +BIG3_VOUT=$(echo "$BIG_UTXO3" | awk "{print \$2}") +ACKTAKE=$(cli nft_takeoffer "{\"offerBlob\":\"$OBLOB3\",\"fundingInputs\":[\"$BIG3_TXID:$BIG3_VOUT\"],\"acknowledge\":true}") +echo "$ACKTAKE" | sed 's/^/ /' +ACK_TXID=$(jget "$ACKTAKE" txid) +ACK_OVERSHOOT=$(jget "$ACKTAKE" overshootZat) +[ -n "$ACK_TXID" ] && pass "overshoot WITH acknowledge:true -> take proceeds (txid=$ACK_TXID)" \ + || fail "acknowledge:true take did NOT proceed: $ACKTAKE" +{ [ -n "$ACK_OVERSHOOT" ] && [ "$ACK_OVERSHOOT" -gt 100000 ]; } \ + && pass "overshootZat surfaced in result ($ACK_OVERSHOOT zat donated to fees)" \ + || fail "overshootZat not surfaced/too small ($ACK_OVERSHOOT)" +cli generate 1 >/dev/null +# The big NFT moved to buyer3 -> confirms the acknowledged swap actually settled. +ACK_NFT_BAL=$(cli zslp_listmytokens | python3 -c ' +import sys,json +a=json.load(sys.stdin); t="'$TOKEN3'"; b="'$BUYER3'" +for x in a: + if x["tokenid"]==t: + for ad in x.get("addresses",[]): + if ad["address"]==b: print(ad["balance"]); break + break +else: print("0")') +[ "${ACK_NFT_BAL:-0}" = "1" ] && pass "acknowledged swap settled: NFT now at buyer3" \ + || fail "acknowledged swap did not settle (bal=${ACK_NFT_BAL:-0})" + +# ---- Verdict -------------------------------------------------------------- +hdr "VERDICT" +if [ "$FAILS" -eq 0 ]; then + echo "ALL ASSERTIONS GREEN"; exit 0 +else + echo "$FAILS ASSERTION(S) FAILED"; exit 1 +fi diff --git a/qa/zslp/zslp-nft-regtest.sh b/qa/zslp/zslp-nft-regtest.sh new file mode 100755 index 00000000000..5f84b46711b --- /dev/null +++ b/qa/zslp/zslp-nft-regtest.sh @@ -0,0 +1,381 @@ +#!/usr/bin/env bash +# ============================================================================ +# ZSLP NFT / token end-to-end LIVE regtest harness (committed, repeatable). +# +# This is the COMMITTED, repo-discoverable form of the live RPC -> builder -> +# confirm -> re-read loop that the gtest unit suite explicitly disclaims +# (src/gtest/test_zslp_wallet.cpp, header HONESTY note). It brings up a fresh, +# isolated regtest zclassicd (UNIQUE datadir + port, Sapling params bound), then +# drives the full ZSLP write+read path through the REAL zclassic-cli and ASSERTS +# every step with concrete on-chain evidence: +# - NFT genesis (txid==tokenid, decimals 0, totalminted 1, hasmintbaton false) +# - NFT transfer: the genesis token dust (txid:1) IS pinned in the SEND vin, +# and the holding address moves +# - anti-burn vs sendtoaddress: the NFT dust is ABSENT from listunspent and is +# never a vin of an unrelated ZCL spend +# - fungible genesis+baton + re-mint (100 -> 125) +# - 0-conf token-change protection (a concurrent sendtoaddress never selects an +# unconfirmed token-change output) +# - self-validate refusals (over-send / unknown token / mint-without-baton), +# each refused WITHOUT broadcasting (mempool unchanged) +# - anti-burn vs shielding (z_sendmany), when Sapling is active here +# +# ALWAYS tears the daemon down and removes the datadir on exit (trap), so it is +# safely re-runnable and never touches a real node. +# +# Usage: +# qa/zslp/zslp-nft-regtest.sh [ZCLASSICD] [ZCLASSIC_CLI] +# Resolution order for the binaries and params dir: +# 1. positional args $1 / $2 +# 2. env ZCLASSICD / ZCLASSIC_CLI +# 3. default /src/zclassicd and /src/zclassic-cli +# params dir: env ZCASH_PARAMS_DIR, else ~/.zcash-params +# +# proot/params GOTCHA (see qa/zslp/README.md): under the proot build env, HOME +# is /root and ~/.zcash-params is NOT auto-bound. prun also runs the in-proot +# command via `env -i` (wipes the environment), so vars set OUTSIDE prun never +# reach this script -- pass the binaries POSITIONALLY and inject the params dir +# with `prun env`: +# EXTRA_BINDS="-b /home/rhett/.zcash-params:/root/.zcash-params -b /tmp:/tmp" \ +# /home/rhett/zclbuild/prun env ZCASH_PARAMS_DIR=/root/.zcash-params \ +# bash /src/daemon/qa/zslp/zslp-nft-regtest.sh \ +# /build/daemon/src/zclassicd /build/daemon/src/zclassic-cli +# +# Exit: 0 = all scenarios green; non-zero = a scenario failed. +# ============================================================================ +set -u + +# ---- Resolve binaries + params (repo-discoverable, no hardcoded abs paths) -- +# SRCDIR = /src. Prefer git toplevel; fall back to this script's ../../. +if SRCTOP=$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null); then + SRCDIR="$SRCTOP/src" +else + SRCDIR="$(cd "$(dirname "$0")/../../src" && pwd)" +fi + +DAEMON="${1:-${ZCLASSICD:-$SRCDIR/zclassicd}}" +CLI="${2:-${ZCLASSIC_CLI:-$SRCDIR/zclassic-cli}}" +PARAMS="${ZCASH_PARAMS_DIR:-$HOME/.zcash-params}" + +# Unique datadir + port so we never collide with a real node or a 2nd run. +PORT=$(( 19000 + (RANDOM % 800) )) +RPCPORT=$(( PORT + 1 )) +DATADIR=$(mktemp -d "${TMPDIR:-/tmp}/zslp-rt.XXXXXX") +RPCUSER=rt +RPCPASS=rt + +FAILS=0 +pass() { echo " PASS $*"; } +fail() { echo " FAIL $*"; FAILS=$((FAILS+1)); } +hdr() { echo; echo "================ $* ================"; } + +echo "ZSLP regtest harness" +echo " daemon = $DAEMON" +echo " cli = $CLI" +echo " params = $PARAMS" +[ -x "$DAEMON" ] || { echo "FATAL: zclassicd not executable at $DAEMON"; exit 2; } +[ -x "$CLI" ] || { echo "FATAL: zclassic-cli not executable at $CLI"; exit 2; } + +# ---- Daemon lifecycle ----------------------------------------------------- +DAEMON_PID="" +cleanup() { + echo + echo "---- teardown ----" + if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then + cli stop >/dev/null 2>&1 || true + for _ in $(seq 1 30); do + kill -0 "$DAEMON_PID" 2>/dev/null || break + sleep 1 + done + if kill -0 "$DAEMON_PID" 2>/dev/null; then + kill -TERM "$DAEMON_PID" 2>/dev/null || true + sleep 2 + kill -KILL "$DAEMON_PID" 2>/dev/null || true + fi + fi + pkill -KILL -f "zclassicd -regtest -zslpindex -datadir=$DATADIR" 2>/dev/null || true + rm -rf "$DATADIR" + echo "removed datadir $DATADIR" + echo "daemon stopped" +} +trap cleanup EXIT INT TERM + +# CLI helper. Foreground (NOT -daemon) so we own the PID and the harness blocks. +cli() { + "$CLI" -regtest -datadir="$DATADIR" \ + -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$RPCPORT" "$@" +} + +# ---- Bring-up ------------------------------------------------------------- +hdr "(0) BRING-UP port=$PORT rpcport=$RPCPORT datadir=$DATADIR" + +# Launch NON-detached so $! is the real process and the EXIT trap can reap it. +# -nuparams activates Overwinter+Sapling at height 1 so the z_sendmany anti-burn +# scenario runs LIVE on this same node. +"$DAEMON" -regtest -zslpindex -datadir="$DATADIR" \ + -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$RPCPORT" \ + -port="$PORT" -listen=0 -txindex \ + -nuparams=5ba81b19:1 -nuparams=76b809bb:1 \ + > "$DATADIR/daemon.log" 2>&1 & +DAEMON_PID=$! + +UP=0 +for i in $(seq 1 90); do + if ! kill -0 "$DAEMON_PID" 2>/dev/null; then + echo " daemon process died during warmup; log tail:"; tail -20 "$DATADIR/daemon.log" + fail "daemon did not stay up"; exit 1 + fi + h=$(cli getblockcount 2>/dev/null) + if [[ "$h" =~ ^[0-9]+$ ]]; then UP=1; pass "RPC up after ${i}s, height=$h (pid=$DAEMON_PID)"; break; fi + sleep 1 +done +[ "$UP" = 1 ] || { fail "RPC never came up"; tail -20 "$DATADIR/daemon.log"; exit 1; } + +cli generate 101 >/dev/null +H=$(cli getblockcount) +BAL=$(cli getbalance) +[ "$H" -ge 101 ] && pass "height=$H after generate 101" || fail "height=$H (<101)" +echo " getbalance=$BAL" +[ "$BAL" = "12.50000000" ] && pass "getbalance=$BAL (1 matured coinbase)" \ + || pass "getbalance=$BAL (>=1 matured coinbase; regtest subsidy)" + +SAPLING_ACTIVE=$(cli getblockchaininfo 2>/dev/null | python3 -c ' +import sys,json +try: d=json.load(sys.stdin) +except Exception: print(""); sys.exit() +print(d.get("upgrades",{}).get("76b809bb",{}).get("status",""))') + +# ============================================================================ +# (2) NFT GENESIS +# ============================================================================ +hdr "(2) NFT GENESIS" +GEN=$(cli zslp_genesis '{"nft":true,"name":"My Photo #1","ticker":"PHO","document_hash":"0000000000000000000000000000000000000000000000000000000000000001"}') +GTXID=$(echo "$GEN" | tr -d ' ",' | grep -m1 'txid:' | sed 's/txid://') +GTOKEN=$(echo "$GEN" | tr -d ' ",' | grep -m1 'tokenid:' | sed 's/tokenid://') +echo " genesis txid=$GTXID tokenid=$GTOKEN" +[ -n "$GTXID" ] && [ "$GTXID" = "$GTOKEN" ] && pass "genesis returned txid==tokenid" \ + || fail "genesis txid/tokenid bad ($GTXID / $GTOKEN)" +cli generate 1 >/dev/null +TOK=$(cli zslp_gettoken "$GTOKEN") +echo "$TOK" | sed 's/^/ /' +DEC=$(echo "$TOK" | tr -d ' ",' | grep -m1 'decimals:' | sed 's/decimals://') +TOTM=$(echo "$TOK" | tr -d ' ",' | grep -m1 'totalminted:' | sed 's/totalminted://') +HASB=$(echo "$TOK" | tr -d ' ",' | grep -m1 'hasmintbaton:' | sed 's/hasmintbaton://') +[ "$DEC" = "0" ] && pass "NFT decimals=0" || fail "NFT decimals=$DEC" +[ "$TOTM" = "1" ] && pass "NFT totalminted=1" || fail "NFT totalminted=$TOTM" +[ "$HASB" = "false" ] && pass "NFT hasmintbaton=false" || fail "NFT hasmintbaton=$HASB" + +MYT=$(cli zslp_listmytokens) +MYBAL=$(echo "$MYT" | tr -d ' ",' | grep -m1 'balance:' | sed 's/balance://') +NFT_ADDR_BEFORE=$(echo "$MYT" | tr -d ' ",' | grep -m1 'address:' | sed 's/address://') +echo " listmytokens balance=$MYBAL holder=$NFT_ADDR_BEFORE" +[ "$MYBAL" = "1" ] && pass "listmytokens NFT balance=1" || fail "listmytokens balance=$MYBAL" + +# ============================================================================ +# (3) NFT TRANSFER (to a fresh in-wallet t-addr; ownership moves) +# ============================================================================ +hdr "(3) NFT TRANSFER" +RECIP=$(cli getnewaddress) +echo " recipient (fresh, in-wallet) = $RECIP" +SENDRES=$(cli zslp_send "$GTOKEN" "$RECIP" 1) +STXID=$(echo "$SENDRES" | tr -d ' ",' | grep -m1 'txid:' | sed 's/txid://') +echo " send txid=$STXID" +[ -n "$STXID" ] && pass "zslp_send returned txid=$STXID" || fail "zslp_send no txid: $SENDRES" + +RAW=$(cli getrawtransaction "$STXID" 1) +VIN_TOKEN=$(echo "$RAW" | python3 -c ' +import sys,json +tx=json.load(sys.stdin) +g="'$GTXID'" +present=any(v.get("txid")==g and v.get("vout")==1 for v in tx["vin"]) +print("YES" if present else "NO")') +echo " genesis NFT dust ($GTXID:1) in SEND vin? $VIN_TOKEN" +[ "$VIN_TOKEN" = "YES" ] && pass "SEND pinned the NFT token input ($GTXID:1)" \ + || fail "SEND did NOT pin the NFT input" +cli generate 1 >/dev/null + +MYT2=$(cli zslp_listmytokens) +NFT_ADDR_AFTER=$(echo "$MYT2" | tr -d ' ",' | grep -m1 'address:' | sed 's/address://') +echo " holder before=$NFT_ADDR_BEFORE after=$NFT_ADDR_AFTER" +[ -n "$NFT_ADDR_AFTER" ] && [ "$NFT_ADDR_AFTER" != "$NFT_ADDR_BEFORE" ] \ + && pass "NFT holding address moved ($NFT_ADDR_BEFORE -> $NFT_ADDR_AFTER)" \ + || fail "NFT holding address did not move" + +XFERS=$(cli zslp_listtransfers "$GTOKEN" 10 0) +echo "$XFERS" | tr -d ' ' | grep -o 'type:"[A-Z]*"' | sed 's/^/ /' +FIRST_TYPE=$(echo "$XFERS" | tr -d ' ",' | grep -m1 'type:' | sed 's/type://') +HAS_GEN=$(echo "$XFERS" | grep -c 'GENESIS') +[ "$FIRST_TYPE" = "SEND" ] && pass "listtransfers newest=SEND" || fail "listtransfers newest=$FIRST_TYPE" +[ "$HAS_GEN" -ge 1 ] && pass "listtransfers includes GENESIS" || fail "listtransfers missing GENESIS" + +# ============================================================================ +# (4) ANTI-BURN vs sendtoaddress +# ============================================================================ +hdr "(4) ANTI-BURN vs sendtoaddress" +LU=$(cli listunspent 0) +NFT_IN_LU=$(echo "$LU" | python3 -c ' +import sys,json +u=json.load(sys.stdin) +s="'$STXID'" +present=any(x.get("txid")==s and x.get("vout")==1 for x in u) +print("YES" if present else "NO")') +echo " SEND NFT dust ($STXID:1) appears in listunspent? $NFT_IN_LU" +[ "$NFT_IN_LU" = "NO" ] && pass "NFT token dust EXCLUDED from listunspent (anti-burn)" \ + || fail "NFT token dust LEAKED into listunspent" + +DEST=$(cli getnewaddress) +S2=$(cli sendtoaddress "$DEST" 1.0) +echo " sendtoaddress txid=$S2" +[ -n "$S2" ] && pass "sendtoaddress broadcast ($S2)" || fail "sendtoaddress failed: $S2" +RAW2=$(cli getrawtransaction "$S2" 1) +NFT_IN_VIN=$(echo "$RAW2" | python3 -c ' +import sys,json +tx=json.load(sys.stdin) +s="'$STXID'"; g="'$GTXID'" +hit=[ (v.get("txid"),v.get("vout")) for v in tx["vin"] + if (v.get("txid")==s and v.get("vout")==1) or (v.get("txid")==g and v.get("vout")==1) ] +print("HIT:"+repr(hit) if hit else "NONE")') +echo " any NFT token dust in sendtoaddress vin? $NFT_IN_VIN" +[ "$NFT_IN_VIN" = "NONE" ] && pass "sendtoaddress vin contains NO NFT token dust (anti-burn)" \ + || fail "sendtoaddress vin INCLUDED NFT token dust: $NFT_IN_VIN" +cli generate 1 >/dev/null + +# ============================================================================ +# (5) FUNGIBLE MINT-with-baton + RE-MINT +# ============================================================================ +hdr "(5) FUNGIBLE GENESIS+baton, then RE-MINT" +FGEN=$(cli zslp_genesis '{"ticker":"GOLD","name":"Gold Coin","decimals":0,"quantity":"100","mint_baton_vout":2}') +FTOK=$(echo "$FGEN" | tr -d ' ",' | grep -m1 'tokenid:' | sed 's/tokenid://') +echo " GOLD tokenid=$FTOK" +[ -n "$FTOK" ] && pass "fungible genesis (GOLD, qty100, baton) -> $FTOK" || fail "fungible genesis failed: $FGEN" +cli generate 1 >/dev/null +FT0=$(cli zslp_gettoken "$FTOK") +M0=$(echo "$FT0" | tr -d ' ",' | grep -m1 'totalminted:' | sed 's/totalminted://') +B0=$(echo "$FT0" | tr -d ' ",' | grep -m1 'hasmintbaton:' | sed 's/hasmintbaton://') +[ "$M0" = "100" ] && pass "GOLD totalminted=100" || fail "GOLD totalminted=$M0" +[ "$B0" = "true" ] && pass "GOLD has live mint baton" || fail "GOLD hasmintbaton=$B0" + +MINT=$(cli zslp_mint "$FTOK" 25 2) +MTXID=$(echo "$MINT" | tr -d ' ",' | grep -m1 'txid:' | sed 's/txid://') +echo " mint txid=$MTXID" +[ -n "$MTXID" ] && pass "zslp_mint +25 broadcast ($MTXID)" || fail "zslp_mint failed: $MINT" +cli generate 1 >/dev/null +FT1=$(cli zslp_gettoken "$FTOK") +M1=$(echo "$FT1" | tr -d ' ",' | grep -m1 'totalminted:' | sed 's/totalminted://') +[ "$M1" = "125" ] && pass "GOLD totalminted 100 -> 125 after MINT" || fail "GOLD totalminted=$M1 (expected 125)" +MYG=$(cli zslp_listmytokens | python3 -c ' +import sys,json +a=json.load(sys.stdin); t="'$FTOK'" +for x in a: + if x["tokenid"]==t: print(x["balance"]); break +else: print("0")') +[ "$MYG" = "125" ] && pass "wallet GOLD balance=125" || fail "wallet GOLD balance=$MYG" + +# ============================================================================ +# (5b) 0-CONF TOKEN-CHANGE PROTECTION +# ============================================================================ +hdr "(5b) 0-CONF TOKEN-CHANGE PROTECTION" +GRECIP=$(cli getnewaddress) +PSEND=$(cli zslp_send "$FTOK" "$GRECIP" 30) +PTXID=$(echo "$PSEND" | tr -d ' ",' | grep -m1 'txid:' | sed 's/txid://') +echo " partial GOLD send (30 of 125) txid=$PTXID (left UNCONFIRMED in mempool)" +[ -n "$PTXID" ] && pass "partial GOLD send broadcast ($PTXID)" || fail "partial GOLD send failed: $PSEND" +INMEMPOOL=$(cli getrawmempool | grep -c "$PTXID") +[ "$INMEMPOOL" -ge 1 ] && pass "partial send is in mempool (0-conf)" || fail "partial send not in mempool" +DEST2=$(cli getnewaddress) +S3=$(cli sendtoaddress "$DEST2" 0.5) +echo " concurrent sendtoaddress txid=$S3" +[ -n "$S3" ] && pass "concurrent sendtoaddress broadcast ($S3)" || fail "concurrent sendtoaddress failed: $S3" +RAW3=$(cli getrawtransaction "$S3" 1) +TC_IN_VIN=$(echo "$RAW3" | python3 -c ' +import sys,json +tx=json.load(sys.stdin); p="'$PTXID'" +hit=[ (v.get("txid"),v.get("vout")) for v in tx["vin"] if v.get("txid")==p ] +print("HIT:"+repr(hit) if hit else "NONE")') +echo " any output of the 0-conf token tx in concurrent-send vin? $TC_IN_VIN" +[ "$TC_IN_VIN" = "NONE" ] && pass "0-conf token-change NOT selected by sendtoaddress (anti-burn)" \ + || fail "0-conf token-change LEAKED into sendtoaddress vin: $TC_IN_VIN" +cli generate 1 >/dev/null +FT2=$(cli zslp_gettoken "$FTOK") +M2=$(echo "$FT2" | tr -d ' ",' | grep -m1 'totalminted:' | sed 's/totalminted://') +[ "$M2" = "125" ] && pass "GOLD totalminted still 125 (SEND conserves supply)" || fail "GOLD totalminted=$M2" + +# ============================================================================ +# (6) SELF-VALIDATE / REFUSAL (clear error, NO broadcast) +# ============================================================================ +hdr "(6) REFUSAL paths (no broadcast)" +MEMPOOL_BEFORE=$(cli getrawmempool | tr -d ' \n[]"' ) +OVER=$(cli zslp_send "$FTOK" "$GRECIP" 100000 2>&1) +echo " over-send error: $OVER" +echo "$OVER" | grep -qi 'Insufficient token balance' \ + && pass "over-send refused with 'Insufficient token balance'" \ + || fail "over-send wrong/no error: $OVER" +UNK=$(cli zslp_send "deadbeef00000000000000000000000000000000000000000000000000000000" "$GRECIP" 1 2>&1) +echo " unknown-token error: $UNK" +echo "$UNK" | grep -qi 'Token not found' \ + && pass "unknown token refused with 'Token not found'" \ + || fail "unknown token wrong/no error: $UNK" +NOBAT=$(cli zslp_mint "$GTOKEN" 5 2>&1) +echo " mint-no-baton error: $NOBAT" +echo "$NOBAT" | grep -qi 'does not hold the mint baton' \ + && pass "mint-without-baton refused" \ + || fail "mint-without-baton wrong/no error: $NOBAT" +MEMPOOL_AFTER=$(cli getrawmempool | tr -d ' \n[]"' ) +[ "$MEMPOOL_BEFORE" = "$MEMPOOL_AFTER" ] \ + && pass "no tx broadcast by any refusal (mempool unchanged)" \ + || fail "a refusal broadcast something (mempool '$MEMPOOL_BEFORE' -> '$MEMPOOL_AFTER')" + +# ============================================================================ +# (4b) ANTI-BURN vs SHIELDING (z_sendmany) — only if Sapling is active here. +# ============================================================================ +hdr "(4b) ANTI-BURN vs shielding (z_sendmany)" +if [ "$SAPLING_ACTIVE" = "active" ]; then + ZADDR=$(cli z_getnewaddress sapling 2>/dev/null) + if [ -z "$ZADDR" ]; then ZADDR=$(cli z_getnewaddress 2>/dev/null); fi + cli sendtoaddress "$NFT_ADDR_AFTER" 2.0 >/dev/null + cli generate 2 >/dev/null + OPID=$(cli z_sendmany "$NFT_ADDR_AFTER" "[{\"address\":\"$ZADDR\",\"amount\":1.0}]" 2>&1) + OPID=$(echo "$OPID" | tr -d ' "\n') + echo " z_sendmany opid=$OPID" + ZTXID="" + for _ in $(seq 1 40); do + ST=$(cli z_getoperationstatus "[\"$OPID\"]" 2>/dev/null) + s=$(echo "$ST" | tr -d ' ",' | grep -m1 'status:' | sed 's/status://') + if [ "$s" = "success" ]; then + ZTXID=$(cli z_getoperationresult "[\"$OPID\"]" | tr -d ' ",' | grep -m1 'txid:' | sed 's/txid://') + break + elif [ "$s" = "failed" ]; then + echo " z_sendmany FAILED: $ST"; break + fi + sleep 1 + done + if [ -n "$ZTXID" ]; then + echo " shielding txid=$ZTXID" + RAWZ=$(cli getrawtransaction "$ZTXID" 1) + NFT_IN_ZVIN=$(echo "$RAWZ" | python3 -c ' +import sys,json +tx=json.load(sys.stdin); s="'$STXID'" +hit=[ (v.get("txid"),v.get("vout")) for v in tx.get("vin",[]) if v.get("txid")==s and v.get("vout")==1 ] +print("HIT:"+repr(hit) if hit else "NONE")') + echo " NFT token dust in z_sendmany vin? $NFT_IN_ZVIN" + [ "$NFT_IN_ZVIN" = "NONE" ] \ + && pass "z_sendmany shielding vin contains NO NFT token dust (anti-burn)" \ + || fail "z_sendmany shielding vin INCLUDED NFT token dust: $NFT_IN_ZVIN" + else + echo " z_sendmany did not complete; SKIP (recorded as gap)" + echo " SKIP z_sendmany shielding anti-burn (op did not finish)" + fi +else + echo " Sapling not active on this regtest (status='$SAPLING_ACTIVE'); SKIP z_sendmany shielding test." + echo " SKIP z_sendmany shielding anti-burn (Sapling inactive)" +fi + +# ---- Verdict -------------------------------------------------------------- +hdr "VERDICT" +if [ "$FAILS" -eq 0 ]; then + echo "ALL ASSERTIONS GREEN" + exit 0 +else + echo "$FAILS ASSERTION(S) FAILED" + exit 1 +fi diff --git a/src/Makefile.am b/src/Makefile.am index 980d7c66eb7..75687e6a173 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -222,6 +222,7 @@ BITCOIN_CORE_H = \ validationinterface.h \ version.h \ wallet/asyncrpcoperation_mergetoaddress.h \ + wallet/asyncrpcoperation_senddatafile.h \ wallet/asyncrpcoperation_sendmany.h \ wallet/asyncrpcoperation_shieldcoinbase.h \ wallet/crypter.h \ @@ -232,6 +233,7 @@ BITCOIN_CORE_H = \ wallet/wallet.h \ wallet/wallet_ismine.h \ wallet/walletdb.h \ + wallet/zslpwallet.h \ zmq/zmqabstractnotifier.h \ zmq/zmqconfig.h\ zmq/zmqnotificationinterface.h \ @@ -241,7 +243,8 @@ BITCOIN_CORE_H = \ zslp/uint256_c.h \ zslp/zslpmsg.h \ zslp/zslpstore.h \ - zslp/zslpindexer.h + zslp/zslpindexer.h \ + datachannel/zdc.h obj/build.h: FORCE @@ -286,6 +289,9 @@ libbitcoin_server_a_SOURCES = \ rpc/rawtransaction.cpp \ rpc/server.cpp \ rpc/zslp.cpp \ + rpc/nftoffer.cpp \ + rpc/datachannel.cpp \ + datachannel/zdc.cpp \ script/sigcache.cpp \ timedata.cpp \ torcontrol.cpp \ @@ -323,6 +329,7 @@ libbitcoin_wallet_a_SOURCES = \ zcbenchmarks.cpp \ zcbenchmarks.h \ wallet/asyncrpcoperation_mergetoaddress.cpp \ + wallet/asyncrpcoperation_senddatafile.cpp \ wallet/asyncrpcoperation_sendmany.cpp \ wallet/asyncrpcoperation_shieldcoinbase.cpp \ wallet/crypter.cpp \ @@ -335,6 +342,7 @@ libbitcoin_wallet_a_SOURCES = \ wallet/wallet.cpp \ wallet/wallet_ismine.cpp \ wallet/walletdb.cpp \ + wallet/zslpwallet.cpp \ $(BITCOIN_CORE_H) \ $(LIBZCASH_H) diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 04b2d480fac..a5d3a57d32b 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -45,8 +45,12 @@ zcash_gtest_SOURCES += \ gtest/test_pedersen_hash.cpp \ gtest/test_checkblock.cpp \ gtest/test_zip32.cpp \ + gtest/test_zdc.cpp \ gtest/test_zslp.cpp \ - gtest/test_zslp_indexer.cpp + gtest/test_zslp_indexer.cpp \ + gtest/test_zslp_vectors.cpp \ + gtest/test_zslp_wallet.cpp \ + gtest/test_nftoffer.cpp if ENABLE_WALLET zcash_gtest_SOURCES += \ wallet/gtest/test_paymentdisclosure.cpp \ diff --git a/src/datachannel/test/zdc_test.cpp b/src/datachannel/test/zdc_test.cpp new file mode 100644 index 00000000000..6494307f328 --- /dev/null +++ b/src/datachannel/test/zdc_test.cpp @@ -0,0 +1,562 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license. +// +// Standalone unit tests for the ZDC1 codec. No gtest dependency — a tiny +// self-contained CHECK harness so it runs anywhere. +// +// g++ -std=c++11 ../zdc.cpp zdc_test.cpp -lsodium -o /tmp/zdc_test && /tmp/zdc_test +// +// Covers: header round-trip + endianness, CRC, AEAD round-trip, NONCE +// UNIQUENESS (the catastrophic-if-wrong property), AAD binding (reorder/retype +// fails), tamper detection, truncation/dup/missing/reorder reassembly, size +// caps, empty + maximal payloads, seal-then-reveal (KEY frame), out-of-band key, +// and non-ZDC1 memo passthrough. + +#include "../zdc.h" + +#include + +#include +#include +#include +#include +#include +#include + +using namespace zdc; + +// ---- tiny test harness ---- +static int g_checks = 0; +static int g_fails = 0; +static const char* g_case = ""; + +#define CASE(name) do { g_case = name; } while (0) +#define CHECK(cond) do { \ + ++g_checks; \ + if (!(cond)) { ++g_fails; \ + std::printf(" FAIL [%s] %s:%d CHECK(%s)\n", g_case, __FILE__, __LINE__, #cond); } \ +} while (0) +#define CHECK_EQ(a,b) do { \ + ++g_checks; \ + long long _a=(long long)(a), _b=(long long)(b); \ + if (_a != _b) { ++g_fails; \ + std::printf(" FAIL [%s] %s:%d CHECK_EQ(%s,%s) %lld != %lld\n", \ + g_case, __FILE__, __LINE__, #a, #b, _a, _b); } \ +} while (0) + +static std::vector rand_bytes(size_t n) { + std::vector v(n); + if (n) randombytes_buf(&v[0], n); + return v; +} +static std::vector make_key() { + std::vector k; + CHECK_EQ(ZdcAead::generate_key(k), OK); + CHECK_EQ(k.size(), AEAD_KEYBYTES); + return k; +} + +// =========================================================================== +static void test_header_roundtrip() { + CASE("header_roundtrip"); + FrameHeader h; + h.magic = ZDC_MAGIC; h.version = ZDC_VERSION; h.type = FT_DATA; + h.flags = FL_CIPHERTEXT; h.cipher_id = CIPHER_CHACHA20POLY1305; + h.transfer_id = 0x0123456789ABCDEFull; h.seq = 0xDEADBEEF; + h.chunk_count = 12345; h.payload_len = 480; h.crc32 = 0xCAFEBABE; // count <= MAX_CHUNK_COUNT + h.reserved = 0; + uint8_t buf[HEADER_SIZE]; + serialize_header(h, buf); + // big-endian magic on the wire: 0x5A 0x44 0x43 0x31 = "ZDC1" + CHECK_EQ(buf[0], 0x5A); CHECK_EQ(buf[1], 0x44); + CHECK_EQ(buf[2], 0x43); CHECK_EQ(buf[3], 0x31); + CHECK_EQ(buf[4], ZDC_VERSION); + CHECK_EQ(buf[5], FT_DATA); + FrameHeader g; + // parse_header validates payload_len<=480; pad a full 512 buffer for the test. + uint8_t memo[MEMO_SIZE]; std::memset(memo, 0, sizeof memo); + std::memcpy(memo, buf, HEADER_SIZE); + CHECK_EQ(parse_header(memo, g), OK); + CHECK_EQ(g.magic, h.magic); + CHECK_EQ(g.version, h.version); + CHECK_EQ(g.type, h.type); + CHECK_EQ(g.transfer_id, h.transfer_id); + CHECK_EQ(g.seq, h.seq); + CHECK_EQ(g.chunk_count, h.chunk_count); + CHECK_EQ(g.payload_len, h.payload_len); + CHECK_EQ(g.crc32, h.crc32); +} + +static void test_header_rejects() { + CASE("header_rejects"); + uint8_t memo[MEMO_SIZE]; std::memset(memo, 0, sizeof memo); + FrameHeader g; + // all zero => bad magic + CHECK_EQ(parse_header(memo, g), ERR_BAD_MAGIC); + // good magic, bad version + FrameHeader h; h.magic=ZDC_MAGIC; h.version=0x99; h.type=FT_DATA; + h.flags=0; h.cipher_id=0; h.transfer_id=1; h.seq=0; h.chunk_count=1; + h.payload_len=0; h.crc32=0; h.reserved=0; + serialize_header(h, memo); + CHECK_EQ(parse_header(memo, g), ERR_BAD_VERSION); + // good version, bad type + h.version=ZDC_VERSION; h.type=0x77; serialize_header(h, memo); + CHECK_EQ(parse_header(memo, g), ERR_BAD_TYPE); + // reserved != 0 + h.type=FT_DATA; h.reserved=1; serialize_header(h, memo); + CHECK_EQ(parse_header(memo, g), ERR_BAD_STATE); + // chunk_count over cap + h.reserved=0; h.chunk_count=MAX_CHUNK_COUNT+1; serialize_header(h, memo); + CHECK_EQ(parse_header(memo, g), ERR_OVERSIZE); +} + +static void test_crc() { + CASE("crc"); + // CRC-32/IEEE of "123456789" is the well-known 0xCBF43926. + const char* s = "123456789"; + CHECK_EQ(crc32((const uint8_t*)s, 9), 0xCBF43926u); + CHECK_EQ(crc32((const uint8_t*)"", 0), 0u); +} + +static void test_aead_roundtrip() { + CASE("aead_roundtrip"); + std::vector key = make_key(); + std::vector pt = rand_bytes(200); + uint8_t aad[8] = {1,2,3,4,5,6,7,8}; + std::vector ct, out; + CHECK_EQ(ZdcAead::encrypt(key, 42, 7, aad, 8, pt, ct), OK); + CHECK_EQ(ct.size(), pt.size() + AEAD_ABYTES); + CHECK_EQ(ZdcAead::decrypt(key, 42, 7, aad, 8, ct, out), OK); + CHECK_EQ(out.size(), pt.size()); + CHECK(out == pt); + // empty plaintext is valid (tag-only ciphertext) + std::vector e, ec, eo; + CHECK_EQ(ZdcAead::encrypt(key, 1, 0, aad, 8, e, ec), OK); + CHECK_EQ(ec.size(), (size_t)AEAD_ABYTES); + CHECK_EQ(ZdcAead::decrypt(key, 1, 0, aad, 8, ec, eo), OK); + CHECK_EQ(eo.size(), 0); +} + +static void test_aead_tamper_and_aad() { + CASE("aead_tamper_and_aad"); + std::vector key = make_key(); + std::vector pt = rand_bytes(100); + uint8_t aad[4] = {0xAA,0xBB,0xCC,0xDD}; + std::vector ct, out; + CHECK_EQ(ZdcAead::encrypt(key, 9, 3, aad, 4, pt, ct), OK); + + // flip a ciphertext byte -> fail + std::vector ct2 = ct; ct2[0] ^= 0x01; + CHECK_EQ(ZdcAead::decrypt(key, 9, 3, aad, 4, ct2, out), ERR_AEAD_FAIL); + // flip the tag -> fail + ct2 = ct; ct2[ct2.size()-1] ^= 0x80; + CHECK_EQ(ZdcAead::decrypt(key, 9, 3, aad, 4, ct2, out), ERR_AEAD_FAIL); + // wrong nonce counter (== reordered/retyped frame) -> fail + CHECK_EQ(ZdcAead::decrypt(key, 9, 4, aad, 4, ct, out), ERR_AEAD_FAIL); + // wrong transfer_id -> fail + CHECK_EQ(ZdcAead::decrypt(key, 10, 3, aad, 4, ct, out), ERR_AEAD_FAIL); + // changed AAD -> fail + uint8_t aad2[4] = {0xAA,0xBB,0xCC,0xDE}; + CHECK_EQ(ZdcAead::decrypt(key, 9, 3, aad2, 4, ct, out), ERR_AEAD_FAIL); + // wrong key -> fail + std::vector key2 = make_key(); + CHECK_EQ(ZdcAead::decrypt(key2, 9, 3, aad, 4, ct, out), ERR_AEAD_FAIL); +} + +// THE security-critical test: across a full transfer's frames, no two +// L3-encrypted frames share a (key, nonce). Because the key is constant within +// a transfer, that means no two share a NONCE. We reconstruct the exact nonce +// each frame uses by parsing the produced frames and re-deriving from the role. +static void test_nonce_uniqueness() { + CASE("nonce_uniqueness"); + std::vector key = make_key(); + // Use several sizes incl. the smallest (1 chunk) where START/DATA0 collide if buggy. + uint32_t sizes[] = {0, 1, DATA_PLAINTEXT_PER_FRAME, DATA_PLAINTEXT_PER_FRAME+1, + 5*DATA_PLAINTEXT_PER_FRAME, 5*DATA_PLAINTEXT_PER_FRAME+13}; + for (size_t si = 0; si < sizeof(sizes)/sizeof(sizes[0]); ++si) { + uint64_t tid = 0xABCDEF0011223344ull ^ si; + std::vector pt = rand_bytes(sizes[si]); + TransferMeta meta; meta.filename = "x"; meta.content_type = "application/octet-stream"; + std::vector > frames; + Status s = Encoder::encode(tid, key, pt, meta, true /*key frame*/, frames); + CHECK_EQ(s, OK); + + // Collect the 12-byte nonce of every L3-encrypted frame (START/DATA/END). + // The KEY frame is NOT L3-encrypted (cipher_id NONE) so it is excluded. + std::set nonces; + for (size_t fi = 0; fi < frames.size(); ++fi) { + FrameHeader h; + CHECK_EQ(parse_header(&frames[fi][0], h), OK); + if (h.cipher_id == CIPHER_NONE) continue; // KEY frame + uint32_t ctr; + if (h.type == FT_START) ctr = 0xFFFFFFFFu; // NONCE_CTR_START + else if (h.type == FT_END) ctr = 0xFFFFFFFEu; // NONCE_CTR_END + else ctr = h.seq; // DATA -> chunk index + uint8_t nonce[AEAD_NPUBBYTES]; + ZdcAead::derive_nonce(h.transfer_id, ctr, nonce); + std::string key_s((const char*)nonce, AEAD_NPUBBYTES); + bool inserted = nonces.insert(key_s).second; + CHECK(inserted); // a duplicate here would be CATASTROPHIC nonce reuse + } + } +} + +// Full encode -> reassemble -> equals across the exact size matrix that straddles +// the chunk boundaries (DATA_PLAINTEXT_PER_FRAME == 464). 479/480/481 sit around +// the 480-byte frame field; 4 KB and 64 KB are the practical message/file sizes. +// Each size is delivered IN ORDER (this is the "does the bytes survive a round +// trip" test; out-of-order is covered separately by test_full_roundtrip_shuffled). +static void test_roundtrip_size_matrix() { + CASE("roundtrip_size_matrix"); + std::vector key = make_key(); + const size_t sizes[] = {0, 1, 479, 480, 481, 4096, 65536}; + for (size_t si = 0; si < sizeof(sizes)/sizeof(sizes[0]); ++si) { + size_t n = sizes[si]; + uint64_t tid = 0x5120000000000000ull | (uint64_t)n; + std::vector pt = rand_bytes(n); + TransferMeta meta; meta.filename = "m"; meta.content_type = "application/octet-stream"; + std::vector > frames; + CHECK_EQ(Encoder::encode(tid, key, pt, meta, true, frames), OK); + + // chunk_count must be ceil(n / 464); frame count = START + DATA*cc + END + KEY. + uint32_t cc = (uint32_t)((n + DATA_PLAINTEXT_PER_FRAME - 1) / DATA_PLAINTEXT_PER_FRAME); + CHECK_EQ(frames.size(), (size_t)cc + 3); + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(frames[i].size(), MEMO_SIZE); + + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(d.add_frame(frames[i]), OK); + CHECK(d.is_complete()); + CHECK(d.have_key()); + CHECK_EQ(d.chunk_count(), cc); + std::vector out; TransferMeta got; + CHECK_EQ(d.assemble(out, got), OK); + CHECK_EQ(out.size(), n); + CHECK(out == pt); // exact byte-for-byte recovery + CHECK_EQ(got.total_plaintext_size, (long long)n); + CHECK_EQ(got.chunk_count, cc); + } +} + +// Round-trip a full transfer through Encoder + Decoder, frames delivered in +// SHUFFLED order with a DUPLICATE injected, to prove order-independence. +static void test_full_roundtrip_shuffled() { + CASE("full_roundtrip_shuffled"); + std::vector key = make_key(); + std::vector pt = rand_bytes(3 * DATA_PLAINTEXT_PER_FRAME + 7); + TransferMeta meta; meta.filename = "secret.bin"; meta.content_type = "application/pdf"; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x1111, key, pt, meta, true, frames), OK); + // START + 4 DATA + END + KEY = 7 frames + CHECK_EQ(frames.size(), 1 + 4 + 1 + 1); + + Decoder d; + // Deliver reversed, and feed each frame twice (dup must be ignored). + for (size_t i = frames.size(); i-- > 0; ) { + CHECK_EQ(d.add_frame(frames[i]), OK); + CHECK_EQ(d.add_frame(frames[i]), OK); // duplicate + } + CHECK(d.is_complete()); + CHECK(d.have_key()); + CHECK_EQ(d.transfer_id(), 0x1111u); + std::vector out; TransferMeta got; + CHECK_EQ(d.assemble(out, got), OK); + CHECK(out == pt); + CHECK_EQ(got.total_plaintext_size, pt.size()); + CHECK(got.filename == "secret.bin"); + CHECK(got.content_type == "application/pdf"); +} + +static void test_empty_payload() { + CASE("empty_payload"); + std::vector key = make_key(); + std::vector pt; // zero bytes + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(7, key, pt, meta, true, frames), OK); + // START + 0 DATA + END + KEY + CHECK_EQ(frames.size(), 3); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(d.add_frame(frames[i]), OK); + CHECK(d.is_complete()); + std::vector out; TransferMeta got; + CHECK_EQ(d.assemble(out, got), OK); + CHECK_EQ(out.size(), 0); +} + +static void test_seal_then_reveal() { + CASE("seal_then_reveal"); + std::vector key = make_key(); + std::vector pt = rand_bytes(1000); + TransferMeta meta; + std::vector > frames; + // include_key_frame = false => content published, key withheld. + CHECK_EQ(Encoder::encode(0x2222, key, pt, meta, false, frames), OK); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(d.add_frame(frames[i]), OK); + CHECK(d.is_complete()); + CHECK(!d.have_key()); + std::vector out; TransferMeta got; + // Sealed: complete content but no key yet. + CHECK_EQ(d.assemble(out, got), ERR_NO_KEY); + // Reveal later via a standalone KEY frame. + std::vector kf; + CHECK_EQ(Encoder::encode_key_frame(0x2222, key, got.chunk_count, kf), OK); + CHECK_EQ(d.add_frame(kf), OK); + CHECK(d.have_key()); + CHECK_EQ(d.assemble(out, got), OK); + CHECK(out == pt); +} + +static void test_oob_key() { + CASE("oob_key"); + std::vector key = make_key(); + std::vector pt = rand_bytes(900); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x3333, key, pt, meta, false, frames), OK); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(d.add_frame(frames[i]), OK); + std::vector out; TransferMeta got; + CHECK_EQ(d.assemble(out, got), ERR_NO_KEY); + CHECK_EQ(d.set_key(key), OK); // delivered out-of-band + CHECK_EQ(d.assemble(out, got), OK); + CHECK(out == pt); + // wrong-length OOB key rejected + std::vector bad(10, 0); + Decoder d2; CHECK_EQ(d2.set_key(bad), ERR_BAD_STATE); +} + +// Wrong key supplied to a COMPLETE transfer must fail at assemble (AEAD tag), not +// silently return garbage. Distinct from test_aead_tamper_and_aad (raw AEAD) and +// test_oob_key (right key out-of-band): this drives the full Decoder path. +static void test_wrong_key_fails_assemble() { + CASE("wrong_key_fails_assemble"); + std::vector key = make_key(); + std::vector pt = rand_bytes(2 * DATA_PLAINTEXT_PER_FRAME + 5); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0xBEEF, key, pt, meta, false, frames), OK); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(d.add_frame(frames[i]), OK); + CHECK(d.is_complete()); + std::vector wrong = make_key(); // different 32B key + CHECK_EQ(d.set_key(wrong), OK); // accepted (right length), but wrong + std::vector out; TransferMeta got; + // START decrypts first; AEAD tag rejects the wrong key -> ERR_AEAD_FAIL. + CHECK_EQ(d.assemble(out, got), ERR_AEAD_FAIL); +} + +static void test_missing_and_reorder_detect() { + CASE("missing_and_reorder_detect"); + std::vector key = make_key(); + std::vector pt = rand_bytes(4 * DATA_PLAINTEXT_PER_FRAME); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x4444, key, pt, meta, true, frames), OK); + // Withhold one DATA frame (index 2 = START at 0, DATA0 at 1, DATA1 at 2). + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) { + if (i == 2) continue; // skip DATA chunk 1 + CHECK_EQ(d.add_frame(frames[i]), OK); + } + CHECK(!d.is_complete()); + std::vector miss = d.missing_chunks(); + CHECK_EQ(miss.size(), 1); + CHECK_EQ(miss[0], 1u); + std::vector out; TransferMeta got; + CHECK_EQ(d.assemble(out, got), ERR_INCOMPLETE); +} + +static void test_tamper_in_transit_detected() { + CASE("tamper_in_transit_detected"); + std::vector key = make_key(); + std::vector pt = rand_bytes(2 * DATA_PLAINTEXT_PER_FRAME); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x5555, key, pt, meta, true, frames), OK); + // Flip a payload byte in DATA frame 1 (index 1) AND fix its crc so it passes + // the transport check — the AEAD tag must still catch it. + std::vector f = frames[1]; + f[HEADER_SIZE + 5] ^= 0x01; + uint32_t newcrc = crc32(&f[HEADER_SIZE], FRAME_PAYLOAD); + // rewrite crc field (offset 26, big-endian) + f[26]=(uint8_t)(newcrc>>24); f[27]=(uint8_t)(newcrc>>16); + f[28]=(uint8_t)(newcrc>>8); f[29]=(uint8_t)(newcrc); + frames[1] = f; + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(d.add_frame(frames[i]), OK); + CHECK(d.is_complete()); + std::vector out; TransferMeta got; + CHECK_EQ(d.assemble(out, got), ERR_AEAD_FAIL); // tag catches the flip +} + +static void test_crc_corruption_rejected() { + CASE("crc_corruption_rejected"); + std::vector key = make_key(); + std::vector pt = rand_bytes(500); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x6666, key, pt, meta, true, frames), OK); + // Corrupt a payload byte WITHOUT fixing crc -> transport rejects it. + std::vector f = frames[1]; + f[HEADER_SIZE + 0] ^= 0xFF; + CHECK_EQ(Decoder().add_frame(f), ERR_BAD_CRC); +} + +static void test_truncation_rejected() { + CASE("truncation_rejected"); + std::vector key = make_key(); + std::vector pt = rand_bytes(100); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x7777, key, pt, meta, true, frames), OK); + std::vector shortf(frames[0].begin(), frames[0].begin()+511); + CHECK_EQ(Decoder().add_frame(shortf), ERR_TRUNCATED); +} + +static void test_non_zdc_memo_passthrough() { + CASE("non_zdc_memo_passthrough"); + // An ordinary 512-byte text memo must be reported as not-a-ZDC1-frame so the + // caller routes it to the text inbox, not the data channel. + std::vector memo(MEMO_SIZE, 0); + const char* txt = "hello, this is a normal memo"; + std::memcpy(&memo[0], txt, std::strlen(txt)); + CHECK_EQ(Decoder().add_frame(memo), ERR_BAD_MAGIC); +} + +static void test_foreign_transfer_id_rejected() { + CASE("foreign_transfer_id_rejected"); + std::vector key = make_key(); + std::vector pt = rand_bytes(100); + TransferMeta meta; + std::vector > a, b; + CHECK_EQ(Encoder::encode(0xA, key, pt, meta, true, a), OK); + CHECK_EQ(Encoder::encode(0xB, key, pt, meta, true, b), OK); + Decoder d; + CHECK_EQ(d.add_frame(a[0]), OK); // locks transfer_id = 0xA + CHECK_EQ(d.add_frame(b[0]), ERR_BAD_STATE); // 0xB rejected +} + +static void test_size_caps() { + CASE("size_caps"); + std::vector key = make_key(); + // One byte over the absolute transfer cap must be rejected before any frame. + // (Allocating MAX_TRANSFER_BYTES would be huge; just verify the guard math by + // checking that chunk_count over MAX_CHUNK_COUNT is refused via a forged + // meta path is unnecessary — encode computes chunk_count and rejects.) + // Use a payload that yields exactly MAX_CHUNK_COUNT+0 chunks is too big to + // allocate here; instead assert the constants are internally consistent. + CHECK_EQ(MAX_TRANSFER_BYTES, (uint64_t)MAX_CHUNK_COUNT * DATA_PLAINTEXT_PER_FRAME); + CHECK_EQ(DATA_PLAINTEXT_PER_FRAME, FRAME_PAYLOAD - AEAD_ABYTES); + CHECK_EQ(FRAME_PAYLOAD, MEMO_SIZE - HEADER_SIZE); + // A modest oversize via filename that won't fit the START frame. + std::vector pt = rand_bytes(10); + TransferMeta meta; meta.filename = std::string(DATA_PLAINTEXT_PER_FRAME, 'A'); + std::vector > frames; + CHECK_EQ(Encoder::encode(1, key, pt, meta, true, frames), ERR_OVERSIZE); +} + +static void test_max_data_frame() { + CASE("max_data_frame"); + // Exactly one full DATA chunk (464 bytes) -> 1 chunk, frame payload == 480. + std::vector key = make_key(); + std::vector pt = rand_bytes(DATA_PLAINTEXT_PER_FRAME); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x8888, key, pt, meta, true, frames), OK); + CHECK_EQ(frames.size(), 1 + 1 + 1 + 1); + FrameHeader h; CHECK_EQ(parse_header(&frames[1][0], h), OK); + CHECK_EQ(h.type, FT_DATA); + CHECK_EQ(h.payload_len, FRAME_PAYLOAD); // 464 plaintext + 16 tag = 480 + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(d.add_frame(frames[i]), OK); + std::vector out; TransferMeta got; + CHECK_EQ(d.assemble(out, got), OK); + CHECK(out == pt); +} + +// Every produced frame must be exactly 512 bytes. +static void test_frame_sizes() { + CASE("frame_sizes"); + std::vector key = make_key(); + std::vector pt = rand_bytes(1234); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(1, key, pt, meta, true, frames), OK); + for (size_t i = 0; i < frames.size(); ++i) CHECK_EQ(frames[i].size(), MEMO_SIZE); +} + +static void test_ciphertext_fingerprint() { + CASE("ciphertext_fingerprint"); + std::vector key = make_key(); + std::vector pt = rand_bytes(3 * DATA_PLAINTEXT_PER_FRAME + 11); + TransferMeta meta; + std::vector > frames; + CHECK_EQ(Encoder::encode(0x9999, key, pt, meta, true, frames), OK); + + uint8_t fp1[CONTENT_HASH_LEN]; + CHECK_EQ(ciphertext_fingerprint(frames, fp1), OK); + + // Deterministic: recomputing over the same frames yields the same anchor. + uint8_t fp2[CONTENT_HASH_LEN]; + CHECK_EQ(ciphertext_fingerprint(frames, fp2), OK); + CHECK_EQ(std::memcmp(fp1, fp2, CONTENT_HASH_LEN), 0); + + // Order-independent: shuffle the frame vector, anchor is unchanged (it sorts + // DATA frames by seq internally). + std::vector > shuffled(frames.rbegin(), frames.rend()); + uint8_t fp3[CONTENT_HASH_LEN]; + CHECK_EQ(ciphertext_fingerprint(shuffled, fp3), OK); + CHECK_EQ(std::memcmp(fp1, fp3, CONTENT_HASH_LEN), 0); + + // Verify-BEFORE-decrypt: the anchor is computable without the key, and it + // equals SHA-256 over the concatenated DATA ciphertext payloads. + crypto_hash_sha256_state hst; crypto_hash_sha256_init(&hst); + for (size_t i = 0; i < frames.size(); ++i) { + FrameHeader h; CHECK_EQ(parse_header(&frames[i][0], h), OK); + if (h.type != FT_DATA) continue; + crypto_hash_sha256_update(&hst, &frames[i][HEADER_SIZE], h.payload_len); + } + uint8_t manual[CONTENT_HASH_LEN]; crypto_hash_sha256_final(&hst, manual); + CHECK_EQ(std::memcmp(fp1, manual, CONTENT_HASH_LEN), 0); + + // A flipped ciphertext byte changes the anchor (tamper visible pre-decrypt). + std::vector > tampered = frames; + tampered[1][HEADER_SIZE] ^= 0x01; + uint8_t fp4[CONTENT_HASH_LEN]; + CHECK_EQ(ciphertext_fingerprint(tampered, fp4), OK); + CHECK(std::memcmp(fp1, fp4, CONTENT_HASH_LEN) != 0); +} + +int main() { + if (sodium_init() < 0) { std::printf("sodium_init failed\n"); return 2; } + std::printf("ZDC1 codec unit tests\n"); + + test_header_roundtrip(); + test_header_rejects(); + test_crc(); + test_aead_roundtrip(); + test_aead_tamper_and_aad(); + test_nonce_uniqueness(); + test_roundtrip_size_matrix(); + test_full_roundtrip_shuffled(); + test_empty_payload(); + test_seal_then_reveal(); + test_oob_key(); + test_wrong_key_fails_assemble(); + test_missing_and_reorder_detect(); + test_tamper_in_transit_detected(); + test_crc_corruption_rejected(); + test_truncation_rejected(); + test_non_zdc_memo_passthrough(); + test_foreign_transfer_id_rejected(); + test_size_caps(); + test_max_data_frame(); + test_frame_sizes(); + test_ciphertext_fingerprint(); + + std::printf("\n%d checks, %d failures\n", g_checks, g_fails); + if (g_fails) { std::printf("RESULT: FAIL\n"); return 1; } + std::printf("RESULT: PASS\n"); + return 0; +} diff --git a/src/datachannel/zdc.cpp b/src/datachannel/zdc.cpp new file mode 100644 index 00000000000..6a3eff864b0 --- /dev/null +++ b/src/datachannel/zdc.cpp @@ -0,0 +1,574 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license. +// +// ZDC1 codec implementation. See zdc.h for the full security model. C++11 only. + +#include "zdc.h" + +#include + +#include + +namespace zdc { + +// One-time libsodium init. Thread-safe per libsodium docs (sodium_init may be +// called multiple times / concurrently after the first success). +static bool ensure_sodium() { + static int rc = sodium_init(); // 0 = ok, 1 = already initialized, -1 = fail + return rc >= 0; +} + +// Compile-time sanity: our constants must match libsodium's. +static_assert(AEAD_KEYBYTES == crypto_aead_chacha20poly1305_ietf_KEYBYTES, "key size"); +static_assert(AEAD_NPUBBYTES == crypto_aead_chacha20poly1305_ietf_NPUBBYTES, "nonce size"); +static_assert(AEAD_ABYTES == crypto_aead_chacha20poly1305_ietf_ABYTES, "tag size"); +static_assert(AEAD_NPUBBYTES == 12, "nonce must be transfer_id(8)+counter(4)"); + +// ---------------------------------------------------------------------------- +// NONCE COUNTER DOMAIN (the security-critical part). +// +// The 12-byte AEAD nonce = transfer_id(8 BE) || counter(4 BE). The key is fresh +// per transfer, so uniqueness reduces to: every L3-encrypted frame in ONE +// transfer must use a DISTINCT 32-bit counter. The wire `seq` field alone is +// UNSAFE as that counter, because START (seq 0) and DATA chunk 0 (seq 0) would +// collide, and END (seq == chunk_count) could collide with a future use of the +// seq space. We therefore map each frame ROLE to a globally-unique counter, +// reserving the top of the 32-bit range for the singleton control frames. This +// is collision-free by construction because DATA counters are exactly the chunk +// index in [0, chunk_count) and chunk_count <= MAX_CHUNK_COUNT (65535), far +// below the reserved band. (Proven in test/zdc_test.cpp.) +// +// DATA chunk i -> counter = i (0 .. chunk_count-1, <= 65534) +// START -> counter = 0xFFFFFFFF NONCE_CTR_START +// END -> counter = 0xFFFFFFFE NONCE_CTR_END +// (KEY frame is NOT L3-encrypted, so it consumes no counter.) +// ---------------------------------------------------------------------------- +static const uint32_t NONCE_CTR_START = 0xFFFFFFFFu; +static const uint32_t NONCE_CTR_END = 0xFFFFFFFEu; + +// ============================================================================ +// big-endian helpers +// ============================================================================ +static void put_be16(uint8_t* p, uint16_t v) { p[0]=(uint8_t)(v>>8); p[1]=(uint8_t)v; } +static void put_be32(uint8_t* p, uint32_t v) { + p[0]=(uint8_t)(v>>24); p[1]=(uint8_t)(v>>16); p[2]=(uint8_t)(v>>8); p[3]=(uint8_t)v; +} +static void put_be64(uint8_t* p, uint64_t v) { + for (int i = 0; i < 8; ++i) p[i] = (uint8_t)(v >> (56 - 8*i)); +} +static uint16_t get_be16(const uint8_t* p) { return (uint16_t)((p[0]<<8) | p[1]); } +static uint32_t get_be32(const uint8_t* p) { + return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)|((uint32_t)p[2]<<8)|((uint32_t)p[3]); +} +static uint64_t get_be64(const uint8_t* p) { + uint64_t v = 0; for (int i = 0; i < 8; ++i) v = (v<<8) | p[i]; return v; +} + +// ============================================================================ +// CRC-32 (IEEE 802.3, reflected). Transport integrity only — NOT security. +// ============================================================================ +uint32_t crc32(const uint8_t* data, size_t len) { + static uint32_t table[256]; + static bool built = false; + if (!built) { + for (uint32_t i = 0; i < 256; ++i) { + uint32_t c = i; + for (int k = 0; k < 8; ++k) + c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + table[i] = c; + } + built = true; + } + uint32_t c = 0xFFFFFFFFu; + for (size_t i = 0; i < len; ++i) + c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8); + return c ^ 0xFFFFFFFFu; +} + +// ============================================================================ +// header (de)serialization +// ============================================================================ +// Layout (32 bytes, all multi-byte big-endian): +// 0 4 magic 4 1 version 5 1 type 6 1 flags 7 1 cipher_id +// 8 8 transfer_id 16 4 seq 20 4 chunk_count +// 24 2 payload_len 26 4 crc32 30 2 reserved +void serialize_header(const FrameHeader& h, uint8_t* out) { + put_be32(out + 0, h.magic); + out[4] = h.version; + out[5] = h.type; + out[6] = h.flags; + out[7] = h.cipher_id; + put_be64(out + 8, h.transfer_id); + put_be32(out + 16, h.seq); + put_be32(out + 20, h.chunk_count); + put_be16(out + 24, h.payload_len); + put_be32(out + 26, h.crc32); + put_be16(out + 30, h.reserved); +} + +Status parse_header(const uint8_t* in, FrameHeader& h) { + h.magic = get_be32(in + 0); + h.version = in[4]; + h.type = in[5]; + h.flags = in[6]; + h.cipher_id = in[7]; + h.transfer_id = get_be64(in + 8); + h.seq = get_be32(in + 16); + h.chunk_count = get_be32(in + 20); + h.payload_len = get_be16(in + 24); + h.crc32 = get_be32(in + 26); + h.reserved = get_be16(in + 30); + + if (h.magic != ZDC_MAGIC) return ERR_BAD_MAGIC; + if (h.version != ZDC_VERSION) return ERR_BAD_VERSION; + if (h.type != FT_START && h.type != FT_DATA && + h.type != FT_END && h.type != FT_KEY) return ERR_BAD_TYPE; + if (h.payload_len > FRAME_PAYLOAD) return ERR_BAD_PAYLOAD_LEN; + if (h.reserved != 0) return ERR_BAD_STATE; + if (h.chunk_count > MAX_CHUNK_COUNT) return ERR_OVERSIZE; + return OK; +} + +// Build a full 512-byte frame from header + (already-final) payload bytes. +// Sets payload_len and crc32 over the zero-padded 480-byte payload field. +static void pack_frame(FrameHeader& h, const uint8_t* payload, size_t payload_len, + std::vector& out) { + out.assign(MEMO_SIZE, 0); + h.payload_len = (uint16_t)payload_len; + if (payload_len > 0) + std::memcpy(&out[HEADER_SIZE], payload, payload_len); + // crc covers the full 480-byte payload field (including zero padding) so it is + // deterministic regardless of payload_len. + h.crc32 = crc32(&out[HEADER_SIZE], FRAME_PAYLOAD); + serialize_header(h, &out[0]); +} + +// ============================================================================ +// L3 AEAD +// ============================================================================ +Status ZdcAead::generate_key(std::vector& key) { + if (!ensure_sodium()) return ERR_INTERNAL; + key.assign(AEAD_KEYBYTES, 0); + randombytes_buf(&key[0], AEAD_KEYBYTES); + return OK; +} + +void ZdcAead::derive_nonce(uint64_t transfer_id, uint32_t nonce_ctr, + uint8_t out_nonce[AEAD_NPUBBYTES]) { + // nonce = transfer_id(8 BE) || nonce_ctr(4 BE). The caller passes a counter + // that is unique per frame WITHIN the transfer (DATA->chunk index, START/END + // -> reserved high values). It is NOT the wire `seq`; see the NONCE COUNTER + // DOMAIN note above. Combined with a per-transfer-unique key this guarantees + // every (key, nonce) pair is used at most once. + put_be64(out_nonce + 0, transfer_id); + put_be32(out_nonce + 8, nonce_ctr); +} + +Status ZdcAead::encrypt(const std::vector& key, + uint64_t transfer_id, uint32_t seq, + const uint8_t* aad, size_t aad_len, + const std::vector& plaintext, + std::vector& ciphertext) { + if (!ensure_sodium()) return ERR_INTERNAL; + if (key.size() != AEAD_KEYBYTES) return ERR_INTERNAL; + uint8_t nonce[AEAD_NPUBBYTES]; + derive_nonce(transfer_id, seq, nonce); + ciphertext.assign(plaintext.size() + AEAD_ABYTES, 0); + unsigned long long clen = 0; + const unsigned char* m = plaintext.empty() ? (const unsigned char*)"" : &plaintext[0]; + int rc = crypto_aead_chacha20poly1305_ietf_encrypt( + &ciphertext[0], &clen, + m, plaintext.size(), + aad, aad_len, + NULL, nonce, &key[0]); + if (rc != 0) return ERR_INTERNAL; + ciphertext.resize((size_t)clen); + return OK; +} + +Status ZdcAead::decrypt(const std::vector& key, + uint64_t transfer_id, uint32_t seq, + const uint8_t* aad, size_t aad_len, + const std::vector& ciphertext, + std::vector& plaintext) { + if (!ensure_sodium()) return ERR_INTERNAL; + if (key.size() != AEAD_KEYBYTES) return ERR_NO_KEY; + if (ciphertext.size() < AEAD_ABYTES) return ERR_AEAD_FAIL; + uint8_t nonce[AEAD_NPUBBYTES]; + derive_nonce(transfer_id, seq, nonce); + plaintext.assign(ciphertext.size() - AEAD_ABYTES, 0); + unsigned long long mlen = 0; + unsigned char* m = plaintext.empty() ? NULL : &plaintext[0]; + int rc = crypto_aead_chacha20poly1305_ietf_decrypt( + m, &mlen, NULL, + &ciphertext[0], ciphertext.size(), + aad, aad_len, + nonce, &key[0]); + if (rc != 0) return ERR_AEAD_FAIL; + plaintext.resize((size_t)mlen); + return OK; +} + +Status ZdcAead::sha256(const uint8_t* data, size_t len, uint8_t out[CONTENT_HASH_LEN]) { + if (!ensure_sodium()) return ERR_INTERNAL; + static_assert(CONTENT_HASH_LEN == crypto_hash_sha256_BYTES, "sha256 len"); + const unsigned char* d = (len == 0) ? (const unsigned char*)"" : data; + if (crypto_hash_sha256(out, d, len) != 0) return ERR_INTERNAL; + return OK; +} + +// ============================================================================ +// START metadata (de)serialization — the PLAINTEXT that gets AEAD-encrypted into +// the START frame payload. Compact, self-describing, fits in 464 plaintext bytes. +// u64 total_plaintext_size | u32 chunk_count +// u16 filename_len | filename | u16 content_type_len | content_type +// ============================================================================ +static void serialize_meta(const TransferMeta& m, std::vector& out) { + out.clear(); + uint8_t tmp[8]; + put_be64(tmp, m.total_plaintext_size); out.insert(out.end(), tmp, tmp+8); + put_be32(tmp, m.chunk_count); out.insert(out.end(), tmp, tmp+4); + uint16_t fl = (uint16_t)m.filename.size(); + put_be16(tmp, fl); out.insert(out.end(), tmp, tmp+2); + out.insert(out.end(), m.filename.begin(), m.filename.end()); + uint16_t cl = (uint16_t)m.content_type.size(); + put_be16(tmp, cl); out.insert(out.end(), tmp, tmp+2); + out.insert(out.end(), m.content_type.begin(), m.content_type.end()); +} + +static Status deserialize_meta(const std::vector& in, TransferMeta& m) { + size_t p = 0; + if (in.size() < 14) return ERR_BAD_STATE; + m.total_plaintext_size = get_be64(&in[p]); p += 8; + m.chunk_count = get_be32(&in[p]); p += 4; + uint16_t fl = get_be16(&in[p]); p += 2; + if (p + fl > in.size()) return ERR_BAD_STATE; + m.filename.assign(in.begin()+p, in.begin()+p+fl); p += fl; + if (p + 2 > in.size()) return ERR_BAD_STATE; + uint16_t cl = get_be16(&in[p]); p += 2; + if (p + cl > in.size()) return ERR_BAD_STATE; + m.content_type.assign(in.begin()+p, in.begin()+p+cl); p += cl; + return OK; +} + +// ============================================================================ +// Encoder +// ============================================================================ +static void base_header(FrameHeader& h, uint64_t transfer_id, uint8_t type, + uint32_t seq, uint32_t chunk_count, bool ciphertext) { + h.magic = ZDC_MAGIC; h.version = ZDC_VERSION; h.type = type; + h.flags = ciphertext ? FL_CIPHERTEXT : 0; + h.cipher_id = ciphertext ? CIPHER_CHACHA20POLY1305 : CIPHER_NONE; + h.transfer_id = transfer_id; h.seq = seq; h.chunk_count = chunk_count; + h.payload_len = 0; h.crc32 = 0; h.reserved = 0; +} + +// Build the 32-byte AAD = the header WITH crc32 and payload_len zeroed, so that +// AAD is computable identically by encoder (before crc/len known) and decoder +// (which strips them). AAD binds version/type/transfer_id/seq/chunk_count/flags/ +// cipher — everything that defines the frame's ROLE — but not the transport-only +// crc or the length (the AEAD tag already protects the ciphertext length). +static void aad_from_header(const FrameHeader& h, uint8_t out_aad[HEADER_SIZE]) { + FrameHeader a = h; + a.crc32 = 0; a.payload_len = 0; + serialize_header(a, out_aad); +} + +Status Encoder::encode(uint64_t transfer_id, + const std::vector& key, + const std::vector& plaintext, + const TransferMeta& meta_in, + bool include_key_frame, + std::vector >& frames_out) { + if (!ensure_sodium()) return ERR_INTERNAL; + if (key.size() != AEAD_KEYBYTES) return ERR_INTERNAL; + if (plaintext.size() > MAX_TRANSFER_BYTES) return ERR_OVERSIZE; + + // chunk_count = ceil(len / 464); a zero-length payload still has 0 DATA frames. + uint64_t cc64 = (plaintext.size() + DATA_PLAINTEXT_PER_FRAME - 1) / DATA_PLAINTEXT_PER_FRAME; + if (cc64 > MAX_CHUNK_COUNT) return ERR_OVERSIZE; + uint32_t chunk_count = (uint32_t)cc64; + + frames_out.clear(); + frames_out.reserve(chunk_count + 3); + + // ---- START ---- + { + TransferMeta meta = meta_in; + meta.total_plaintext_size = plaintext.size(); + meta.chunk_count = chunk_count; + std::vector meta_pt; serialize_meta(meta, meta_pt); + if (meta_pt.size() > DATA_PLAINTEXT_PER_FRAME) { + // filename/content_type too long to fit one START frame. + return ERR_OVERSIZE; + } + FrameHeader h; base_header(h, transfer_id, FT_START, 0, chunk_count, true); + uint8_t aad[HEADER_SIZE]; aad_from_header(h, aad); + std::vector ct; + // START uses the reserved START nonce counter (NOT seq 0) so it can never + // collide with DATA chunk 0's nonce under the same key. + Status s = ZdcAead::encrypt(key, transfer_id, NONCE_CTR_START, aad, HEADER_SIZE, meta_pt, ct); + if (s != OK) return s; + std::vector frame; pack_frame(h, ct.empty()?NULL:&ct[0], ct.size(), frame); + frames_out.push_back(frame); + } + + // ---- DATA ---- + for (uint32_t i = 0; i < chunk_count; ++i) { + size_t off = (size_t)i * DATA_PLAINTEXT_PER_FRAME; + size_t n = plaintext.size() - off; + if (n > DATA_PLAINTEXT_PER_FRAME) n = DATA_PLAINTEXT_PER_FRAME; + std::vector chunk(plaintext.begin()+off, plaintext.begin()+off+n); + FrameHeader h; base_header(h, transfer_id, FT_DATA, i, chunk_count, true); + uint8_t aad[HEADER_SIZE]; aad_from_header(h, aad); + std::vector ct; + Status s = ZdcAead::encrypt(key, transfer_id, i, aad, HEADER_SIZE, chunk, ct); + if (s != OK) return s; + std::vector frame; pack_frame(h, &ct[0], ct.size(), frame); + frames_out.push_back(frame); + } + + // ---- END (content hash over full plaintext) ---- + { + uint8_t hash[CONTENT_HASH_LEN]; + Status s = ZdcAead::sha256(plaintext.empty()?NULL:&plaintext[0], plaintext.size(), hash); + if (s != OK) return s; + std::vector hpt(hash, hash + CONTENT_HASH_LEN); + // END wire seq == chunk_count (distinct from any DATA seq for routing), but + // its AEAD nonce uses the reserved END counter so it never collides even when + // chunk_count==0 (END counter == 0xFFFFFFFE, START == 0xFFFFFFFF, DATA < count). + FrameHeader h; base_header(h, transfer_id, FT_END, chunk_count, chunk_count, true); + uint8_t aad[HEADER_SIZE]; aad_from_header(h, aad); + std::vector ct; + s = ZdcAead::encrypt(key, transfer_id, NONCE_CTR_END, aad, HEADER_SIZE, hpt, ct); + if (s != OK) return s; + std::vector frame; pack_frame(h, &ct[0], ct.size(), frame); + frames_out.push_back(frame); + } + + // ---- KEY (optional; plaintext key — its confidentiality is L0/L1 Sapling) ---- + if (include_key_frame) { + std::vector frame; + Status s = encode_key_frame(transfer_id, key, chunk_count, frame); + if (s != OK) return s; + frames_out.push_back(frame); + } + return OK; +} + +Status Encoder::encode_key_frame(uint64_t transfer_id, + const std::vector& key, + uint32_t chunk_count, + std::vector& frame_out) { + if (key.size() != AEAD_KEYBYTES) return ERR_INTERNAL; + // The KEY payload is the raw 32-byte key. It is NOT L3-AEAD-encrypted (it IS the + // L3 secret); its on-chain confidentiality is the Sapling memo encryption to the + // recipient's ivk (L1). cipher_id=NONE, flags=0 so it is self-describing. + FrameHeader h; base_header(h, transfer_id, FT_KEY, 0, chunk_count, false); + pack_frame(h, &key[0], key.size(), frame_out); + return OK; +} + +// ============================================================================ +// Decoder +// ============================================================================ +Decoder::Decoder() + : seen_any_(false), transfer_id_(0), have_start_(false), have_end_(false), + have_key_(false), chunk_count_(0) {} + +// Wipe the 32-byte key from memory on destruction. sodium_memzero is not optimized +// away by the compiler (unlike memset), so the secret cannot survive in freed heap. +Decoder::~Decoder() { + if (!key_.empty()) + sodium_memzero(&key_[0], key_.size()); +} + +Status Decoder::add_frame(const std::vector& memo) { + return add_frame(memo.empty()?NULL:&memo[0], memo.size()); +} + +Status Decoder::add_frame(const uint8_t* memo, size_t len) { + if (memo == NULL || len < MEMO_SIZE) return ERR_TRUNCATED; + FrameHeader h; + Status s = parse_header(memo, h); + if (s != OK) return s; // BAD_MAGIC => "ordinary memo, ignore" + + // verify transport crc over the 480-byte payload field + uint32_t got = crc32(memo + HEADER_SIZE, FRAME_PAYLOAD); + if (got != h.crc32) return ERR_BAD_CRC; + + if (h.payload_len > FRAME_PAYLOAD) return ERR_BAD_PAYLOAD_LEN; + return ingest_parsed(h, memo + HEADER_SIZE); +} + +Status Decoder::ingest_parsed(const FrameHeader& h, const uint8_t* payload) { + if (!seen_any_) { seen_any_ = true; transfer_id_ = h.transfer_id; } + else if (h.transfer_id != transfer_id_) return ERR_BAD_STATE; + + uint8_t aad[HEADER_SIZE]; aad_from_header(h, aad); + std::vector body(payload, payload + h.payload_len); + + switch (h.type) { + case FT_START: + if (h.seq != 0) return ERR_BAD_STATE; + if (!have_start_) { + have_start_ = true; + chunk_count_ = h.chunk_count; + start_meta_ct_ = body; + start_meta_aad_.assign(aad, aad + HEADER_SIZE); + } + return OK; + case FT_DATA: + if (h.seq >= h.chunk_count) return ERR_BAD_STATE; + if (data_.find(h.seq) == data_.end()) { // first wins; dup ignored + data_[h.seq] = body; + data_aad_[h.seq].assign(aad, aad + HEADER_SIZE); + } + return OK; + case FT_END: + if (!have_end_) { + have_end_ = true; + end_hash_ct_ = body; + end_hash_aad_.assign(aad, aad + HEADER_SIZE); + } + return OK; + case FT_KEY: + if (!have_key_) { + if (h.payload_len != AEAD_KEYBYTES) return ERR_BAD_STATE; + key_.assign(body.begin(), body.end()); + have_key_ = true; + } + return OK; + default: + return ERR_BAD_TYPE; + } +} + +Status Decoder::set_key(const std::vector& key) { + if (key.size() != AEAD_KEYBYTES) return ERR_BAD_STATE; + // Wipe any prior key bytes before the assignment can reallocate the buffer, + // so a replaced secret is not left behind in freed memory. + if (!key_.empty()) + sodium_memzero(&key_[0], key_.size()); + key_ = key; + have_key_ = true; + return OK; +} + +bool Decoder::is_complete() const { + if (!have_start_ || !have_end_) return false; + if (data_.size() != chunk_count_) return false; + if (chunk_count_ == 0) return true; // empty transfer: no DATA frames + // data_ is a sorted map of UNIQUE seqs. Given size == chunk_count_, the keys are + // exactly {0..chunk_count_-1} iff the largest key is < chunk_count_ (pigeonhole: + // chunk_count_ distinct non-negative ints all below chunk_count_ must be that set). + // O(1) via rbegin() — was O(N) per call (O(N^2) over the documented add-then-check loop). + return data_.rbegin()->first < chunk_count_; +} + +std::vector Decoder::missing_chunks() const { + std::vector miss; + if (!have_start_) return miss; + for (uint32_t i = 0; i < chunk_count_; ++i) + if (data_.find(i) == data_.end()) miss.push_back(i); + return miss; +} + +Status Decoder::assemble(std::vector& out_plaintext, TransferMeta& out_meta) const { + if (!is_complete()) return ERR_INCOMPLETE; + if (!have_key_) return ERR_NO_KEY; + + // START meta (decrypts with the reserved START nonce counter) + std::vector meta_pt; + Status s = ZdcAead::decrypt(key_, transfer_id_, NONCE_CTR_START, + &start_meta_aad_[0], HEADER_SIZE, + start_meta_ct_, meta_pt); + if (s != OK) return s; + s = deserialize_meta(meta_pt, out_meta); + if (s != OK) return s; + if (out_meta.chunk_count != chunk_count_) return ERR_HASH_MISMATCH; + + // DATA chunks, in seq order + out_plaintext.clear(); + out_plaintext.reserve((size_t)out_meta.total_plaintext_size); + for (uint32_t i = 0; i < chunk_count_; ++i) { + std::map >::const_iterator it = data_.find(i); + std::map >::const_iterator ai = data_aad_.find(i); + std::vector pt; + s = ZdcAead::decrypt(key_, transfer_id_, i, + &ai->second[0], HEADER_SIZE, it->second, pt); + if (s != OK) return s; + out_plaintext.insert(out_plaintext.end(), pt.begin(), pt.end()); + } + if (out_plaintext.size() != out_meta.total_plaintext_size) return ERR_HASH_MISMATCH; + + // END content hash (decrypts with the reserved END nonce counter) + std::vector end_pt; + s = ZdcAead::decrypt(key_, transfer_id_, NONCE_CTR_END, + &end_hash_aad_[0], HEADER_SIZE, end_hash_ct_, end_pt); + if (s != OK) return s; + if (end_pt.size() != CONTENT_HASH_LEN) return ERR_HASH_MISMATCH; + uint8_t calc[CONTENT_HASH_LEN]; + s = ZdcAead::sha256(out_plaintext.empty()?NULL:&out_plaintext[0], + out_plaintext.size(), calc); + if (s != OK) return s; + if (sodium_memcmp(calc, &end_pt[0], CONTENT_HASH_LEN) != 0) return ERR_HASH_MISMATCH; + + return OK; +} + +// ============================================================================ +// NFT fingerprint over ciphertext (the on-chain anchor). DATA frames only. +// ============================================================================ +Status ciphertext_fingerprint(const std::vector >& frames, + uint8_t out[CONTENT_HASH_LEN]) { + if (!ensure_sodium()) return ERR_INTERNAL; + crypto_hash_sha256_state hst; + crypto_hash_sha256_init(&hst); + // Hash DATA-frame ciphertext payloads IN ASCENDING seq order so the anchor is + // deterministic regardless of the order frames appear in the vector. + // Gather (seq -> ciphertext pointer/len) first, then fold in order. + std::map > by_seq; + for (size_t i = 0; i < frames.size(); ++i) { + const std::vector& f = frames[i]; + if (f.size() < MEMO_SIZE) return ERR_BAD_STATE; + FrameHeader h; + Status s = parse_header(&f[0], h); + if (s != OK) return s; + if (h.type != FT_DATA) continue; + if (h.payload_len > FRAME_PAYLOAD) return ERR_BAD_PAYLOAD_LEN; + by_seq[h.seq] = std::make_pair(&f[HEADER_SIZE], (size_t)h.payload_len); + } + for (std::map >::const_iterator + it = by_seq.begin(); it != by_seq.end(); ++it) { + const uint8_t* p = it->second.first; + size_t n = it->second.second; + crypto_hash_sha256_update(&hst, n ? p : (const unsigned char*)"", n); + } + crypto_hash_sha256_final(&hst, out); + return OK; +} + +// ============================================================================ +const char* status_str(Status s) { + switch (s) { + case OK: return "OK"; + case ERR_TRUNCATED: return "frame shorter than 512 bytes"; + case ERR_BAD_MAGIC: return "not a ZDC1 frame"; + case ERR_BAD_VERSION: return "unsupported ZDC1 version"; + case ERR_BAD_TYPE: return "unknown frame type"; + case ERR_BAD_PAYLOAD_LEN: return "payload length out of range"; + case ERR_BAD_CRC: return "transport CRC mismatch (corruption)"; + case ERR_BAD_CIPHER: return "unsupported cipher"; + case ERR_AEAD_FAIL: return "AEAD verification failed (tamper or wrong key)"; + case ERR_OVERSIZE: return "transfer exceeds size cap"; + case ERR_INCOMPLETE: return "transfer incomplete (missing frames)"; + case ERR_HASH_MISMATCH: return "content hash mismatch after reassembly"; + case ERR_NO_KEY: return "key not yet available (sealed)"; + case ERR_BAD_STATE: return "protocol state error"; + case ERR_INTERNAL: return "internal/libsodium error"; + } + return "unknown status"; +} + +} // namespace zdc diff --git a/src/datachannel/zdc.h b/src/datachannel/zdc.h new file mode 100644 index 00000000000..94a3bcab29c --- /dev/null +++ b/src/datachannel/zdc.h @@ -0,0 +1,340 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license. +// +// ZDC1 — ZClassic Shielded Data Channel, version 1. +// +// PURE LOGIC CODEC. No daemon, no chain, no Qt, no globals. Depends ONLY on +// libsodium and the C++11 standard library, so it builds BOTH standalone +// (host g++ -lsodium, for unit tests) AND inside the daemon (which already +// links -lsodium and compiles -std=c++11 -noext; see configure.ac:68,783). +// +// WHAT THIS IS +// ------------ +// A transport for moving an arbitrary ENCRYPTED byte stream (a private message, +// a file, a private NFT's asset bytes) across many 512-byte Sapling memos. Each +// memo carries exactly one ZDC1 FRAME. The Sapling shielded pool is the privacy +// base layer (consensus-enforced zk-SNARKs hide sender/recipient/amount, and the +// memo is itself ChaCha20-Poly1305-encrypted to the recipient's incoming viewing +// key). ON TOP of that, this codec applies an INDEPENDENT application-layer AEAD +// (libsodium ChaCha20-Poly1305 IETF) under a per-transfer symmetric key, so that: +// * content can be published now and the key revealed later ("seal then reveal"), +// * a break in one layer does not cascade into the other, +// * a single ciphertext can be opened by N recipients (one KEY frame each). +// +// THE LAYERS (full stack): +// L0 Sapling shielded pool consensus zk-SNARK privacy (NOT this code) +// L1 Sapling per-output memo 512B, ChaCha20-Poly1305 to ivk (NOT this code) +// L2 ZDC1 transport framing + reassembly (this code) +// L3 ZDC1 application AEAD per-transfer key, per-chunk Poly1305 (this code) +// +// HONEST LIMITS (the codec cannot fix these; callers must surface them): +// * METADATA LEAKS: the NUMBER of outputs reveals approximate transfer size; +// timing of the burst is observable; "a shielded tx occurred" is observable. +// "Private" is NOT "undetectable". This is a CONFIDENTIALITY channel, not a +// steganographic one. +// * PERMANENCE: every memo is stored by every full node FOREVER. Encrypted-but- +// undeletable. Size caps below are about responsibility, not just performance. +// * NO CONSENSUS ENFORCES ANY OF THIS. It is wallet/application policy only. +// +// SECURITY MODEL (see ZdcAead): +// * Key = 32 random bytes from randombytes_buf() per transfer. Never reused, +// never logged. The KEY frame carries it (or it travels out-of-band). +// * Nonce= 12 bytes = transfer_id(8) || nonce_ctr(4), where nonce_ctr is a +// per-frame COUNTER unique within the transfer (DATA[i]->i, START-> +// 0xFFFFFFFF, END->0xFFFFFFFE), NOT the wire seq (START seq 0 and +// DATA[0] seq 0 would otherwise collide). The key is fresh per transfer, +// so every (key, nonce) pair is unique by construction. Nonce reuse is +// catastrophic for ChaCha20-Poly1305; this is unit-tested. +// * AAD = the 32-byte frame header (version/type/transfer_id/seq/chunk_count/...) +// so a reordered, retyped, or rewritten frame fails decryption. +// * Tag = per-chunk Poly1305 (16B) is the SECURITY integrity check. +// * crc32= header field is TRANSPORT integrity only (corruption / foreign-data +// detection). It is NOT security. Do not rely on it for tamper-evidence. +// * Content hash = SHA-256 over the full PLAINTEXT, carried in the END frame and +// verified after reassembly+decrypt. This binds the stream to the NFT +// fingerprint (the ZSLP document_hash anchor; see doc/nft/CONTENT_MODEL.md). + +#ifndef ZCLASSIC_DATACHANNEL_ZDC_H +#define ZCLASSIC_DATACHANNEL_ZDC_H + +#include +#include +#include +#include + +namespace zdc { + +// ---- wire constants (frozen; changing any of these is a NEW protocol version) ---- +static const uint32_t ZDC_MAGIC = 0x5A444331u; // "ZDC1" +static const uint8_t ZDC_VERSION = 0x01; + +static const size_t MEMO_SIZE = 512; // Sapling ZC_MEMO_SIZE +static const size_t HEADER_SIZE = 32; // fixed header +static const size_t FRAME_PAYLOAD = MEMO_SIZE - HEADER_SIZE; // 480 bytes/frame + +// AEAD (libsodium ChaCha20-Poly1305 IETF) sizes — asserted against sodium in .cpp. +static const size_t AEAD_KEYBYTES = 32; +static const size_t AEAD_NPUBBYTES = 12; // = 8 (transfer_id) + 4 (nonce_ctr) +static const size_t AEAD_ABYTES = 16; // Poly1305 tag +static const size_t CONTENT_HASH_LEN = 32; // SHA-256 + +// L3 AEAD expands each chunk by the 16-byte tag, so the usable PLAINTEXT per DATA +// frame is 480 - 16 = 464 bytes. START/END/KEY use the payload differently (below). +static const size_t DATA_PLAINTEXT_PER_FRAME = FRAME_PAYLOAD - AEAD_ABYTES; // 464 + +// ---- responsibility caps (policy, not consensus). Reject oversize transfers. ---- +// Max DATA chunks the codec will encode/reassemble. 65535 * 464B ~= 29 MB plaintext. +// Callers SHOULD impose far tighter limits (64 KB default per shielded-data-protocol.md); +// this is the codec's absolute structural ceiling so a hostile START can't allocate forever. +static const uint32_t MAX_CHUNK_COUNT = 65535; +static const uint64_t MAX_TRANSFER_BYTES = (uint64_t)MAX_CHUNK_COUNT * DATA_PLAINTEXT_PER_FRAME; + +// Frame types (header byte 5). +enum FrameType { + FT_START = 0x01, // encrypted metadata blob; carries authoritative chunk_count + FT_DATA = 0x02, // one AEAD-encrypted plaintext chunk + FT_END = 0x03, // sha256(full plaintext); seq == chunk_count + FT_KEY = 0x04, // the 32B per-transfer key (reveal-later); processed last +}; + +// Header flag bits (header byte 6). +enum FrameFlags { + FL_CIPHERTEXT = 0x01, // payload is L3-AEAD ciphertext (DATA/START/END always set) +}; + +// Cipher id (header byte 7). +enum CipherId { + CIPHER_NONE = 0x00, + CIPHER_CHACHA20POLY1305 = 0x01, // the only one implemented +}; + +// Decode/codec status codes. 0 == OK; negatives are hard failures. +enum Status { + OK = 0, + ERR_TRUNCATED = -1, // buffer shorter than 512 bytes + ERR_BAD_MAGIC = -2, // not a ZDC1 frame (e.g. an ordinary text memo) + ERR_BAD_VERSION = -3, + ERR_BAD_TYPE = -4, + ERR_BAD_PAYLOAD_LEN = -5, // payload_len > 480 + ERR_BAD_CRC = -6, // transport corruption + ERR_BAD_CIPHER = -7, + ERR_AEAD_FAIL = -8, // Poly1305 verification failed (tamper / wrong key) + ERR_OVERSIZE = -9, // chunk_count or transfer exceeds caps + ERR_INCOMPLETE = -10, // reassembly asked but frames missing + ERR_HASH_MISMATCH = -11, // reassembled plaintext != END content hash + ERR_NO_KEY = -12, // content present but key not yet revealed + ERR_BAD_STATE = -13, // protocol misuse (e.g. START seq != 0) + ERR_INTERNAL = -14, // libsodium / invariant failure +}; + +// Parsed frame header (host-endian fields; wire is big-endian, handled in .cpp). +struct FrameHeader { + uint32_t magic; + uint8_t version; + uint8_t type; + uint8_t flags; + uint8_t cipher_id; + uint64_t transfer_id; + uint32_t seq; + uint32_t chunk_count; + uint16_t payload_len; + uint32_t crc32; // over the 480-byte payload field (transport integrity) + uint16_t reserved; // must be 0 +}; + +// Plaintext metadata that lives (encrypted) inside the START frame. +struct TransferMeta { + std::string filename; // may be empty (e.g. a raw message) + std::string content_type; // MIME-ish; may be empty + uint64_t total_plaintext_size; // exact byte count of the reassembled plaintext + uint32_t chunk_count; // number of DATA frames + // content_hash is carried in END, not here, so it cannot be forged independent of bytes. +}; + +// ============================================================================ +// Low-level header (de)serialization + transport CRC. No crypto here. +// ============================================================================ + +// CRC-32 (IEEE 802.3, reflected, poly 0xEDB88320) over [data, data+len). +// Transport integrity only; NOT a security primitive. +uint32_t crc32(const uint8_t* data, size_t len); + +// Serialize header into out[0..31]. out must have >= HEADER_SIZE bytes. +void serialize_header(const FrameHeader& h, uint8_t* out); + +// Parse header from in[0..31]. Returns OK or a hard error. Validates magic, +// version, type, payload_len bound, reserved==0. Does NOT verify crc (crc covers +// the payload, which the caller has; see verify_payload_crc / decode_frame). +Status parse_header(const uint8_t* in, FrameHeader& out); + +// ============================================================================ +// L3 application AEAD — ChaCha20-Poly1305 IETF, per-transfer key. +// ============================================================================ +class ZdcAead { +public: + // Fill key with 32 fresh CSPRNG bytes. One-time global sodium_init() is handled. + // Returns OK or ERR_INTERNAL. + static Status generate_key(std::vector& key /*out, 32B*/); + + // Deterministic nonce = transfer_id(8, big-endian) || nonce_ctr(4, big-endian). + // nonce_ctr is a per-frame COUNTER unique WITHIN the transfer (NOT the wire seq): + // DATA chunk i -> i ; START -> 0xFFFFFFFF ; END -> 0xFFFFFFFE. + // Using the wire seq directly would be UNSAFE (START seq 0 collides with DATA[0] + // seq 0 under the same key). The key is fresh per transfer, so a unique counter + // makes every (key, nonce) pair unique. Exposed so tests can assert uniqueness. + static void derive_nonce(uint64_t transfer_id, uint32_t nonce_ctr, + uint8_t out_nonce[AEAD_NPUBBYTES]); + + // Encrypt plaintext -> ciphertext (= plaintext_len + 16B tag). aad binds the frame + // header. Returns OK or ERR_INTERNAL. key must be 32 bytes. + static Status encrypt(const std::vector& key, + uint64_t transfer_id, uint32_t seq, + const uint8_t* aad, size_t aad_len, + const std::vector& plaintext, + std::vector& ciphertext /*out*/); + + // Decrypt ciphertext (>= 16B) -> plaintext. Returns OK, ERR_AEAD_FAIL (tamper / + // wrong key / wrong aad), or ERR_INTERNAL. + static Status decrypt(const std::vector& key, + uint64_t transfer_id, uint32_t seq, + const uint8_t* aad, size_t aad_len, + const std::vector& ciphertext, + std::vector& plaintext /*out*/); + + // SHA-256 over [data,data+len) -> out[0..31]. Used for the content-hash anchor. + static Status sha256(const uint8_t* data, size_t len, uint8_t out[CONTENT_HASH_LEN]); +}; + +// ============================================================================ +// Encoder — plaintext bytes + meta -> a vector of 512-byte memo frames. +// ============================================================================ +class Encoder { +public: + // Build the full frame list for a transfer: + // frames[0] = START (encrypted meta) + // frames[1..N] = DATA (encrypted chunks, seq 0..N-1) + // frames[N+1] = END (encrypted content hash, seq == chunk_count) + // frames[N+2] = KEY (the 32B key) IFF include_key_frame == true + // Each frame is exactly 512 bytes (zero-padded past payload_len). + // + // transfer_id: caller-chosen (random 64-bit, or a ZSLP token_id) so concurrent + // transfers don't collide. + // key: 32 bytes (from ZdcAead::generate_key). Reused only within this one transfer. + // include_key_frame: false = "seal then reveal" (deliver key later / out-of-band). + // + // Returns OK, ERR_OVERSIZE (plaintext too large), or ERR_INTERNAL. + static Status encode(uint64_t transfer_id, + const std::vector& key, + const std::vector& plaintext, + const TransferMeta& meta_in, + bool include_key_frame, + std::vector >& frames_out); + + // Build ONLY the KEY frame (for reveal-later as a separate later tx). + static Status encode_key_frame(uint64_t transfer_id, + const std::vector& key, + uint32_t chunk_count, + std::vector& frame_out); +}; + +// ============================================================================ +// Decoder — stateful reassembly across many memos (out-of-order, dup, partial). +// +// USAGE: +// Decoder d; +// for each decrypted memo m (512 bytes): d.add_frame(m); // any order, dups ok +// if (d.is_complete()) { +// std::vector out; TransferMeta meta; +// Status s = d.assemble(out, meta); // needs key (via add_frame KEY or set_key) +// } +// +// One Decoder == one transfer_id. Caller routes frames by (zaddr, transfer_id); +// add_frame rejects frames whose transfer_id doesn't match the first one seen. +// ============================================================================ +class Decoder { +public: + Decoder(); + // Zeroizes the per-transfer key (sodium_memzero) so the secret never lingers + // in freed heap memory. Ciphertext/aad buffers hold no secret and are not wiped. + ~Decoder(); + + // Feed one 512-byte memo. Non-ZDC1 / wrong-version / bad-crc memos are rejected + // with the corresponding ERR_* (caller treats ERR_BAD_MAGIC as "ordinary memo, + // not for me"). Duplicate seq is ignored (first wins) and returns OK. A KEY frame + // populates the key. Mismatched transfer_id returns ERR_BAD_STATE. + Status add_frame(const uint8_t* memo, size_t len); + Status add_frame(const std::vector& memo); + + // Supply the per-transfer key out-of-band (when no KEY frame is on chain). + Status set_key(const std::vector& key); + + bool have_start() const { return have_start_; } + bool have_end() const { return have_end_; } + bool have_key() const { return have_key_; } + uint64_t transfer_id() const { return transfer_id_; } + + // chunk_count from START (authoritative) or 0 if START not yet seen. + uint32_t chunk_count() const { return chunk_count_; } + // how many distinct DATA seqs received so far. + uint32_t received_chunks() const { return (uint32_t)data_.size(); } + + // Structurally complete: START + END + all DATA seqs in [0,chunk_count). Does + // NOT require the key (you can be complete-but-sealed). + bool is_complete() const; + + // List of missing DATA seqs in [0,chunk_count) (empty if complete or no START). + std::vector missing_chunks() const; + + // Reassemble + AEAD-decrypt + verify END content hash. Requires is_complete() + // AND have_key(). On success out_plaintext holds the exact original bytes and + // out_meta the START metadata. + // Returns OK, ERR_INCOMPLETE, ERR_NO_KEY, ERR_AEAD_FAIL, ERR_HASH_MISMATCH. + Status assemble(std::vector& out_plaintext, TransferMeta& out_meta) const; + +private: + bool seen_any_; + uint64_t transfer_id_; + bool have_start_; + bool have_end_; + bool have_key_; + uint32_t chunk_count_; + std::vector key_; // 32B once known + std::vector start_meta_ct_; // START payload ciphertext + std::vector start_meta_aad_; // START header bytes (for AEAD aad) + std::vector end_hash_ct_; // END payload ciphertext + std::vector end_hash_aad_; // END header bytes + std::map > data_; // seq -> ciphertext + std::map > data_aad_; // seq -> header bytes + + Status ingest_parsed(const FrameHeader& h, const uint8_t* payload); +}; + +// ============================================================================ +// NFT fingerprint binding (the on-chain anchor). +// +// The END frame carries SHA-256 over the PLAINTEXT (an integrity check used at +// reassembly). The on-chain NFT anchor, however, commits to the CIPHERTEXT (see +// doc/nft/CONTENT_MODEL.md §2.6 "the Merkle is over CIPHERTEXT", verify-before- +// decrypt). This helper computes that ciphertext fingerprint = SHA-256 over the +// concatenated DATA-frame ciphertext payloads (payload_len bytes each, in seq +// order), so: +// * the MINT path can set ZSLP document_hash = ciphertext_fingerprint(frames), +// making the public token cryptographically commit to the private bytes; +// * a RECIPIENT (or any node) can verify the on-chain anchor matches the +// received frames BEFORE possessing the key (verify-before-decrypt), proving +// "these are the committed bytes" without revealing the plaintext. +// +// `frames` is the encoder output (START, DATA*, END[, KEY]); only DATA frames +// contribute. Returns ERR_BAD_STATE if a frame is malformed, else OK with the +// 32-byte fingerprint in `out`. Computing it over ciphertext means it is stable +// regardless of whether the key has been revealed. +// ============================================================================ +Status ciphertext_fingerprint(const std::vector >& frames, + uint8_t out[CONTENT_HASH_LEN]); + +// Human-readable status string (for logs / RPC errors). Never logs key material. +const char* status_str(Status s); + +} // namespace zdc + +#endif // ZCLASSIC_DATACHANNEL_ZDC_H diff --git a/src/gtest/test_nftoffer.cpp b/src/gtest/test_nftoffer.cpp new file mode 100644 index 00000000000..4081a2c4ceb --- /dev/null +++ b/src/gtest/test_nftoffer.cpp @@ -0,0 +1,390 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// Unit tests for the NFT SELL pillar's load-bearing, wallet-independent pieces +// (mechanism A', doc/nft/NFT_SELL_DESIGN.md). The full RPC path (live CWallet, +// keypool, mempool, blob store) is exercised by the committed regtest harness +// qa/zslp/nft-sell-regtest.sh. What IS unit-tested here is every PURE decision +// the offer builder/verifier delegates: +// +// 1. THE TEMPLATE: vout[0] is the EXISTING ZSLP SEND encoder ZSLPBuildSend(BE, +// {1}); vout[1] is the buyer's dust at the fee-rate-derived floor; vout[2] +// is the seller payout. The REAL parse seam (CZSLPIndexer::ParseTx) reads +// vout[0] as a SEND for tokenId crediting vout[1], and the REAL read-only +// conservation gate (CZSLPStore::WouldBeValid) ACCEPTS the swap (availIn=1, +// requiredOut=1) and REJECTS a recipient-redirect/over-map. +// +// 2. SIGHASH ALL|ANYONECANPAY: the seller signs ONLY vin[0] over the COMPLETE +// 3-output set. We sign with real keys, then prove (a) the signature +// VERIFIES against the unmodified template, (b) editing vout[2] price or +// vout[1] recipient BREAKS VerifyScript (the seller's commitment is +// un-editable), and (c) APPENDING a buyer funding input (vin[1]) does NOT +// break vin[0] (ANYONECANPAY zeroes the prevouts hash). +// +// 3. IsStandardTx: the assembled swap tx is RELAY-STANDARD on MAINNET at +// Sapling height — the no-fork premise (unmodified nodes relay+mine it). +// +// HONESTY: the offer-blob (de)serializer and the nft_verifyoffer field-mismatch +// reasons live in rpc/nftoffer.cpp behind ENABLE_WALLET and need a live wallet +// to drive end-to-end; they are covered by the regtest harness, not here. + +#include + +#include "chainparams.h" +#include "consensus/upgrades.h" +#include "key.h" +#include "key_io.h" +#include "keystore.h" +#include "main.h" +#include "primitives/transaction.h" +#include "script/interpreter.h" +#include "script/script.h" +#include "script/sign.h" +#include "script/standard.h" +#include "uint256.h" +#include "wallet/zslpwallet.h" +#include "zslp/zslpindexer.h" +#include "zslp/zslpmsg.h" +#include "zslp/zslpstore.h" + +#include +#include +#include + +namespace { + +uint256 H(uint8_t b) +{ + std::vector v(32, 0); + v[0] = b; + return uint256(v); +} + +CZSLPStore* NewStore() +{ + return new CZSLPStore("nft-offer-test", 1 << 20, /*fMemory=*/true, + /*fWipe=*/true); +} + +std::function AddrLabels() +{ + return [](int32_t n) -> std::string { + if (n <= 0) return std::string(); + return std::string("t1vout") + std::to_string(n); + }; +} + +// Drive a tx through the EXACT production indexer path (parse + apply). +uint256 ApplyRealTx(CZSLPStore* s, const CTransaction& tx, int64_t height) +{ + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesis = false; + bool present = CZSLPIndexer::ParseTx(tx, height, parsed, genesisMeta, + haveGenesis); + std::vector vin; + for (size_t k = 0; k < tx.vin.size(); ++k) + vin.push_back(tx.vin[k].prevout); + s->ApplyTransaction(vin, present ? &parsed : NULL, tx.GetHash(), height, + haveGenesis ? &genesisMeta : NULL, AddrLabels(), + (int32_t)tx.vout.size()); + return tx.GetHash(); +} + +// The self-validate gate, exactly as nft_makeoffer/nft_takeoffer call it. +bool SelfValidate(CZSLPStore* s, const CTransaction& tx, int64_t height, + std::string& reason) +{ + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesis = false; + if (!CZSLPIndexer::ParseTx(tx, height, parsed, genesisMeta, haveGenesis)) { + reason = "no SLP message at vout[0]"; + return false; + } + std::vector vin; + for (size_t k = 0; k < tx.vin.size(); ++k) + vin.push_back(tx.vin[k].prevout); + return s->WouldBeValid(vin, &parsed, tx.GetHash(), + haveGenesis ? &genesisMeta : NULL, + (int32_t)tx.vout.size(), reason); +} + +// Assemble the EXACT 3-output sell template nft_makeoffer builds: vout[0] = +// ZSLPBuildSend(BE,{1}), vout[1] = buyer NFT dust, vout[2] = seller payout. The +// NFT outpoint is vin[0]; `fundingOps` are appended as vin[1..]. +CMutableTransaction MakeSellTemplate(const uint256& tokenId, + const COutPoint& nftOutpoint, + const CScript& buyerScript, CAmount dust, + const CScript& payoutScript, CAmount price, + const std::vector& fundingOps) +{ + uint8_t be[32]; ZSLPTokenIdToBE(tokenId, be); + std::vector opret = ZSLPBuildSend(be, {1}); + + CMutableTransaction mtx; + mtx.fOverwintered = true; + mtx.nVersion = 4; + mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID; + mtx.vin.push_back(CTxIn(nftOutpoint)); + for (size_t i = 0; i < fundingOps.size(); ++i) + mtx.vin.push_back(CTxIn(fundingOps[i])); + mtx.vout.push_back(CTxOut(0, CScript(opret.begin(), opret.end()))); // vout[0] + mtx.vout.push_back(CTxOut(dust, buyerScript)); // vout[1] + mtx.vout.push_back(CTxOut(price, payoutScript)); // vout[2] + return mtx; +} + +CScript P2PKH(uint8_t seed) +{ + std::vector h(20, seed); + return GetScriptForDestination(CKeyID(uint160(h))); +} + +} // namespace + +// ════════════════════════════════════════════════════════════════════════ +// 1. TEMPLATE -> correct ledger effect (vout[0]=SEND encoder, credits vout[1]) +// ════════════════════════════════════════════════════════════════════════ + +TEST(NftOfferTemplate, Vout0IsSendEncoderCreditingVout1) +{ + CZSLPStore* s = NewStore(); + + // Genesis an NFT (qty 1) to vout[1]. + std::vector gen = + ZSLPBuildGenesis("", "Art #1", "", NULL, /*dec=*/0, /*baton=*/0, /*qty=*/1); + ASSERT_FALSE(gen.empty()); + CMutableTransaction gmtx; + gmtx.vout.push_back(CTxOut(0, CScript(gen.begin(), gen.end()))); + gmtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0x01))); + CTransaction gtx(gmtx); + uint256 tid = ApplyRealTx(s, gtx, 1); + CZSLPTokenUtxo nftRec; + ASSERT_TRUE(s->GetUtxo(tid, 1, nftRec)); + ASSERT_EQ(nftRec.amount, (int64_t)1); + + // Build the sell template spending the NFT (gtx:1). + CScript buyer = P2PKH(0xBB), payout = P2PKH(0xAA); + CMutableTransaction swap = MakeSellTemplate( + tid, COutPoint(gtx.GetHash(), 1), buyer, SLP_TOKEN_DUST, payout, + /*price=*/100000000, /*fundingOps=*/{COutPoint(H(0x77), 0)}); + CTransaction swapTx(swap); + + // vout[0] parses as a SEND for tid crediting vout[1] with qty 1. + CZSLPParsedMsg parsed; CZSLPToken meta; bool haveGen = false; + ASSERT_TRUE(CZSLPIndexer::ParseTx(swapTx, 2, parsed, meta, haveGen)); + EXPECT_EQ(parsed.type, ZSLP_MSG_SEND); + EXPECT_EQ(parsed.tokenId, tid); + EXPECT_EQ(parsed.numOutputs, 1); + EXPECT_EQ(parsed.outputQuantities[0], (int64_t)1); + + // Conservation gate ACCEPTS (availIn=1 from vin[0]=NFT, requiredOut=1). + std::string why; + EXPECT_TRUE(SelfValidate(s, swapTx, 2, why)) << why; + + // Applying it for real moves the NFT to vout[1] (the buyer) and conserves. + ApplyRealTx(s, swapTx, 2); + CZSLPTokenUtxo moved; + ASSERT_TRUE(s->GetUtxo(swapTx.GetHash(), 1, moved)); + EXPECT_EQ(moved.amount, (int64_t)1); + EXPECT_EQ(moved.address, std::string("t1vout1")); // the buyer's vout[1] + // The seller's old NFT UTXO is consumed (no double-ownership). + CZSLPTokenUtxo spent; + EXPECT_FALSE(s->GetUtxo(gtx.GetHash(), 1, spent)); + // The payout output (vout[2]) carries no token. + CZSLPTokenUtxo none; + EXPECT_FALSE(s->GetUtxo(swapTx.GetHash(), 2, none)); + + delete s; +} + +TEST(NftOfferTemplate, ConservationRejectsRedirectedSecondCredit) +{ + // A SEND that names TWO credits ({1,1}) but the NFT input only carries 1 + // unit must be REJECTED by WouldBeValid (the buyer must never be promised a + // credit the input can't cover). This pins the verifyoffer conservation arm. + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("", "Art", "", NULL, 0, 0, /*qty=*/1); + CMutableTransaction gmtx; + gmtx.vout.push_back(CTxOut(0, CScript(gen.begin(), gen.end()))); + gmtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0x01))); + CTransaction gtx(gmtx); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; ZSLPTokenIdToBE(tid, be); + std::vector opret = ZSLPBuildSend(be, {1, 1}); // over-credit + CMutableTransaction mtx; + mtx.vin.push_back(CTxIn(COutPoint(gtx.GetHash(), 1))); + mtx.vout.push_back(CTxOut(0, CScript(opret.begin(), opret.end()))); + mtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0xBB))); + mtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0xCC))); + CTransaction tx(mtx); + std::string why; + EXPECT_FALSE(SelfValidate(s, tx, 2, why)); // availIn 1 < requiredOut 2 + delete s; +} + +// ════════════════════════════════════════════════════════════════════════ +// 2. SIGHASH ALL|ANYONECANPAY: seller pins ALL outputs, only inputs are open +// ════════════════════════════════════════════════════════════════════════ + +namespace { +// Sign vin[0] of `mtx` over scriptPubKey `spk`/`amount` with sighash `sht`. +bool SignVin0(const CKeyStore& ks, CMutableTransaction& mtx, const CScript& spk, + CAmount amount, int sht, uint32_t branchId) +{ + return SignSignature(ks, spk, mtx, 0, amount, SigHashType(sht), branchId); +} + +// Verify vin[i] of `tx` against `spk`/`amount`. +bool CheckVin(const CTransaction& tx, unsigned i, const CScript& spk, + CAmount amount, uint32_t branchId) +{ + ScriptError serr = SCRIPT_ERR_OK; + return VerifyScript(tx.vin[i].scriptSig, spk, STANDARD_SCRIPT_VERIFY_FLAGS, + TransactionSignatureChecker(&tx, i, amount), branchId, + &serr); +} +} // namespace + +TEST(NftOfferSighash, AllAnyonecanpayPinsOutputsButOpensInputs) +{ + SelectParams(CBaseChainParams::REGTEST); + uint32_t branchId = NetworkUpgradeInfo[Consensus::UPGRADE_SAPLING].nBranchId; + + // Seller key + the NFT-bearing P2PKH the offer spends at vin[0]. + CBasicKeyStore keystore; + CKey sellerKey; sellerKey.MakeNewKey(true); + keystore.AddKeyPubKey(sellerKey, sellerKey.GetPubKey()); + CScript nftSpk = GetScriptForDestination(sellerKey.GetPubKey().GetID()); + const CAmount nftValue = SLP_TOKEN_DUST; + + uint256 tid = H(0x42); + CScript buyer = P2PKH(0xBB), payout = P2PKH(0xAA); + const CAmount price = 250000000; + + // --- (a) seller signs ONLY vin[0] over the complete 3-output set --- + CMutableTransaction offer = MakeSellTemplate( + tid, COutPoint(H(0x10), 0), buyer, SLP_TOKEN_DUST, payout, price, {}); + ASSERT_TRUE(SignVin0(keystore, offer, nftSpk, nftValue, + SIGHASH_ALL | SIGHASH_ANYONECANPAY, branchId)); + EXPECT_TRUE(CheckVin(CTransaction(offer), 0, nftSpk, nftValue, branchId)) + << "seller's ALL|ANYONECANPAY signature must verify on the template"; + + // --- (b) BUYER APPENDS a funding input (vin[1]) -> vin[0] still verifies --- + // ANYONECANPAY zeroes hashPrevouts/hashSequence, so adding inputs does not + // invalidate the seller's signature. (No output edit -> hashOutputs intact.) + CMutableTransaction filled = offer; // keep the seller's signed scriptSig + filled.vin.push_back(CTxIn(COutPoint(H(0x99), 3))); // buyer funding input + EXPECT_TRUE(CheckVin(CTransaction(filled), 0, nftSpk, nftValue, branchId)) + << "appending a buyer funding input must NOT break the seller's vin[0]"; + + // --- (c) TAMPER vout[2] price DOWN -> vin[0] VerifyScript FAILS --- + { + CMutableTransaction tampered = offer; + tampered.vout[2].nValue = price - 1; // shave a satoshi off the payout + EXPECT_FALSE(CheckVin(CTransaction(tampered), 0, nftSpk, nftValue, branchId)) + << "lowering the payout must break the seller's ALL signature"; + } + + // --- (d) TAMPER vout[1] recipient -> vin[0] VerifyScript FAILS --- + { + CMutableTransaction tampered = offer; + tampered.vout[1].scriptPubKey = P2PKH(0xEE); // redirect the NFT + EXPECT_FALSE(CheckVin(CTransaction(tampered), 0, nftSpk, nftValue, branchId)) + << "redirecting the NFT recipient must break the seller's ALL signature"; + } + + SelectParams(CBaseChainParams::REGTEST); +} + +// SINGLE|ANYONECANPAY would NOT pin the payout (it commits vin[0] only to +// vout[0]=OP_RETURN), proving the design's §0 rejection: only ALL fits ZSLP. +TEST(NftOfferSighash, SingleWouldFailToPinThePayout) +{ + SelectParams(CBaseChainParams::REGTEST); + uint32_t branchId = NetworkUpgradeInfo[Consensus::UPGRADE_SAPLING].nBranchId; + + CBasicKeyStore keystore; + CKey k; k.MakeNewKey(true); + keystore.AddKeyPubKey(k, k.GetPubKey()); + CScript nftSpk = GetScriptForDestination(k.GetPubKey().GetID()); + const CAmount nftValue = SLP_TOKEN_DUST; + + CMutableTransaction offer = MakeSellTemplate( + H(0x42), COutPoint(H(0x10), 0), P2PKH(0xBB), SLP_TOKEN_DUST, + P2PKH(0xAA), 250000000, {}); + ASSERT_TRUE(SignVin0(keystore, offer, nftSpk, nftValue, + SIGHASH_SINGLE | SIGHASH_ANYONECANPAY, branchId)); + // Under SINGLE, vin[0] is committed only to vout[0] (the OP_RETURN), so + // editing the payout vout[2] does NOT break the signature — exactly why + // SINGLE is unusable for this template (the payout is unpinned). ALL is + // required (test above). Demonstrate the gap: + CMutableTransaction tampered = offer; + tampered.vout[2].nValue = 1; // gut the payout + EXPECT_TRUE(CheckVin(CTransaction(tampered), 0, nftSpk, nftValue, branchId)) + << "SINGLE leaves the payout editable — the §0 reason ALL is mandatory"; + + SelectParams(CBaseChainParams::REGTEST); +} + +// ════════════════════════════════════════════════════════════════════════ +// 3. IsStandardTx: the swap tx is RELAY-STANDARD on MAINNET (no-fork premise) +// ════════════════════════════════════════════════════════════════════════ + +TEST(NftOfferStandardness, SwapTxIsStandardOnMainnet) +{ + SelectParams(CBaseChainParams::MAIN); + const int nHeight = 476969; // MAIN Sapling activation + ASSERT_TRUE(Params().GetConsensus().NetworkUpgradeActive( + nHeight, Consensus::UPGRADE_SAPLING)); + + uint256 tid = H(0x42); + // Two real P2PKH outputs (buyer dust + payout); one funding input. Use a + // push-only 72-byte dummy scriptSig per input so the per-txin standardness + // checks pass without real signing (mirrors test_zslp_wallet's G2 helper). + CMutableTransaction mtx = CreateNewContextualCMutableTransaction( + Params().GetConsensus(), nHeight); + uint8_t be[32]; ZSLPTokenIdToBE(tid, be); + std::vector opret = ZSLPBuildSend(be, {1}); + ASSERT_FALSE(opret.empty()); + + CTxIn nftIn(COutPoint(uint256S("01"), 1)); + nftIn.scriptSig = CScript() << std::vector(72, 0); + mtx.vin.push_back(nftIn); + CTxIn fundIn(COutPoint(uint256S("02"), 0)); + fundIn.scriptSig = CScript() << std::vector(72, 0); + mtx.vin.push_back(fundIn); + + mtx.vout.push_back(CTxOut(0, CScript(opret.begin(), opret.end()))); // vout[0] + mtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0xBB))); // vout[1] + mtx.vout.push_back(CTxOut(100000000, P2PKH(0xAA))); // vout[2] + CTransaction swap(mtx); + + std::string reason; + EXPECT_TRUE(IsStandardTx(swap, reason, nHeight)) + << "swap carrier not standard: reason=" << reason; + EXPECT_EQ(swap.nVersion, 4); + + // Exactly one OP_RETURN (else IsStandardTx rejects multi-op-return). + int nNull = 0; + for (size_t i = 0; i < swap.vout.size(); ++i) + if (swap.vout[i].scriptPubKey.size() > 0 && + swap.vout[i].scriptPubKey[0] == OP_RETURN) + ++nNull; + EXPECT_EQ(nNull, 1); + + SelectParams(CBaseChainParams::REGTEST); // restore for sibling tests +} + +// The fee-rate-derived dust floor for vout[1] is never below the 546 SLP +// convention (so older relays that assume that floor still accept the offer). +TEST(NftOfferTemplate, DustFloorNeverBelow546) +{ + CScript p = P2PKH(0xBB); + CTxOut probe(0, p); + CAmount floor = probe.GetDustThreshold(::minRelayTxFee); + CAmount D = std::max((CAmount)SLP_TOKEN_DUST, floor); + EXPECT_GE(D, (CAmount)SLP_TOKEN_DUST); +} diff --git a/src/gtest/test_zdc.cpp b/src/gtest/test_zdc.cpp new file mode 100644 index 00000000000..a74fd52ea6d --- /dev/null +++ b/src/gtest/test_zdc.cpp @@ -0,0 +1,515 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license. +// +// GoogleTest port of the standalone ZDC1 codec self-checks +// (src/datachannel/test/zdc_test.cpp). Same coverage, run under zcash-gtest so +// the codec has a CI gate inside the daemon build: +// header round-trip + endianness, CRC, AEAD round-trip, NONCE UNIQUENESS +// (the catastrophic-if-wrong property), AAD binding (reorder/retype fails), +// tamper detection, truncation/dup/missing/reorder reassembly, size caps, +// empty + maximal payloads, seal-then-reveal (KEY frame), out-of-band key, +// non-ZDC1 memo passthrough, and the ciphertext fingerprint anchor. + +#include + +#include "datachannel/zdc.h" +#include "consensus/consensus.h" // MAX_TX_SIZE_AFTER_SAPLING + +#include + +#include +#include +#include +#include + +using namespace zdc; + +namespace { + +// One-time libsodium init shared across the ZDC tests. ZdcAead also calls +// sodium_init() lazily, but the standalone test does it explicitly, so mirror it. +// (sodium_init returns 0 on first success, 1 if already initialized, -1 on +// failure; -1 here would surface as an AEAD failure in the first test anyway.) +static const int g_zdc_sodium_init = sodium_init(); + +std::vector rand_bytes(size_t n) { + std::vector v(n); + if (n) randombytes_buf(&v[0], n); + return v; +} + +std::vector make_key() { + std::vector k; + EXPECT_EQ(ZdcAead::generate_key(k), OK); + EXPECT_EQ(k.size(), AEAD_KEYBYTES); + return k; +} + +} // namespace + +TEST(ZDC, HeaderRoundtrip) { + FrameHeader h; + h.magic = ZDC_MAGIC; h.version = ZDC_VERSION; h.type = FT_DATA; + h.flags = FL_CIPHERTEXT; h.cipher_id = CIPHER_CHACHA20POLY1305; + h.transfer_id = 0x0123456789ABCDEFull; h.seq = 0xDEADBEEF; + h.chunk_count = 12345; h.payload_len = 480; h.crc32 = 0xCAFEBABE; + h.reserved = 0; + uint8_t buf[HEADER_SIZE]; + serialize_header(h, buf); + // big-endian magic on the wire: 0x5A 0x44 0x43 0x31 = "ZDC1" + EXPECT_EQ(buf[0], 0x5A); EXPECT_EQ(buf[1], 0x44); + EXPECT_EQ(buf[2], 0x43); EXPECT_EQ(buf[3], 0x31); + EXPECT_EQ(buf[4], ZDC_VERSION); + EXPECT_EQ(buf[5], FT_DATA); + FrameHeader g; + uint8_t memo[MEMO_SIZE]; std::memset(memo, 0, sizeof memo); + std::memcpy(memo, buf, HEADER_SIZE); + EXPECT_EQ(parse_header(memo, g), OK); + EXPECT_EQ(g.magic, h.magic); + EXPECT_EQ(g.version, h.version); + EXPECT_EQ(g.type, h.type); + EXPECT_EQ(g.transfer_id, h.transfer_id); + EXPECT_EQ(g.seq, h.seq); + EXPECT_EQ(g.chunk_count, h.chunk_count); + EXPECT_EQ(g.payload_len, h.payload_len); + EXPECT_EQ(g.crc32, h.crc32); +} + +TEST(ZDC, HeaderRejects) { + uint8_t memo[MEMO_SIZE]; std::memset(memo, 0, sizeof memo); + FrameHeader g; + EXPECT_EQ(parse_header(memo, g), ERR_BAD_MAGIC); + FrameHeader h; h.magic=ZDC_MAGIC; h.version=0x99; h.type=FT_DATA; + h.flags=0; h.cipher_id=0; h.transfer_id=1; h.seq=0; h.chunk_count=1; + h.payload_len=0; h.crc32=0; h.reserved=0; + serialize_header(h, memo); + EXPECT_EQ(parse_header(memo, g), ERR_BAD_VERSION); + h.version=ZDC_VERSION; h.type=0x77; serialize_header(h, memo); + EXPECT_EQ(parse_header(memo, g), ERR_BAD_TYPE); + h.type=FT_DATA; h.reserved=1; serialize_header(h, memo); + EXPECT_EQ(parse_header(memo, g), ERR_BAD_STATE); + h.reserved=0; h.chunk_count=MAX_CHUNK_COUNT+1; serialize_header(h, memo); + EXPECT_EQ(parse_header(memo, g), ERR_OVERSIZE); +} + +TEST(ZDC, Crc) { + // CRC-32/IEEE of "123456789" is the well-known 0xCBF43926. + const char* s = "123456789"; + EXPECT_EQ(crc32((const uint8_t*)s, 9), 0xCBF43926u); + EXPECT_EQ(crc32((const uint8_t*)"", 0), 0u); +} + +TEST(ZDC, AeadRoundtrip) { + std::vector key = make_key(); + std::vector pt = rand_bytes(200); + uint8_t aad[8] = {1,2,3,4,5,6,7,8}; + std::vector ct, out; + EXPECT_EQ(ZdcAead::encrypt(key, 42, 7, aad, 8, pt, ct), OK); + EXPECT_EQ(ct.size(), pt.size() + AEAD_ABYTES); + EXPECT_EQ(ZdcAead::decrypt(key, 42, 7, aad, 8, ct, out), OK); + EXPECT_EQ(out.size(), pt.size()); + EXPECT_TRUE(out == pt); + std::vector e, ec, eo; + EXPECT_EQ(ZdcAead::encrypt(key, 1, 0, aad, 8, e, ec), OK); + EXPECT_EQ(ec.size(), (size_t)AEAD_ABYTES); + EXPECT_EQ(ZdcAead::decrypt(key, 1, 0, aad, 8, ec, eo), OK); + EXPECT_EQ(eo.size(), 0u); +} + +TEST(ZDC, AeadTamperAndAad) { + std::vector key = make_key(); + std::vector pt = rand_bytes(100); + uint8_t aad[4] = {0xAA,0xBB,0xCC,0xDD}; + std::vector ct, out; + EXPECT_EQ(ZdcAead::encrypt(key, 9, 3, aad, 4, pt, ct), OK); + + std::vector ct2 = ct; ct2[0] ^= 0x01; + EXPECT_EQ(ZdcAead::decrypt(key, 9, 3, aad, 4, ct2, out), ERR_AEAD_FAIL); + ct2 = ct; ct2[ct2.size()-1] ^= 0x80; + EXPECT_EQ(ZdcAead::decrypt(key, 9, 3, aad, 4, ct2, out), ERR_AEAD_FAIL); + EXPECT_EQ(ZdcAead::decrypt(key, 9, 4, aad, 4, ct, out), ERR_AEAD_FAIL); + EXPECT_EQ(ZdcAead::decrypt(key, 10, 3, aad, 4, ct, out), ERR_AEAD_FAIL); + uint8_t aad2[4] = {0xAA,0xBB,0xCC,0xDE}; + EXPECT_EQ(ZdcAead::decrypt(key, 9, 3, aad2, 4, ct, out), ERR_AEAD_FAIL); + std::vector key2 = make_key(); + EXPECT_EQ(ZdcAead::decrypt(key2, 9, 3, aad, 4, ct, out), ERR_AEAD_FAIL); +} + +// THE security-critical test: across a full transfer's frames, no two +// L3-encrypted frames share a (key, nonce). The key is constant within a +// transfer, so a duplicate nonce here would be CATASTROPHIC nonce reuse. +TEST(ZDC, NonceUniqueness) { + std::vector key = make_key(); + uint32_t sizes[] = {0, 1, DATA_PLAINTEXT_PER_FRAME, DATA_PLAINTEXT_PER_FRAME+1, + 5*DATA_PLAINTEXT_PER_FRAME, 5*DATA_PLAINTEXT_PER_FRAME+13}; + for (size_t si = 0; si < sizeof(sizes)/sizeof(sizes[0]); ++si) { + uint64_t tid = 0xABCDEF0011223344ull ^ si; + std::vector pt = rand_bytes(sizes[si]); + TransferMeta meta; meta.filename = "x"; meta.content_type = "application/octet-stream"; + std::vector > frames; + ASSERT_EQ(Encoder::encode(tid, key, pt, meta, true, frames), OK); + + std::set nonces; + for (size_t fi = 0; fi < frames.size(); ++fi) { + FrameHeader h; + ASSERT_EQ(parse_header(&frames[fi][0], h), OK); + if (h.cipher_id == CIPHER_NONE) continue; // KEY frame + uint32_t ctr; + if (h.type == FT_START) ctr = 0xFFFFFFFFu; + else if (h.type == FT_END) ctr = 0xFFFFFFFEu; + else ctr = h.seq; + uint8_t nonce[AEAD_NPUBBYTES]; + ZdcAead::derive_nonce(h.transfer_id, ctr, nonce); + std::string key_s((const char*)nonce, AEAD_NPUBBYTES); + bool inserted = nonces.insert(key_s).second; + EXPECT_TRUE(inserted); + } + } +} + +TEST(ZDC, RoundtripSizeMatrix) { + std::vector key = make_key(); + const size_t sizes[] = {0, 1, 479, 480, 481, 4096, 65536}; + for (size_t si = 0; si < sizeof(sizes)/sizeof(sizes[0]); ++si) { + size_t n = sizes[si]; + uint64_t tid = 0x5120000000000000ull | (uint64_t)n; + std::vector pt = rand_bytes(n); + TransferMeta meta; meta.filename = "m"; meta.content_type = "application/octet-stream"; + std::vector > frames; + ASSERT_EQ(Encoder::encode(tid, key, pt, meta, true, frames), OK); + + uint32_t cc = (uint32_t)((n + DATA_PLAINTEXT_PER_FRAME - 1) / DATA_PLAINTEXT_PER_FRAME); + EXPECT_EQ(frames.size(), (size_t)cc + 3); + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(frames[i].size(), MEMO_SIZE); + + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(d.add_frame(frames[i]), OK); + EXPECT_TRUE(d.is_complete()); + EXPECT_TRUE(d.have_key()); + EXPECT_EQ(d.chunk_count(), cc); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), OK); + EXPECT_EQ(out.size(), n); + EXPECT_TRUE(out == pt); + EXPECT_EQ(got.total_plaintext_size, (uint64_t)n); + EXPECT_EQ(got.chunk_count, cc); + } +} + +TEST(ZDC, FullRoundtripShuffled) { + std::vector key = make_key(); + std::vector pt = rand_bytes(3 * DATA_PLAINTEXT_PER_FRAME + 7); + TransferMeta meta; meta.filename = "secret.bin"; meta.content_type = "application/pdf"; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x1111, key, pt, meta, true, frames), OK); + EXPECT_EQ(frames.size(), (size_t)(1 + 4 + 1 + 1)); + + Decoder d; + for (size_t i = frames.size(); i-- > 0; ) { + EXPECT_EQ(d.add_frame(frames[i]), OK); + EXPECT_EQ(d.add_frame(frames[i]), OK); // duplicate ignored + } + EXPECT_TRUE(d.is_complete()); + EXPECT_TRUE(d.have_key()); + EXPECT_EQ(d.transfer_id(), 0x1111u); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), OK); + EXPECT_TRUE(out == pt); + EXPECT_EQ(got.total_plaintext_size, pt.size()); + EXPECT_EQ(got.filename, "secret.bin"); + EXPECT_EQ(got.content_type, "application/pdf"); +} + +TEST(ZDC, EmptyPayload) { + std::vector key = make_key(); + std::vector pt; + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(7, key, pt, meta, true, frames), OK); + EXPECT_EQ(frames.size(), 3u); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(d.add_frame(frames[i]), OK); + EXPECT_TRUE(d.is_complete()); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), OK); + EXPECT_EQ(out.size(), 0u); +} + +TEST(ZDC, SealThenReveal) { + std::vector key = make_key(); + std::vector pt = rand_bytes(1000); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x2222, key, pt, meta, false, frames), OK); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(d.add_frame(frames[i]), OK); + EXPECT_TRUE(d.is_complete()); + EXPECT_FALSE(d.have_key()); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), ERR_NO_KEY); + std::vector kf; + // Use the decoder's authoritative chunk_count (assemble does NOT populate + // out_meta when it returns ERR_NO_KEY early, so got.chunk_count is unset). + EXPECT_EQ(Encoder::encode_key_frame(0x2222, key, d.chunk_count(), kf), OK); + EXPECT_EQ(d.add_frame(kf), OK); + EXPECT_TRUE(d.have_key()); + EXPECT_EQ(d.assemble(out, got), OK); + EXPECT_TRUE(out == pt); +} + +TEST(ZDC, OobKey) { + std::vector key = make_key(); + std::vector pt = rand_bytes(900); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x3333, key, pt, meta, false, frames), OK); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(d.add_frame(frames[i]), OK); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), ERR_NO_KEY); + EXPECT_EQ(d.set_key(key), OK); + EXPECT_EQ(d.assemble(out, got), OK); + EXPECT_TRUE(out == pt); + std::vector bad(10, 0); + Decoder d2; EXPECT_EQ(d2.set_key(bad), ERR_BAD_STATE); +} + +TEST(ZDC, WrongKeyFailsAssemble) { + std::vector key = make_key(); + std::vector pt = rand_bytes(2 * DATA_PLAINTEXT_PER_FRAME + 5); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0xBEEF, key, pt, meta, false, frames), OK); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(d.add_frame(frames[i]), OK); + EXPECT_TRUE(d.is_complete()); + std::vector wrong = make_key(); + EXPECT_EQ(d.set_key(wrong), OK); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), ERR_AEAD_FAIL); +} + +TEST(ZDC, MissingAndReorderDetect) { + std::vector key = make_key(); + std::vector pt = rand_bytes(4 * DATA_PLAINTEXT_PER_FRAME); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x4444, key, pt, meta, true, frames), OK); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) { + if (i == 2) continue; // skip DATA chunk 1 + EXPECT_EQ(d.add_frame(frames[i]), OK); + } + EXPECT_FALSE(d.is_complete()); + std::vector miss = d.missing_chunks(); + ASSERT_EQ(miss.size(), 1u); + EXPECT_EQ(miss[0], 1u); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), ERR_INCOMPLETE); +} + +TEST(ZDC, TamperInTransitDetected) { + std::vector key = make_key(); + std::vector pt = rand_bytes(2 * DATA_PLAINTEXT_PER_FRAME); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x5555, key, pt, meta, true, frames), OK); + std::vector f = frames[1]; + f[HEADER_SIZE + 5] ^= 0x01; + uint32_t newcrc = crc32(&f[HEADER_SIZE], FRAME_PAYLOAD); + f[26]=(uint8_t)(newcrc>>24); f[27]=(uint8_t)(newcrc>>16); + f[28]=(uint8_t)(newcrc>>8); f[29]=(uint8_t)(newcrc); + frames[1] = f; + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(d.add_frame(frames[i]), OK); + EXPECT_TRUE(d.is_complete()); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), ERR_AEAD_FAIL); +} + +TEST(ZDC, CrcCorruptionRejected) { + std::vector key = make_key(); + std::vector pt = rand_bytes(500); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x6666, key, pt, meta, true, frames), OK); + std::vector f = frames[1]; + f[HEADER_SIZE + 0] ^= 0xFF; + EXPECT_EQ(Decoder().add_frame(f), ERR_BAD_CRC); +} + +TEST(ZDC, TruncationRejected) { + std::vector key = make_key(); + std::vector pt = rand_bytes(100); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x7777, key, pt, meta, true, frames), OK); + std::vector shortf(frames[0].begin(), frames[0].begin()+511); + EXPECT_EQ(Decoder().add_frame(shortf), ERR_TRUNCATED); +} + +TEST(ZDC, NonZdcMemoPassthrough) { + std::vector memo(MEMO_SIZE, 0); + const char* txt = "hello, this is a normal memo"; + std::memcpy(&memo[0], txt, std::strlen(txt)); + EXPECT_EQ(Decoder().add_frame(memo), ERR_BAD_MAGIC); +} + +TEST(ZDC, ForeignTransferIdRejected) { + std::vector key = make_key(); + std::vector pt = rand_bytes(100); + TransferMeta meta; + std::vector > a, b; + ASSERT_EQ(Encoder::encode(0xA, key, pt, meta, true, a), OK); + ASSERT_EQ(Encoder::encode(0xB, key, pt, meta, true, b), OK); + Decoder d; + EXPECT_EQ(d.add_frame(a[0]), OK); + EXPECT_EQ(d.add_frame(b[0]), ERR_BAD_STATE); +} + +TEST(ZDC, SizeCaps) { + std::vector key = make_key(); + EXPECT_EQ(MAX_TRANSFER_BYTES, (uint64_t)MAX_CHUNK_COUNT * DATA_PLAINTEXT_PER_FRAME); + EXPECT_EQ(DATA_PLAINTEXT_PER_FRAME, FRAME_PAYLOAD - AEAD_ABYTES); + EXPECT_EQ(FRAME_PAYLOAD, MEMO_SIZE - HEADER_SIZE); + std::vector pt = rand_bytes(10); + TransferMeta meta; meta.filename = std::string(DATA_PLAINTEXT_PER_FRAME, 'A'); + std::vector > frames; + EXPECT_EQ(Encoder::encode(1, key, pt, meta, true, frames), ERR_OVERSIZE); +} + +TEST(ZDC, MaxDataFrame) { + std::vector key = make_key(); + std::vector pt = rand_bytes(DATA_PLAINTEXT_PER_FRAME); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x8888, key, pt, meta, true, frames), OK); + EXPECT_EQ(frames.size(), (size_t)(1 + 1 + 1 + 1)); + FrameHeader h; ASSERT_EQ(parse_header(&frames[1][0], h), OK); + EXPECT_EQ(h.type, FT_DATA); + EXPECT_EQ(h.payload_len, FRAME_PAYLOAD); + Decoder d; + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(d.add_frame(frames[i]), OK); + std::vector out; TransferMeta got; + EXPECT_EQ(d.assemble(out, got), OK); + EXPECT_TRUE(out == pt); +} + +TEST(ZDC, FrameSizes) { + std::vector key = make_key(); + std::vector pt = rand_bytes(1234); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(1, key, pt, meta, true, frames), OK); + for (size_t i = 0; i < frames.size(); ++i) EXPECT_EQ(frames[i].size(), MEMO_SIZE); +} + +// ── SINGLE-TX BROADCASTABILITY (the shipped file cap is provably honest) ────── +// +// Mirrors the production constants from rpc/datachannel.cpp + wallet/ +// asyncrpcoperation_senddatafile.cpp. The point of this test is to FAIL THE BUILD +// if someone later raises the file cap past what one shielded tx can broadcast, +// turning the previously-reproduced "advertise 64KB, fail late with bad-txns- +// oversize" blocker into a compile-gated invariant. +namespace { + // KEEP IN SYNC WITH rpc/datachannel.cpp + const size_t TEST_ZDC_MAX_FILE_BYTES = 40000; + const size_t TEST_ZDC_MAX_FRAMES_PER_TX = 90; + // KEEP IN SYNC WITH wallet/asyncrpcoperation_senddatafile.cpp + const size_t TEST_SPEND_DESC_BYTES = 384; // SpendDescription on the wire + const size_t TEST_OUTPUT_DESC_BYTES = 948; // OutputDescription on the wire + const size_t TEST_TX_ENVELOPE_BYTES = 256; // conservative fixed overhead + // A worst-case-ish input-note count for the broadcastability check. The async + // op selects biggest-notes-first, so a funded transfer is usually 1-2 spends; + // we still prove headroom for a chunky 16-note spend. + const size_t TEST_WORST_CASE_SPENDS = 16; + + size_t projected_tx_size(size_t nSpends, size_t nDataFrames) { + size_t nOutputs = nDataFrames + 1; // + change output + return TEST_TX_ENVELOPE_BYTES + + nSpends * TEST_SPEND_DESC_BYTES + + nOutputs * TEST_OUTPUT_DESC_BYTES; + } +} + +TEST(ZDC, SingleTxFrameCeilingMatchesFileCap) { + // The max file (40000 bytes) must produce <= ZDC_MAX_FRAMES_PER_TX frames. + uint32_t cc = (uint32_t)((TEST_ZDC_MAX_FILE_BYTES + DATA_PLAINTEXT_PER_FRAME - 1) + / DATA_PLAINTEXT_PER_FRAME); + size_t totalFrames = (size_t)cc + 3; // START + END + KEY + EXPECT_LE(totalFrames, TEST_ZDC_MAX_FRAMES_PER_TX) + << "file cap implies " << totalFrames << " frames > frame ceiling " + << TEST_ZDC_MAX_FRAMES_PER_TX; + + // And actually encode a max-size payload to confirm the real encoder agrees. + std::vector key = make_key(); + std::vector pt = rand_bytes(TEST_ZDC_MAX_FILE_BYTES); + TransferMeta meta; meta.filename = "max.bin"; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0xCAFE, key, pt, meta, true, frames), OK); + EXPECT_LE(frames.size(), TEST_ZDC_MAX_FRAMES_PER_TX); +} + +TEST(ZDC, MaxFileTxIsBroadcastable) { + // The whole point of the blocker fix: the worst-case tx for a max-size file + // MUST serialize under the consensus limit (MAX_TX_SIZE_AFTER_SAPLING) — so + // it broadcasts instead of dying with "bad-txns-oversize" after proving. + size_t projected = projected_tx_size(TEST_WORST_CASE_SPENDS, TEST_ZDC_MAX_FRAMES_PER_TX); + EXPECT_LE(projected, (size_t)MAX_TX_SIZE_AFTER_SAPLING) + << "max-file tx projects to " << projected + << " bytes, over consensus limit " << MAX_TX_SIZE_AFTER_SAPLING; + + // Even with the 1-spend common case the same holds, with lots of margin. + EXPECT_LE(projected_tx_size(1, TEST_ZDC_MAX_FRAMES_PER_TX), + (size_t)MAX_TX_SIZE_AFTER_SAPLING); +} + +TEST(ZDC, OldCapWouldHaveOverflowed) { + // Regression sentinel for the ORIGINAL blocker: the previous 64KB cap (and + // anything near it) overflows a single tx. This documents WHY the cap moved. + const size_t oldCap = 64 * 1024; + uint32_t cc = (uint32_t)((oldCap + DATA_PLAINTEXT_PER_FRAME - 1) + / DATA_PLAINTEXT_PER_FRAME); + size_t oldFrames = (size_t)cc + 3; + // With the 1-spend best case the old cap STILL overflows on outputs alone. + size_t projectedOld = projected_tx_size(1, oldFrames); + EXPECT_GT(projectedOld, (size_t)MAX_TX_SIZE_AFTER_SAPLING) + << "the old 64KB cap should overflow one tx; it projects to " << projectedOld; +} + +TEST(ZDC, CiphertextFingerprint) { + std::vector key = make_key(); + std::vector pt = rand_bytes(3 * DATA_PLAINTEXT_PER_FRAME + 11); + TransferMeta meta; + std::vector > frames; + ASSERT_EQ(Encoder::encode(0x9999, key, pt, meta, true, frames), OK); + + uint8_t fp1[CONTENT_HASH_LEN]; + EXPECT_EQ(ciphertext_fingerprint(frames, fp1), OK); + + uint8_t fp2[CONTENT_HASH_LEN]; + EXPECT_EQ(ciphertext_fingerprint(frames, fp2), OK); + EXPECT_EQ(std::memcmp(fp1, fp2, CONTENT_HASH_LEN), 0); + + std::vector > shuffled(frames.rbegin(), frames.rend()); + uint8_t fp3[CONTENT_HASH_LEN]; + EXPECT_EQ(ciphertext_fingerprint(shuffled, fp3), OK); + EXPECT_EQ(std::memcmp(fp1, fp3, CONTENT_HASH_LEN), 0); + + // Verify-BEFORE-decrypt: anchor computable without the key, equals SHA-256 + // over the concatenated DATA ciphertext payloads. + crypto_hash_sha256_state hst; crypto_hash_sha256_init(&hst); + for (size_t i = 0; i < frames.size(); ++i) { + FrameHeader h; ASSERT_EQ(parse_header(&frames[i][0], h), OK); + if (h.type != FT_DATA) continue; + crypto_hash_sha256_update(&hst, &frames[i][HEADER_SIZE], h.payload_len); + } + uint8_t manual[CONTENT_HASH_LEN]; crypto_hash_sha256_final(&hst, manual); + EXPECT_EQ(std::memcmp(fp1, manual, CONTENT_HASH_LEN), 0); + + std::vector > tampered = frames; + tampered[1][HEADER_SIZE] ^= 0x01; + uint8_t fp4[CONTENT_HASH_LEN]; + EXPECT_EQ(ciphertext_fingerprint(tampered, fp4), OK); + EXPECT_NE(std::memcmp(fp1, fp4, CONTENT_HASH_LEN), 0); +} diff --git a/src/gtest/test_zslp.cpp b/src/gtest/test_zslp.cpp index 164271ba0ed..37788177cc5 100644 --- a/src/gtest/test_zslp.cpp +++ b/src/gtest/test_zslp.cpp @@ -358,21 +358,44 @@ TEST(ZSLP, BuildSendTwentyOutputsOverMaxShouldFail) EXPECT_EQ(len, 0u); // builder should reject >19 outputs } -// ── Large quantity (UINT64_MAX) ─────────────────────────────────── - -TEST(ZSLP, BuildGenesisMaxQuantity) +// ── Large quantity (UINT64_MAX / high-bit) — REJECTED (R-INT-1 / R-10) ── +// +// AMENDED: these two cases previously asserted that a UINT64_MAX (high-bit-set, +// i.e. >= 2^63) quantity PARSES and round-trips. The canonical rule (R-INT-1 / +// R-10, SECURITY_MODEL.md) now makes any quantity with the high bit set INVALID +// for the WHOLE message (it would cast to a negative int64 downstream and is a +// signed/unsigned fork surface). The builders still EMIT such a quantity (they +// do not enforce the ledger domain), but the canonical PARSER rejects it, so a +// high-bit GENESIS/MINT creates nothing. The largest VALID quantity is +// 2^63 - 1. The vector corpus (test_zslp_vectors.cpp) pins 2^63 and 2^64-1 for +// all three message types. + +TEST(ZSLP, BuildGenesisHighBitQuantityRejected) { uint8_t buf[512]; size_t len = slp_build_genesis(buf, sizeof(buf), "MAX", "Max Supply", "", nullptr, 0, 0, UINT64_MAX); ASSERT_GT(len, 0u); + struct slp_message msg; + EXPECT_FALSE(slp_parse(buf, len, &msg)); // high bit set => whole msg INVALID +} + +TEST(ZSLP, BuildGenesisMaxValidQuantity) +{ + // Largest in-domain quantity: 2^63 - 1 (high bit clear) still parses. + const uint64_t kMaxValid = (UINT64_C(1) << 63) - 1; + uint8_t buf[512]; + size_t len = slp_build_genesis(buf, sizeof(buf), + "MAX", "Max Supply", "", nullptr, 0, 0, kMaxValid); + ASSERT_GT(len, 0u); + struct slp_message msg; ASSERT_TRUE(slp_parse(buf, len, &msg)); - EXPECT_EQ(msg.initial_quantity, UINT64_MAX); + EXPECT_EQ(msg.initial_quantity, kMaxValid); } -TEST(ZSLP, BuildMintMaxQuantity) +TEST(ZSLP, BuildMintHighBitQuantityRejected) { struct uint256 token_id; memset(token_id.data, 0xAA, 32); @@ -381,8 +404,7 @@ TEST(ZSLP, BuildMintMaxQuantity) ASSERT_GT(len, 0u); struct slp_message msg; - ASSERT_TRUE(slp_parse(buf, len, &msg)); - EXPECT_EQ(msg.additional_quantity, UINT64_MAX); + EXPECT_FALSE(slp_parse(buf, len, &msg)); // high bit set => whole msg INVALID } // ── Zero quantity ───────────────────────────────────────────────── diff --git a/src/gtest/test_zslp_indexer.cpp b/src/gtest/test_zslp_indexer.cpp index 7f075daf3ff..ee9da64ad30 100644 --- a/src/gtest/test_zslp_indexer.cpp +++ b/src/gtest/test_zslp_indexer.cpp @@ -1,17 +1,29 @@ // Copyright 2026 Rhett Creighton - Apache License 2.0 // -// Unit tests for the ZSLP token store (CZSLPStore): put/get/list, balance -// accounting, and the reorg invariant — connecting then disconnecting a -// block must restore the store byte-for-byte to its prior state. +// Unit tests for the ZSLP token store (CZSLPStore) under the real SLP +// Token-Type-1, UTXO-bound conservation model: token-carrying UTXOs are the +// source of truth, the per-address balance is a derived view, and a SEND/MINT +// can only move/issue tokens that exist on spent inputs (or, for MINT, with the +// mint baton on a spent input). This file drives the store's single conservation +// entry point ApplyTransaction(...) with synthetic vin / parsed messages / +// vout-address closures — the same path the live indexer drives. // -// These tests feed parsed messages directly to the store (no full chain), -// exercising the same code path the indexer drives. +// Coverage: +// - genesis / mint / send accounting under conservation +// - the reorg invariant: connecting then disconnecting a block restores the +// store byte-for-byte (UTXOs, balances, tokens, transfers, tip) +// - the FORGE-REJECTION suite: a SEND with no token input credits NOBODY; an +// NFT (qty 1) cannot be duplicated by a forged SEND; an over-send burns its +// inputs and creates nothing; a MINT without the baton input is rejected; a +// non-SLP spend of a token UTXO burns it; intra-block spend visibility. #include #include "zslp/zslpstore.h" +#include "primitives/transaction.h" #include "uint256.h" +#include #include #include @@ -44,9 +56,73 @@ CZSLPStore* NewMemStore() return new CZSLPStore("zslp-test", 1 << 20, /*fMemory=*/true, /*fWipe=*/true); } +// A vout-index -> address map for a tx, so ApplyTransaction can resolve +// recipient addresses without a real CTransaction. Index 0 is the OP_RETURN +// by convention (empty address). +typedef std::map AddrMap; + +std::function AddrOf(const AddrMap& m) +{ + return [m](int32_t n) -> std::string { + AddrMap::const_iterator it = m.find(n); + return it == m.end() ? std::string() : it->second; + }; +} + +// Convenience: a GENESIS message. +CZSLPParsedMsg GenMsg(int64_t initialQty, int32_t batonVout = 0) +{ + CZSLPParsedMsg m; + m.type = ZSLP_MSG_GENESIS; + m.initialQuantity = initialQty; + m.mintBatonVout = batonVout; + return m; +} + +// A MINT message. +CZSLPParsedMsg MintMsg(const uint256& tokenId, int64_t addQty, int32_t batonVout = 0) +{ + CZSLPParsedMsg m; + m.type = ZSLP_MSG_MINT; + m.tokenId = tokenId; + m.additionalQuantity = addQty; + m.mintBatonVout = batonVout; + return m; +} + +// A SEND message with up to a few output quantities. The parsed-message array +// is sized to the single canonical cap (ZSLP_SEND_MAX_OUTPUTS_STORE = 19); the +// parser rejects any SEND with more than that, so the store never sees a larger +// count. Tests that exercise the cap supply exactly 19 (valid) entries. +CZSLPParsedMsg SendMsg(const uint256& tokenId, const std::vector& outs) +{ + CZSLPParsedMsg m; + m.type = ZSLP_MSG_SEND; + m.tokenId = tokenId; + m.numOutputs = (int)outs.size(); + for (size_t i = 0; i < outs.size() && i < (size_t)ZSLP_SEND_MAX_OUTPUTS_STORE; ++i) + m.outputQuantities[i] = outs[i]; + return m; +} + +// Drive a single transaction through the store inside its own connect block. +bool ApplyTx(CZSLPStore* s, const uint256& blk, int64_t height, + const std::vector& vin, const CZSLPParsedMsg* msg, + const uint256& txid, const CZSLPToken* genesisMeta, + const AddrMap& addrs, int32_t voutCount) +{ + s->ConnectBlockBegin(blk); + bool ok = s->ApplyTransaction(vin, msg, txid, height, genesisMeta, + AddrOf(addrs), voutCount); + s->ConnectBlockEnd(height, blk); + return ok; +} + +COutPoint OutPoint(const uint256& txid, uint32_t n) { return COutPoint(txid, n); } + } // namespace -// ── Genesis put/get + balance + total_minted ─────────────────────── +// ── Genesis put/get + balance + total_minted + UTXO ──────────────── TEST(ZSLPStore, GenesisPutGet) { @@ -56,10 +132,10 @@ TEST(ZSLPStore, GenesisPutGet) uint256 tid = HashFromByte(0xA1); std::string addr = "t1ExampleAddressAaa"; - s->ConnectBlockBegin(blk); - CZSLPToken token = MakeToken(tid, "ABC", 100, /*baton=*/2); - ASSERT_TRUE(s->ApplyGenesis(token, addr, tid, 1, 1000)); - s->ConnectBlockEnd(100, blk); + CZSLPToken meta = MakeToken(tid, "ABC", 100, /*baton=*/2); + CZSLPParsedMsg m = GenMsg(1000, /*batonVout=*/2); + AddrMap addrs; addrs[1] = addr; // baton at vout 2 lands at empty addr + ASSERT_TRUE(ApplyTx(s, blk, 100, {}, &m, tid, &meta, addrs, /*voutCount=*/3)); CZSLPToken got; ASSERT_TRUE(s->GetToken(tid, got)); @@ -68,11 +144,21 @@ TEST(ZSLPStore, GenesisPutGet) EXPECT_EQ(got.decimals, 2); EXPECT_EQ(got.genesisHeight, 100); EXPECT_EQ(got.totalMinted, 1000); - EXPECT_EQ(got.mintBatonVout, 2); + EXPECT_EQ(got.mintBatonVout, 2); // baton UTXO live -> mirror shows it EXPECT_EQ(s->GetBalance(tid, addr), 1000); EXPECT_EQ(s->TokenCount(), 1); + // One quantity UTXO at vout1 + one baton UTXO at vout2. + EXPECT_EQ(s->UtxoCount(), 2); + CZSLPTokenUtxo u1, u2; + ASSERT_TRUE(s->GetUtxo(tid, 1, u1)); + EXPECT_EQ(u1.amount, 1000); + EXPECT_FALSE(u1.isMintBaton); + ASSERT_TRUE(s->GetUtxo(tid, 2, u2)); + EXPECT_EQ(u2.amount, 0); + EXPECT_TRUE(u2.isMintBaton); + int64_t h; uint256 bh; ASSERT_TRUE(s->ReadTip(h, bh)); EXPECT_EQ(h, 100); @@ -81,7 +167,7 @@ TEST(ZSLPStore, GenesisPutGet) delete s; } -// ── Mint increases total_minted and balance ──────────────────────── +// ── Mint requires the baton input; without it nothing is issued ───── TEST(ZSLPStore, MintAccounting) { @@ -89,24 +175,44 @@ TEST(ZSLPStore, MintAccounting) uint256 tid = HashFromByte(0xB2); std::string addr = "t1MintRecipient"; - s->ConnectBlockBegin(HashFromByte(0x20)); - ASSERT_TRUE(s->ApplyGenesis(MakeToken(tid, "MNT", 200, 2), addr, tid, 1, 500)); - s->ConnectBlockEnd(200, HashFromByte(0x20)); + // Genesis: 500 at vout1, baton at vout2. + CZSLPToken meta = MakeToken(tid, "MNT", 200, 2); + CZSLPParsedMsg gm = GenMsg(500, 2); + AddrMap g; g[1] = addr; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x20), 200, {}, &gm, tid, &meta, g, 3)); + EXPECT_EQ(s->GetBalance(tid, addr), 500); + // MINT spending the genesis baton (tid, vout2): +250, baton continues at vout2. uint256 mintTx = HashFromByte(0xC3); - s->ConnectBlockBegin(HashFromByte(0x21)); - ASSERT_TRUE(s->ApplyMint(tid, addr, mintTx, 201, 1, 250, - /*batonMoved=*/false, 2)); - s->ConnectBlockEnd(201, HashFromByte(0x21)); + CZSLPParsedMsg mm = MintMsg(tid, 250, /*batonVout=*/2); + AddrMap mo; mo[1] = addr; + std::vector vin = { OutPoint(tid, 2) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x21), 201, vin, &mm, mintTx, NULL, mo, 3)); CZSLPToken got; ASSERT_TRUE(s->GetToken(tid, got)); EXPECT_EQ(got.totalMinted, 750); EXPECT_EQ(s->GetBalance(tid, addr), 750); + // Baton moved: genesis baton UTXO gone, new baton at the mint tx vout2. + CZSLPTokenUtxo b; + EXPECT_FALSE(s->GetUtxo(tid, 2, b)); // old baton consumed + ASSERT_TRUE(s->GetUtxo(mintTx, 2, b)); // new baton at mint tx + EXPECT_TRUE(b.isMintBaton); + + // Sibling negative: a MINT with NO baton input issues nothing. + uint256 mintTx2 = HashFromByte(0xC4); + CZSLPParsedMsg mm2 = MintMsg(tid, 1000, /*batonVout=*/2); + AddrMap mo2; mo2[1] = addr; + std::vector noBaton = { OutPoint(HashFromByte(0xEE), 0) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x22), 202, noBaton, &mm2, mintTx2, NULL, mo2, 3)); + ASSERT_TRUE(s->GetToken(tid, got)); + EXPECT_EQ(got.totalMinted, 750); // unchanged + EXPECT_EQ(s->GetBalance(tid, addr), 750); // unchanged + EXPECT_FALSE(s->GetUtxo(mintTx2, 1, b)); // no quantity UTXO created delete s; } -// ── Send credits recipients; list newest-first ───────────────────── +// ── Send moves only what spent inputs carry; list newest-first ───── TEST(ZSLPStore, SendAndListTransfers) { @@ -115,16 +221,27 @@ TEST(ZSLPStore, SendAndListTransfers) std::string a1 = "t1Sender"; std::string a2 = "t1Recipient"; - s->ConnectBlockBegin(HashFromByte(0x30)); - ASSERT_TRUE(s->ApplyGenesis(MakeToken(tid, "SND", 300), a1, tid, 1, 1000)); - s->ConnectBlockEnd(300, HashFromByte(0x30)); + // Genesis: 1000 -> a1 at (tid, vout1). + CZSLPToken meta = MakeToken(tid, "SND", 300); + CZSLPParsedMsg gm = GenMsg(1000); + AddrMap g; g[1] = a1; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x30), 300, {}, &gm, tid, &meta, g, 2)); + EXPECT_EQ(s->GetBalance(tid, a1), 1000); - s->ConnectBlockBegin(HashFromByte(0x31)); + // SEND spending (tid, vout1): 400 -> a2. (availIn-required) = 600 BURNED. uint256 sendTx = HashFromByte(0xE5); - ASSERT_TRUE(s->ApplySend(tid, a2, sendTx, 305, 1, 400)); - s->ConnectBlockEnd(305, HashFromByte(0x31)); + CZSLPParsedMsg sm = SendMsg(tid, {400}); + AddrMap so; so[1] = a2; + std::vector vin = { OutPoint(tid, 1) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x31), 305, vin, &sm, sendTx, NULL, so, 2)); EXPECT_EQ(s->GetBalance(tid, a2), 400); + EXPECT_EQ(s->GetBalance(tid, a1), 0); // sender's UTXO consumed + // Only the recipient's 400 UTXO is live now (the 600 difference was burned). + EXPECT_EQ(s->UtxoCount(), 1); + CZSLPTokenUtxo u; + ASSERT_TRUE(s->GetUtxo(sendTx, 1, u)); + EXPECT_EQ(u.amount, 400); std::vector xfers; int n = s->ListTransfers(tid, 0, 100, xfers); @@ -145,9 +262,10 @@ TEST(ZSLPStore, ListTokensBounded) CZSLPStore* s = NewMemStore(); for (int i = 0; i < 5; ++i) { uint256 tid = HashFromByte((uint8_t)(0x40 + i)); - s->ConnectBlockBegin(HashFromByte((uint8_t)(0x50 + i))); - s->ApplyGenesis(MakeToken(tid, "T", 400 + i), "t1addr", tid, 1, 10); - s->ConnectBlockEnd(400 + i, HashFromByte((uint8_t)(0x50 + i))); + CZSLPToken meta = MakeToken(tid, "T", 400 + i); + CZSLPParsedMsg gm = GenMsg(10); + AddrMap g; g[1] = "t1addr"; + ApplyTx(s, HashFromByte((uint8_t)(0x50 + i)), 400 + i, {}, &gm, tid, &meta, g, 2); } EXPECT_EQ(s->TokenCount(), 5); @@ -169,13 +287,15 @@ TEST(ZSLPStore, TokensForAddress) std::string mine = "t1Mine"; std::string other = "t1Other"; - s->ConnectBlockBegin(HashFromByte(0x80)); - ASSERT_TRUE(s->ApplyGenesis(MakeToken(t1, "AAA", 500), mine, t1, 1, 100)); - s->ConnectBlockEnd(500, HashFromByte(0x80)); + CZSLPToken m1 = MakeToken(t1, "AAA", 500); + CZSLPParsedMsg g1 = GenMsg(100); + AddrMap a1; a1[1] = mine; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x80), 500, {}, &g1, t1, &m1, a1, 2)); - s->ConnectBlockBegin(HashFromByte(0x81)); - ASSERT_TRUE(s->ApplyGenesis(MakeToken(t2, "BBB", 501), other, t2, 1, 200)); - s->ConnectBlockEnd(501, HashFromByte(0x81)); + CZSLPToken m2 = MakeToken(t2, "BBB", 501); + CZSLPParsedMsg g2 = GenMsg(200); + AddrMap a2; a2[1] = other; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x81), 501, {}, &g2, t2, &m2, a2, 2)); std::vector > rows; s->GetTokensForAddress(mine, rows); @@ -189,39 +309,53 @@ TEST(ZSLPStore, TokensForAddress) delete s; } -// ── REORG INVARIANT: connect then disconnect == prior state ──────── +// ── REORG INVARIANT: genesis + send round-trip ───────────────────── TEST(ZSLPStore, ReorgGenesisRoundTrip) { CZSLPStore* s = NewMemStore(); - // Pre-state: one token already present from an earlier block. + // Pre-state: one token already present, 5000 at baseAddr (baseTok, vout1). uint256 baseBlk = HashFromByte(0x01); uint256 baseTok = HashFromByte(0x02); std::string baseAddr = "t1Base"; - s->ConnectBlockBegin(baseBlk); - ASSERT_TRUE(s->ApplyGenesis(MakeToken(baseTok, "BASE", 10), baseAddr, - baseTok, 1, 5000)); - s->ConnectBlockEnd(10, baseBlk); + CZSLPToken bmeta = MakeToken(baseTok, "BASE", 10); + CZSLPParsedMsg bg = GenMsg(5000); + AddrMap bgaddr; bgaddr[1] = baseAddr; + ASSERT_TRUE(ApplyTx(s, baseBlk, 10, {}, &bg, baseTok, &bmeta, bgaddr, 2)); const int64_t preCount = s->TokenCount(); const int64_t preBaseBal = s->GetBalance(baseTok, baseAddr); + const int64_t preUtxos = s->UtxoCount(); + EXPECT_EQ(preBaseBal, 5000); - // Connect a new block carrying a fresh genesis + a send of the base token. + // Connect ONE block carrying: a fresh genesis (txA), and a send of the base + // token that spends (baseTok, vout1) -> 1500 to addrA + 3500 change to base. uint256 newBlk = HashFromByte(0x03); uint256 newTok = HashFromByte(0x04); std::string addrA = "t1New"; s->ConnectBlockBegin(newBlk); - ASSERT_TRUE(s->ApplyGenesis(MakeToken(newTok, "NEW", 11), addrA, - newTok, 1, 1000)); + + // tx1: new genesis 1000 -> addrA. + CZSLPToken nmeta = MakeToken(newTok, "NEW", 11); + CZSLPParsedMsg ng = GenMsg(1000); + AddrMap ngaddr; ngaddr[1] = addrA; + ASSERT_TRUE(s->ApplyTransaction({}, &ng, newTok, 11, &nmeta, + AddrOf(ngaddr), 2)); + + // tx2: send base token; spends (baseTok,1)=5000, pays 1500->addrA, 3500->base. uint256 sendTx = HashFromByte(0x05); - ASSERT_TRUE(s->ApplySend(baseTok, addrA, sendTx, 11, 1, 1500)); + CZSLPParsedMsg sm = SendMsg(baseTok, {1500, 3500}); + AddrMap saddr; saddr[1] = addrA; saddr[2] = baseAddr; + std::vector vin = { OutPoint(baseTok, 1) }; + ASSERT_TRUE(s->ApplyTransaction(vin, &sm, sendTx, 11, NULL, AddrOf(saddr), 3)); s->ConnectBlockEnd(11, newBlk); // Post-connect: state changed. EXPECT_EQ(s->TokenCount(), preCount + 1); EXPECT_EQ(s->GetBalance(newTok, addrA), 1000); EXPECT_EQ(s->GetBalance(baseTok, addrA), 1500); + EXPECT_EQ(s->GetBalance(baseTok, baseAddr), 3500); // change back int64_t h; uint256 bh; ASSERT_TRUE(s->ReadTip(h, bh)); EXPECT_EQ(h, 11); @@ -235,7 +369,11 @@ TEST(ZSLPStore, ReorgGenesisRoundTrip) EXPECT_FALSE(s->GetToken(newTok, gone)); // new genesis erased EXPECT_EQ(s->GetBalance(newTok, addrA), 0); // its balance erased EXPECT_EQ(s->GetBalance(baseTok, addrA), 0); // send credit reversed - EXPECT_EQ(s->GetBalance(baseTok, baseAddr), preBaseBal); // base untouched + EXPECT_EQ(s->GetBalance(baseTok, baseAddr), preBaseBal); // base UTXO restored + EXPECT_EQ(s->UtxoCount(), preUtxos); // exactly the base 5000 UTXO again + CZSLPTokenUtxo bu; + ASSERT_TRUE(s->GetUtxo(baseTok, 1, bu)); + EXPECT_EQ(bu.amount, 5000); // The new token's transfer rows are gone; base token keeps only genesis. std::vector xfers; @@ -250,7 +388,7 @@ TEST(ZSLPStore, ReorgGenesisRoundTrip) delete s; } -// ── REORG INVARIANT: mint baton + total_minted reversal ──────────── +// ── REORG INVARIANT: mint via baton input round-trip ─────────────── TEST(ZSLPStore, ReorgMintRoundTrip) { @@ -258,24 +396,27 @@ TEST(ZSLPStore, ReorgMintRoundTrip) uint256 tid = HashFromByte(0x90); std::string addr = "t1MintAddr"; - s->ConnectBlockBegin(HashFromByte(0xA0)); - ASSERT_TRUE(s->ApplyGenesis(MakeToken(tid, "BAT", 20, /*baton=*/2), addr, - tid, 1, 1000)); - s->ConnectBlockEnd(20, HashFromByte(0xA0)); + // Genesis: 1000 at vout1, baton at vout2. + CZSLPToken meta = MakeToken(tid, "BAT", 20, /*baton=*/2); + CZSLPParsedMsg gm = GenMsg(1000, 2); + AddrMap g; g[1] = addr; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0xA0), 20, {}, &gm, tid, &meta, g, 3)); CZSLPToken before; ASSERT_TRUE(s->GetToken(tid, before)); const int64_t preMinted = before.totalMinted; const uint8_t preBaton = before.mintBatonVout; const int64_t preBal = s->GetBalance(tid, addr); + const int64_t preUtxos = s->UtxoCount(); + EXPECT_EQ(preBaton, 2); - // Mint more and move the baton to vout 3. + // MINT spending the baton (tid,2): +500 at vout1, baton moves to vout3. uint256 mintBlk = HashFromByte(0xA1); uint256 mintTx = HashFromByte(0xA2); - s->ConnectBlockBegin(mintBlk); - ASSERT_TRUE(s->ApplyMint(tid, addr, mintTx, 21, 1, 500, - /*batonMoved=*/true, /*newBatonVout=*/3)); - s->ConnectBlockEnd(21, mintBlk); + CZSLPParsedMsg mm = MintMsg(tid, 500, /*batonVout=*/3); + AddrMap mo; mo[1] = addr; + std::vector vin = { OutPoint(tid, 2) }; + ASSERT_TRUE(ApplyTx(s, mintBlk, 21, vin, &mm, mintTx, NULL, mo, 4)); CZSLPToken mid; ASSERT_TRUE(s->GetToken(tid, mid)); @@ -283,7 +424,7 @@ TEST(ZSLPStore, ReorgMintRoundTrip) EXPECT_EQ(mid.mintBatonVout, 3); EXPECT_EQ(s->GetBalance(tid, addr), preBal + 500); - // Disconnect: total_minted, baton, and balance must all revert. + // Disconnect: total_minted, baton mirror, balance, and UTXOs all revert. ASSERT_TRUE(s->DisconnectBlock(mintBlk, 20, HashFromByte(0xA0))); CZSLPToken after; @@ -291,6 +432,13 @@ TEST(ZSLPStore, ReorgMintRoundTrip) EXPECT_EQ(after.totalMinted, preMinted); EXPECT_EQ(after.mintBatonVout, preBaton); EXPECT_EQ(s->GetBalance(tid, addr), preBal); + EXPECT_EQ(s->UtxoCount(), preUtxos); + // The genesis baton UTXO is restored; the mint's new UTXOs erased. + CZSLPTokenUtxo b; + ASSERT_TRUE(s->GetUtxo(tid, 2, b)); + EXPECT_TRUE(b.isMintBaton); + EXPECT_FALSE(s->GetUtxo(mintTx, 1, b)); + EXPECT_FALSE(s->GetUtxo(mintTx, 3, b)); delete s; } @@ -312,3 +460,372 @@ TEST(ZSLPStore, DisconnectEmptyBlock) EXPECT_EQ(bh, HashFromByte(0xEF)); delete s; } + +// ─────────────────────── FORGE-REJECTION SUITE ──────────────────── + +// (a) A SEND of an existing token with NO token input credits NOBODY. +TEST(ZSLPStore, ForgeSendWithoutInputCreditsNobody) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0x21); + std::string victim = "t1Victim"; + std::string attacker = "t1Attacker"; + + CZSLPToken meta = MakeToken(tid, "REAL", 1000); + CZSLPParsedMsg gm = GenMsg(1000); + AddrMap g; g[1] = victim; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x30), 1000, {}, &gm, tid, &meta, g, 2)); + EXPECT_EQ(s->GetBalance(tid, victim), 1000); + + // Attacker broadcasts a SEND OP_RETURN crediting themselves 1000 — but holds + // NO token input (vin references an unrelated, non-token prevout). + uint256 forgeTx = HashFromByte(0x40); + CZSLPParsedMsg sm = SendMsg(tid, {1000}); + AddrMap so; so[1] = attacker; + std::vector vin = { OutPoint(HashFromByte(0xCC), 0) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x31), 1001, vin, &sm, forgeTx, NULL, so, 2)); + + EXPECT_EQ(s->GetBalance(tid, attacker), 0); // forgery created nothing + EXPECT_EQ(s->GetBalance(tid, victim), 1000); // victim untouched + CZSLPTokenUtxo u; + EXPECT_FALSE(s->GetUtxo(forgeTx, 1, u)); // no UTXO minted out of thin air + std::vector xfers; + EXPECT_EQ(s->ListTransfers(tid, 0, 100, xfers), 1); // only the genesis row + delete s; +} + +// (b) Over-send: availIn < requiredOut burns the inputs and creates NO outputs. +TEST(ZSLPStore, OverSendBurnsInputsNoOutputs) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0x22); + std::string owner = "t1Owner"; + std::string dest = "t1Dest"; + + CZSLPToken meta = MakeToken(tid, "OVR", 100); + CZSLPParsedMsg gm = GenMsg(100); + AddrMap g; g[1] = owner; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x32), 100, {}, &gm, tid, &meta, g, 2)); + EXPECT_EQ(s->GetBalance(tid, owner), 100); + + // SEND tries to pay out 9999 while only 100 is on the spent input. + uint256 sendTx = HashFromByte(0x41); + CZSLPParsedMsg sm = SendMsg(tid, {9999}); + AddrMap so; so[1] = dest; + std::vector vin = { OutPoint(tid, 1) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x33), 101, vin, &sm, sendTx, NULL, so, 2)); + + EXPECT_EQ(s->GetBalance(tid, dest), 0); // nothing created + EXPECT_EQ(s->GetBalance(tid, owner), 0); // input consumed (burned) + EXPECT_EQ(s->UtxoCount(), 0); // the 100 is gone, no output minted + CZSLPTokenUtxo u; + EXPECT_FALSE(s->GetUtxo(sendTx, 1, u)); + delete s; +} + +// (c) MINT without the baton input issues nothing. +TEST(ZSLPStore, MintWithoutBatonRejected) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0x23); + std::string addr = "t1MintC"; + + CZSLPToken meta = MakeToken(tid, "NBT", 500, /*baton=*/2); + CZSLPParsedMsg gm = GenMsg(500, 2); + AddrMap g; g[1] = addr; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x34), 500, {}, &gm, tid, &meta, g, 3)); + + // Spend the QUANTITY UTXO (vout1), not the baton, while quoting a MINT. + uint256 mintTx = HashFromByte(0x42); + CZSLPParsedMsg mm = MintMsg(tid, 10000, 2); + AddrMap mo; mo[1] = addr; + std::vector vin = { OutPoint(tid, 1) }; // NOT the baton + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x35), 501, vin, &mm, mintTx, NULL, mo, 3)); + + CZSLPToken got; + ASSERT_TRUE(s->GetToken(tid, got)); + EXPECT_EQ(got.totalMinted, 500); // no inflation + CZSLPTokenUtxo u; + EXPECT_FALSE(s->GetUtxo(mintTx, 1, u)); // no new UTXO created + // The spent quantity UTXO was burned (consumed, not reissued). + EXPECT_FALSE(s->GetUtxo(tid, 1, u)); + EXPECT_EQ(s->GetBalance(tid, addr), 0); + // The baton UTXO at vout2 still exists (untouched). + ASSERT_TRUE(s->GetUtxo(tid, 2, u)); + EXPECT_TRUE(u.isMintBaton); + delete s; +} + +// (d) An NFT (qty 1, dec 0, no baton) cannot be duplicated by a forged SEND. +TEST(ZSLPStore, NftCannotBeDuplicated) +{ + CZSLPStore* s = NewMemStore(); + uint256 nft = HashFromByte(0x24); + std::string owner = "t1NftOwner"; + std::string buyer = "t1NftBuyer"; + std::string thief = "t1NftThief"; + + // Genesis NFT: qty 1, decimals 0, NO baton. + CZSLPToken meta = MakeToken(nft, "NFT", 700); + meta.decimals = 0; + CZSLPParsedMsg gm = GenMsg(1); + AddrMap g; g[1] = owner; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x36), 700, {}, &gm, nft, &meta, g, 2)); + EXPECT_EQ(s->GetBalance(nft, owner), 1); + EXPECT_EQ(s->UtxoCount(), 1); + + // Legit transfer: owner sends the single NFT UTXO to buyer. + uint256 moveTx = HashFromByte(0x43); + CZSLPParsedMsg sm = SendMsg(nft, {1}); + AddrMap so; so[1] = buyer; + std::vector vin = { OutPoint(nft, 1) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x37), 701, vin, &sm, moveTx, NULL, so, 2)); + EXPECT_EQ(s->GetBalance(nft, owner), 0); + EXPECT_EQ(s->GetBalance(nft, buyer), 1); + EXPECT_EQ(s->UtxoCount(), 1); // still exactly one live NFT UTXO + + // Forgery: a thief quotes the SAME tokenId in a SEND but does not hold the + // (now buyer-owned) UTXO. Nothing is created — the NFT is not duplicated. + uint256 forgeTx = HashFromByte(0x44); + CZSLPParsedMsg fm = SendMsg(nft, {1}); + AddrMap fo; fo[1] = thief; + std::vector fvin = { OutPoint(HashFromByte(0xBB), 0) }; // not the NFT + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x38), 702, fvin, &fm, forgeTx, NULL, fo, 2)); + + EXPECT_EQ(s->GetBalance(nft, thief), 0); + EXPECT_EQ(s->GetBalance(nft, buyer), 1); // buyer still holds the one-and-only + EXPECT_EQ(s->UtxoCount(), 1); // exactly one live NFT UTXO, always + delete s; +} + +// (e) A non-SLP tx that spends a token UTXO burns it (derived balance drops). +TEST(ZSLPStore, NonSlpSpendBurnsUtxo) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0x25); + std::string owner = "t1BurnOwner"; + + CZSLPToken meta = MakeToken(tid, "BRN", 800); + CZSLPParsedMsg gm = GenMsg(1000); + AddrMap g; g[1] = owner; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x39), 800, {}, &gm, tid, &meta, g, 2)); + EXPECT_EQ(s->GetBalance(tid, owner), 1000); + EXPECT_EQ(s->UtxoCount(), 1); + + // A plain (non-SLP) transaction spends the token UTXO: msg == NULL. + uint256 burnTx = HashFromByte(0x45); + AddrMap bo; bo[1] = owner; // ordinary p2pkh outputs, but no SLP semantics + std::vector vin = { OutPoint(tid, 1) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x3A), 801, vin, /*msg=*/NULL, burnTx, + NULL, bo, 2)); + + EXPECT_EQ(s->GetBalance(tid, owner), 0); // tokens destroyed + EXPECT_EQ(s->UtxoCount(), 0); // UTXO erased + CZSLPTokenUtxo u; + EXPECT_FALSE(s->GetUtxo(tid, 1, u)); + delete s; +} + +// (h) MULTI-TOKEN MIXED-INPUT SILENT BURN (G4): a SEND that spends a token-A +// UTXO AND a token-B UTXO but declares ONLY token A in its OP_RETURN. +// ApplyTransaction step (a) (zslpstore.cpp:437-446) consumes EVERY token +// UTXO on the vin — both A and B. The SEND dispatch (:548-592) only +// re-creates outputs for msg->tokenId (A). Token B is consumed and never +// re-created => SILENTLY BURNED (credited to NOBODY). This documents + +// guards that reality so a future change is forced to update the test. +// The wallet-side defense that ensures normal coin-selection would NEVER +// assemble such a tx (AvailableCoins fExcludeZSLPTokens via +// ZSLPIsProtectedTokenOutpoint) is proven in +// test_zslp_wallet.cpp::FundingFilterDropsUndeclaredTokenInput. +TEST(ZSLPStore, MixedInputSendBurnsUndeclaredTokenB) +{ + CZSLPStore* s = NewMemStore(); + uint256 tA = HashFromByte(0xA0), tB = HashFromByte(0xB0); + std::string oA = "t1A", oB = "t1B", dest = "t1Dest"; + + // Genesis token A: 1000 -> oA at (tA,1). + { + CZSLPToken mA = MakeToken(tA, "TA", 1); + CZSLPParsedMsg g = GenMsg(1000); + AddrMap a; a[1] = oA; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0xA1), 1, {}, &g, tA, &mA, a, 2)); + } + // Genesis token B: 500 -> oB at (tB,1). + { + CZSLPToken mB = MakeToken(tB, "TB", 2); + CZSLPParsedMsg g = GenMsg(500); + AddrMap b; b[1] = oB; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0xB1), 2, {}, &g, tB, &mB, b, 2)); + } + ASSERT_EQ(s->GetBalance(tA, oA), (int64_t)1000); + ASSERT_EQ(s->GetBalance(tB, oB), (int64_t)500); + const int64_t preUtxos = s->UtxoCount(); // 2 (tA,1) + (tB,1) + + // ONE SEND that declares ONLY token A (1000 -> dest at vout[1]) but spends + // BOTH the token-A AND token-B UTXOs on its vin. + uint256 sendTx = HashFromByte(0xC0); + CZSLPParsedMsg sm = SendMsg(tA, {1000}); + AddrMap so; so[1] = dest; + std::vector vin = { OutPoint(tA, 1), OutPoint(tB, 1) }; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0xC1), 3, vin, &sm, sendTx, NULL, so, 2)); + + // Token A fully moved to dest. + EXPECT_EQ(s->GetBalance(tA, dest), (int64_t)1000); + EXPECT_EQ(s->GetBalance(tA, oA), (int64_t)0); + // Token B's old UTXO is consumed. + CZSLPTokenUtxo u; + EXPECT_FALSE(s->GetUtxo(tB, 1, u)); + EXPECT_EQ(s->GetBalance(tB, oB), (int64_t)0); + // Token B is credited to NOBODY — the silent burn. + EXPECT_EQ(s->GetBalance(tB, dest), (int64_t)0); + // No token-B UTXO exists anywhere now (no B output minted by the A-only SEND). + EXPECT_FALSE(s->GetUtxo(sendTx, 1, u) && u.tokenId == tB); + // Token B's declared supply is UNCHANGED but now UNRECOVERABLE (burned): + // total_minted records issuance, not live balance. + CZSLPToken tokB; + ASSERT_TRUE(s->GetToken(tB, tokB)); + EXPECT_EQ(tokB.totalMinted, (int64_t)500); + // Live UTXO set: only the new token-A UTXO at (sendTx,1). Both inputs were + // consumed; only A was re-created => net one live UTXO (was 2). + EXPECT_EQ(s->UtxoCount(), (int64_t)1); + EXPECT_EQ(preUtxos, (int64_t)2); + CZSLPTokenUtxo a1; + ASSERT_TRUE(s->GetUtxo(sendTx, 1, a1)); + EXPECT_EQ(a1.amount, (int64_t)1000); + EXPECT_EQ(a1.tokenId, tA); + delete s; +} + +// (f) Intra-block spend: a second tx spends a UTXO the FIRST tx created in the +// SAME block. Requires per-tx commit visibility. +TEST(ZSLPStore, IntraBlockSpend) +{ + CZSLPStore* s = NewMemStore(); + uint256 tid = HashFromByte(0x26); + std::string a = "t1IntraA"; + std::string b = "t1IntraB"; + + uint256 blk = HashFromByte(0x50); + s->ConnectBlockBegin(blk); + + // tx1: genesis 500 -> a at (tid, vout1). + CZSLPToken meta = MakeToken(tid, "INB", 900); + CZSLPParsedMsg gm = GenMsg(500); + AddrMap ga; ga[1] = a; + ASSERT_TRUE(s->ApplyTransaction({}, &gm, tid, 900, &meta, AddrOf(ga), 2)); + + // tx2 (same block): spends (tid, vout1) created by tx1 -> 500 to b. + uint256 sendTx = HashFromByte(0x46); + CZSLPParsedMsg sm = SendMsg(tid, {500}); + AddrMap sb; sb[1] = b; + std::vector vin = { OutPoint(tid, 1) }; + ASSERT_TRUE(s->ApplyTransaction(vin, &sm, sendTx, 900, NULL, AddrOf(sb), 2)); + + s->ConnectBlockEnd(900, blk); + + // tx2 must have SEEN tx1's UTXO and moved it: a==0, b==500. + EXPECT_EQ(s->GetBalance(tid, a), 0); + EXPECT_EQ(s->GetBalance(tid, b), 500); + EXPECT_EQ(s->UtxoCount(), 1); + CZSLPTokenUtxo u; + ASSERT_TRUE(s->GetUtxo(sendTx, 1, u)); + EXPECT_EQ(u.amount, 500); + EXPECT_FALSE(s->GetUtxo(tid, 1, u)); // tx1's UTXO consumed in-block + delete s; +} + +// (g) Reorg byte-identity across a burn/consume mix: a block with a valid send, +// an invalid (over-send) burn, and a non-SLP burn must disconnect to the +// exact pre-block state. +TEST(ZSLPStore, ReorgByteIdentityBurnConsumeMix) +{ + CZSLPStore* s = NewMemStore(); + + // Pre-state: three independent token holdings from earlier blocks. + uint256 tA = HashFromByte(0x60), tB = HashFromByte(0x61), tC = HashFromByte(0x62); + std::string oA = "t1A", oB = "t1B", oC = "t1C", dest = "t1Dest"; + + { + CZSLPToken mA = MakeToken(tA, "TA", 1), mB = MakeToken(tB, "TB", 2), + mC = MakeToken(tC, "TC", 3); + CZSLPParsedMsg g = GenMsg(1000); + AddrMap a1; a1[1] = oA; AddrMap a2; a2[1] = oB; AddrMap a3; a3[1] = oC; + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x70), 1, {}, &g, tA, &mA, a1, 2)); + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x71), 2, {}, &g, tB, &mB, a2, 2)); + ASSERT_TRUE(ApplyTx(s, HashFromByte(0x72), 3, {}, &g, tC, &mC, a3, 2)); + } + + // Snapshot the pre-block state. + const int64_t preTokens = s->TokenCount(); + const int64_t preUtxos = s->UtxoCount(); + const int64_t preBalA = s->GetBalance(tA, oA); + const int64_t preBalB = s->GetBalance(tB, oB); + const int64_t preBalC = s->GetBalance(tC, oC); + std::vector preXA, preXB, preXC; + s->ListTransfers(tA, 0, 100, preXA); + s->ListTransfers(tB, 0, 100, preXB); + s->ListTransfers(tC, 0, 100, preXC); + int64_t preH; uint256 preBH; + ASSERT_TRUE(s->ReadTip(preH, preBH)); + + // Connect ONE block with three txs: + uint256 blk = HashFromByte(0x73); + s->ConnectBlockBegin(blk); + + // tx1 (valid send): spends (tA,1)=1000, pays 400->dest (600 burned). + uint256 tx1 = HashFromByte(0x80); + CZSLPParsedMsg s1 = SendMsg(tA, {400}); + AddrMap o1; o1[1] = dest; + ASSERT_TRUE(s->ApplyTransaction({ OutPoint(tA, 1) }, &s1, tx1, 4, NULL, + AddrOf(o1), 2)); + + // tx2 (invalid over-send): spends (tB,1)=1000, asks 5000 -> all burned. + uint256 tx2 = HashFromByte(0x81); + CZSLPParsedMsg s2 = SendMsg(tB, {5000}); + AddrMap o2; o2[1] = dest; + ASSERT_TRUE(s->ApplyTransaction({ OutPoint(tB, 1) }, &s2, tx2, 4, NULL, + AddrOf(o2), 2)); + + // tx3 (non-SLP burn): spends (tC,1)=1000 with NO SLP message. + uint256 tx3 = HashFromByte(0x82); + AddrMap o3; o3[1] = dest; + ASSERT_TRUE(s->ApplyTransaction({ OutPoint(tC, 1) }, NULL, tx3, 4, NULL, + AddrOf(o3), 2)); + + s->ConnectBlockEnd(4, blk); + + // Post-connect sanity: everything moved/burned. + EXPECT_EQ(s->GetBalance(tA, oA), 0); + EXPECT_EQ(s->GetBalance(tA, dest), 400); + EXPECT_EQ(s->GetBalance(tB, oB), 0); + EXPECT_EQ(s->GetBalance(tB, dest), 0); // invalid send created nothing + EXPECT_EQ(s->GetBalance(tC, oC), 0); // non-SLP burn + + // Disconnect: must restore byte-identical pre-block state. + ASSERT_TRUE(s->DisconnectBlock(blk, preH, preBH)); + + EXPECT_EQ(s->TokenCount(), preTokens); + EXPECT_EQ(s->UtxoCount(), preUtxos); + EXPECT_EQ(s->GetBalance(tA, oA), preBalA); + EXPECT_EQ(s->GetBalance(tB, oB), preBalB); + EXPECT_EQ(s->GetBalance(tC, oC), preBalC); + EXPECT_EQ(s->GetBalance(tA, dest), 0); // no residue at dest + // The original UTXOs are restored exactly. + CZSLPTokenUtxo u; + ASSERT_TRUE(s->GetUtxo(tA, 1, u)); EXPECT_EQ(u.amount, 1000); + ASSERT_TRUE(s->GetUtxo(tB, 1, u)); EXPECT_EQ(u.amount, 1000); + ASSERT_TRUE(s->GetUtxo(tC, 1, u)); EXPECT_EQ(u.amount, 1000); + // The block's created UTXOs are gone. + EXPECT_FALSE(s->GetUtxo(tx1, 1, u)); + // Transfer logs restored. + std::vector xA, xB, xC; + EXPECT_EQ((size_t)s->ListTransfers(tA, 0, 100, xA), preXA.size()); + EXPECT_EQ((size_t)s->ListTransfers(tB, 0, 100, xB), preXB.size()); + EXPECT_EQ((size_t)s->ListTransfers(tC, 0, 100, xC), preXC.size()); + // Tip rewound. + int64_t h; uint256 bh; + ASSERT_TRUE(s->ReadTip(h, bh)); + EXPECT_EQ(h, preH); + EXPECT_EQ(bh, preBH); + delete s; +} diff --git a/src/gtest/test_zslp_vectors.cpp b/src/gtest/test_zslp_vectors.cpp new file mode 100644 index 00000000000..77837700472 --- /dev/null +++ b/src/gtest/test_zslp_vectors.cpp @@ -0,0 +1,1007 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP R-VECTORS corpus — the cross-implementation AGREEMENT contract. +// +// Security for the ZSLP overlay IS cross-implementation bit-exact agreement +// (SECURITY_MODEL.md §1.3): there is no consensus over the token ledger, so two +// observers that disagree on ANY edge case fork the ledger and the attacker +// shows each victim a different "ownership truth". This file is the published, +// versioned set of adversarial input -> expected-result vectors that pins every +// canonical rule unambiguously, so any second implementation of F can prove +// agreement (R-VECTORS / R-DIFF, SECURITY_MODEL.md §7 closure criterion). +// +// Two layers are covered: +// (A) PARSER vectors: a raw OP_RETURN byte string -> expected parse +// accept/reject (and the parsed fields). These exercise the REAL canonical +// parser (slp_parse via ZSLPParseScript), the load-bearing push grammar, +// field-length, quantity-domain, trailing-data, and SEND-cap rules. +// (B) LEDGER vectors: a transaction (or same-block sequence) -> expected +// ledger snapshot (token rows / token-UTXO set / balances). These exercise +// the REAL indexer parse seam (CZSLPIndexer::ParseTx — vout[0]-ONLY, +// coinbase-skip) feeding the REAL store (CZSLPStore::ApplyTransaction), +// i.e. the exact production path, with no chain state required. +// +// Rule keys (R-*) reference SECURITY_MODEL.md. Where the prose split (R-SEND-4), +// SECURITY_MODEL.md §2.6 PINS Reading A (out-of-range positive quantity burns +// ONLY that quantity; in-range outputs still apply; budget checked first) at +// ZSLP_SPEC_VERSION = 1 — these vectors are the authoritative tiebreaker. + +#include + +#include "primitives/transaction.h" +#include "script/script.h" +#include "uint256.h" +#include "zslp/zslpindexer.h" +#include "zslp/zslpmsg.h" // the C++ bridge; we MUST NOT include zslp/slp.h here +#include "zslp/zslpstore.h" +// +// NOTE on includes: zslp/slp.h pulls in the protocol library's plain-C +// `struct uint256` (uint256_c.h), which collides with the daemon's +// `class uint256` (src/uint256.h, reached via primitives/transaction.h). The +// two cannot coexist in one TU — that is the entire reason the ZSLPParseScript +// bridge exists. This corpus therefore drives the parser EXCLUSIVELY through +// ZSLPParseScript (which compiles slp.c in its own TU) and never includes +// slp.h. The bridge result (ZSLPMessage) exposes every field these vectors +// assert. The canonical SEND cap is ZSLP_MAX_SEND_OUTPUTS (== the C parser's +// ZSLP_SEND_MAX_OUTPUTS, pinned by a static_assert in zslpmsg.cpp). + +#include +#include +#include +#include +#include +#include + +namespace { + +// ── Raw-script builders (hand-rolled, so we control EVERY byte) ───────── + +// A single canonical data push using the smallest direct/PUSHDATA1/PUSHDATA2 +// encoding (matches op_return_push.h's writer). Used to build VALID baselines. +void PushBytes(std::vector& s, const uint8_t* d, size_t len) +{ + if (len <= 0x4b) { + s.push_back((uint8_t)len); + } else if (len <= 0xff) { + s.push_back(0x4c); + s.push_back((uint8_t)len); + } else { + s.push_back(0x4d); + s.push_back((uint8_t)(len & 0xff)); + s.push_back((uint8_t)((len >> 8) & 0xff)); + } + for (size_t i = 0; i < len; ++i) s.push_back(d[i]); +} +void PushStr(std::vector& s, const char* str) +{ + PushBytes(s, (const uint8_t*)str, strlen(str)); +} +void PushU8(std::vector& s, uint8_t v) { PushBytes(s, &v, 1); } +void PushEmpty(std::vector& s) { s.push_back(0x4c); s.push_back(0x00); } +void PushU64BE(std::vector& s, uint64_t v) +{ + uint8_t b[8]; + for (int i = 7; i >= 0; --i) { b[i] = (uint8_t)(v & 0xff); v >>= 8; } + PushBytes(s, b, 8); +} +// Force a PUSHDATA1 encoding regardless of length (for the dual-encoding vector). +void PushBytesP1(std::vector& s, const uint8_t* d, size_t len) +{ + s.push_back(0x4c); + s.push_back((uint8_t)len); + for (size_t i = 0; i < len; ++i) s.push_back(d[i]); +} + +// "SLP\0" lokad (4 bytes incl. the NUL) + token_type 1 + tx_type. +std::vector SlpHeader(const char* txType) +{ + std::vector s; + s.push_back(0x6a); // OP_RETURN + static const uint8_t kLokad[4] = { 'S', 'L', 'P', 0x00 }; + PushBytes(s, kLokad, 4); + PushU8(s, 1); // token_type = 1 + PushStr(s, txType); // "GENESIS" / "MINT" / "SEND" + return s; +} + +// A minimal VALID GENESIS: empty ticker/name/url, no doc hash, decimals 0, no +// baton, initial_quantity = qty. (qty must be < 2^63 to be valid.) +std::vector GenesisScript(uint64_t qty, int batonVout = 0, + int decimals = 0, int hashLen = 0) +{ + std::vector s = SlpHeader("GENESIS"); + PushEmpty(s); // ticker + PushEmpty(s); // name + PushEmpty(s); // document_url + if (hashLen == 0) { + PushEmpty(s); // document_hash (absent) + } else { + std::vector h(hashLen, 0xAB); + PushBytes(s, h.data(), h.size()); + } + PushU8(s, (uint8_t)decimals); // decimals + if (batonVout >= 2) PushU8(s, (uint8_t)batonVout); + else PushEmpty(s); // mint_baton_vout + PushU64BE(s, qty); // initial_quantity + return s; +} + +std::vector MintScript(const uint8_t tokenId[32], uint64_t qty, + int batonVout = 0) +{ + std::vector s = SlpHeader("MINT"); + PushBytes(s, tokenId, 32); + if (batonVout >= 2) PushU8(s, (uint8_t)batonVout); + else PushEmpty(s); + PushU64BE(s, qty); + return s; +} + +std::vector SendScript(const uint8_t tokenId[32], + const std::vector& qtys) +{ + std::vector s = SlpHeader("SEND"); + PushBytes(s, tokenId, 32); + for (size_t i = 0; i < qtys.size(); ++i) PushU64BE(s, qtys[i]); + return s; +} + +// Parse a raw OP_RETURN through the canonical bridge (which runs slp.c). All +// parser vectors below assert on the resulting ZSLPMessage. +bool Parse(const std::vector& s, ZSLPMessage& m) +{ + return ZSLPParseScript(s.data(), s.size(), m); +} + +const uint64_t kTwo63 = (UINT64_C(1) << 63); // 2^63 (high bit set) +const uint64_t kTwo64m1 = ~UINT64_C(0); // 2^64 - 1 +const uint64_t kMaxValid = (UINT64_C(1) << 63) - 1; // 2^63 - 1 (high bit clear) + +} // namespace + +// ════════════════════════════════════════════════════════════════════════ +// (A) PARSER VECTORS — raw OP_RETURN bytes -> expected parse result +// ════════════════════════════════════════════════════════════════════════ + +// ── Baselines parse (sanity for the hand-rolled builders) ─────────────── + +TEST(ZslpVectors, BaselineGenesisParses) +{ + ZSLPMessage m; + ASSERT_TRUE(Parse(GenesisScript(1000), m)); + EXPECT_EQ(m.type, ZSLPMSG_GENESIS); + EXPECT_EQ(m.initialQuantity, 1000u); + EXPECT_EQ(m.decimals, 0); + EXPECT_EQ(m.mintBatonVout, 0); +} + +TEST(ZslpVectors, BaselineSendParses) +{ + uint8_t tid[32]; memset(tid, 0x11, 32); + ZSLPMessage m; + ASSERT_TRUE(Parse(SendScript(tid, {5, 6, 7}), m)); + EXPECT_EQ(m.type, ZSLPMSG_SEND); + EXPECT_EQ(m.numOutputs, 3); + EXPECT_EQ(m.outputQuantities[0], 5u); + EXPECT_EQ(m.outputQuantities[2], 7u); +} + +// ── R-SCRIPT-1: push grammar — reject PUSHDATA4 and OP_N (R-5) ─────────── + +TEST(ZslpVectors, RejectPushdata4Field) +{ + // A token_type field encoded with OP_PUSHDATA4 (0x4e) must be rejected: + // read_push accepts ONLY {0x01..0x4b, 0x4c, 0x4d}. + std::vector s; + s.push_back(0x6a); + static const uint8_t kLokad[4] = { 'S', 'L', 'P', 0x00 }; + PushBytes(s, kLokad, 4); + // token_type via PUSHDATA4 (len=1): 0x4e 01 00 00 00 01 + s.push_back(0x4e); + s.push_back(0x01); s.push_back(0x00); s.push_back(0x00); s.push_back(0x00); + s.push_back(0x01); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, RejectOpNAsField) +{ + // OP_1 (0x51) is NOT a data push for ZSLP; a field encoded as OP_1 must + // reject (read_push returns NULL for 0x51). + std::vector s; + s.push_back(0x6a); + static const uint8_t kLokad[4] = { 'S', 'L', 'P', 0x00 }; + PushBytes(s, kLokad, 4); + s.push_back(0x51); // OP_1 where token_type push is expected + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, RejectOp0AsField) +{ + // OP_0 (0x00) is not a data push for ZSLP fields either. + std::vector s; + s.push_back(0x6a); + static const uint8_t kLokad[4] = { 'S', 'L', 'P', 0x00 }; + PushBytes(s, kLokad, 4); + s.push_back(0x00); // OP_0 where token_type push is expected + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +// ── R-SCRIPT-2: dual encoding (direct vs PUSHDATA1) parses identically (R-6) ─ + +TEST(ZslpVectors, DualEncodingTokenTypeEqual) +{ + // token_type 1 via direct push (0x01 0x01) vs via PUSHDATA1 (0x4c 0x01 0x01) + // MUST yield the IDENTICAL parsed message. No minimal-push requirement; the + // lenient behavior is FROZEN canonical (SECURITY_MODEL R-SCRIPT-2). + uint8_t tid[32]; memset(tid, 0x22, 32); + + std::vector direct = MintScript(tid, 42, 0); + + std::vector p1; + p1.push_back(0x6a); + static const uint8_t kLokad[4] = { 'S', 'L', 'P', 0x00 }; + PushBytes(p1, kLokad, 4); + uint8_t tt = 1; PushBytesP1(p1, &tt, 1); // token_type via PUSHDATA1 + PushStr(p1, "MINT"); + PushBytes(p1, tid, 32); + PushEmpty(p1); // no baton + PushU64BE(p1, 42); + + ZSLPMessage md, mp; + ASSERT_TRUE(Parse(direct, md)); + ASSERT_TRUE(Parse(p1, mp)); + EXPECT_EQ(md.type, mp.type); + // (the bridge has no token_type field; a parsed message implies type 1, + // since the parser rejects any token_type != 1) + EXPECT_EQ(md.additionalQuantity, mp.additionalQuantity); + EXPECT_EQ(memcmp(md.tokenId, mp.tokenId, 32), 0); +} + +TEST(ZslpVectors, Pushdata2AcceptedFrozen) +{ + // SECURITY_MODEL.md R-SCRIPT-1 FREEZES OP_PUSHDATA2 (0x4d) as ACCEPTED + // (unlike the determinism-spec draft which proposed rejecting it). Pin the + // pinned choice: a field via PUSHDATA2 parses. token_type 1 via 0x4d. + std::vector s; + s.push_back(0x6a); + static const uint8_t kLokad[4] = { 'S', 'L', 'P', 0x00 }; + PushBytes(s, kLokad, 4); + // token_type via PUSHDATA2 len=1: 0x4d 01 00 01 + s.push_back(0x4d); s.push_back(0x01); s.push_back(0x00); s.push_back(0x01); + PushStr(s, "MINT"); + uint8_t tid[32]; memset(tid, 0x33, 32); + PushBytes(s, tid, 32); + PushEmpty(s); + PushU64BE(s, 7); + ZSLPMessage m; + ASSERT_TRUE(Parse(s, m)); + EXPECT_EQ(m.type, ZSLPMSG_MINT); + EXPECT_EQ(m.additionalQuantity, 7u); +} + +// ── R-7 / R-SCRIPT-5: trailing data after GENESIS/MINT => reject ───────── + +TEST(ZslpVectors, GenesisTrailingByteRejected) +{ + std::vector s = GenesisScript(1000); + PushU8(s, 0x00); // one appended push after the final required field + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, MintTrailingByteRejected) +{ + uint8_t tid[32]; memset(tid, 0x44, 32); + std::vector s = MintScript(tid, 5); + PushU8(s, 0x00); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +// ── R-8 / R-SCRIPT-6: field-length rules ───────────────────────────────── + +TEST(ZslpVectors, Genesis31ByteHashRejected) +{ + std::vector s = GenesisScript(1000, /*baton=*/0, /*dec=*/0, + /*hashLen=*/31); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); // document_hash length must be exactly 0 or 32 +} + +TEST(ZslpVectors, Genesis33ByteHashRejected) +{ + std::vector s = GenesisScript(1000, 0, 0, /*hashLen=*/33); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, Genesis32ByteHashAccepted) +{ + std::vector s = GenesisScript(1000, 0, 0, /*hashLen=*/32); + ZSLPMessage m; + ASSERT_TRUE(Parse(s, m)); + EXPECT_TRUE(m.hasDocumentHash); +} + +TEST(ZslpVectors, BatonLenGreaterThanOneRejected) +{ + // mint_baton_vout pushed as a 2-byte value => reject the whole message. + std::vector s = SlpHeader("GENESIS"); + PushEmpty(s); PushEmpty(s); PushEmpty(s); // ticker/name/url + PushEmpty(s); // doc hash + PushU8(s, 0); // decimals + uint8_t baton2[2] = { 0x02, 0x00 }; // 2-byte baton push + PushBytes(s, baton2, 2); + PushU64BE(s, 1000); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, BatonValueOneRejected) +{ + // mint_baton_vout value 1 (must be >= 2) => reject. + std::vector s = GenesisScript(1000, /*baton=*/0); + // Rebuild with baton value 1 explicitly. + s = SlpHeader("GENESIS"); + PushEmpty(s); PushEmpty(s); PushEmpty(s); + PushEmpty(s); + PushU8(s, 0); + PushU8(s, 1); // baton vout = 1 (illegal) + PushU64BE(s, 1000); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, BatonValueZeroRejected) +{ + std::vector s = SlpHeader("GENESIS"); + PushEmpty(s); PushEmpty(s); PushEmpty(s); + PushEmpty(s); + PushU8(s, 0); + PushU8(s, 0); // baton vout = 0 (illegal as 1-byte) + PushU64BE(s, 1000); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, Decimals10Rejected) +{ + std::vector s = GenesisScript(1000, /*baton=*/0, /*dec=*/10); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); // decimals must be 0..9 +} + +TEST(ZslpVectors, Decimals9Accepted) +{ + std::vector s = GenesisScript(1000, 0, /*dec=*/9); + ZSLPMessage m; + ASSERT_TRUE(Parse(s, m)); + EXPECT_EQ(m.decimals, 9); +} + +TEST(ZslpVectors, SevenByteQuantityRejected) +{ + // initial_quantity pushed as 7 bytes (must be exactly 8) => reject. + std::vector s = SlpHeader("GENESIS"); + PushEmpty(s); PushEmpty(s); PushEmpty(s); + PushEmpty(s); + PushU8(s, 0); + PushEmpty(s); // no baton + uint8_t q7[7] = {0,0,0,0,0,0,1}; + PushBytes(s, q7, 7); // 7-byte quantity + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, SendThirtyOneByteTokenIdRejected) +{ + // token_id must be exactly 32 bytes. + std::vector s = SlpHeader("SEND"); + std::vector tid31(31, 0x55); + PushBytes(s, tid31.data(), tid31.size()); + PushU64BE(s, 1); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +// ── R-INT-1 / R-10: high-bit (>= 2^63) quantity => whole message INVALID ── +// Pinned for ALL THREE message types with both 2^63 and 2^64-1. + +TEST(ZslpVectors, GenesisHighBitQuantityRejected) +{ + ZSLPMessage m; + EXPECT_FALSE(Parse(GenesisScript(kTwo63), m)); + EXPECT_FALSE(Parse(GenesisScript(kTwo64m1), m)); + EXPECT_TRUE(Parse(GenesisScript(kMaxValid), m)); // 2^63-1 is valid + EXPECT_EQ(m.initialQuantity, kMaxValid); +} + +TEST(ZslpVectors, MintHighBitQuantityRejected) +{ + uint8_t tid[32]; memset(tid, 0x66, 32); + ZSLPMessage m; + EXPECT_FALSE(Parse(MintScript(tid, kTwo63), m)); + EXPECT_FALSE(Parse(MintScript(tid, kTwo64m1), m)); + EXPECT_TRUE(Parse(MintScript(tid, kMaxValid), m)); + EXPECT_EQ(m.additionalQuantity, kMaxValid); +} + +TEST(ZslpVectors, SendHighBitOutputQuantityRejected) +{ + uint8_t tid[32]; memset(tid, 0x77, 32); + ZSLPMessage m; + // a single high-bit output + EXPECT_FALSE(Parse(SendScript(tid, {kTwo63}), m)); + EXPECT_FALSE(Parse(SendScript(tid, {kTwo64m1}), m)); + // high bit on a LATER output still rejects the whole SEND + EXPECT_FALSE(Parse(SendScript(tid, {1, 2, kTwo63}), m)); + // all in-domain is fine + ASSERT_TRUE(Parse(SendScript(tid, {1, 2, kMaxValid}), m)); + EXPECT_EQ(m.outputQuantities[2], kMaxValid); +} + +// ── R-SEND-1 / R-12: SEND output count 0 / 19 / 20 ─────────────────────── + +TEST(ZslpVectors, SendZeroOutputsRejected) +{ + uint8_t tid[32]; memset(tid, 0x88, 32); + std::vector s = SlpHeader("SEND"); + PushBytes(s, tid, 32); + // no quantity pushes at all + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, SendNineteenOutputsAccepted) +{ + uint8_t tid[32]; memset(tid, 0x99, 32); + std::vector q(19); + for (int i = 0; i < 19; ++i) q[i] = (uint64_t)(i + 1); + ZSLPMessage m; + ASSERT_TRUE(Parse(SendScript(tid, q), m)); + EXPECT_EQ(m.numOutputs, ZSLP_MAX_SEND_OUTPUTS); + EXPECT_EQ(m.numOutputs, 19); + EXPECT_EQ(m.outputQuantities[18], 19u); +} + +TEST(ZslpVectors, SendTwentyOutputsRejected) +{ + // A 20th 8-byte push is TRAILING DATA => whole SEND INVALID (NOT first 19). + uint8_t tid[32]; memset(tid, 0xAA, 32); + std::vector q(20, 1); + ZSLPMessage m; + EXPECT_FALSE(Parse(SendScript(tid, q), m)); +} + +// ── R-SCRIPT-3: lokad + token_type ─────────────────────────────────────── + +TEST(ZslpVectors, WrongLokadRejected) +{ + std::vector s; + s.push_back(0x6a); + static const uint8_t kBad[4] = { 'S', 'L', 'P', 0x01 }; // not "SLP\0" + PushBytes(s, kBad, 4); + PushU8(s, 1); + PushStr(s, "SEND"); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +TEST(ZslpVectors, WrongTokenTypeRejected) +{ + uint8_t tid[32]; memset(tid, 0xBB, 32); + std::vector s; + s.push_back(0x6a); + static const uint8_t kLokad[4] = { 'S', 'L', 'P', 0x00 }; + PushBytes(s, kLokad, 4); + PushU8(s, 2); // token_type 2 (we implement only 1) + PushStr(s, "SEND"); + PushBytes(s, tid, 32); + PushU64BE(s, 1); + ZSLPMessage m; + EXPECT_FALSE(Parse(s, m)); +} + +// ── R-INT-2: be_to_u64 canonical decode (0 and 2^63-1) ─────────────────── + +TEST(ZslpVectors, BigEndianQuantityDecode) +{ + ZSLPMessage m; + ASSERT_TRUE(Parse(GenesisScript(0), m)); + EXPECT_EQ(m.initialQuantity, 0u); + ASSERT_TRUE(Parse(GenesisScript(kMaxValid), m)); + EXPECT_EQ(m.initialQuantity, kMaxValid); + // A specific BE pattern: 0x0102030405060708. + ASSERT_TRUE(Parse(GenesisScript(UINT64_C(0x0102030405060708)), m)); + EXPECT_EQ(m.initialQuantity, UINT64_C(0x0102030405060708)); +} + +// ── Bridge agreement: the C++ bridge (ZSLPParseScript -> ZSLPMessage) is the +// daemon-side surface of slp.c. Pin that the SEND quantities survive the +// bridge copy intact and the rejects propagate (this is the exact path the +// indexer uses; Parse() == ZSLPParseScript here). + +TEST(ZslpVectors, BridgeMatchesParserOnRejects) +{ + uint8_t tid[32]; memset(tid, 0xCC, 32); + ZSLPMessage m; + EXPECT_FALSE(Parse(GenesisScript(kTwo63), m)); // high bit + { + std::vector s = GenesisScript(1000); PushU8(s, 0); + EXPECT_FALSE(Parse(s, m)); // trailing + } + { + std::vector q(20, 1); + EXPECT_FALSE(Parse(SendScript(tid, q), m)); // 20 outputs + } + { + ASSERT_TRUE(Parse(SendScript(tid, {1, 2, 3}), m)); + EXPECT_EQ(m.numOutputs, 3); + EXPECT_EQ(m.outputQuantities[2], 3u); + } +} + +// ════════════════════════════════════════════════════════════════════════ +// (B) LEDGER VECTORS — tx / same-block sequence -> expected snapshot +// Driven through the REAL indexer parse seam + REAL store. +// ════════════════════════════════════════════════════════════════════════ + +namespace { + +uint256 H(uint8_t b) +{ + std::vector v(32, 0); + v[0] = b; + return uint256(v); +} + +// Deterministic test address per vout index (the indexer's AddressOfVout is a +// pure function of the real scriptPubKey; here the recipient identity is not +// under test, only the position/conservation rules, so a fixed per-vout label +// keys the balances deterministically). vout 0 (the OP_RETURN) -> "". +std::function AddrLabels() +{ + return [](int32_t n) -> std::string { + if (n <= 0) return std::string(); + return std::string("t1vout") + std::to_string(n); + }; +} + +// Build a CTransaction with `nOut` outputs; output `opRetIdx` carries `script` +// (an OP_RETURN), the rest are dummy non-OP_RETURN outputs. `vins` are the +// prevouts spent. `coinbase` makes vin[0] the null prevout. +CTransaction MakeTx(const std::vector& opRetScript, int opRetIdx, + int nOut, const std::vector& vins, + bool coinbase = false) +{ + CMutableTransaction mtx; + if (coinbase) { + CTxIn in; + in.prevout.SetNull(); + mtx.vin.push_back(in); + } else { + for (size_t i = 0; i < vins.size(); ++i) + mtx.vin.push_back(CTxIn(vins[i])); + } + for (int i = 0; i < nOut; ++i) { + CTxOut out; + out.nValue = (i == opRetIdx) ? 0 : 1000; + if (i == opRetIdx) { + out.scriptPubKey = CScript(opRetScript.begin(), opRetScript.end()); + } else { + // A dummy non-OP_RETURN script (a single OP_TRUE) — its content is + // irrelevant; the test addrOfVout supplies the keying address. + out.scriptPubKey = CScript() << OP_TRUE; + } + mtx.vout.push_back(out); + } + return CTransaction(mtx); +} + +// Apply a CTransaction through the EXACT production path: ParseTx (vout[0]-only) +// + ApplyTransaction, using the per-vout label addresses. Returns the tx hash. +uint256 ApplyRealTx(CZSLPStore* s, const CTransaction& tx, int64_t height) +{ + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesis = false; + bool present = CZSLPIndexer::ParseTx(tx, height, parsed, genesisMeta, + haveGenesis); + std::vector vin; + for (size_t k = 0; k < tx.vin.size(); ++k) + vin.push_back(tx.vin[k].prevout); + s->ApplyTransaction(vin, present ? &parsed : NULL, tx.GetHash(), height, + haveGenesis ? &genesisMeta : NULL, AddrLabels(), + (int32_t)tx.vout.size()); + return tx.GetHash(); +} + +CZSLPStore* NewStore() +{ + return new CZSLPStore("zslp-vectors", 1 << 20, /*fMemory=*/true, + /*fWipe=*/true); +} + +// On-chain token_id field = the genesis txid in DISPLAY (big-endian) order = +// the reverse of the uint256 internal bytes. The indexer reverses it back via +// TokenIdToUint256, so this round-trips to the genesis tokenId. +void TokenIdField(const uint256& genesisTxid, uint8_t out[32]) +{ + std::vector v(genesisTxid.begin(), genesisTxid.end()); + for (int i = 0; i < 32; ++i) out[i] = v[31 - i]; +} + +} // namespace + +// ── R-PARSE-1/2: SLP message at vout[1] is IGNORED; vout[0] decides ───── + +TEST(ZslpVectors, MessageAtVout1Ignored) +{ + CZSLPStore* s = NewStore(); + // GENESIS at vout[1] (a payment-looking output at vout[0]). vout[0] is a + // non-OP_RETURN script => the tx has NO SLP message. No token is created. + std::vector gen = GenesisScript(1000); + CTransaction tx = MakeTx(gen, /*opRetIdx=*/1, /*nOut=*/2, {}); + uint256 txid = ApplyRealTx(s, tx, 100); + + EXPECT_EQ(s->TokenCount(), 0); + CZSLPToken t; + EXPECT_FALSE(s->GetToken(txid, t)); + EXPECT_EQ(s->UtxoCount(), 0); + delete s; +} + +// ── R-PARSE-2: two OP_RETURNs (vout0 + vout1) — vout[0] wins, vout[1] noop ─ + +TEST(ZslpVectors, TwoOpReturnsVout0Wins) +{ + CZSLPStore* s = NewStore(); + // vout[0] is a VALID GENESIS qty 500; vout[1] is ALSO an OP_RETURN GENESIS + // qty 999 (would mint a different supply). Only vout[0] is parsed. + std::vector g0 = GenesisScript(500); + std::vector g1 = GenesisScript(999); + CMutableTransaction mtx; + { + CTxOut o0; o0.nValue = 0; + o0.scriptPubKey = CScript(g0.begin(), g0.end()); + mtx.vout.push_back(o0); + CTxOut o1; o1.nValue = 0; + o1.scriptPubKey = CScript(g1.begin(), g1.end()); + mtx.vout.push_back(o1); + // A real recipient output at vout[2] so the genesis qty has a home. + CTxOut o2; o2.nValue = 1000; o2.scriptPubKey = CScript() << OP_TRUE; + mtx.vout.push_back(o2); + } + CTransaction tx(mtx); + uint256 txid = ApplyRealTx(s, tx, 100); + + CZSLPToken t; + ASSERT_TRUE(s->GetToken(txid, t)); + EXPECT_EQ(t.totalMinted, 500); // vout[0]'s 500, NEVER vout[1]'s 999 + EXPECT_EQ(s->GetBalance(txid, "t1vout1"), 500); // created at vout[1] + delete s; +} + +// ── R-PARSE-3: coinbase is never SLP (skipped by ConnectBlock) ────────── + +TEST(ZslpVectors, CoinbaseIgnored) +{ + // We model ConnectBlock's coinbase skip: a coinbase whose vout[0] is a + // valid GENESIS must create NOTHING. (ConnectBlock starts the tx loop at + // index 1; here we assert the rule by simply never applying the coinbase.) + CZSLPStore* s = NewStore(); + std::vector gen = GenesisScript(1000); + CTransaction cb = MakeTx(gen, 0, 2, {}, /*coinbase=*/true); + EXPECT_TRUE(cb.IsCoinBase()); + // Per R-PARSE-3 the indexer SKIPS vtx[0] — we do not apply it. + EXPECT_EQ(s->TokenCount(), 0); + EXPECT_EQ(s->UtxoCount(), 0); + // Sanity: the SAME tx, if it WERE applied (non-coinbase position), would + // create the token — proving the skip is what suppresses it. + CTransaction nonCb = MakeTx(gen, 0, 2, {}); + uint256 txid = ApplyRealTx(s, nonCb, 100); + CZSLPToken t; + EXPECT_TRUE(s->GetToken(txid, t)); + delete s; +} + +// ── R-GEN-3 / R-MINT-3: GENESIS / MINT with NO vout[1] => totalMinted 0 ── + +TEST(ZslpVectors, GenesisNoVout1TotalMintedZero) +{ + CZSLPStore* s = NewStore(); + // GENESIS with ONLY the OP_RETURN output (voutCount == 1). The initial + // quantity is declared 1000 but vout[1] does not exist => nothing created, + // totalMinted == 0 (R-GEN-3 FIX), token row still inserted. + std::vector gen = GenesisScript(1000); + CTransaction tx = MakeTx(gen, 0, /*nOut=*/1, {}); + uint256 txid = ApplyRealTx(s, tx, 100); + + CZSLPToken t; + ASSERT_TRUE(s->GetToken(txid, t)); + EXPECT_EQ(t.totalMinted, 0); // NOT 1000 + EXPECT_EQ(s->UtxoCount(), 0); // no token UTXO created + delete s; +} + +TEST(ZslpVectors, MintNoVout1TotalMintedUnchanged) +{ + CZSLPStore* s = NewStore(); + // GENESIS with a baton (vout 2), 100 at vout1. + std::vector gen = GenesisScript(100, /*baton=*/2); + CTransaction g = MakeTx(gen, 0, /*nOut=*/3, {}); + uint256 tokenId = ApplyRealTx(s, g, 100); + CZSLPToken t0; ASSERT_TRUE(s->GetToken(tokenId, t0)); + EXPECT_EQ(t0.totalMinted, 100); + + // MINT that spends the baton (tokenId, vout2) but has NO vout[1] + // (voutCount == 1): declares +5000 but creates nothing => totalMinted + // unchanged (R-MINT-3 FIX). Baton not re-declared (no room) => baton ends. + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + std::vector mint = MintScript(tidField, 5000, /*baton=*/0); + CTransaction m = MakeTx(mint, 0, /*nOut=*/1, { COutPoint(tokenId, 2) }); + ApplyRealTx(s, m, 101); + + CZSLPToken t1; ASSERT_TRUE(s->GetToken(tokenId, t1)); + EXPECT_EQ(t1.totalMinted, 100); // unchanged + EXPECT_EQ(t1.mintBatonVout, 0); // baton consumed, not re-declared => ended + delete s; +} + +// ── R-MINT-1: unknown-token MINT and MINT-without-baton create nothing ── + +TEST(ZslpVectors, UnknownTokenMintCreatesNothing) +{ + CZSLPStore* s = NewStore(); + uint256 neverGenesised = H(0xEE); + uint8_t tidField[32]; TokenIdField(neverGenesised, tidField); + std::vector mint = MintScript(tidField, 1000, /*baton=*/2); + // Spend SOME input that happens to be a baton of a DIFFERENT (nonexistent) + // token — there is no token row, so nothing is created. + CTransaction m = MakeTx(mint, 0, 3, { COutPoint(H(0xDD), 0) }); + ApplyRealTx(s, m, 100); + EXPECT_EQ(s->TokenCount(), 0); + EXPECT_EQ(s->UtxoCount(), 0); + delete s; +} + +TEST(ZslpVectors, MintWithoutBatonCreatesNothing) +{ + CZSLPStore* s = NewStore(); + std::vector gen = GenesisScript(100, /*baton=*/2); + CTransaction g = MakeTx(gen, 0, 3, {}); + uint256 tokenId = ApplyRealTx(s, g, 100); + + // MINT that spends the QUANTITY UTXO (vout1), NOT the baton (vout2). + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + std::vector mint = MintScript(tidField, 9999, /*baton=*/2); + CTransaction m = MakeTx(mint, 0, 3, { COutPoint(tokenId, 1) }); // not baton + ApplyRealTx(s, m, 101); + + CZSLPToken t; ASSERT_TRUE(s->GetToken(tokenId, t)); + EXPECT_EQ(t.totalMinted, 100); // no inflation + CZSLPTokenUtxo u; + EXPECT_FALSE(s->GetUtxo(m.GetHash(), 1, u)); // no new UTXO created + // The spent quantity UTXO was burned; baton at vout2 untouched. + EXPECT_FALSE(s->GetUtxo(tokenId, 1, u)); + ASSERT_TRUE(s->GetUtxo(tokenId, 2, u)); + EXPECT_TRUE(u.isMintBaton); + delete s; +} + +// ── R-SEND-4 (Reading A PINNED): out-of-range output index burns ONLY that +// quantity; in-range outputs still apply; budget checked over ALL declared. + +TEST(ZslpVectors, OutOfRangeOutputBurnsThatQuantityOnly) +{ + CZSLPStore* s = NewStore(); + // GENESIS 1000 at vout1. + std::vector gen = GenesisScript(1000); + CTransaction g = MakeTx(gen, 0, 2, {}); + uint256 tokenId = ApplyRealTx(s, g, 100); + EXPECT_EQ(s->GetBalance(tokenId, "t1vout1"), 1000); + + // SEND with THREE quantities {400, 100, 100} = 600 required, availIn 1000 + // (>= 600 OK), on a tx with only 2 outputs (vout0 OP_RETURN + vout1). So: + // q[0]=400 -> vout1 (exists) => created (400) + // q[1]=100 -> vout2 (DOES NOT EXIST) => burned (Reading A) + // q[2]=100 -> vout3 (DOES NOT EXIST) => burned (Reading A) + // Reading B (rejected) would invalidate the whole SEND. We PIN Reading A: + // only vout1's 400 is created; the rest (and the 400 surplus) burned. + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + std::vector snd = SendScript(tidField, {400, 100, 100}); + CTransaction m = MakeTx(snd, 0, /*nOut=*/2, { COutPoint(tokenId, 1) }); + ApplyRealTx(s, m, 101); + + EXPECT_EQ(s->GetBalance(tokenId, "t1vout1"), 400); // ONLY the in-range output + EXPECT_EQ(s->UtxoCount(), 1); + CZSLPTokenUtxo u; + ASSERT_TRUE(s->GetUtxo(m.GetHash(), 1, u)); + EXPECT_EQ(u.amount, 400); + delete s; +} + +// ── R-SEND-3: output-sum overflow => SEND INVALID (create nothing, burn) ── + +TEST(ZslpVectors, SendSumOverflowInvalid) +{ + CZSLPStore* s = NewStore(); + // Genesis a large but in-domain supply at vout1. + std::vector gen = GenesisScript(kMaxValid); + CTransaction g = MakeTx(gen, 0, 2, {}); + uint256 tokenId = ApplyRealTx(s, g, 100); + + // SEND two outputs each (2^63 - 1): sum overflows int64 => INVALID. Inputs + // (the kMaxValid UTXO) are still burned; nothing is created. + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + std::vector snd = SendScript(tidField, {kMaxValid, kMaxValid}); + CTransaction m = MakeTx(snd, 0, 3, { COutPoint(tokenId, 1) }); + ApplyRealTx(s, m, 101); + + EXPECT_EQ(s->GetBalance(tokenId, "t1vout1"), 0); // input burned + EXPECT_EQ(s->UtxoCount(), 0); // nothing created + delete s; +} + +TEST(ZslpVectors, SendSumAtInt64MaxValid) +{ + CZSLPStore* s = NewStore(); + // availIn must be >= requiredOut. Genesis kMaxValid (= 2^63-1) at vout1, + // then SEND a single output of exactly kMaxValid => valid, fully conserved. + std::vector gen = GenesisScript(kMaxValid); + CTransaction g = MakeTx(gen, 0, 2, {}); + uint256 tokenId = ApplyRealTx(s, g, 100); + + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + std::vector snd = SendScript(tidField, {kMaxValid}); + CTransaction m = MakeTx(snd, 0, 2, { COutPoint(tokenId, 1) }); + ApplyRealTx(s, m, 101); + + EXPECT_EQ(s->GetBalance(tokenId, "t1vout1"), (int64_t)kMaxValid); + delete s; +} + +// ── R-BURN-3 / R-BURN-4: same-block genesis (tx1) then send (tx2) ─────── + +TEST(ZslpVectors, SameBlockGenesisThenSend) +{ + CZSLPStore* s = NewStore(); + uint256 blk = H(0x55); + s->ConnectBlockBegin(blk); + + // tx1: GENESIS 700 -> vout1. + std::vector gen = GenesisScript(700); + CTransaction g = MakeTx(gen, 0, 2, {}); + uint256 tokenId = ApplyRealTx(s, g, 7); + + // tx2 (same block): SEND spending (tokenId, vout1) -> 700 to vout1. + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + std::vector snd = SendScript(tidField, {700}); + CTransaction m = MakeTx(snd, 0, 2, { COutPoint(tokenId, 1) }); + uint256 sendTxid = ApplyRealTx(s, m, 7); + + s->ConnectBlockEnd(7, blk); + + // tx2 must have SEEN tx1's UTXO and moved it. + EXPECT_EQ(s->GetBalance(tokenId, "t1vout1"), 700); // now under the send tx + EXPECT_EQ(s->UtxoCount(), 1); + CZSLPTokenUtxo u; + EXPECT_FALSE(s->GetUtxo(tokenId, 1, u)); // genesis UTXO consumed + ASSERT_TRUE(s->GetUtxo(sendTxid, 1, u)); // send UTXO live + EXPECT_EQ(u.amount, 700); + delete s; +} + +// ── R-ID-1 / R-9: token_id endianness round-trip (genesis -> MINT/SEND) ── + +TEST(ZslpVectors, TokenIdEndiannessRoundTrip) +{ + CZSLPStore* s = NewStore(); + std::vector gen = GenesisScript(50, /*baton=*/2); + CTransaction g = MakeTx(gen, 0, 3, {}); + uint256 tokenId = ApplyRealTx(s, g, 100); // tokenId == genesis txid + + // A MINT quoting the genesis txid's DISPLAY-hex (big-endian) must resolve to + // the SAME tokenId the GENESIS produced (TokenIdToUint256 reverses it). + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + std::vector mint = MintScript(tidField, 25, /*baton=*/2); + CTransaction m = MakeTx(mint, 0, 3, { COutPoint(tokenId, 2) }); + ApplyRealTx(s, m, 101); + + CZSLPToken t; ASSERT_TRUE(s->GetToken(tokenId, t)); + EXPECT_EQ(t.totalMinted, 75); // 50 + 25: the MINT resolved the right token + delete s; +} + +// ── R-24: ListTransfers is bounded — peak memory O(from+count) ─────────── +// (Functional pin: with many transfers, paging returns the right window in +// newest-first order and never returns more than `count`.) + +TEST(ZslpVectors, ListTransfersBoundedAndOrdered) +{ + CZSLPStore* s = NewStore(); + std::vector gen = GenesisScript(100000); + CTransaction g = MakeTx(gen, 0, 2, {}); + uint256 tokenId = ApplyRealTx(s, g, 1); + + // Chain 30 SENDs, each moving the whole balance forward one hop, at + // increasing heights -> 31 transfer rows (1 genesis + 30 sends). + uint256 prevTxid = g.GetHash(); + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + for (int i = 0; i < 30; ++i) { + std::vector snd = SendScript(tidField, {100000}); + CTransaction m = MakeTx(snd, 0, 2, { COutPoint(prevTxid, 1) }); + ApplyRealTx(s, m, 2 + i); + prevTxid = m.GetHash(); + } + + // count clamps the slice; newest-first ordering preserved. + std::vector page; + int n = s->ListTransfers(tokenId, /*from=*/0, /*count=*/5, page); + EXPECT_EQ(n, 5); + ASSERT_EQ(page.size(), 5u); + // Newest first: the highest-height transfer (height 31) is row 0. + EXPECT_EQ(page[0].blockHeight, 31); + EXPECT_EQ(page[1].blockHeight, 30); + EXPECT_GT(page[0].blockHeight, page[4].blockHeight); + + // Paging: from=5 returns the next-older window, still newest-first. + std::vector page2; + int n2 = s->ListTransfers(tokenId, /*from=*/5, /*count=*/5, page2); + EXPECT_EQ(n2, 5); + EXPECT_EQ(page2[0].blockHeight, 26); // one older than page[4] (height 27) + delete s; +} + +// ── R-24 (regression): a huge `from` must NOT allocate O(from) ─────────── +// The first ring-buffer fix sized its window to (from+count), so a single +// `zslp_listtransfers "tid" 1 2000000000` would try to allocate ~2e9 rows +// and OOM the daemon. Peak memory is now O(count) regardless of `from`: +// a from >= total returns empty immediately, and a near-INT_MAX from on a +// tiny token returns empty without a giant allocation. (If this regressed, +// the process would OOM/crash here rather than fail the assertion.) + +TEST(ZslpVectors, ListTransfersHugeFromIsBounded) +{ + CZSLPStore* s = NewStore(); + std::vector gen = GenesisScript(100000); + CTransaction g = MakeTx(gen, 0, 2, {}); + uint256 tokenId = ApplyRealTx(s, g, 1); + + // A handful of transfers (total rows is small). + uint256 prevTxid = g.GetHash(); + uint8_t tidField[32]; TokenIdField(tokenId, tidField); + for (int i = 0; i < 4; ++i) { + std::vector snd = SendScript(tidField, {100000}); + CTransaction m = MakeTx(snd, 0, 2, { COutPoint(prevTxid, 1) }); + ApplyRealTx(s, m, 2 + i); + prevTxid = m.GetHash(); + } + // 1 genesis + 4 sends = 5 transfer rows total. + + // from far beyond INT-range-but-valid: returns empty, no OOM. + std::vector page; + int n = s->ListTransfers(tokenId, /*from=*/2000000000, /*count=*/10, page); + EXPECT_EQ(n, 0); + EXPECT_TRUE(page.empty()); + + // from exactly at total -> empty; from just under total -> the single + // oldest row, still bounded. + int n_at = s->ListTransfers(tokenId, /*from=*/5, /*count=*/10, page); + EXPECT_EQ(n_at, 0); + int n_last = s->ListTransfers(tokenId, /*from=*/4, /*count=*/10, page); + EXPECT_EQ(n_last, 1); + ASSERT_EQ(page.size(), 1u); + EXPECT_EQ(page[0].blockHeight, 1); // the genesis row (oldest) + + // count larger than total still bounded to total, newest-first. + int n_all = s->ListTransfers(tokenId, /*from=*/0, /*count=*/10, page); + EXPECT_EQ(n_all, 5); + ASSERT_EQ(page.size(), 5u); + EXPECT_EQ(page[0].blockHeight, 5); // newest (4th send at height 5) + EXPECT_EQ(page[4].blockHeight, 1); // oldest (genesis) + delete s; +} diff --git a/src/gtest/test_zslp_wallet.cpp b/src/gtest/test_zslp_wallet.cpp new file mode 100644 index 00000000000..8bd199fb98b --- /dev/null +++ b/src/gtest/test_zslp_wallet.cpp @@ -0,0 +1,834 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// Unit tests for the ZSLP WRITE path's load-bearing, wallet-independent pieces: +// +// 1. The C++<->C build bridge (ZSLPBuildGenesis/Mint/Send) — that it emits the +// complete OP_RETURN script (leading 0x6a), that the daemon parse seam +// (CZSLPIndexer::ParseTx) round-trips the exact fields, and that it fails +// (empty result) on over-cap / invalid input. +// +// 2. The deterministic CANONICAL LAYOUT the builder must produce: vout[0] = +// OP_RETURN, vout[1..N] = token recipients in OP_RETURN order, any change +// strictly AFTER the token outputs. We assemble a tx with that exact layout +// (the same bytes BuildAndCommitZSLP assembles), drive it through the REAL +// ParseTx + REAL store ApplyTransaction, and assert the ledger credits the +// intended recipients and nothing is mis-credited or burned. +// +// 3. The READ-ONLY self-validation predicate CZSLPStore::WouldBeValid — the +// exact gate the builder calls before broadcast (R-WALLET-9). We assert it +// ACCEPTS a conserved/correctly-ordered tx and REJECTS the burn/mis-order +// classes: OP_RETURN not at vout[0] (no SLP message), Σout > Σin (over-send), +// a surplus with no token-change output, a quantity mapped to a nonexistent +// vout, a MINT without the baton input, and a SEND that would map a token to +// a nonexistent output. +// +// HONESTY: the full BuildAndCommitZSLP (coin selection + signing + CommitTransaction) +// needs a live CWallet, keystore, chainActive and mempool — far heavier than a +// gtest unit. That end-to-end path is NOT exercised here. What IS unit-tested is +// every PURE decision the builder delegates: the bridge bytes, the canonical +// layout's ledger effect through the real indexer, and the self-validation +// predicate that decides broadcast/refuse. The anti-burn funding fence and the +// signing loop are covered by code review against CreateTransaction, not gtest. + +#include + +#include "chainparams.h" +#include "key.h" +#include "key_io.h" +#include "main.h" +#include "primitives/transaction.h" +#include "script/script.h" +#include "script/standard.h" +#include "uint256.h" +#include "utiltest.h" +#include "wallet/wallet.h" +#include "wallet/zslpwallet.h" +#include "zslp/zslpindexer.h" +#include "zslp/zslpmsg.h" +#include "zslp/zslpstore.h" + +#include +#include +#include + +namespace { + +uint256 H(uint8_t b) +{ + std::vector v(32, 0); + v[0] = b; + return uint256(v); +} + +// On-chain (BE) token-id bytes for a daemon uint256 — what the SEND/MINT bridge +// expects (reverse of internal bytes). +void TokenIdBE(const uint256& id, uint8_t out[32]) +{ + const unsigned char* p = id.begin(); + for (int i = 0; i < 32; ++i) out[i] = p[31 - i]; +} + +CZSLPStore* NewStore() +{ + return new CZSLPStore("zslp-wallet-test", 1 << 20, /*fMemory=*/true, + /*fWipe=*/true); +} + +// Per-vout deterministic address label; vout 0 (OP_RETURN) -> "". +std::function AddrLabels() +{ + return [](int32_t n) -> std::string { + if (n <= 0) return std::string(); + return std::string("t1vout") + std::to_string(n); + }; +} + +// Build a CTransaction whose vout[0] is `opret` (the OP_RETURN) and vout[1..N] +// are dummy 546-sat outputs, plus an optional trailing change output. This is +// EXACTLY the canonical layout BuildAndCommitZSLP assembles. `vins` are spent +// prevouts. +CTransaction MakeCanonicalTx(const std::vector& opret, + int nTokenOuts, + const std::vector& vins, + bool withChange) +{ + CMutableTransaction mtx; + for (size_t i = 0; i < vins.size(); ++i) + mtx.vin.push_back(CTxIn(vins[i])); + // vout[0] = OP_RETURN, value 0. + { + CTxOut o; + o.nValue = 0; + o.scriptPubKey = CScript(opret.begin(), opret.end()); + mtx.vout.push_back(o); + } + for (int i = 0; i < nTokenOuts; ++i) { + CTxOut o; + o.nValue = 546; + o.scriptPubKey = CScript() << OP_TRUE; // dummy recipient + mtx.vout.push_back(o); + } + if (withChange) { + CTxOut o; + o.nValue = 100000; + o.scriptPubKey = CScript() << OP_DUP; // dummy change + mtx.vout.push_back(o); + } + return CTransaction(mtx); +} + +// Drive a tx through the EXACT production path used by the live indexer. +uint256 ApplyRealTx(CZSLPStore* s, const CTransaction& tx, int64_t height) +{ + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesis = false; + bool present = CZSLPIndexer::ParseTx(tx, height, parsed, genesisMeta, + haveGenesis); + std::vector vin; + for (size_t k = 0; k < tx.vin.size(); ++k) + vin.push_back(tx.vin[k].prevout); + s->ApplyTransaction(vin, present ? &parsed : NULL, tx.GetHash(), height, + haveGenesis ? &genesisMeta : NULL, AddrLabels(), + (int32_t)tx.vout.size()); + return tx.GetHash(); +} + +// The self-validate gate exactly as BuildAndCommitZSLP calls it. +bool SelfValidate(CZSLPStore* s, const CTransaction& tx, int64_t height, + std::string& reason) +{ + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesis = false; + if (!CZSLPIndexer::ParseTx(tx, height, parsed, genesisMeta, haveGenesis)) { + reason = "no SLP message at vout[0]"; + return false; + } + std::vector vin; + for (size_t k = 0; k < tx.vin.size(); ++k) + vin.push_back(tx.vin[k].prevout); + return s->WouldBeValid(vin, &parsed, tx.GetHash(), + haveGenesis ? &genesisMeta : NULL, + (int32_t)tx.vout.size(), reason); +} + +} // namespace + +// ════════════════════════════════════════════════════════════════════════ +// 1. Build bridge round-trips through the REAL parse seam +// ════════════════════════════════════════════════════════════════════════ + +TEST(ZslpWalletBridge, GenesisRoundTripVout0) +{ + uint8_t hash[32]; memset(hash, 0xAB, 32); + std::vector opret = + ZSLPBuildGenesis("GOLD", "Gold Coin", "https://x.io", hash, + /*decimals=*/2, /*baton=*/2, /*qty=*/100000); + ASSERT_FALSE(opret.empty()); + EXPECT_EQ(opret[0], 0x6a); // leading OP_RETURN opcode + // Tie to the LIVE relay cap, not a magic 223 (see G2 below). + EXPECT_LE(opret.size(), (size_t)nMaxDatacarrierBytes); + + // The genesis tx: vout[0]=OP_RETURN, vout[1]=recipient, vout[2]=baton. + CTransaction tx = MakeCanonicalTx(opret, /*nTokenOuts=*/2, {}, false); + CZSLPParsedMsg parsed; CZSLPToken meta; bool haveGen = false; + ASSERT_TRUE(CZSLPIndexer::ParseTx(tx, 1, parsed, meta, haveGen)); + ASSERT_TRUE(haveGen); + EXPECT_EQ(parsed.type, ZSLP_MSG_GENESIS); + EXPECT_EQ(parsed.initialQuantity, (int64_t)100000); + EXPECT_EQ(parsed.mintBatonVout, 2); + EXPECT_EQ(meta.ticker, "GOLD"); + EXPECT_EQ(meta.name, "Gold Coin"); + EXPECT_EQ(meta.documentUrl, "https://x.io"); + EXPECT_EQ(meta.decimals, 2); + EXPECT_TRUE(meta.hasDocumentHash); + EXPECT_EQ(parsed.tokenId, tx.GetHash()); // tokenId == genesis txid +} + +TEST(ZslpWalletBridge, NftGenesisIsBatonlessQty1) +{ + std::vector opret = + ZSLPBuildGenesis("", "My Photo #1", "", NULL, + /*decimals=*/0, /*baton=*/0, /*qty=*/1); + ASSERT_FALSE(opret.empty()); + EXPECT_EQ(opret[0], 0x6a); + CTransaction tx = MakeCanonicalTx(opret, 1, {}, false); + CZSLPParsedMsg parsed; CZSLPToken meta; bool haveGen = false; + ASSERT_TRUE(CZSLPIndexer::ParseTx(tx, 7, parsed, meta, haveGen)); + EXPECT_EQ(parsed.initialQuantity, (int64_t)1); + EXPECT_EQ(parsed.mintBatonVout, 0); + EXPECT_EQ(meta.decimals, 0); + EXPECT_FALSE(meta.hasDocumentHash); +} + +TEST(ZslpWalletBridge, SendRoundTripTokenId) +{ + uint256 tid = H(0x42); + uint8_t be[32]; TokenIdBE(tid, be); + std::vector opret = ZSLPBuildSend(be, {5, 3}); + ASSERT_FALSE(opret.empty()); + EXPECT_EQ(opret[0], 0x6a); + CTransaction tx = MakeCanonicalTx(opret, 2, {COutPoint(H(0x99), 1)}, false); + CZSLPParsedMsg parsed; CZSLPToken meta; bool haveGen = false; + ASSERT_TRUE(CZSLPIndexer::ParseTx(tx, 3, parsed, meta, haveGen)); + EXPECT_EQ(parsed.type, ZSLP_MSG_SEND); + EXPECT_EQ(parsed.tokenId, tid); // BE -> internal round-trips to the daemon id + EXPECT_EQ(parsed.numOutputs, 2); + EXPECT_EQ(parsed.outputQuantities[0], (int64_t)5); + EXPECT_EQ(parsed.outputQuantities[1], (int64_t)3); +} + +TEST(ZslpWalletBridge, MintRoundTrip) +{ + uint256 tid = H(0x11); + uint8_t be[32]; TokenIdBE(tid, be); + std::vector opret = ZSLPBuildMint(be, /*baton=*/2, /*qty=*/777); + ASSERT_FALSE(opret.empty()); + CTransaction tx = MakeCanonicalTx(opret, 2, {COutPoint(H(0x99), 0)}, false); + CZSLPParsedMsg parsed; CZSLPToken meta; bool haveGen = false; + ASSERT_TRUE(CZSLPIndexer::ParseTx(tx, 9, parsed, meta, haveGen)); + EXPECT_EQ(parsed.type, ZSLP_MSG_MINT); + EXPECT_EQ(parsed.tokenId, tid); + EXPECT_EQ(parsed.additionalQuantity, (int64_t)777); + EXPECT_EQ(parsed.mintBatonVout, 2); +} + +TEST(ZslpWalletBridge, RejectsOverCapAndInvalid) +{ + // SEND with 0 outputs -> empty. + uint8_t be[32]; memset(be, 0x55, 32); + EXPECT_TRUE(ZSLPBuildSend(be, {}).empty()); + // SEND with >19 outputs -> empty (slp_build_send rejects, FinishBuild maps). + EXPECT_TRUE(ZSLPBuildSend(be, std::vector(20, 1)).empty()); + // GENESIS with a name long enough to blow the 223-byte relay cap -> empty. + std::string longName(250, 'A'); + EXPECT_TRUE(ZSLPBuildGenesis("TICK", longName, "", NULL, 0, 0, 1).empty()); + // SEND at exactly 19 outputs fits (= 217 bytes per the spec). + std::vector ok = ZSLPBuildSend(be, std::vector(19, 1)); + ASSERT_FALSE(ok.empty()); + EXPECT_LE(ok.size(), (size_t)nMaxDatacarrierBytes); +} + +// ════════════════════════════════════════════════════════════════════════ +// 2. Canonical layout -> correct ledger effect (deterministic ordering) +// ════════════════════════════════════════════════════════════════════════ + +TEST(ZslpWalletLayout, GenesisCreditsVout1NotChange) +{ + CZSLPStore* s = NewStore(); + + std::vector opret = + ZSLPBuildGenesis("NFT", "Art", "", NULL, 0, 0, 1); + ASSERT_FALSE(opret.empty()); + + // Canonical: vout[0]=OP_RETURN, vout[1]=recipient, vout[2]=ZEC change. + CTransaction tx = MakeCanonicalTx(opret, /*nTokenOuts=*/1, {}, /*withChange=*/true); + // Sanity: OP_RETURN really is at vout[0]. + ASSERT_GE(tx.vout.size(), (size_t)3u); + EXPECT_TRUE(tx.vout[0].scriptPubKey.size() > 0 && + tx.vout[0].scriptPubKey[0] == OP_RETURN); + + uint256 tid = ApplyRealTx(s, tx, 100); + + // The qty-1 NFT lands on vout[1] (the recipient), NOT on the change output. + CZSLPTokenUtxo u1; + ASSERT_TRUE(s->GetUtxo(tid, 1, u1)); + EXPECT_EQ(u1.amount, (int64_t)1); + EXPECT_FALSE(u1.isMintBaton); + // The change output (vout[2]) carries no token. + CZSLPTokenUtxo u2; + EXPECT_FALSE(s->GetUtxo(tid, 2, u2)); + // Supply is exactly 1. + CZSLPToken tok; ASSERT_TRUE(s->GetToken(tid, tok)); + EXPECT_EQ(tok.totalMinted, (int64_t)1); + + delete s; +} + +TEST(ZslpWalletLayout, SendMovesOwnershipAndConserves) +{ + CZSLPStore* s = NewStore(); + + // Genesis 10 units to vout[1]. + std::vector gen = + ZSLPBuildGenesis("FUN", "Fungible", "", NULL, 0, 0, 10); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + ASSERT_EQ(s->GetBalance(tid, "t1vout1"), (int64_t)10); + + // SEND 7 to a recipient (vout[1]) + 3 token-change to self (vout[2]). + uint8_t be[32]; TokenIdBE(tid, be); + std::vector snd = ZSLPBuildSend(be, {7, 3}); + CTransaction stx = MakeCanonicalTx(snd, /*nTokenOuts=*/2, + {COutPoint(gtx.GetHash(), 1)}, false); + ApplyRealTx(s, stx, 2); + + // Conservation: recipient 7, change 3, nothing burned, supply unchanged. + EXPECT_EQ(s->GetBalance(tid, "t1vout1"), (int64_t)7); + EXPECT_EQ(s->GetBalance(tid, "t1vout2"), (int64_t)3); + CZSLPToken tok; ASSERT_TRUE(s->GetToken(tid, tok)); + EXPECT_EQ(tok.totalMinted, (int64_t)10); // SEND never mints/burns supply + // The genesis UTXO at vout[1] was consumed. + CZSLPTokenUtxo spent; + EXPECT_FALSE(s->GetUtxo(gtx.GetHash(), 1, spent)); + + delete s; +} + +// ════════════════════════════════════════════════════════════════════════ +// 3. Self-validation (R-WALLET-9): ACCEPT good, REFUSE every burn/mis-order +// ════════════════════════════════════════════════════════════════════════ + +TEST(ZslpWalletSelfValidate, AcceptsConservedSend) +{ + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("OK", "Ok", "", NULL, 0, 0, 10); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + std::vector snd = ZSLPBuildSend(be, {7, 3}); // 7 + 3 == 10 in + CTransaction stx = MakeCanonicalTx(snd, 2, + {COutPoint(gtx.GetHash(), 1)}, false); + std::string reason; + EXPECT_TRUE(SelfValidate(s, stx, 2, reason)) << reason; + delete s; +} + +TEST(ZslpWalletSelfValidate, RefusesOverSend) +{ + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("OS", "Os", "", NULL, 0, 0, 5); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + // SEND requires 9 but the single input carries only 5 -> would burn. + std::vector snd = ZSLPBuildSend(be, {9}); + CTransaction stx = MakeCanonicalTx(snd, 1, + {COutPoint(gtx.GetHash(), 1)}, false); + std::string reason; + EXPECT_FALSE(SelfValidate(s, stx, 2, reason)); + EXPECT_NE(reason.find("burn"), std::string::npos); + delete s; +} + +TEST(ZslpWalletSelfValidate, RefusesUnaccountedSurplus) +{ + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("SP", "Sp", "", NULL, 0, 0, 10); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + // SEND only 7 of the 10-unit input but provides NO token-change output -> + // the missing 3 would be silently burned; the builder must refuse. + std::vector snd = ZSLPBuildSend(be, {7}); + CTransaction stx = MakeCanonicalTx(snd, 1, + {COutPoint(gtx.GetHash(), 1)}, false); + std::string reason; + EXPECT_FALSE(SelfValidate(s, stx, 2, reason)); + EXPECT_NE(reason.find("surplus"), std::string::npos); + delete s; +} + +TEST(ZslpWalletSelfValidate, RefusesOpReturnNotAtVout0) +{ + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("MO", "Mo", "", NULL, 0, 0, 10); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + std::vector snd = ZSLPBuildSend(be, {10}); + + // Build a MIS-ORDERED tx: a normal output at vout[0], OP_RETURN at vout[1] + // (the random-change-insert hazard). ParseTx (vout[0]-only) finds NO SLP + // message, so self-validate refuses. + CMutableTransaction mtx; + mtx.vin.push_back(CTxIn(COutPoint(gtx.GetHash(), 1))); + { CTxOut o; o.nValue = 546; o.scriptPubKey = CScript() << OP_TRUE; mtx.vout.push_back(o); } + { CTxOut o; o.nValue = 0; o.scriptPubKey = CScript(snd.begin(), snd.end()); mtx.vout.push_back(o); } + CTransaction badtx(mtx); + + std::string reason; + EXPECT_FALSE(SelfValidate(s, badtx, 2, reason)); + EXPECT_NE(reason.find("vout[0]"), std::string::npos); + delete s; +} + +TEST(ZslpWalletSelfValidate, RefusesMintWithoutBaton) +{ + CZSLPStore* s = NewStore(); + // Genesis WITHOUT a baton. + std::vector gen = + ZSLPBuildGenesis("NB", "NoBaton", "", NULL, 0, 0, 10); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + std::vector mnt = ZSLPBuildMint(be, 0, 100); + // The input is the qty UTXO, NOT a baton -> MINT is invalid. + CTransaction mtx = MakeCanonicalTx(mnt, 1, + {COutPoint(gtx.GetHash(), 1)}, false); + std::string reason; + EXPECT_FALSE(SelfValidate(s, mtx, 2, reason)); + EXPECT_NE(reason.find("baton"), std::string::npos); + delete s; +} + +// A MINT that spends the live baton and lands its new supply on vout[1] is +// ACCEPTED by the self-validate gate, and ApplyTransaction creates exactly the +// new-supply UTXO + the continued baton (the §3 mint-path happy case). +TEST(ZslpWalletSelfValidate, AcceptsMintWithBatonAndCreatesSupply) +{ + CZSLPStore* s = NewStore(); + // Genesis WITH a baton at vout[2] (recipient at vout[1]). + std::vector gen = + ZSLPBuildGenesis("WB", "WithBaton", "", NULL, 0, /*baton=*/2, 100); + CTransaction gtx = MakeCanonicalTx(gen, /*nTokenOuts=*/2, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + // Baton exists at vout[2]. + CZSLPTokenUtxo b0; + ASSERT_TRUE(s->GetUtxo(gtx.GetHash(), 2, b0)); + ASSERT_TRUE(b0.isMintBaton); + + // MINT 50 more, continuing the baton at vout[2]; spend the baton input. + uint8_t be[32]; TokenIdBE(tid, be); + std::vector mnt = ZSLPBuildMint(be, /*baton=*/2, /*qty=*/50); + CTransaction mtx = MakeCanonicalTx(mnt, /*nTokenOuts=*/2, + {COutPoint(gtx.GetHash(), 2)}, false); + std::string reason; + EXPECT_TRUE(SelfValidate(s, mtx, 2, reason)) << reason; + + // Apply for real: new 50-unit UTXO at vout[1], continued baton at vout[2], + // supply 100 -> 150, the spent baton consumed. + ApplyRealTx(s, mtx, 2); + CZSLPTokenUtxo u1; + ASSERT_TRUE(s->GetUtxo(mtx.GetHash(), 1, u1)); + EXPECT_EQ(u1.amount, (int64_t)50); + EXPECT_FALSE(u1.isMintBaton); + CZSLPTokenUtxo nb; + ASSERT_TRUE(s->GetUtxo(mtx.GetHash(), 2, nb)); + EXPECT_TRUE(nb.isMintBaton); + CZSLPToken tok; ASSERT_TRUE(s->GetToken(tid, tok)); + EXPECT_EQ(tok.totalMinted, (int64_t)150); + CZSLPTokenUtxo gone; + EXPECT_FALSE(s->GetUtxo(gtx.GetHash(), 2, gone)); // old baton consumed + delete s; +} + +// A MINT that omits vout[1] (declares additional quantity with no output to +// carry it) silently creates supply 0 in the ledger — the builder must refuse +// it, mirroring the SEND missing-vout guard. +TEST(ZslpWalletSelfValidate, RefusesMintWithNoSupplyVout) +{ + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("M0", "M0", "", NULL, 0, /*baton=*/2, 100); + CTransaction gtx = MakeCanonicalTx(gen, /*nTokenOuts=*/2, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + // MINT with NO continued baton and NO recipient output (only the OP_RETURN). + std::vector mnt = ZSLPBuildMint(be, /*baton=*/0, /*qty=*/50); + CTransaction mtx = MakeCanonicalTx(mnt, /*nTokenOuts=*/0, + {COutPoint(gtx.GetHash(), 2)}, false); + std::string reason; + EXPECT_FALSE(SelfValidate(s, mtx, 2, reason)); + EXPECT_NE(reason.find("vout[1]"), std::string::npos); + delete s; +} + +TEST(ZslpWalletSelfValidate, RefusesSendQtyToMissingVout) +{ + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("MV", "Mv", "", NULL, 0, 0, 10); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + // Two quantities (5,5) but only ONE token output exists (so qty[1]->vout[2] + // is missing): the second 5 would be burned. Refuse. + std::vector snd = ZSLPBuildSend(be, {5, 5}); + CTransaction stx = MakeCanonicalTx(snd, /*nTokenOuts=*/1, + {COutPoint(gtx.GetHash(), 1)}, false); + std::string reason; + EXPECT_FALSE(SelfValidate(s, stx, 2, reason)); + delete s; +} + +// Self-validate must AGREE with ApplyTransaction: if WouldBeValid says yes, the +// real apply credits exactly the message; if no, the real apply burns. This +// pins the "no divergence" requirement (R-WALLET-9). +TEST(ZslpWalletSelfValidate, AgreesWithApplyTransaction) +{ + CZSLPStore* s = NewStore(); + std::vector gen = + ZSLPBuildGenesis("AG", "Ag", "", NULL, 0, 0, 10); + CTransaction gtx = MakeCanonicalTx(gen, 1, {}, false); + uint256 tid = ApplyRealTx(s, gtx, 1); + + uint8_t be[32]; TokenIdBE(tid, be); + std::vector snd = ZSLPBuildSend(be, {6, 4}); + CTransaction stx = MakeCanonicalTx(snd, 2, + {COutPoint(gtx.GetHash(), 1)}, false); + std::string reason; + ASSERT_TRUE(SelfValidate(s, stx, 2, reason)) << reason; + + // Apply for real on a fresh store seeded identically and confirm the credit + // matches the prediction (6 to vout1, 4 to vout2, conserved). + ApplyRealTx(s, stx, 2); + EXPECT_EQ(s->GetBalance(tid, "t1vout1"), (int64_t)6); + EXPECT_EQ(s->GetBalance(tid, "t1vout2"), (int64_t)4); + delete s; +} + +// ════════════════════════════════════════════════════════════════════════ +// G2. NO-FORK / RELAY-STANDARDNESS: a built ZSLP carrier (GENESIS / SEND / +// MINT) is a STANDARD transaction under MAINNET CChainParams at Sapling +// activation height. This is the load-bearing premise of the entire +// non-consensus model: unmodified ZClassic nodes must RELAY and MINE the +// OP_RETURN carrier. We run the EXACT predicate AcceptToMemoryPool gates +// on — IsStandardTx (main.cpp:714; ATMP calls it at main.cpp via +// Params().RequireStandard() && !IsStandardTx(...)) — under MAIN params, +// and tie the OP_RETURN size assertion to the live policy constant +// (nMaxDatacarrierBytes == MAX_OP_RETURN_RELAY) so a future regression of +// the relay cap fails this test rather than silently breaking relay. +// +// HONESTY: this does NOT call AcceptToMemoryPool (that needs a live chain + +// UTXO view + signed inputs). It runs IsStandardTx, the one relay-standardness +// gate, which is exactly the no-fork claim. The live RPC->builder->confirm loop +// (real signing + mempool acceptance) is covered by the committed regtest +// harness qa/zslp/zslp-nft-regtest.sh. +// ════════════════════════════════════════════════════════════════════════ + +namespace { + +// A real, standard P2PKH scriptPubKey for an arbitrary keyid, so dust + the +// TX_PUBKEYHASH standardness check pass (the §1/§2 helpers use OP_TRUE/OP_DUP +// dummies which are intentionally NON-standard and unsuitable for G2). +CScript P2PKH(uint8_t seed) +{ + std::vector h(20, seed); + return GetScriptForDestination(CKeyID(uint160(h))); +} + +// Assemble the canonical ZSLP layout INTO a Sapling (v4) contextual tx so it +// passes the saplingActive nVersion gate in IsStandardTx, with REAL p2pkh +// recipients (standard + non-dust) and one push-only, <=1650-byte scriptSig +// per input (so the txin standardness checks pass without real signing). +CTransaction MakeStandardCarrier(const std::vector& opret, + int nTokenOuts, bool withChange, int nHeight) +{ + CMutableTransaction mtx = + CreateNewContextualCMutableTransaction(Params().GetConsensus(), nHeight); + // One input with a push-only dummy scriptSig (72-byte push: push-only and + // well under the 1650 cap), so IsStandardTx's per-txin checks pass. + CTxIn in(COutPoint(uint256S("01"), 0)); + in.scriptSig = CScript() << std::vector(72, 0); + mtx.vin.push_back(in); + // vout[0] = OP_RETURN (value 0). + mtx.vout.push_back(CTxOut(0, CScript(opret.begin(), opret.end()))); + // vout[1..N] = 546-sat token recipients (above the relay dust floor). + for (int i = 0; i < nTokenOuts; ++i) + mtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH((uint8_t)(0x10 + i)))); + if (withChange) + mtx.vout.push_back(CTxOut(100000, P2PKH(0x77))); // ZEC change, strictly last + return CTransaction(mtx); +} + +} // namespace + +TEST(ZslpWalletStandardness, GenesisAndSendAreStandardOnMainnet) +{ + SelectParams(CBaseChainParams::MAIN); + const int nHeight = 476969; // MAIN Sapling activation (chainparams.cpp:110) + ASSERT_TRUE(Params().GetConsensus().NetworkUpgradeActive( + nHeight, Consensus::UPGRADE_SAPLING)); + + // ---- GENESIS carrier (recipient at vout[1], baton at vout[2], change) ---- + uint8_t hash[32]; memset(hash, 0xAB, 32); + std::vector gen = + ZSLPBuildGenesis("GOLD", "Gold Coin", "https://x.io", hash, + /*decimals=*/2, /*baton=*/2, /*qty=*/100000); + ASSERT_FALSE(gen.empty()); + CTransaction gtx = MakeStandardCarrier(gen, /*nTokenOuts=*/2, + /*withChange=*/true, nHeight); + std::string reason; + bool genStd = IsStandardTx(gtx, reason, nHeight); + EXPECT_TRUE(genStd) << "GENESIS carrier not standard: reason=" << reason; + // Exactly one OP_RETURN (else IsStandardTx rejects with multi-op-return). + int nNull = 0; + for (size_t i = 0; i < gtx.vout.size(); ++i) + if (gtx.vout[i].scriptPubKey.size() > 0 && + gtx.vout[i].scriptPubKey[0] == OP_RETURN) + ++nNull; + EXPECT_EQ(nNull, 1); + + // ---- SEND carrier (two token recipients + change) ---- + uint256 tid = gtx.GetHash(); + uint8_t be[32]; TokenIdBE(tid, be); + std::vector snd = ZSLPBuildSend(be, {5, 3}); + ASSERT_FALSE(snd.empty()); + CTransaction stx = MakeStandardCarrier(snd, /*nTokenOuts=*/2, + /*withChange=*/true, nHeight); + std::string reason2; + bool sendStd = IsStandardTx(stx, reason2, nHeight); + EXPECT_TRUE(sendStd) << "SEND carrier not standard: reason=" << reason2; + + // The carrier is a Sapling v4 tx (the version the live relay will see). + EXPECT_EQ(gtx.nVersion, 4); + EXPECT_EQ(stx.nVersion, 4); + + SelectParams(CBaseChainParams::REGTEST); // restore for sibling tests +} + +TEST(ZslpWalletStandardness, OpReturnCapIsThePolicyConstantNotAMagicLiteral) +{ + SelectParams(CBaseChainParams::MAIN); + const int nHeight = 476969; + + // Tripwire: the builder's cap MUST be the live relay policy constant, not a + // magic 223. If someone changes the relay cap, this fails first. + EXPECT_EQ(nMaxDatacarrierBytes, MAX_OP_RETURN_RELAY); + + // The OP_RETURN scriptPubKey of a MAX-size SEND (19 outputs) and a max-size + // GENESIS must each fit under the LIVE relay cap. + uint8_t be[32]; memset(be, 0x55, 32); + std::vector maxSend = + ZSLPBuildSend(be, std::vector(19, 1)); + ASSERT_FALSE(maxSend.empty()); + EXPECT_LE(maxSend.size(), (size_t)nMaxDatacarrierBytes); + + uint8_t hash[32]; memset(hash, 0xCD, 32); + // A genesis sized to push the OP_RETURN near the cap (long-ish metadata that + // still builds): ticker+name+url within limits. + std::vector maxGen = + ZSLPBuildGenesis("TICKER", std::string(40, 'N'), + "https://example.com/very/long/document/url/here", + hash, /*decimals=*/8, /*baton=*/2, /*qty=*/21000000); + ASSERT_FALSE(maxGen.empty()); + EXPECT_LE(maxGen.size(), (size_t)nMaxDatacarrierBytes); + + // NEGATIVE: a hand-crafted OP_RETURN of (cap+1) data bytes is NON-standard + // and IsStandardTx must reject it with reason "scriptpubkey" — proving the + // cap is the thing that actually gates relay. + { + CMutableTransaction mtx = CreateNewContextualCMutableTransaction( + Params().GetConsensus(), nHeight); + CTxIn in(COutPoint(uint256S("02"), 0)); + in.scriptSig = CScript() << std::vector(72, 0); + mtx.vin.push_back(in); + // OP_RETURN : total + // scriptPubKey size = 1 (OP_RETURN) + pushdata header + payload. Build + // the payload so the whole scriptPubKey is nMaxDatacarrierBytes+1 bytes. + // For a 220-byte payload the header is OP_PUSHDATA1+len = 2 bytes, so + // scriptPubKey = 1+2+220 = 223. To EXCEED the cap, use payload that + // makes scriptPubKey == nMaxDatacarrierBytes+1. + size_t payload = (size_t)nMaxDatacarrierBytes + 1 - 3; // OP_RETURN + OP_PUSHDATA1 + len byte + CScript oversize = CScript() << OP_RETURN + << std::vector(payload, 0xEE); + EXPECT_GT(oversize.size(), (size_t)nMaxDatacarrierBytes); + mtx.vout.push_back(CTxOut(0, oversize)); + mtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0x20))); + CTransaction big(mtx); + std::string reason; + EXPECT_FALSE(IsStandardTx(big, reason, nHeight)); + EXPECT_EQ(reason, "scriptpubkey") + << "oversize OP_RETURN should fail on the relay cap, got: " << reason; + } + + SelectParams(CBaseChainParams::REGTEST); +} + +// ════════════════════════════════════════════════════════════════════════ +// G3. ANTI-BURN DECISION FN (ZSLPIsProtectedTokenOutpoint / +// MsgWouldMakeTokenOutput): the predicate that keeps coin-selection from +// spending a token UTXO or mint baton as an ordinary fee/change coin. A +// vout-arithmetic regression here would silently burn an NFT as fee. +// Tested BOTH ways: protected==true for the token-qty / baton outpoint, +// protected==false for the OP_RETURN and the ZEC-change outpoint and an +// unrelated outpoint. +// ════════════════════════════════════════════════════════════════════════ + +// Source-1 (CONFIRMED store path): deterministic, needs no keys. +TEST(ZslpAntiBurnPredicate, ProtectsConfirmedTokenUtxoAndBatonNotChange) +{ + SelectParams(CBaseChainParams::REGTEST); + CWallet wallet; // empty wallet => only source-1 (store) can fire + CZSLPStore* s = NewStore(); + + // Genesis WITH a baton: vout[1]=qty UTXO, vout[2]=baton, vout[3]=ZEC change. + std::vector gen = + ZSLPBuildGenesis("WB", "WithBaton", "", NULL, /*decimals=*/0, + /*baton=*/2, /*qty=*/100); + CTransaction gtx = MakeCanonicalTx(gen, /*nTokenOuts=*/2, {}, + /*withChange=*/true); + uint256 tid = ApplyRealTx(s, gtx, 1); + // Sanity: store really recorded the qty UTXO and the baton. + CZSLPTokenUtxo qu, bu; + ASSERT_TRUE(s->GetUtxo(tid, 1, qu)); ASSERT_EQ(qu.amount, (int64_t)100); + ASSERT_TRUE(s->GetUtxo(tid, 2, bu)); ASSERT_TRUE(bu.isMintBaton); + + LOCK2(cs_main, wallet.cs_wallet); + // PROTECTED: the token quantity UTXO (vout[1]) and the mint baton (vout[2]). + EXPECT_TRUE(ZSLPIsProtectedTokenOutpoint(&wallet, s, COutPoint(tid, 1))); + EXPECT_TRUE(ZSLPIsProtectedTokenOutpoint(&wallet, s, COutPoint(tid, 2))); + // NOT protected: the OP_RETURN (vout[0]) and the ZEC change (vout[3]) — the + // exact vout-arithmetic boundaries a regression would burn an NFT as fee on. + EXPECT_FALSE(ZSLPIsProtectedTokenOutpoint(&wallet, s, COutPoint(tid, 0))); + EXPECT_FALSE(ZSLPIsProtectedTokenOutpoint(&wallet, s, COutPoint(tid, 3))); + // NOT protected: an entirely unrelated outpoint. + EXPECT_FALSE(ZSLPIsProtectedTokenOutpoint(&wallet, s, COutPoint(H(0xEE), 0))); + + delete s; +} + +// Source-2 (PENDING/0-conf path): exercises the static MsgWouldMakeTokenOutput +// through a from-me wallet tx with store==NULL, so ONLY source-2 can fire. To +// make IsFromMe(ISMINE_ALL) true deterministically we give the wallet a key, +// add a prevout wtx paying that key, then add the SEND wtx spending it. +TEST(ZslpAntiBurnPredicate, ProtectsPendingZeroConfTokenChangeViaMsgWouldMakeTokenOutput) +{ + SelectParams(CBaseChainParams::REGTEST); + CWallet wallet; + CKey mine = AddTestCKeyToKeyStore(wallet); + CScript myScript = GetScriptForDestination(mine.GetPubKey().GetID()); + + // Prevout wtx: a transparent output the wallet owns (so spending it makes + // the SEND from-me). Add it under the lock. + CMutableTransaction prevMtx; + prevMtx.vout.push_back(CTxOut(100000, myScript)); + CWalletTx prevWtx(&wallet, CTransaction(prevMtx)); + { + LOCK(wallet.cs_wallet); + wallet.AddToWallet(prevWtx, true, NULL); + } + uint256 prevHash = prevWtx.GetHash(); + + // Canonical SEND wtx: vout[0]=OP_RETURN(SEND 7,3), vout[1]/vout[2]=token + // outputs, vout[3]=ZEC change. Spends the wallet-owned prevout. + uint8_t be[32]; memset(be, 0x42, 32); + std::vector snd = ZSLPBuildSend(be, {7, 3}); + ASSERT_FALSE(snd.empty()); + CMutableTransaction sendMtx; + sendMtx.vin.push_back(CTxIn(COutPoint(prevHash, 0))); + sendMtx.vout.push_back(CTxOut(0, CScript(snd.begin(), snd.end()))); // 0 + sendMtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, myScript)); // 1 token + sendMtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, myScript)); // 2 token + sendMtx.vout.push_back(CTxOut(50000, myScript)); // 3 change + CWalletTx sendWtx(&wallet, CTransaction(sendMtx)); + { + LOCK(wallet.cs_wallet); + wallet.AddToWallet(sendWtx, true, NULL); + } + uint256 sendHash = sendWtx.GetHash(); + + LOCK2(cs_main, wallet.cs_wallet); + // Precondition the source-2 path depends on: the SEND is from-me. + const CWalletTx* w = wallet.GetWalletTx(sendHash); + ASSERT_TRUE(w != NULL); + ASSERT_TRUE(w->IsFromMe(ISMINE_ALL)) + << "source-2 needs a from-me wtx; key/prevout wiring failed"; + + // store==NULL => source-1 disabled; only MsgWouldMakeTokenOutput decides. + // PROTECTED: the two pending token outputs (vout[1], vout[2]). + EXPECT_TRUE(ZSLPIsProtectedTokenOutpoint(&wallet, NULL, COutPoint(sendHash, 1))); + EXPECT_TRUE(ZSLPIsProtectedTokenOutpoint(&wallet, NULL, COutPoint(sendHash, 2))); + // NOT protected: OP_RETURN (vout[0]) and the trailing ZEC change (vout[3]). + EXPECT_FALSE(ZSLPIsProtectedTokenOutpoint(&wallet, NULL, COutPoint(sendHash, 0))); + EXPECT_FALSE(ZSLPIsProtectedTokenOutpoint(&wallet, NULL, COutPoint(sendHash, 3))); +} + +// ════════════════════════════════════════════════════════════════════════ +// G4 (wallet half): the anti-burn fence would NEVER let normal coin-selection +// assemble the multi-token mixed-input SEND that silently burns token B (the +// burn itself is documented + guarded in +// test_zslp_indexer.cpp::MixedInputSendBurnsUndeclaredTokenB). The funding +// pool comes from CWallet::AvailableCoins, which when fExcludeZSLPTokens=true +// DROPS every outpoint for which ZSLPIsProtectedTokenOutpoint(this, zslpStore, +// op) is true (wallet.cpp:3197-3199). We assert that SAME predicate, the one +// AvailableCoins consults, returns true for a token-B UTXO and false for a +// plain ZEC-change coin — so a SEND that declares only token A can never be +// funded with a token-B input via normal selection. +// +// (We exercise the predicate directly rather than driving AvailableCoins +// end-to-end: the store the filter reads is the private member of the global +// g_zslpIndexer, which is created on a real disk path inside Init() and has no +// test injection seam. The predicate IS the decision AvailableCoins makes for +// each coin, so testing it directly is exact — see wallet.cpp:3197-3199.) +// ════════════════════════════════════════════════════════════════════════ +TEST(ZslpAntiBurnPredicate, FundingFilterDropsUndeclaredTokenInput) +{ + SelectParams(CBaseChainParams::REGTEST); + CWallet wallet; // empty: predicate decides purely on the store (source-1) + CZSLPStore* s = NewStore(); + + // Seed the store with token B (qty 500 at vout[1], ZEC change at vout[2]). + std::vector genB = + ZSLPBuildGenesis("B", "TokenB", "", NULL, 0, 0, /*qty=*/500); + CTransaction gTx = MakeCanonicalTx(genB, /*nTokenOuts=*/1, {}, + /*withChange=*/true); + uint256 tB = ApplyRealTx(s, gTx, 1); + CZSLPTokenUtxo bu; + ASSERT_TRUE(s->GetUtxo(tB, 1, bu)); + ASSERT_EQ(bu.amount, (int64_t)500); + + LOCK2(cs_main, wallet.cs_wallet); + // The token-B UTXO at (tB,1) is PROTECTED => AvailableCoins drops it from + // the funding pool. A SEND of token A can therefore never spend it. + EXPECT_TRUE(ZSLPIsProtectedTokenOutpoint(&wallet, s, COutPoint(tB, 1))) + << "token-B UTXO would leak into the funding pool — a SEND could " + "mix+burn token B (G4)"; + // The plain ZEC change at (tB,2) is NOT protected => stays spendable. + EXPECT_FALSE(ZSLPIsProtectedTokenOutpoint(&wallet, s, COutPoint(tB, 2))); + + delete s; +} diff --git a/src/init.cpp b/src/init.cpp index e3b123c930e..14f63ea6b79 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -524,6 +524,7 @@ std::string HelpMessage(HelpMessageMode mode) _("If is not supplied or if = 1, output all debugging information.") + " " + _(" can be:") + " " + debugCategories + "."); strUsage += HelpMessageOpt("-experimentalfeatures", _("Enable use of experimental features")); strUsage += HelpMessageOpt("-zslpindex", strprintf(_("Maintain a read-only index of ZSLP token OP_RETURN messages, for the zslp_* RPCs (default: %u)"), 1)); + strUsage += HelpMessageOpt("-datachannel", strprintf(_("Enable the shielded data-channel RPCs (z_senddatafile etc.) for private file transfer over Sapling memos. Bytes are PERMANENT and public-ciphertext on-chain. (default: %u)"), 0)); strUsage += HelpMessageOpt("-help-debug", _("Show all debugging options (usage: --help -help-debug)")); strUsage += HelpMessageOpt("-logips", strprintf(_("Include IP addresses in debug output (default: %u)"), 0)); strUsage += HelpMessageOpt("-debuglogfile", _("Write debug output to debug.log file (default: 0, disabled for privacy)")); diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 555441c3817..4af1447a09b 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -132,7 +132,30 @@ static const CRPCConvertParam vRPCConvertParams[] = { "z_importkey", 2 }, { "z_importviewingkey", 2 }, { "z_getpaymentdisclosure", 1}, - { "z_getpaymentdisclosure", 2} + { "z_getpaymentdisclosure", 2}, + // Shielded data-channel RPCs: each takes a single JSON object param, so + // convert arg 0 (otherwise zclassic-cli would send a raw string). + { "z_senddatafile", 0}, // params object + { "z_getdatatransfer", 0}, // params object + // ZSLP write/read RPCs: convert the non-string args so zclassic-cli sends + // a JSON object/number rather than a raw string the daemon would reject. + { "zslp_genesis", 0}, // params object + { "zslp_mint", 1}, // amount (string|numeric) + { "zslp_mint", 2}, // baton_vout (numeric) + { "zslp_send", 2}, // amount (string|numeric) + { "zslp_listtokens", 0}, // count + { "zslp_listtokens", 1}, // from + { "zslp_listtransfers", 1}, // count + { "zslp_listtransfers", 2}, // from + // NFT sell/offer RPCs: each takes a single JSON object at arg 0; converting + // that arg makes zclassic-cli send an object (its string fields ride inside, + // no per-field conversion needed) rather than a raw string the daemon rejects. + { "nft_makeoffer", 0}, // params object + { "nft_verifyoffer", 0}, // params object + { "nft_takeoffer", 0}, // params object + { "nft_listoffers", 0}, // params object + { "nft_canceloffer", 0}, // params object + { "nft_requestbuy", 0} // params object }; class CRPCConvertTable diff --git a/src/rpc/datachannel.cpp b/src/rpc/datachannel.cpp new file mode 100644 index 00000000000..f9d810c04dd --- /dev/null +++ b/src/rpc/datachannel.cpp @@ -0,0 +1,612 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// +// SHIELD pillar — PRIVATE file/data transfer over the Sapling shielded pool. +// +// z_senddatafile {from,to,filepath|hexdata,acknowledge_permanent,...} +// -> {transfer_id, fingerprint, ...} +// z_listdatatransfers -> [ {transfer_id, fingerprint, direction, frames, +// status, height} ] +// z_getdatatransfer {transfer_id|fingerprint} +// -> reassemble + VERIFY-BEFORE-DECRYPT +// +// NON-CONSENSUS overlay. The data rides inside ordinary Sapling output MEMOs as +// ZDC1 frames (one 512-byte frame per memo; 512 == ZC_MEMO_SIZE exactly). Old +// nodes relay + mine these txs unchanged. No validation/PoW/consensus is touched. +// +// SAFETY GATES (all enforced in the daemon, not the GUI): +// * default OFF behind -datachannel; when off the RPCs are NOT registered, so +// the dispatcher returns RPC_METHOD_NOT_FOUND (-32601) — indistinguishable +// from a nonexistent method. +// * PERMANENCE CONSENT: z_senddatafile REQUIRES acknowledge_permanent=true. +// * transfer_id is RANDOM (8 bytes from libsodium); the on-chain ANCHOR is the +// ciphertext fingerprint (= what a ZSLP NFT document_hash would commit to). +// * DoS caps: per-file size cap (64 KB), inflight TTL (72h), max inflight +// transfers (256), and a basic per-call rate guard. +// * VERIFY-BEFORE-DECRYPT: z_getdatatransfer confirms the on-chain ciphertext +// fingerprint matches the recorded anchor BEFORE any AEAD decrypt, and +// surfaces the distinct codec errors honestly. +// * KEYS NEVER LEAVE THE WALLET: the only secret returned to the caller is the +// per-transfer ZDC1 key it asked us to create; no ivk/spending key is exported. + +#include "rpc/server.h" + +#include "datachannel/zdc.h" +#include "key_io.h" +#include "rpc/protocol.h" +#include "util.h" +#include "utilstrencodings.h" +#include "utiltime.h" + +#include + +#ifdef ENABLE_WALLET +#include "init.h" // pwalletMain +#include "main.h" // cs_main +#include "wallet/wallet.h" +#include "wallet/asyncrpcoperation_senddatafile.h" +#include "asyncrpcqueue.h" +#include "consensus/upgrades.h" +#include "core_io.h" +#include "transaction_builder.h" +extern bool EnsureWalletIsAvailable(bool avoidException); +// EnsureWalletIsUnlocked + getAsyncRPCQueue are declared in rpc/server.h. +#endif + +#include +#include +#include +#include + +// ── DoS / responsibility caps (POLICY, not consensus) ──────────────────────── +// +// SINGLE-TX BROADCASTABILITY (the file-cap is derived, not wished for): +// This transfer is ONE shielded tx. Every ZDC1 frame becomes one Sapling +// OutputDescription = 948 bytes on the wire (cv32+cm32+ephemeralKey32+ +// encCiphertext580+outCiphertext80+zkproof192). A SpendDescription is 384B. +// Consensus rejects any tx over MAX_TX_SIZE_AFTER_SAPLING (102000, see +// consensus/consensus.h) at AcceptToMemoryPool/sendrawtransaction — AFTER all +// Groth proofs are computed. So an honest file-cap MUST guarantee the worst- +// case tx still fits. We budget conservatively: +// +// reserve = envelope(~200) + change output(948) + spends(reserve ~16 * 384) +// ≈ 7300 bytes (rounded to ZDC_TX_OVERHEAD_RESERVE below) +// output budget = 102000 - 7300 = 94700 +// max frames = 94700 / 948 ≈ 99 → capped at ZDC_MAX_FRAMES_PER_TX (90) +// +// 90 frames includes 3 control frames (START, END, KEY), so 87 DATA frames * +// 464 plaintext bytes/frame = 40368 usable bytes. We advertise a clean 40000. +// This is PROVABLY broadcastable: 90 * 948 + 7300 = 92620 < 102000, with +// margin for many small input notes. A pre-build guard in the async op +// double-checks the ACTUAL projected size (incl. real spend count) before any +// proving, so an unusual UTXO set never produces a late "bad-txns-oversize". +static const size_t ZDC_MAX_FILE_BYTES = 40000; // see derivation above +static const size_t ZDC_MAX_FRAMES_PER_TX = 90; // hard single-tx frame ceiling +static const size_t ZDC_MAX_INFLIGHT = 256; // tracked transfers +static const int64_t ZDC_INFLIGHT_TTL_SEC = 72 * 60 * 60; // 72 hours +static const int64_t ZDC_RATE_WINDOW_SEC = 1; // basic rate guard +static const int ZDC_RATE_MAX_PER_WIN = 4; // calls per window + +// ── In-memory transfer registry (the disclosure + verify-before-decrypt hook) ─ +// +// A z_senddatafile records the per-transfer SECRET (key) + the on-chain ANCHOR +// (ciphertext fingerprint) here, keyed by the RANDOM transfer_id. z_getdatatransfer +// reads it to (a) know the authoritative anchor for verify-before-decrypt and +// (b) hold the key needed to decrypt. This never persists to disk and never +// leaves the process; the only thing returned to a caller is its OWN key. +struct ZdcTransferRecord { + uint64_t transferId; + std::string fingerprintHex; // 64 hex (the on-chain anchor) + std::vector key; // 32B per-transfer key + std::string fromAddress; + std::string toAddress; + std::string direction; // "sent" + uint32_t frames; + std::string filename; + int64_t createdAt; // GetTime() +}; + +static CCriticalSection cs_zdc; +static std::map g_zdcTransfers; // by transfer_id +static int64_t g_zdcRateWindowStart = 0; +static int g_zdcRateCount = 0; + +static void ZdcExpireOld() // caller holds cs_zdc +{ + int64_t now = GetTime(); + for (std::map::iterator it = g_zdcTransfers.begin(); + it != g_zdcTransfers.end(); ) { + if (now - it->second.createdAt > ZDC_INFLIGHT_TTL_SEC) { + // wipe the key before dropping the record + if (!it->second.key.empty()) + sodium_memzero(&it->second.key[0], it->second.key.size()); + g_zdcTransfers.erase(it++); + } else { + ++it; + } + } +} + +static void ZdcRateGuard() // caller holds cs_zdc +{ + int64_t now = GetTime(); + if (now - g_zdcRateWindowStart >= ZDC_RATE_WINDOW_SEC) { + g_zdcRateWindowStart = now; + g_zdcRateCount = 0; + } + if (++g_zdcRateCount > ZDC_RATE_MAX_PER_WIN) { + throw JSONRPCError(RPC_INVALID_REQUEST, + "Data channel rate limit exceeded; slow down"); + } +} + +static std::string BytesToHex(const uint8_t* p, size_t n) +{ + static const char* h = "0123456789abcdef"; + std::string s; s.reserve(n * 2); + for (size_t i = 0; i < n; ++i) { s += h[p[i] >> 4]; s += h[p[i] & 0xF]; } + return s; +} + +static const char* ZdcDirToStr(const char* d) { return d; } + +#ifdef ENABLE_WALLET + +// ── z_senddatafile ─────────────────────────────────────────────────────────── +UniValue z_senddatafile(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() != 1) + throw std::runtime_error( + "z_senddatafile '{\"fromaddress\":\"zs..\",\"toaddress\":\"zs..\"," + "\"filepath\":\"/path\"|\"hexdata\":\"hex\",\"acknowledge_permanent\":true," + "\"filename\":?,\"content_type\":?}'\n" + "\nSend a PRIVATE file/data transfer over the Sapling shielded pool. The\n" + "bytes are encrypted with a fresh per-transfer key, chunked into ZDC1\n" + "frames, and emitted as N Sapling output memos in ONE shielded tx.\n" + "\nPERMANENCE: the encrypted bytes are stored by every full node FOREVER\n" + "and are public ciphertext. You MUST pass acknowledge_permanent=true.\n" + + HelpRequiringPassphrase() + + "\nSIZE: a transfer is ONE shielded tx; the per-file cap (40000 bytes) is\n" + "chosen so the tx is always broadcastable (each frame is a Sapling output;\n" + "the consensus tx-size limit caps how many fit). Larger files are rejected\n" + "UP FRONT, before any proving work — never with a late consensus error.\n" + "\nArguments:\n" + "1. \"params\" (object, required)\n" + " fromaddress (string, required) a Sapling z-addr in this wallet\n" + " toaddress (string, required) recipient Sapling z-addr\n" + " filepath (string) path to the file to send (<=40000 bytes), OR\n" + " hexdata (string) raw bytes as hex (<=40000 bytes), one of the two\n" + " acknowledge_permanent (bool, required) must be true\n" + " filename (string, optional) recorded in the transfer metadata\n" + " content_type (string, optional) MIME type recorded in metadata\n" + "\nResult:\n" + "{\n" + " \"operationid\": \"opid\", (async; poll with z_getoperationresult)\n" + " \"transfer_id\": \"hex\", (RANDOM 64-bit id)\n" + " \"fingerprint\": \"hex\", (32-byte ciphertext anchor = NFT document_hash)\n" + " \"frames\": n,\n" + " \"key\": \"hex\" (the per-transfer key, for selective disclosure)\n" + "}\n" + "\nExamples:\n" + + HelpExampleCli("z_senddatafile", + "'{\"fromaddress\":\"zs1..\",\"toaddress\":\"zs1..\",\"filepath\":\"/tmp/secret.bin\",\"acknowledge_permanent\":true}'")); + + LOCK(cs_zdc); + ZdcRateGuard(); + ZdcExpireOld(); + if (g_zdcTransfers.size() >= ZDC_MAX_INFLIGHT) + throw JSONRPCError(RPC_INVALID_REQUEST, + strprintf("Too many tracked transfers (max %u); wait for old ones to expire", (unsigned)ZDC_MAX_INFLIGHT)); + + const UniValue& o = params[0].get_obj(); + + // PERMANENCE CONSENT — enforced at the daemon, never the GUI. + bool ack = false; + { const UniValue& v = find_value(o, "acknowledge_permanent"); + if (!v.isNull()) ack = v.get_bool(); } + if (!ack) + throw JSONRPCError(RPC_INVALID_PARAMETER, + "Refusing: the encrypted bytes are PERMANENT and public-ciphertext on-chain " + "forever. Pass acknowledge_permanent=true to proceed."); + + std::string fromAddress, toAddress; + { const UniValue& v = find_value(o, "fromaddress"); if (v.isStr()) fromAddress = v.get_str(); } + { const UniValue& v = find_value(o, "toaddress"); if (v.isStr()) toAddress = v.get_str(); } + if (fromAddress.empty() || toAddress.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "fromaddress and toaddress are required"); + + std::string filename, contentType; + { const UniValue& v = find_value(o, "filename"); if (v.isStr()) filename = v.get_str(); } + { const UniValue& v = find_value(o, "content_type"); if (v.isStr()) contentType = v.get_str(); } + + // Read the plaintext from filepath OR hexdata (exactly one). + std::vector plaintext; + const UniValue& vfile = find_value(o, "filepath"); + const UniValue& vhex = find_value(o, "hexdata"); + bool haveFile = vfile.isStr() && !vfile.get_str().empty(); + bool haveHex = vhex.isStr() && !vhex.get_str().empty(); + if (haveFile == haveHex) + throw JSONRPCError(RPC_INVALID_PARAMETER, "provide exactly one of filepath or hexdata"); + + if (haveFile) { + std::string path = vfile.get_str(); + std::ifstream f(path.c_str(), std::ios::binary); + if (!f.good()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "cannot open filepath: " + path); + f.seekg(0, std::ios::end); + std::streamoff sz = f.tellg(); + if (sz < 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "cannot size file: " + path); + if ((size_t)sz > ZDC_MAX_FILE_BYTES) + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("file too large (%lld bytes); the data channel cap is %u bytes", + (long long)sz, (unsigned)ZDC_MAX_FILE_BYTES)); + f.seekg(0, std::ios::beg); + plaintext.resize((size_t)sz); + if (sz > 0) f.read((char*)&plaintext[0], sz); + if (!f && sz > 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "failed reading file: " + path); + if (filename.empty()) { + size_t slash = path.find_last_of("/\\"); + filename = (slash == std::string::npos) ? path : path.substr(slash + 1); + } + } else { + std::string h = vhex.get_str(); + if (!IsHex(h)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "hexdata is not valid hex"); + std::vector raw = ParseHex(h); + if (raw.size() > ZDC_MAX_FILE_BYTES) + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("hexdata too large (%u bytes); the data channel cap is %u bytes", + (unsigned)raw.size(), (unsigned)ZDC_MAX_FILE_BYTES)); + plaintext.assign(raw.begin(), raw.end()); + } + + // Fresh per-transfer key + RANDOM transfer_id (NOT the txid, NOT a token id). + std::vector key; + if (zdc::ZdcAead::generate_key(key) != zdc::OK) + throw JSONRPCError(RPC_INTERNAL_ERROR, "failed to generate transfer key"); + uint64_t transferId = 0; + randombytes_buf(&transferId, sizeof(transferId)); + // Avoid an in-flight collision (astronomically unlikely, but cheap to check). + while (g_zdcTransfers.count(transferId)) + randombytes_buf(&transferId, sizeof(transferId)); + + // Encode -> ZDC1 frames. include_key_frame=true puts the KEY frame on-chain + // so the recipient z-addr (who holds the ivk) can decrypt directly; the key + // is ALSO returned to the sender for out-of-band / selective disclosure. + zdc::TransferMeta meta; + meta.filename = filename; + meta.content_type = contentType; + meta.total_plaintext_size = plaintext.size(); + meta.chunk_count = 0; // filled by encoder + std::vector > frames; + zdc::Status es = zdc::Encoder::encode(transferId, key, plaintext, meta, + /*include_key_frame=*/true, frames); + if (es != zdc::OK) { + if (!key.empty()) sodium_memzero(&key[0], key.size()); + throw JSONRPCError(RPC_INVALID_PARAMETER, + std::string("encode failed: ") + zdc::status_str(es)); + } + + // The on-chain ANCHOR = SHA-256 over the DATA-frame ciphertexts. + uint8_t fp[zdc::CONTENT_HASH_LEN]; + if (zdc::ciphertext_fingerprint(frames, fp) != zdc::OK) { + if (!key.empty()) sodium_memzero(&key[0], key.size()); + throw JSONRPCError(RPC_INTERNAL_ERROR, "fingerprint computation failed"); + } + std::string fingerprintHex = BytesToHex(fp, zdc::CONTENT_HASH_LEN); + + // SINGLE-TX FRAME GUARD (reject BEFORE proving, never after). + // The 40000-byte file cap already bounds DATA frames, but guard the total + // frame count explicitly so the invariant is enforced at the point that + // matters and a future cap change can't silently exceed one tx. The async op + // re-checks the ACTUAL projected serialized size (incl. real spend count) + // before Build(); this is the cheap up-front gate. + if (frames.size() > ZDC_MAX_FRAMES_PER_TX) { + if (!key.empty()) sodium_memzero(&key[0], key.size()); + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("transfer needs %u frames but a single shielded tx holds at " + "most %u; reduce the file size (cap is %u bytes)", + (unsigned)frames.size(), (unsigned)ZDC_MAX_FRAMES_PER_TX, + (unsigned)ZDC_MAX_FILE_BYTES)); + } + + // Convert frames to unsigned char vectors for the async op. + std::vector > ucFrames; + ucFrames.reserve(frames.size()); + for (size_t i = 0; i < frames.size(); ++i) + ucFrames.push_back(std::vector(frames[i].begin(), frames[i].end())); + + EnsureWalletIsUnlocked(); + + // Build the dedicated async op (N same-recipient outputs in one tx). + int nextBlockHeight; + { + LOCK(cs_main); + nextBlockHeight = chainActive.Height() + 1; + } + TransactionBuilder builder(Params().GetConsensus(), nextBlockHeight, pwalletMain); + CMutableTransaction contextualTx = + CreateNewContextualCMutableTransaction(Params().GetConsensus(), nextBlockHeight); + + UniValue ctx(UniValue::VOBJ); + ctx.push_back(Pair("fromaddress", fromAddress)); + ctx.push_back(Pair("toaddress", toAddress)); + ctx.push_back(Pair("frames", (int)ucFrames.size())); + ctx.push_back(Pair("fingerprint", fingerprintHex)); + + std::shared_ptr q = getAsyncRPCQueue(); + std::shared_ptr operation(new AsyncRPCOperation_senddatafile( + builder, contextualTx, fromAddress, toAddress, ucFrames, + transferId, fingerprintHex, /*minDepth=*/1, /*fee=*/SENDDATAFILE_DEFAULT_MINERS_FEE, ctx)); + q->addOperation(operation); + AsyncRPCOperationId operationId = operation->getId(); + + // Record the transfer (key + anchor) for list/get + disclosure. + ZdcTransferRecord rec; + rec.transferId = transferId; + rec.fingerprintHex = fingerprintHex; + rec.key = key; // held in-process only; never persisted + rec.fromAddress = fromAddress; + rec.toAddress = toAddress; + rec.direction = "sent"; + rec.frames = (uint32_t)ucFrames.size(); + rec.filename = filename; + rec.createdAt = GetTime(); + g_zdcTransfers[transferId] = rec; + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("operationid", operationId)); + ret.push_back(Pair("transfer_id", strprintf("%016x", transferId))); + ret.push_back(Pair("fingerprint", fingerprintHex)); + ret.push_back(Pair("frames", (int)ucFrames.size())); + ret.push_back(Pair("key", BytesToHex(&key[0], key.size()))); + return ret; +} + +// ── z_listdatatransfers ────────────────────────────────────────────────────── +UniValue z_listdatatransfers(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() > 0) + throw std::runtime_error( + "z_listdatatransfers\n" + "\nList the data transfers this node knows about (sent this session).\n" + "\nResult: [ { \"transfer_id\", \"fingerprint\", \"direction\",\n" + " \"frames\", \"status\", \"toaddress\", \"filename\" }, ... ]\n" + "\nExamples:\n" + + HelpExampleCli("z_listdatatransfers", "")); + + LOCK(cs_zdc); + ZdcExpireOld(); + + UniValue arr(UniValue::VARR); + for (std::map::const_iterator it = g_zdcTransfers.begin(); + it != g_zdcTransfers.end(); ++it) { + const ZdcTransferRecord& r = it->second; + UniValue obj(UniValue::VOBJ); + obj.push_back(Pair("transfer_id", strprintf("%016x", r.transferId))); + obj.push_back(Pair("fingerprint", r.fingerprintHex)); + obj.push_back(Pair("direction", ZdcDirToStr(r.direction.c_str()))); + obj.push_back(Pair("frames", (int)r.frames)); + obj.push_back(Pair("status", "recorded")); + obj.push_back(Pair("fromaddress", r.fromAddress)); + obj.push_back(Pair("toaddress", r.toAddress)); + obj.push_back(Pair("filename", r.filename)); + arr.push_back(obj); + } + return arr; +} + +// ── z_getdatatransfer ──────────────────────────────────────────────────────── +UniValue z_getdatatransfer(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() != 1) + throw std::runtime_error( + "z_getdatatransfer '{\"transfer_id\":\"hex\"|\"fingerprint\":\"hex\",\"address\":\"zs..\"}'\n" + "\nReassemble a data transfer from the on-chain Sapling memos held by\n" + "this wallet, VERIFY-BEFORE-DECRYPT (confirm the ciphertext fingerprint\n" + "matches the recorded anchor BEFORE attempting decryption), then decrypt.\n" + "\nArguments:\n" + "1. \"params\" (object, required)\n" + " transfer_id (string) the 16-hex transfer id, OR\n" + " fingerprint (string) the 64-hex ciphertext anchor\n" + " address (string, optional) the z-addr that received the frames\n" + " (default: the recorded toaddress)\n" + " verify_fingerprint (string, optional) a 64-hex anchor known OUT OF\n" + " BAND (e.g. a published NFT document_hash). If given, the\n" + " on-chain ciphertext MUST hash to THIS value or the call\n" + " refuses to decrypt (ERR_HASH_MISMATCH, no plaintext),\n" + " even when the local registry anchor matches.\n" + "\nResult:\n" + "{\n" + " \"transfer_id\": \"hex\",\n" + " \"fingerprint\": \"hex\",\n" + " \"verified\": true|false, (on-chain anchor == recorded anchor)\n" + " \"complete\": true|false,\n" + " \"frames_received\": n,\n" + " \"hexdata\": \"hex\", (plaintext, only if verified+decrypted)\n" + " \"filename\": \"...\",\n" + " \"error\": \"...\" (honest codec error if any)\n" + "}\n" + "\nExamples:\n" + + HelpExampleCli("z_getdatatransfer", "'{\"transfer_id\":\"0123456789abcdef\"}'")); + + const UniValue& o = params[0].get_obj(); + + std::string transferIdHex, fingerprintHex, address, verifyFingerprintHex; + { const UniValue& v = find_value(o, "transfer_id"); if (v.isStr()) transferIdHex = v.get_str(); } + { const UniValue& v = find_value(o, "fingerprint"); if (v.isStr()) fingerprintHex = v.get_str(); } + { const UniValue& v = find_value(o, "address"); if (v.isStr()) address = v.get_str(); } + // OPTIONAL caller-asserted anchor (the verify-before-decrypt expectation a + // recipient obtained OUT OF BAND, e.g. a published ZSLP NFT document_hash). + // When supplied, the on-chain ciphertext MUST hash to THIS value or we refuse + // to decrypt — even if the in-process registry anchor matches. This is the + // real-world gate: trust the independently-known anchor, not just our own record. + { const UniValue& v = find_value(o, "verify_fingerprint"); if (v.isStr()) verifyFingerprintHex = v.get_str(); } + if (transferIdHex.empty() && fingerprintHex.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "provide transfer_id or fingerprint"); + if (!verifyFingerprintHex.empty() && + (verifyFingerprintHex.size() != 64 || !IsHex(verifyFingerprintHex))) + throw JSONRPCError(RPC_INVALID_PARAMETER, "verify_fingerprint must be 64 hex chars"); + + // Resolve the recorded transfer (holds the authoritative anchor + key). + ZdcTransferRecord rec; + bool haveRec = false; + uint64_t wantId = 0; + { + LOCK(cs_zdc); + ZdcExpireOld(); + if (!transferIdHex.empty()) { + if (transferIdHex.size() != 16 || !IsHex(transferIdHex)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "transfer_id must be 16 hex chars"); + wantId = strtoull(transferIdHex.c_str(), NULL, 16); + std::map::const_iterator it = g_zdcTransfers.find(wantId); + if (it != g_zdcTransfers.end()) { rec = it->second; haveRec = true; } + } else { + if (fingerprintHex.size() != 64 || !IsHex(fingerprintHex)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "fingerprint must be 64 hex chars"); + for (std::map::const_iterator it = g_zdcTransfers.begin(); + it != g_zdcTransfers.end(); ++it) { + if (it->second.fingerprintHex == fingerprintHex) { + rec = it->second; haveRec = true; wantId = it->first; break; + } + } + } + } + if (!haveRec) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "transfer not found in this node's registry (it tracks transfers sent this session)"); + + if (address.empty()) address = rec.toAddress; + + // Scan the wallet's Sapling notes at the recipient address; feed memos to the + // decoder, keeping only frames whose transfer_id matches. + std::vector saplingEntries; + std::vector sproutEntries; + { + LOCK2(cs_main, pwalletMain->cs_wallet); + pwalletMain->GetFilteredNotes(sproutEntries, saplingEntries, address, /*minDepth=*/0, false, false); + } + + zdc::Decoder dec; + uint32_t fed = 0; + for (size_t i = 0; i < saplingEntries.size(); ++i) { + std::vector memo(saplingEntries[i].memo.begin(), saplingEntries[i].memo.end()); + // Peek the transfer_id before adding so foreign/text memos are skipped + // cleanly (decoder locks to the first transfer_id it accepts). + zdc::FrameHeader h; + if (zdc::parse_header(&memo[0], h) != zdc::OK) continue; + if (h.transfer_id != wantId) continue; + zdc::Status as = dec.add_frame(memo); + if (as == zdc::OK) ++fed; + } + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("transfer_id", strprintf("%016x", wantId))); + ret.push_back(Pair("fingerprint", rec.fingerprintHex)); + ret.push_back(Pair("frames_received", (int)fed)); + ret.push_back(Pair("complete", dec.is_complete())); + + // Gather the frames actually on chain (in seq order) to recompute the anchor. + // We recompute over the DATA frames we received and compare to the recorded + // anchor BEFORE any decrypt. This is the verify-before-decrypt gate. + bool verified = false; + if (dec.is_complete()) { + // Re-collect DATA frames for fingerprinting. The decoder doesn't expose + // raw frames, so re-scan the same memos into a frame vector. + std::vector > frames; + { + LOCK2(cs_main, pwalletMain->cs_wallet); + for (size_t i = 0; i < saplingEntries.size(); ++i) { + std::vector memo(saplingEntries[i].memo.begin(), saplingEntries[i].memo.end()); + zdc::FrameHeader h; + if (zdc::parse_header(&memo[0], h) != zdc::OK) continue; + if (h.transfer_id != wantId) continue; + frames.push_back(memo); + } + } + uint8_t fp[zdc::CONTENT_HASH_LEN]; + if (zdc::ciphertext_fingerprint(frames, fp) == zdc::OK) { + std::string onchain = BytesToHex(fp, zdc::CONTENT_HASH_LEN); + // The anchor we verify the on-chain ciphertext against: the caller's + // out-of-band expectation if supplied, else our recorded anchor. + const std::string& expected = + verifyFingerprintHex.empty() ? rec.fingerprintHex : verifyFingerprintHex; + verified = (onchain == expected); + ret.push_back(Pair("onchain_fingerprint", onchain)); + if (!verifyFingerprintHex.empty()) + ret.push_back(Pair("expected_fingerprint", verifyFingerprintHex)); + } + } + ret.push_back(Pair("verified", verified)); + + if (!dec.is_complete()) { + ret.push_back(Pair("error", + std::string(zdc::status_str(zdc::ERR_INCOMPLETE)) + " (frames still missing)")); + return ret; + } + if (!verified) { + // VERIFY-BEFORE-DECRYPT refusal: NEVER attempt decrypt or return plaintext + // when the on-chain anchor does not match the expected fingerprint (the + // caller-asserted out-of-band anchor if given, else the recorded anchor). + ret.push_back(Pair("error", + std::string(zdc::status_str(zdc::ERR_HASH_MISMATCH)) + + (verifyFingerprintHex.empty() + ? " (on-chain fingerprint != recorded anchor; refusing to decrypt)" + : " (on-chain fingerprint != caller-asserted verify_fingerprint; refusing to decrypt)"))); + return ret; + } + + // Anchor verified. Supply the key out-of-band from the registry (the on-chain + // KEY frame also works, but the registry key is authoritative for the sender) + // and decrypt. + if (!rec.key.empty()) + dec.set_key(rec.key); + + std::vector out; + zdc::TransferMeta gotMeta; + zdc::Status ds = dec.assemble(out, gotMeta); + if (ds != zdc::OK) { + // Surface the DISTINCT codec error honestly (ERR_NO_KEY / ERR_AEAD_FAIL / + // ERR_HASH_MISMATCH) — never return plaintext on failure. + ret.push_back(Pair("error", zdc::status_str(ds))); + return ret; + } + + ret.push_back(Pair("hexdata", BytesToHex(out.empty() ? (const uint8_t*)"" : &out[0], out.size()))); + ret.push_back(Pair("size", (int)out.size())); + ret.push_back(Pair("filename", gotMeta.filename)); + ret.push_back(Pair("content_type", gotMeta.content_type)); + return ret; +} + +#endif // ENABLE_WALLET + +// ── Registration (default OFF -> RPC_METHOD_NOT_FOUND when -datachannel off) ── +static const CRPCCommand commands[] = +{ // category name actor (function) okSafeMode +#ifdef ENABLE_WALLET + { "datachannel", "z_senddatafile", &z_senddatafile, false }, + { "datachannel", "z_listdatatransfers", &z_listdatatransfers, true }, + { "datachannel", "z_getdatatransfer", &z_getdatatransfer, true }, +#endif +}; + +void RegisterDataChannelRPCCommands(CRPCTable& tableRPC) +{ + // GATE: only register when -datachannel is on. When off, the methods are + // ABSENT, so rpc/server.cpp's dispatcher throws RPC_METHOD_NOT_FOUND + // (-32601) — indistinguishable from a nonexistent method. (Default OFF.) + if (!GetBoolArg("-datachannel", false)) + return; + for (unsigned int vcidx = 0; vcidx < ARRAYLEN(commands); vcidx++) + tableRPC.appendCommand(commands[vcidx].name, &commands[vcidx]); +} diff --git a/src/rpc/nftoffer.cpp b/src/rpc/nftoffer.cpp new file mode 100644 index 00000000000..ba339e5439c --- /dev/null +++ b/src/rpc/nftoffer.cpp @@ -0,0 +1,1194 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// NFT SELL pillar — non-consensus atomic NFT->ZCL sale RPCs. +// +// Mechanism A' from doc/nft/NFT_SELL_DESIGN.md (canonical): a fixed 3-output +// ZSLP SEND template, ALL outputs pinned by the seller's SIGHASH_ALL|ANYONECANPAY +// signature on vin[0] (the NFT dust UTXO); the buyer may only APPEND funding +// inputs (vin[1..]), never edit an output. The settlement is ONE ordinary +// transparent transaction that unmodified ZClassic nodes relay and mine. +// +// vout[0] = OP_RETURN ZSLP SEND { tokenId, [1] } (value 0; credits vout[1]) +// vout[1] = buyer NFT dust (D sat, sealed to buyerNftAddr) ← new owner +// vout[2] = seller ZCL payout (priceZat) +// +// DRY: vout[0] is built with the EXISTING ZSLP SEND encoder (ZSLPBuildSend); +// the anti-burn filter (ZSLPIsProtectedTokenOutpoint), the read-only +// conservation check (CZSLPStore::WouldBeValid), and the real parse seam +// (CZSLPIndexer::ParseTx) are all reused, not reimplemented. Cancel reuses the +// whole BuildAndCommitZSLP path (a 1-output self-send SEND). +// +// nft_makeoffer {tokenId,priceZat,payoutAddr?,buyerNftAddr,expiryHeight?} +// nft_verifyoffer {offerBlob} (read-only, mandatory) +// nft_takeoffer {offerBlob,fundingInputs?,changeAddr?,acknowledge?} +// nft_listoffers {mine?} +// nft_canceloffer {offerId} +// nft_requestbuy {tokenId|offerId} + +#include "rpc/server.h" + +#include "base58.h" +#include "consensus/upgrades.h" +#include "consensus/validation.h" +#include "core_io.h" +#include "hash.h" +#include "key_io.h" +#include "main.h" +#include "net.h" +#include "rpc/protocol.h" +#include "script/sign.h" +#include "script/standard.h" +#include "streams.h" +#include "util.h" +#include "utilmoneystr.h" +#include "utilstrencodings.h" +#include "zslp/zslpindexer.h" +#include "zslp/zslpmsg.h" +#include "zslp/zslpstore.h" + +#ifdef ENABLE_WALLET +#include "init.h" // pwalletMain +#include "wallet/wallet.h" +#include "wallet/zslpwallet.h" +extern bool EnsureWalletIsAvailable(bool avoidException); +#endif + +#include +#include + +#include +#include + +#ifdef ENABLE_WALLET + +// ── shared helpers ────────────────────────────────────────────────── + +static CZSLPStore* NftGetStoreOrThrow() +{ + if (g_zslpIndexer == NULL || g_zslpIndexer->Store() == NULL) + throw JSONRPCError(RPC_MISC_ERROR, + "ZSLP index is not enabled. Start zclassicd with -zslpindex."); + return g_zslpIndexer->Store(); +} + +// A t-address string -> P2PKH/P2SH script (throws on invalid). +static CScript NftScriptForTAddr(const std::string& addr) +{ + CTxDestination dest = DecodeDestination(addr); + if (!IsValidDestination(dest)) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "Invalid transparent address: " + addr); + return GetScriptForDestination(dest); +} + +// Decode a script back to a t-address string ("" if not a standard address). +static std::string NftAddrFromScript(const CScript& spk) +{ + CTxDestination dest; + if (ExtractDestination(spk, dest) && IsValidDestination(dest)) + return EncodeDestination(dest); + return std::string(); +} + +static CScript NftFreshWalletScript() +{ + CPubKey vchPubKey; + if (!pwalletMain->GetKeyFromPool(vchPubKey)) + throw JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT, + "Keypool ran out, call keypoolrefill first"); + return GetScriptForDestination(vchPubKey.GetID()); +} + +// Parse a non-negative zatoshi amount from a JSON string|integer. +static int64_t NftParseZat(const UniValue& v, const std::string& field) +{ + std::string s; + if (v.isStr()) s = v.get_str(); + else if (v.isNum()) s = v.getValStr(); + else throw JSONRPCError(RPC_TYPE_ERROR, field + " must be a string or integer"); + if (s.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is empty"); + for (size_t i = 0; i < s.size(); ++i) + if (s[i] < '0' || s[i] > '9') + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " must be a non-negative integer (zatoshi)"); + errno = 0; + char* end = NULL; + unsigned long long q = strtoull(s.c_str(), &end, 10); + if (errno != 0 || end == NULL || *end != '\0') + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is not a valid integer"); + if (q > (unsigned long long)MAX_MONEY) + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " exceeds MAX_MONEY"); + return (int64_t)q; +} + +// Fee-rate-derived dust floor for a token-bearing output (§2.2). Never below the +// SLP convention 546 so older relays that assume that floor still accept it. +static CAmount NftTokenDust(const CScript& dest) +{ + CTxOut probe(0, dest); + CAmount floor = probe.GetDustThreshold(::minRelayTxFee); + return std::max((CAmount)SLP_TOKEN_DUST, floor); +} + +// ── offer blob format (base64; §4) ────────────────────────────────── +// +// Self-describing, versioned. The header is ADVISORY only — nft_verifyoffer +// always re-derives every field from offerHex and ignores a header that lies. +static const unsigned char NFT_OFFER_MAGIC[4] = { 'Z', 'N', 'F', 'T' }; +static const unsigned char NFT_OFFER_VERSION = 0x01; + +class CNftOfferBlob +{ +public: + uint256 tokenId; //!< internal order (render reversed) + int64_t priceZat; + std::string payoutAddr; + std::string buyerNftAddr; + uint32_t expiryHeight; + std::string offerHex; //!< the partial ALL|ANYONECANPAY tx hex + + CNftOfferBlob() : priceZat(0), expiryHeight(0) { tokenId.SetNull(); } + + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + for (int i = 0; i < 4; ++i) { + unsigned char m = NFT_OFFER_MAGIC[i]; + READWRITE(m); + if (ser_action.ForRead() && m != NFT_OFFER_MAGIC[i]) + throw std::ios_base::failure("offer blob: bad magic"); + } + unsigned char ver = NFT_OFFER_VERSION; + READWRITE(ver); + if (ser_action.ForRead() && ver != NFT_OFFER_VERSION) + throw std::ios_base::failure("offer blob: unsupported version"); + READWRITE(tokenId); + READWRITE(priceZat); + READWRITE(payoutAddr); + READWRITE(buyerNftAddr); + READWRITE(expiryHeight); + READWRITE(offerHex); + } + + std::string ToBase64() const + { + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << *this; + return EncodeBase64((const unsigned char*)&ss[0], ss.size()); + } + + // offerId = first 8 bytes of SHA256(blob) hex; stable content fingerprint. + std::string OfferId() const + { + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << *this; + uint256 h = Hash((const unsigned char*)&ss[0], + (const unsigned char*)&ss[0] + ss.size()); + return h.GetHex().substr(0, 16); + } + + bool FromBase64(const std::string& b64, std::string& err) + { + bool invalid = false; + std::vector raw = DecodeBase64(b64.c_str(), &invalid); + if (invalid) { err = "offer blob is not valid base64"; return false; } + try { + CDataStream ss(raw, SER_NETWORK, PROTOCOL_VERSION); + ss >> *this; + } catch (const std::exception& e) { + err = std::string("offer blob decode failed: ") + e.what(); + return false; + } + return true; + } +}; + +// Strip a "znftoffer:" URI prefix if present, returning the bare base64. +static std::string NftStripPrefix(const std::string& in) +{ + const std::string p = "znftoffer:"; + if (in.size() >= p.size() && in.compare(0, p.size(), p) == 0) + return in.substr(p.size()); + return in; +} + +// ── local offer store (datadir/nftoffers.json) ────────────────────── +// +// A small, human-inspectable cache (NOT leveldb): the canonical blob holder + +// status cache. listoffers recomputes status live against the UTXO set. +static boost::filesystem::path NftOfferStorePath() +{ + return GetDataDir() / "nftoffers.json"; +} + +static UniValue NftLoadStore() +{ + UniValue arr(UniValue::VARR); + boost::filesystem::path p = NftOfferStorePath(); + if (!boost::filesystem::exists(p)) + return arr; + boost::filesystem::ifstream f(p); + std::string data((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + if (data.empty()) + return arr; + UniValue parsed; + if (!parsed.read(data) || !parsed.isArray()) + return arr; // corrupt/legacy: start fresh rather than crash + return parsed; +} + +static void NftSaveStore(const UniValue& arr) +{ + boost::filesystem::path p = NftOfferStorePath(); + boost::filesystem::path tmp = p; + tmp += ".tmp"; + { + boost::filesystem::ofstream f(tmp); + f << arr.write(1) << "\n"; + } + boost::filesystem::rename(tmp, p); // atomic replace +} + +// Upsert a record by offerId. +static void NftUpsertOffer(const UniValue& rec) +{ + std::string id = find_value(rec, "offerId").get_str(); + UniValue arr = NftLoadStore(); + UniValue out(UniValue::VARR); + bool replaced = false; + for (size_t i = 0; i < arr.size(); ++i) { + if (find_value(arr[i], "offerId").get_str() == id) { + out.push_back(rec); + replaced = true; + } else { + out.push_back(arr[i]); + } + } + if (!replaced) + out.push_back(rec); + NftSaveStore(out); +} + +static bool NftFindOffer(const std::string& offerId, UniValue& recOut) +{ + UniValue arr = NftLoadStore(); + for (size_t i = 0; i < arr.size(); ++i) { + if (find_value(arr[i], "offerId").get_str() == offerId) { + recOut = arr[i]; + return true; + } + } + return false; +} + +// ── decode + verify the partial offer tx (the core safety logic) ──── +// +// Re-derives every advertised field from offerHex and re-runs the real indexer +// parse + conservation check + a live-UTXO check on vin[0]. Used by both +// nft_verifyoffer (read-only) and nft_takeoffer (refuse-if-not-ok). +// +// Fills `reasons` with one string per failed check; ok == reasons.empty(). +// Also fills the derived (truth) fields so callers can echo them. +struct NftVerifyResult { + bool ok; + uint256 tokenId; + int64_t priceZat; + std::string payoutAddr; + std::string buyerNftAddr; + uint32_t expiryHeight; + CMutableTransaction tx; //!< the decoded partial tx (for takeoffer) + CScript nftPrevScript; //!< vin[0]'s prevout scriptPubKey (live) + CAmount nftPrevValue; //!< vin[0]'s prevout value (live) + std::vector reasons; + NftVerifyResult() : ok(false), priceZat(0), expiryHeight(0), nftPrevValue(0) + { tokenId.SetNull(); } +}; + +static void NftVerify(CZSLPStore* store, const CNftOfferBlob& blob, + NftVerifyResult& r) +{ + AssertLockHeld(cs_main); + r.expiryHeight = blob.expiryHeight; + + CMutableTransaction mtx; + { + CTransaction tmp; + if (!DecodeHexTx(tmp, blob.offerHex)) { + r.reasons.push_back("offerHex does not decode as a transaction"); + r.ok = false; + return; + } + mtx = CMutableTransaction(tmp); + } + r.tx = mtx; + const CTransaction tx(mtx); + + // Output shape. + if (tx.vout.size() != 3) { + r.reasons.push_back(strprintf("expected exactly 3 outputs, got %d", + (int)tx.vout.size())); + } + if (tx.vin.empty()) { + r.reasons.push_back("offer has no inputs (vin[0] = NFT missing)"); + r.ok = false; + return; // nothing more we can check without vin[0] + } + + // vout[0] must parse as a ZSLP SEND crediting vout[1] with qty 1. + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesisMeta = false; + int nextHeight = chainActive.Height() + 1; + bool parsedOk = CZSLPIndexer::ParseTx(tx, nextHeight, parsed, genesisMeta, + haveGenesisMeta); + if (!parsedOk) { + r.reasons.push_back("vout[0] is not a parsable ZSLP message"); + } else { + if (parsed.type != ZSLP_MSG_SEND) + r.reasons.push_back("vout[0] is not a ZSLP SEND"); + r.tokenId = parsed.tokenId; + if (parsed.tokenId != blob.tokenId) + r.reasons.push_back("vout[0] token id does not match the offered token"); + if (parsed.numOutputs != 1 || parsed.outputQuantities[0] != 1) + r.reasons.push_back("vout[0] SEND does not credit exactly qty 1 to vout[1]"); + } + + // vout[1] sealed to buyerNftAddr; vout[2] price + payout (re-derived). + if (tx.vout.size() >= 2) { + std::string gotBuyer = NftAddrFromScript(tx.vout[1].scriptPubKey); + r.buyerNftAddr = gotBuyer; + if (gotBuyer.empty() || gotBuyer != blob.buyerNftAddr) + r.reasons.push_back("vout[1] (NFT recipient) does not match buyerNftAddr"); + } + if (tx.vout.size() >= 3) { + std::string gotPayout = NftAddrFromScript(tx.vout[2].scriptPubKey); + r.payoutAddr = gotPayout; + r.priceZat = tx.vout[2].nValue; + if (tx.vout[2].nValue != blob.priceZat) + r.reasons.push_back("vout[2] (payout) value does not match priceZat"); + if (gotPayout.empty() || gotPayout != blob.payoutAddr) + r.reasons.push_back("vout[2] (payout) address does not match payoutAddr"); + } + + // vin[0] must be the LIVE unspent NFT UTXO for this token (qty 1). + const COutPoint& nftOp = tx.vin[0].prevout; + { + CCoins coins; + bool live = pcoinsTip->GetCoins(nftOp.hash, coins) && + coins.IsAvailable(nftOp.n); + if (!live) { + r.reasons.push_back("vin[0] is not a live (unspent) UTXO — already spent or never existed"); + } else { + r.nftPrevScript = coins.vout[nftOp.n].scriptPubKey; + r.nftPrevValue = coins.vout[nftOp.n].nValue; + + // CRYPTOGRAPHIC BACKSTOP (the buyer pays on the strength of this): + // the seller's ALL|ANYONECANPAY signature on vin[0] must validly bind + // these EXACT outputs. Run VerifyScript over the live prevout exactly + // as signrawtransaction validates each input (rawtransaction.cpp:976). + // A signature that does not cover the outputs (a price/recipient edit), + // or a missing/garbage scriptSig, must make ok=false HERE — not only + // later at broadcast. + uint32_t branchId = CurrentEpochBranchId(nextHeight, + Params().GetConsensus()); + CTransaction txConst(tx); + ScriptError serr = SCRIPT_ERR_OK; + if (!VerifyScript(tx.vin[0].scriptSig, r.nftPrevScript, + STANDARD_SCRIPT_VERIFY_FLAGS, + TransactionSignatureChecker(&txConst, 0, + r.nftPrevValue), + branchId, &serr)) { + r.reasons.push_back( + std::string("seller signature does not validly bind this " + "offer (vin[0] VerifyScript failed: ") + + ScriptErrorString(serr) + ")"); + } + } + CZSLPTokenUtxo rec; + bool isToken = store->GetUtxo(nftOp.hash, (int32_t)nftOp.n, rec); + if (!isToken) { + r.reasons.push_back("vin[0] is not a confirmed ZSLP token UTXO"); + } else { + if (!r.tokenId.IsNull() && rec.tokenId != r.tokenId) + r.reasons.push_back("vin[0] token UTXO is a different token than vout[0] declares"); + if (rec.amount != 1 || rec.isMintBaton) + r.reasons.push_back("vin[0] is not a quantity-1 NFT UTXO"); + } + } + + // Not expired. + if (tx.nExpiryHeight > 0 && + tx.nExpiryHeight <= (uint32_t)(nextHeight + TX_EXPIRING_SOON_THRESHOLD)) { + r.reasons.push_back(strprintf("offer is expired or expiring too soon (expiry %d, tip+1 %d)", + tx.nExpiryHeight, nextHeight)); + } + + // Conservation: the overlay ledger would fully credit the buyer. + if (parsedOk) { + std::vector vin; + for (size_t k = 0; k < tx.vin.size(); ++k) + vin.push_back(tx.vin[k].prevout); + std::string why; + if (!store->WouldBeValid(vin, &parsed, tx.GetHash(), + haveGenesisMeta ? &genesisMeta : NULL, + (int32_t)tx.vout.size(), why)) { + r.reasons.push_back("ledger conservation check failed: " + why); + } + } + + r.ok = r.reasons.empty(); +} + +// ── nft_makeoffer ─────────────────────────────────────────────────── + +UniValue nft_makeoffer(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() != 1) + throw std::runtime_error( + "nft_makeoffer '{\"tokenId\":?,\"priceZat\":?,\"buyerNftAddr\":?," + "\"payoutAddr\":?,\"expiryHeight\":?}'\n" + "\nCreate a buyer-sealed atomic sell offer for an NFT (mechanism A').\n" + "Builds the fixed 3-output ZSLP SEND template, signs ONLY vin[0]\n" + "(your NFT dust UTXO) with ALL|ANYONECANPAY so the buyer can only\n" + "append funding inputs, locks the NFT outpoint, self-validates, and\n" + "returns a base64 offer blob to share offline.\n" + + HelpRequiringPassphrase() + + "\nArguments:\n" + "1. \"params\" (object, required)\n" + " tokenId (string, required) the NFT token id (hex)\n" + " priceZat (string|numeric, required) asking price in zatoshi\n" + " buyerNftAddr (string, required) the buyer's NFT receive t-address\n" + " payoutAddr (string, optional) your payout t-address (default: fresh)\n" + " expiryHeight (numeric, optional) offer deadline (default: tip+~7d)\n" + "\nResult:\n" + "{ \"offerBlob\":\"base64\", \"offerId\":\"hex\", \"nftOutpoint\":\"txid:n\",\n" + " \"fingerprint\":\"hex\" }\n" + "\nExamples:\n" + + HelpExampleCli("nft_makeoffer", + "'{\"tokenId\":\"\",\"priceZat\":\"100000000\",\"buyerNftAddr\":\"t1...\"}'")); + + CZSLPStore* store = NftGetStoreOrThrow(); + LOCK2(cs_main, pwalletMain->cs_wallet); + + const UniValue& o = params[0].get_obj(); + uint256 tokenId = ParseHashV(find_value(o, "tokenId"), "tokenId"); + int64_t priceZat = NftParseZat(find_value(o, "priceZat"), "priceZat"); + if (priceZat <= 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "priceZat must be > 0"); + + const UniValue& vBuyer = find_value(o, "buyerNftAddr"); + if (!vBuyer.isStr() || vBuyer.get_str().empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "buyerNftAddr is required"); + std::string buyerNftAddr = vBuyer.get_str(); + CScript buyerScript = NftScriptForTAddr(buyerNftAddr); + + std::string payoutAddr; + CScript payoutScript; + { const UniValue& v = find_value(o, "payoutAddr"); + if (v.isStr() && !v.get_str().empty()) { + payoutAddr = v.get_str(); + payoutScript = NftScriptForTAddr(payoutAddr); + } else { + payoutScript = NftFreshWalletScript(); + payoutAddr = NftAddrFromScript(payoutScript); + } } + + CZSLPToken token; + if (!store->GetToken(tokenId, token)) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Token not found"); + + EnsureWalletIsUnlocked(); + + // PRECONDITION: a CONFIRMED quantity-1 NFT UTXO in this wallet. + std::vector utxos; + std::string ferr; + if (!ZSLPFindWalletTokenUtxos(pwalletMain, tokenId, /*wantBaton=*/false, + utxos, ferr)) + throw JSONRPCError(RPC_WALLET_ERROR, ferr); + const ZSLPWalletUtxo* nft = NULL; + for (size_t i = 0; i < utxos.size(); ++i) + if (utxos[i].amount == 1) { nft = &utxos[i]; break; } + if (nft == NULL) + throw JSONRPCError(RPC_WALLET_ERROR, + "No confirmed quantity-1 NFT UTXO for that token in this wallet " + "(if you just minted/received it, wait for it to confirm)"); + COutPoint nftOutpoint = nft->outpoint; + + // Confirmed-truth assertion on the chosen outpoint. + { + CZSLPTokenUtxo rec; + if (!store->GetUtxo(nftOutpoint.hash, (int32_t)nftOutpoint.n, rec) || + rec.amount != 1 || rec.isMintBaton) + throw JSONRPCError(RPC_WALLET_ERROR, + "internal: chosen NFT UTXO is not a confirmed qty-1 token"); + } + + // Live prevout (script + value) for signing the NFT input. + CCoins coins; + if (!pcoinsTip->GetCoins(nftOutpoint.hash, coins) || + !coins.IsAvailable(nftOutpoint.n)) + throw JSONRPCError(RPC_WALLET_ERROR, + "NFT UTXO is not live in the chainstate (still confirming?)"); + const CScript nftScript = coins.vout[nftOutpoint.n].scriptPubKey; + const CAmount nftValue = coins.vout[nftOutpoint.n].nValue; + + // Dust floor for vout[1] (fee-rate-derived; never below 546). + CAmount D = NftTokenDust(buyerScript); + + // vout[0] = ZSLP SEND { tokenId, [1] } via the EXISTING encoder (DRY). + uint8_t tidBE[32]; ZSLPTokenIdToBE(tokenId, tidBE); + std::vector quantities; quantities.push_back(1); + std::vector opret = ZSLPBuildSend(tidBE, quantities); + if (opret.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "failed to build SEND OP_RETURN"); + + // Expiry: default tip + ~7d of 150s blocks; validate the relay bounds the + // way createrawtransaction does (rawtransaction.cpp:514-522). + int tip = chainActive.Height(); + uint32_t expiry; + { const UniValue& v = find_value(o, "expiryHeight"); + if (!v.isNull()) { + int e = v.get_int(); + if (e < 0) throw JSONRPCError(RPC_INVALID_PARAMETER, "expiryHeight must be >= 0"); + expiry = (uint32_t)e; + } else { + expiry = (uint32_t)(tip + 7 * 1440); + } } + if (expiry < (uint32_t)(tip + 1 + TX_EXPIRING_SOON_THRESHOLD)) + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("expiryHeight %d is too soon; must be >= %d", + expiry, tip + 1 + TX_EXPIRING_SOON_THRESHOLD)); + if (expiry >= TX_EXPIRY_HEIGHT_THRESHOLD) + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("expiryHeight must be < %d", TX_EXPIRY_HEIGHT_THRESHOLD)); + + // Assemble the fixed template. + CMutableTransaction mtx = CreateNewContextualCMutableTransaction( + Params().GetConsensus(), tip + 1); + mtx.nExpiryHeight = expiry; + mtx.nLockTime = 0; + mtx.vin.clear(); + mtx.vin.push_back(CTxIn(nftOutpoint, CScript(), + std::numeric_limits::max())); + mtx.vout.clear(); + mtx.vout.push_back(CTxOut(0, CScript(opret.begin(), opret.end()))); // vout[0] + mtx.vout.push_back(CTxOut(D, buyerScript)); // vout[1] + mtx.vout.push_back(CTxOut(priceZat, payoutScript)); // vout[2] + + // Sign ONLY vin[0] with ALL|ANYONECANPAY (the exact primitives + // signrawtransaction wraps; in-process to avoid a re-entrant RPC). + uint32_t branchId = CurrentEpochBranchId(tip + 1, Params().GetConsensus()); + { + CTransaction txConst(mtx); + SignatureData sigdata; + if (!ProduceSignature( + TransactionSignatureCreator(pwalletMain, &txConst, 0, nftValue, + SigHashType(SIGHASH_ALL | SIGHASH_ANYONECANPAY)), + nftScript, sigdata, branchId)) + throw JSONRPCError(RPC_WALLET_ERROR, + "failed to sign the NFT input (vin[0]) — wallet does not hold its key?"); + UpdateTransaction(mtx, 0, sigdata); + + // Verify the seller's own signature now (catches a broken sig early). + ScriptError serr = SCRIPT_ERR_OK; + if (!VerifyScript(mtx.vin[0].scriptSig, nftScript, + STANDARD_SCRIPT_VERIFY_FLAGS, + TransactionSignatureChecker(&txConst, 0, nftValue), + branchId, &serr)) + throw JSONRPCError(RPC_WALLET_ERROR, + std::string("self-check: seller signature did not verify: ") + + ScriptErrorString(serr)); + } + + // Self-validate the PARTIAL (vout[0]-only parse + conservation; vin[0] + // alone gives availIn=1, requiredOut=1). + { + CTransaction partial(mtx); + CNftOfferBlob probe; + probe.tokenId = tokenId; + probe.priceZat = priceZat; + probe.payoutAddr = payoutAddr; + probe.buyerNftAddr = buyerNftAddr; + probe.expiryHeight = expiry; + probe.offerHex = EncodeHexTx(partial); + NftVerifyResult vr; + NftVerify(store, probe, vr); + if (!vr.ok) { + std::string joined; + for (size_t i = 0; i < vr.reasons.size(); ++i) + joined += (i ? "; " : "") + vr.reasons[i]; + throw JSONRPCError(RPC_WALLET_ERROR, + "self-validate of the built offer failed: " + joined); + } + } + + // Lock the NFT outpoint so coin selection can't spend/double-offer it. + { COutPoint tmp = nftOutpoint; pwalletMain->LockCoin(tmp); } + + // Build + persist the blob. + CNftOfferBlob blob; + blob.tokenId = tokenId; + blob.priceZat = priceZat; + blob.payoutAddr = payoutAddr; + blob.buyerNftAddr = buyerNftAddr; + blob.expiryHeight = expiry; + blob.offerHex = EncodeHexTx(CTransaction(mtx)); + std::string offerId = blob.OfferId(); + std::string offerB64 = blob.ToBase64(); + std::string nftOutStr = nftOutpoint.hash.GetHex() + ":" + + std::to_string(nftOutpoint.n); + + { + UniValue rec(UniValue::VOBJ); + rec.push_back(Pair("offerId", offerId)); + rec.push_back(Pair("role", "sell")); + rec.push_back(Pair("tokenId", tokenId.GetHex())); + rec.push_back(Pair("priceZat", priceZat)); + rec.push_back(Pair("payoutAddr", payoutAddr)); + rec.push_back(Pair("buyerNftAddr", buyerNftAddr)); + rec.push_back(Pair("nftOutpoint", nftOutStr)); + rec.push_back(Pair("expiryHeight", (int64_t)expiry)); + rec.push_back(Pair("offerBlob", offerB64)); + rec.push_back(Pair("createdHeight", (int64_t)tip)); + rec.push_back(Pair("status", "open")); + NftUpsertOffer(rec); + } + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("offerBlob", "znftoffer:" + offerB64)); + ret.push_back(Pair("offerId", offerId)); + ret.push_back(Pair("nftOutpoint", nftOutStr)); + ret.push_back(Pair("fingerprint", offerId)); + return ret; +} + +// ── nft_verifyoffer ───────────────────────────────────────────────── + +UniValue nft_verifyoffer(const UniValue& params, bool fHelp) +{ + if (fHelp || params.size() != 1) + throw std::runtime_error( + "nft_verifyoffer '{\"offerBlob\":\"...\"}'\n" + "\nMANDATORY read-only safety check the buyer runs BEFORE signing.\n" + "Decodes the offer, re-derives every field from the partial tx, and\n" + "re-runs the real ZSLP parse + conservation check + a live-UTXO check\n" + "on vin[0]. A forged or edited offer FAILS here with clear reasons.\n" + "\nArguments:\n" + "1. \"params\" (object, required) { \"offerBlob\":\"base64 or znftoffer:...\" }\n" + "\nResult:\n" + "{ \"ok\":true|false, \"tokenId\":\"hex\", \"priceZat\":n,\n" + " \"payoutAddr\":\"t1..\", \"buyerNftAddr\":\"t1..\",\n" + " \"expiryHeight\":n, \"reasons\":[...] }\n" + "\nExamples:\n" + + HelpExampleCli("nft_verifyoffer", "'{\"offerBlob\":\"znftoffer:...\"}'")); + + CZSLPStore* store = NftGetStoreOrThrow(); + LOCK(cs_main); + + const UniValue& o = params[0].get_obj(); + const UniValue& vb = find_value(o, "offerBlob"); + if (!vb.isStr() || vb.get_str().empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "offerBlob is required"); + + CNftOfferBlob blob; + std::string err; + if (!blob.FromBase64(NftStripPrefix(vb.get_str()), err)) + throw JSONRPCError(RPC_DESERIALIZATION_ERROR, err); + + NftVerifyResult r; + NftVerify(store, blob, r); + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("ok", r.ok)); + ret.push_back(Pair("tokenId", r.tokenId.IsNull() ? blob.tokenId.GetHex() + : r.tokenId.GetHex())); + ret.push_back(Pair("priceZat", r.priceZat)); + ret.push_back(Pair("payoutAddr", r.payoutAddr.empty() ? blob.payoutAddr + : r.payoutAddr)); + ret.push_back(Pair("buyerNftAddr", r.buyerNftAddr.empty() ? blob.buyerNftAddr + : r.buyerNftAddr)); + ret.push_back(Pair("expiryHeight", (int64_t)r.expiryHeight)); + UniValue reasons(UniValue::VARR); + for (size_t i = 0; i < r.reasons.size(); ++i) + reasons.push_back(r.reasons[i]); + ret.push_back(Pair("reasons", reasons)); + return ret; +} + +// Parse a "txid:n" outpoint string. +static bool NftParseOutpoint(const std::string& s, COutPoint& out) +{ + size_t colon = s.rfind(':'); + if (colon == std::string::npos) return false; + std::string h = s.substr(0, colon); + std::string nstr = s.substr(colon + 1); + if (h.size() != 64 || !IsHex(h)) return false; + for (size_t i = 0; i < nstr.size(); ++i) + if (nstr[i] < '0' || nstr[i] > '9') return false; + out.hash = uint256S(h); + out.n = (uint32_t)atoi(nstr.c_str()); + return true; +} + +// ── nft_takeoffer ─────────────────────────────────────────────────── + +UniValue nft_takeoffer(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() != 1) + throw std::runtime_error( + "nft_takeoffer '{\"offerBlob\":\"...\",\"fundingInputs\":[\"txid:n\",..]," + "\"changeAddr\":?,\"acknowledge\":?}'\n" + "\nVerify, fund, and broadcast an NFT sell offer atomically. Verifies\n" + "first (refuses if not ok), appends funding inputs that EXCLUDE every\n" + "ZSLP-protected outpoint (anti-burn), adds NO new outputs (no buyer\n" + "change is possible under ALL), signs the buyer inputs ALL|ANYONECANPAY,\n" + "merges the seller's vin[0], self-validates, and sendrawtransaction.\n" + "Any overshoot beyond price+dust+fee is donated to the miner.\n" + + HelpRequiringPassphrase() + + "\nArguments:\n" + "1. \"params\" (object, required)\n" + " offerBlob (string, required) base64 or znftoffer: blob\n" + " fundingInputs (array, optional) explicit [\"txid:n\",..] funding outpoints\n" + " changeAddr (string, optional) reserved for a pre-size prep tx (unused here)\n" + " acknowledge (bool, optional) acknowledge the overshoot-to-fee\n" + "\nResult:\n{ \"txid\":\"hex\", \"overshootZat\":n }\n" + "\nExamples:\n" + + HelpExampleCli("nft_takeoffer", "'{\"offerBlob\":\"znftoffer:...\"}'")); + + CZSLPStore* store = NftGetStoreOrThrow(); + LOCK2(cs_main, pwalletMain->cs_wallet); + + const UniValue& o = params[0].get_obj(); + const UniValue& vb = find_value(o, "offerBlob"); + if (!vb.isStr() || vb.get_str().empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "offerBlob is required"); + + CNftOfferBlob blob; + std::string derr; + if (!blob.FromBase64(NftStripPrefix(vb.get_str()), derr)) + throw JSONRPCError(RPC_DESERIALIZATION_ERROR, derr); + + // The buyer's explicit consent to donate any overshoot beyond + // price+dust+fee to miner fees (no change output is possible under ALL). + bool acknowledge = false; + { const UniValue& va = find_value(o, "acknowledge"); + if (va.isBool()) acknowledge = va.get_bool(); + else if (!va.isNull()) + throw JSONRPCError(RPC_TYPE_ERROR, "acknowledge must be a boolean"); } + + // (1) Verify first; refuse if not ok (TOCTOU is closed: this re-checks + // liveness right before we fund/sign). + NftVerifyResult vr; + NftVerify(store, blob, vr); + if (!vr.ok) { + std::string joined; + for (size_t i = 0; i < vr.reasons.size(); ++i) + joined += (i ? "; " : "") + vr.reasons[i]; + throw JSONRPCError(RPC_VERIFY_REJECTED, "offer failed verification: " + joined); + } + + EnsureWalletIsUnlocked(); + + CMutableTransaction mtx = vr.tx; // decoded partial (vin[0] seller-signed) + + // (2) Output value to cover + an estimated fee. No change output is possible + // under ALL, so the buyer funds exact (or honest overshoot -> fee). + CAmount outValue = 0; + for (size_t i = 0; i < mtx.vout.size(); ++i) + outValue += mtx.vout[i].nValue; // 0 + D + price + CAmount vin0Value = vr.nftPrevValue; // the NFT dust the seller spends + // A 3-output, ~2-input tx is ~ a few hundred bytes; use the wallet's fee + // estimator against a conservative size and refine after selection. + CAmount feeEst = pwalletMain->GetMinimumFee(2000, nTxConfirmTarget, mempool); + + // (3) Funding selection (anti-burn). EXCLUDE every protected outpoint. + std::vector funding; + CAmount fundIn = 0; + const UniValue& vfi = find_value(o, "fundingInputs"); + if (vfi.isArray() && vfi.size() > 0) { + for (size_t i = 0; i < vfi.size(); ++i) { + COutPoint op; + if (!NftParseOutpoint(vfi[i].get_str(), op)) + throw JSONRPCError(RPC_INVALID_PARAMETER, + "fundingInputs entry not in txid:n form: " + vfi[i].get_str()); + if (ZSLPIsProtectedTokenOutpoint(pwalletMain, store, op)) + throw JSONRPCError(RPC_INVALID_PARAMETER, + "anti-burn: funding input is a ZSLP token/baton UTXO: " + vfi[i].get_str()); + CCoins c; + if (!pcoinsTip->GetCoins(op.hash, c) || !c.IsAvailable(op.n)) + throw JSONRPCError(RPC_INVALID_PARAMETER, + "funding input is not live: " + vfi[i].get_str()); + funding.push_back(op); + fundIn += c.vout[op.n].nValue; + } + } else { + // Auto-select: AvailableCoins with fExcludeZSLPTokens=true already drops + // protected outpoints; greedy smallest-combo >= target. + std::vector coins; + pwalletMain->AvailableCoins(coins, /*fOnlyConfirmed=*/true, NULL, + /*fIncludeZeroValue=*/false, + /*fIncludeCoinBase=*/false, + /*fExcludeZSLPTokens=*/true); + std::sort(coins.begin(), coins.end(), + [](const COutput& a, const COutput& b) { + return a.tx->vout[a.i].nValue < b.tx->vout[b.i].nValue; + }); + CAmount target = (outValue - vin0Value) + feeEst; + for (size_t i = 0; i < coins.size() && fundIn < target; ++i) { + if (!coins[i].fSpendable) continue; + COutPoint op(coins[i].tx->GetHash(), coins[i].i); + // Belt-and-suspenders: re-assert not protected. + if (ZSLPIsProtectedTokenOutpoint(pwalletMain, store, op)) continue; + funding.push_back(op); + fundIn += coins[i].tx->vout[coins[i].i].nValue; + } + } + + CAmount target = (outValue - vin0Value) + feeEst; + if (fundIn < target) + throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, + strprintf("Insufficient funds to fund the swap: have %d, need ~%d " + "(price+dust+fee, no change output is possible)", + fundIn, target)); + + // Overshoot consent (§2.5: overshoot must NEVER be silent). With NO change + // output possible under ALL, every zat of (funds in - outputs) is the miner + // fee. The portion ABOVE the estimated fee is the buyer's accidental overpay. + // If that exceeds a small dust threshold, REQUIRE acknowledge==true. + CAmount overshootZat = (fundIn + vin0Value) - outValue; // total fee paid + CAmount overpayZat = overshootZat - feeEst; // beyond intended fee + if (overpayZat < 0) overpayZat = 0; + CAmount overshootDust = CTxOut(0, NftFreshWalletScript()) + .GetDustThreshold(::minRelayTxFee); + if (overpayZat > overshootDust && !acknowledge) + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf( + "you would overpay %s ZCL to miner fees (overshoot %d zat, estimated " + "fee %d zat); pass acknowledge:true to proceed", + FormatMoney(overpayZat), overshootZat, feeEst)); + + // Append funding inputs as vin[1..] ONLY (NO new outputs; fundrawtransaction + // is forbidden — its random change insert breaks the ALL-pinned order). + for (size_t i = 0; i < funding.size(); ++i) + mtx.vin.push_back(CTxIn(funding[i], CScript(), + std::numeric_limits::max())); + + // (4) Sign the buyer's inputs (vin[1..]) with ALL|ANYONECANPAY; leave vin[0] + // untouched and merge it via CombineSignatures. + uint32_t branchId = CurrentEpochBranchId(chainActive.Height() + 1, + Params().GetConsensus()); + CMutableTransaction sellerVariant = vr.tx; // carries vin[0]'s scriptSig + { + CTransaction txConst(mtx); + for (size_t i = 1; i < mtx.vin.size(); ++i) { + const COutPoint& op = mtx.vin[i].prevout; + CCoins c; + if (!pcoinsTip->GetCoins(op.hash, c) || !c.IsAvailable(op.n)) + throw JSONRPCError(RPC_WALLET_ERROR, "funding input vanished mid-sign"); + const CScript& spk = c.vout[op.n].scriptPubKey; + CAmount amt = c.vout[op.n].nValue; + SignatureData sigdata; + if (!ProduceSignature( + TransactionSignatureCreator(pwalletMain, &txConst, i, amt, + SigHashType(SIGHASH_ALL | SIGHASH_ANYONECANPAY)), + spk, sigdata, branchId)) + throw JSONRPCError(RPC_WALLET_ERROR, + strprintf("failed to sign funding input %d", (int)i)); + UpdateTransaction(mtx, i, sigdata); + } + // Merge the seller's pre-existing vin[0] scriptSig (CombineSignatures). + { + const CScript& spk0 = vr.nftPrevScript; + CAmount amt0 = vr.nftPrevValue; + SignatureData merged = CombineSignatures( + spk0, TransactionSignatureChecker(&txConst, 0, amt0), + DataFromTransaction(mtx, 0), DataFromTransaction(sellerVariant, 0), + branchId); + UpdateTransaction(mtx, 0, merged); + } + } + + // (5) Verify every input now signs cleanly (incl. the seller's ALL pin). + { + CTransaction txConst(mtx); + for (size_t i = 0; i < mtx.vin.size(); ++i) { + const COutPoint& op = mtx.vin[i].prevout; + CCoins c; + if (!pcoinsTip->GetCoins(op.hash, c) || !c.IsAvailable(op.n)) + throw JSONRPCError(RPC_WALLET_ERROR, "an input is no longer live"); + const CScript& spk = c.vout[op.n].scriptPubKey; + CAmount amt = c.vout[op.n].nValue; + ScriptError serr = SCRIPT_ERR_OK; + if (!VerifyScript(mtx.vin[i].scriptSig, spk, + STANDARD_SCRIPT_VERIFY_FLAGS, + TransactionSignatureChecker(&txConst, i, amt), + branchId, &serr)) + throw JSONRPCError(RPC_WALLET_ERROR, + strprintf("input %d failed VerifyScript: %s", (int)i, + ScriptErrorString(serr))); + } + } + + // (6) Final self-validate (real parse + conservation) + anti-burn post-check. + CTransaction finalTx(mtx); + { + std::vector vin; + for (size_t k = 0; k < finalTx.vin.size(); ++k) { + vin.push_back(finalTx.vin[k].prevout); + if (k >= 1 && ZSLPIsProtectedTokenOutpoint(pwalletMain, store, + finalTx.vin[k].prevout)) + throw JSONRPCError(RPC_WALLET_ERROR, + "anti-burn post-check: a funding input is a live token UTXO"); + } + CZSLPParsedMsg parsed; CZSLPToken gm; bool haveGm = false; + if (!CZSLPIndexer::ParseTx(finalTx, chainActive.Height() + 1, parsed, + gm, haveGm)) + throw JSONRPCError(RPC_WALLET_ERROR, "self-validate: no SLP message at vout[0]"); + std::string why; + if (!store->WouldBeValid(vin, &parsed, finalTx.GetHash(), + haveGm ? &gm : NULL, + (int32_t)finalTx.vout.size(), why)) + throw JSONRPCError(RPC_WALLET_ERROR, + "self-validate: final tx would not be valid in the token ledger (" + why + ")"); + } + + // (7) Broadcast (mirror sendrawtransaction: ATMP + relay). + { + CValidationState state; + bool fMissingInputs = false; + if (!AcceptToMemoryPool(mempool, state, finalTx, false, &fMissingInputs, false)) { + if (state.IsInvalid()) + throw JSONRPCError(RPC_TRANSACTION_REJECTED, + strprintf("%i: %s", state.GetRejectCode(), state.GetRejectReason())); + if (fMissingInputs) + throw JSONRPCError(RPC_TRANSACTION_ERROR, "Missing inputs"); + throw JSONRPCError(RPC_TRANSACTION_ERROR, state.GetRejectReason()); + } + RelayTransaction(finalTx); + } + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("txid", finalTx.GetHash().GetHex())); + ret.push_back(Pair("overshootZat", overshootZat)); // = the network fee donated + return ret; +} + +// ── nft_listoffers ────────────────────────────────────────────────── + +UniValue nft_listoffers(const UniValue& params, bool fHelp) +{ + if (fHelp || params.size() > 1) + throw std::runtime_error( + "nft_listoffers ( {\"mine\":true|false} )\n" + "\nList offers from the local store; status recomputed live against\n" + "the UTXO set (open / filled / expired / canceled).\n" + "\nResult:\n" + "[ { \"offerId\", \"tokenId\", \"priceZat\", \"expiryHeight\",\n" + " \"role\", \"status\" }, ... ]\n"); + + CZSLPStore* store = NftGetStoreOrThrow(); + LOCK(cs_main); + + bool onlyMine = false; + if (params.size() == 1 && params[0].isObject()) { + const UniValue& v = find_value(params[0].get_obj(), "mine"); + if (v.isBool()) onlyMine = v.get_bool(); + } + (void)onlyMine; // every record in the local store is "mine" (sent/received) + + int tip = chainActive.Height(); + UniValue arr = NftLoadStore(); + UniValue out(UniValue::VARR); + for (size_t i = 0; i < arr.size(); ++i) { + const UniValue& rec = arr[i]; + std::string status = "open"; + std::string stored = find_value(rec, "status").get_str(); + int64_t expiry = find_value(rec, "expiryHeight").get_int64(); + + // Live recompute against vin[0]'s prevout (the NFT outpoint). + COutPoint nftOp; + bool haveOp = NftParseOutpoint(find_value(rec, "nftOutpoint").get_str(), nftOp); + bool nftLive = false; + if (haveOp) { + CCoins c; + nftLive = pcoinsTip->GetCoins(nftOp.hash, c) && c.IsAvailable(nftOp.n); + } + if (stored == "canceled" || stored == "filled") { + status = stored; // terminal states are sticky (we broadcast them) + } else if (!nftLive) { + // The NFT outpoint is spent but we did not record a terminal state: + // someone filled/canceled it elsewhere. + status = "filled"; + } else if (tip > expiry) { + status = "expired"; + } else { + status = "open"; + } + + UniValue o(UniValue::VOBJ); + o.push_back(Pair("offerId", find_value(rec, "offerId").get_str())); + o.push_back(Pair("tokenId", find_value(rec, "tokenId").get_str())); + o.push_back(Pair("priceZat", find_value(rec, "priceZat").get_int64())); + o.push_back(Pair("expiryHeight", expiry)); + o.push_back(Pair("role", find_value(rec, "role").get_str())); + o.push_back(Pair("status", status)); + out.push_back(o); + } + (void)store; + return out; +} + +// ── nft_canceloffer ───────────────────────────────────────────────── + +UniValue nft_canceloffer(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() != 1) + throw std::runtime_error( + "nft_canceloffer '{\"offerId\":\"...\"}'\n" + "\nCancel an outstanding sell offer by self-spending its NFT UTXO\n" + "(a 1-output ZSLP SEND to a fresh own address). This voids any offer\n" + "referencing that outpoint and unlocks it.\n" + + HelpRequiringPassphrase() + + "\nArguments:\n" + "1. \"params\" (object, required) { \"offerId\":\"hex\" }\n" + "\nResult:\n{ \"txid\":\"hex\" }\n"); + + CZSLPStore* store = NftGetStoreOrThrow(); + (void)store; + LOCK2(cs_main, pwalletMain->cs_wallet); + + const UniValue& o = params[0].get_obj(); + const UniValue& vid = find_value(o, "offerId"); + if (!vid.isStr() || vid.get_str().empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "offerId is required"); + std::string offerId = vid.get_str(); + + UniValue rec; + if (!NftFindOffer(offerId, rec)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "offerId not found in the local store"); + + uint256 tokenId = uint256S(find_value(rec, "tokenId").get_str()); + COutPoint nftOp; + if (!NftParseOutpoint(find_value(rec, "nftOutpoint").get_str(), nftOp)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "stored nftOutpoint is malformed"); + + EnsureWalletIsUnlocked(); + + // Unlock so BuildAndCommitZSLP can spend it, then self-send the NFT (1-output + // SEND to a fresh own address) — reuses the whole proven builder (DRY). + { COutPoint tmp = nftOp; pwalletMain->UnlockCoin(tmp); } + + uint8_t tidBE[32]; ZSLPTokenIdToBE(tokenId, tidBE); + std::vector quantities; quantities.push_back(1); + std::vector opret = ZSLPBuildSend(tidBE, quantities); + if (opret.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "failed to build cancel SEND OP_RETURN"); + + ZSLPBuildReq req; + req.opret = CScript(opret.begin(), opret.end()); + ZSLPTokenOut recip; recip.dest = NftFreshWalletScript(); recip.dustSats = SLP_TOKEN_DUST; + req.tokenOuts.push_back(recip); + req.tokenInputs.push_back(nftOp); + req.selfValidateTokenId = tokenId; + req.isGenesis = false; + + CWalletTx wtx; + std::string err; + if (!BuildAndCommitZSLP(pwalletMain, req, wtx, err)) { + // Re-lock on failure so a transient error doesn't leave the NFT exposed. + { COutPoint tmp = nftOp; pwalletMain->LockCoin(tmp); } + throw JSONRPCError(RPC_WALLET_ERROR, err); + } + + // Mark canceled in the store. + { + UniValue nrec = rec; + nrec.pushKV("status", "canceled"); + nrec.pushKV("cancelTxid", wtx.GetHash().GetHex()); + NftUpsertOffer(nrec); + } + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("txid", wtx.GetHash().GetHex())); + return ret; +} + +// ── nft_requestbuy ────────────────────────────────────────────────── + +UniValue nft_requestbuy(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() != 1) + throw std::runtime_error( + "nft_requestbuy '{\"tokenId\":\"...\"}'\n" + "\nProduce a fresh buyer NFT receive address + a request blob for the\n" + "buyer-address handshake (so the seller can seal an offer to it).\n" + + HelpRequiringPassphrase() + + "\nArguments:\n" + "1. \"params\" (object, required) { \"tokenId\":\"hex\" } (or \"offerId\")\n" + "\nResult:\n{ \"buyerNftAddr\":\"t1..\", \"requestBlob\":\"base64\" }\n"); + + NftGetStoreOrThrow(); + LOCK2(cs_main, pwalletMain->cs_wallet); + + const UniValue& o = params[0].get_obj(); + uint256 tokenId; + const UniValue& vt = find_value(o, "tokenId"); + const UniValue& voi = find_value(o, "offerId"); + if (vt.isStr() && !vt.get_str().empty()) { + tokenId = ParseHashV(vt, "tokenId"); + } else if (voi.isStr() && !voi.get_str().empty()) { + UniValue rec; + if (!NftFindOffer(voi.get_str(), rec)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "offerId not found"); + tokenId = uint256S(find_value(rec, "tokenId").get_str()); + } else { + throw JSONRPCError(RPC_INVALID_PARAMETER, "tokenId or offerId is required"); + } + + EnsureWalletIsUnlocked(); + + CScript script = NftFreshWalletScript(); + std::string addr = NftAddrFromScript(script); + + // Request blob: versioned base64 { magic ZNFTREQ1, tokenId, buyerNftAddr }. + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + const char rqmagic[8] = { 'Z','N','F','T','R','E','Q','1' }; + ss.write(rqmagic, 8); + ss << tokenId; + ss << addr; + std::string requestBlob = EncodeBase64((const unsigned char*)&ss[0], ss.size()); + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("buyerNftAddr", addr)); + ret.push_back(Pair("requestBlob", "znftreq:" + requestBlob)); + return ret; +} + +#endif // ENABLE_WALLET + +static const CRPCCommand commands[] = +{ // category name actor (function) okSafeMode + { "nft", "nft_verifyoffer", &nft_verifyoffer, true }, + { "nft", "nft_listoffers", &nft_listoffers, true }, +#ifdef ENABLE_WALLET + { "nft", "nft_makeoffer", &nft_makeoffer, false }, + { "nft", "nft_takeoffer", &nft_takeoffer, false }, + { "nft", "nft_canceloffer", &nft_canceloffer, false }, + { "nft", "nft_requestbuy", &nft_requestbuy, false }, +#endif +}; + +void RegisterNFTOfferRPCCommands(CRPCTable& tableRPC) +{ + for (unsigned int vcidx = 0; vcidx < ARRAYLEN(commands); vcidx++) + tableRPC.appendCommand(commands[vcidx].name, &commands[vcidx]); +} diff --git a/src/rpc/register.h b/src/rpc/register.h index aa0f15908ab..4332c9d3837 100644 --- a/src/rpc/register.h +++ b/src/rpc/register.h @@ -21,6 +21,10 @@ void RegisterMiningRPCCommands(CRPCTable &tableRPC); void RegisterRawTransactionRPCCommands(CRPCTable &tableRPC); /** Register ZSLP token read-only RPC commands */ void RegisterZSLPRPCCommands(CRPCTable &tableRPC); +/** Register NFT sell/offer RPC commands (non-consensus atomic NFT->ZCL sale) */ +void RegisterNFTOfferRPCCommands(CRPCTable &tableRPC); +/** Register shielded data-channel RPC commands (only when -datachannel is on) */ +void RegisterDataChannelRPCCommands(CRPCTable &tableRPC); static inline void RegisterAllCoreRPCCommands(CRPCTable &tableRPC) { @@ -30,6 +34,8 @@ static inline void RegisterAllCoreRPCCommands(CRPCTable &tableRPC) RegisterMiningRPCCommands(tableRPC); RegisterRawTransactionRPCCommands(tableRPC); RegisterZSLPRPCCommands(tableRPC); + RegisterNFTOfferRPCCommands(tableRPC); + RegisterDataChannelRPCCommands(tableRPC); } #endif diff --git a/src/rpc/zslp.cpp b/src/rpc/zslp.cpp index 07dd15881e1..1a1f0bdfe3f 100644 --- a/src/rpc/zslp.cpp +++ b/src/rpc/zslp.cpp @@ -16,6 +16,7 @@ #include "rpc/protocol.h" #include "script/standard.h" #include "util.h" +#include "utilstrencodings.h" #include "zslp/zslpindexer.h" #include "zslp/zslpstore.h" @@ -23,6 +24,11 @@ #include "init.h" // pwalletMain #include "main.h" // cs_main #include "wallet/wallet.h" +#include "wallet/zslpwallet.h" +#include "zslp/zslpmsg.h" // ZSLPBuild{Genesis,Mint,Send} +// EnsureWalletIsAvailable is file-extern in wallet/rpcwallet.cpp (not in a +// header); declare it here. EnsureWalletIsUnlocked is in rpc/server.h. +extern bool EnsureWalletIsAvailable(bool avoidException); #endif #include @@ -260,6 +266,388 @@ UniValue zslp_listmytokens(const UniValue& params, bool fHelp) return arr; } +#ifdef ENABLE_WALLET + +// ── Write-path helpers ────────────────────────────────────────────── + +// TokenIdToBE moved to wallet/zslpwallet.h as the shared inline ZSLPTokenIdToBE +// (DRY: rpc/nftoffer.cpp's sell template needs the exact same reversal). Keep a +// local alias so the existing mint/send call sites read unchanged. +static inline void TokenIdToBE(const uint256& tokenId, uint8_t out[32]) +{ + ZSLPTokenIdToBE(tokenId, out); +} + +// Parse a uint64 quantity from a JSON value that is a STRING or a small integer. +// Rejects negatives, non-digits, overflow, and the high bit (>= 2^63) which the +// SLP parser/store treat as INVALID (R-INT-1). Throws on any violation. +static uint64_t ParseQuantity(const UniValue& v, const std::string& field) +{ + std::string s; + if (v.isStr()) + s = v.get_str(); + else if (v.isNum()) + s = v.getValStr(); // exact integer text, no double rounding + else + throw JSONRPCError(RPC_TYPE_ERROR, field + " must be a string or integer"); + if (s.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is empty"); + for (size_t i = 0; i < s.size(); ++i) + if (s[i] < '0' || s[i] > '9') + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " must be a non-negative integer"); + errno = 0; + char* end = NULL; + unsigned long long q = strtoull(s.c_str(), &end, 10); + if (errno != 0 || end == NULL || *end != '\0') + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is not a valid integer"); + if (q >> 63) + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " exceeds the maximum (2^63-1)"); + return (uint64_t)q; +} + +// Decode a t-address to a P2PKH/P2SH script, throwing on an invalid address. +static CScript ScriptForTAddr(const std::string& addr) +{ + CTxDestination dest = DecodeDestination(addr); + if (!IsValidDestination(dest)) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "Invalid transparent address: " + addr); + return GetScriptForDestination(dest); +} + +// Reserve a fresh wallet t-address script (for default recipient / token-change). +static CScript FreshWalletScript() +{ + CPubKey vchPubKey; + if (!pwalletMain->GetKeyFromPool(vchPubKey)) + throw JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT, + "Keypool ran out, call keypoolrefill first"); + return GetScriptForDestination(vchPubKey.GetID()); +} + +UniValue zslp_genesis(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() != 1) + throw std::runtime_error( + "zslp_genesis '{\"ticker\":?,\"name\":?,\"document_url\":?," + "\"document_hash\":?(64hex),\"decimals\":?(0..9,def 0)," + "\"quantity\":?(string,def 1),\"mint_baton_vout\":?(>=2)," + "\"to\":?(t-addr),\"nft\":?(bool)}'\n" + "\nMint a ZSLP token. An NFT is nft=true (decimals 0, quantity 1, no baton).\n" + "Builds ONE OP_RETURN at vout[0] plus a 546-sat token output at vout[1]\n" + "(and a baton output if requested); unchanged nodes relay and mine it.\n" + "The tx is self-validated against the overlay ledger before broadcast.\n" + + HelpRequiringPassphrase() + + "\nArguments:\n" + "1. \"params\" (object, required)\n" + " ticker (string, optional) short symbol\n" + " name (string, optional) display name\n" + " document_url (string, optional) short URL/URI\n" + " document_hash (string, optional) 64 hex chars (32 bytes), file fingerprint\n" + " decimals (numeric, optional, default 0) 0..9\n" + " quantity (string|numeric, optional, default 1) initial supply (<2^63)\n" + " mint_baton_vout (numeric, optional) >=2 to issue a re-issue baton\n" + " to (string, optional) recipient t-address (default: a fresh wallet address)\n" + " nft (bool, optional) force decimals 0, quantity 1, no baton\n" + "\nResult:\n{ \"txid\": \"hex\", \"tokenid\": \"hex\" } (tokenid == txid)\n" + "\nExamples:\n" + + HelpExampleCli("zslp_genesis", + "'{\"nft\":true,\"name\":\"My Photo #1\",\"document_hash\":\"<64hex>\"}'") + + HelpExampleRpc("zslp_genesis", + "{\"ticker\":\"GOLD\",\"decimals\":2,\"quantity\":\"100000\",\"mint_baton_vout\":2}")); + + GetZSLPStoreOrThrow(); // fail CLOSED if -zslpindex off + LOCK2(cs_main, pwalletMain->cs_wallet); + + const UniValue& o = params[0].get_obj(); + + bool nft = false; + { + const UniValue& v = find_value(o, "nft"); + if (!v.isNull()) nft = v.get_bool(); + } + + std::string ticker, name, docUrl; + { const UniValue& v = find_value(o, "ticker"); if (v.isStr()) ticker = v.get_str(); } + { const UniValue& v = find_value(o, "name"); if (v.isStr()) name = v.get_str(); } + { const UniValue& v = find_value(o, "document_url"); if (v.isStr()) docUrl = v.get_str(); } + + // document_hash: empty or exactly 64 hex (32 raw bytes, NOT reversed). + bool hasHash = false; + uint8_t hash32[32]; + { + const UniValue& v = find_value(o, "document_hash"); + if (v.isStr() && !v.get_str().empty()) { + std::string h = v.get_str(); + if (h.size() != 64 || !IsHex(h)) + throw JSONRPCError(RPC_INVALID_PARAMETER, + "document_hash must be 64 hex characters (32 bytes)"); + std::vector raw = ParseHex(h); + memcpy(hash32, raw.data(), 32); + hasHash = true; + } + } + + int decimals = 0; + { const UniValue& v = find_value(o, "decimals"); if (!v.isNull()) decimals = v.get_int(); } + + uint64_t quantity = 1; // default = NFT-style single unit + { const UniValue& v = find_value(o, "quantity"); if (!v.isNull()) quantity = ParseQuantity(v, "quantity"); } + + int batonVout = 0; // 0/1 = none + { const UniValue& v = find_value(o, "mint_baton_vout"); if (!v.isNull()) batonVout = v.get_int(); } + + if (nft) { + // NFT preset: a 1-of-1, indivisible, non-reissuable token. Reject + // conflicting explicit values rather than silently overriding. + if (find_value(o, "decimals").isNull()) decimals = 0; + else if (decimals != 0) throw JSONRPCError(RPC_INVALID_PARAMETER, "an NFT must have decimals 0"); + if (find_value(o, "quantity").isNull()) quantity = 1; + else if (quantity != 1) throw JSONRPCError(RPC_INVALID_PARAMETER, "an NFT must have quantity 1"); + if (!find_value(o, "mint_baton_vout").isNull() && batonVout >= 2) + throw JSONRPCError(RPC_INVALID_PARAMETER, "an NFT cannot have a mint baton"); + batonVout = 0; + } + + if (decimals < 0 || decimals > 9) + throw JSONRPCError(RPC_INVALID_PARAMETER, "decimals must be 0..9"); + if (batonVout == 1) + throw JSONRPCError(RPC_INVALID_PARAMETER, "mint_baton_vout 1 collides with the token output at vout[1]; use 0 (none) or >=2"); + + // Recipient script (vout[1]). + CScript toScript; + { const UniValue& v = find_value(o, "to"); + if (v.isStr() && !v.get_str().empty()) toScript = ScriptForTAddr(v.get_str()); + else toScript = FreshWalletScript(); } + + // Layout: vout[1] = token recipient; if a baton is requested, it is the next + // token output (vout[2]). The encoder's mint_baton_vout MUST equal that index. + uint8_t batonVoutOut = 0; + if (batonVout >= 2) { + batonVoutOut = 2; // baton placed at vout[2], immediately after the recipient + } + + EnsureWalletIsUnlocked(); + + std::vector opret = ZSLPBuildGenesis( + ticker, name, docUrl, hasHash ? hash32 : NULL, + (uint8_t)decimals, batonVoutOut, quantity); + if (opret.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, + "metadata too large for one OP_RETURN (max 223 bytes); shorten name/ticker/url or move data off-chain"); + + ZSLPBuildReq req; + req.opret = CScript(opret.begin(), opret.end()); + ZSLPTokenOut recip; recip.dest = toScript; recip.dustSats = SLP_TOKEN_DUST; + req.tokenOuts.push_back(recip); + if (batonVoutOut >= 2) { + ZSLPTokenOut baton; baton.dest = FreshWalletScript(); baton.dustSats = SLP_TOKEN_DUST; + req.tokenOuts.push_back(baton); // becomes vout[2] + } + req.tokenInputs.clear(); // GENESIS has no token inputs + req.isGenesis = true; + + CWalletTx wtx; + std::string err; + if (!BuildAndCommitZSLP(pwalletMain, req, wtx, err)) + throw JSONRPCError(RPC_WALLET_ERROR, err); + + UniValue ret(UniValue::VOBJ); + std::string txid = wtx.GetHash().GetHex(); + ret.push_back(Pair("txid", txid)); + ret.push_back(Pair("tokenid", txid)); // tokenid == genesis txid + return ret; +} + +UniValue zslp_mint(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() < 2 || params.size() > 3) + throw std::runtime_error( + "zslp_mint \"tokenid\" amount ( baton_vout )\n" + "\nIssue additional supply of an existing fungible token by spending its\n" + "live mint baton (the wallet must hold the baton UTXO). NFTs never use MINT.\n" + "Builds an OP_RETURN at vout[0] + a 546-sat output at vout[1] (and a\n" + "continued baton if baton_vout>=2); self-validated before broadcast.\n" + + HelpRequiringPassphrase() + + "\nArguments:\n" + "1. \"tokenid\" (string, required) the token id (hex)\n" + "2. amount (string|numeric, required) additional quantity (<2^63)\n" + "3. baton_vout (numeric, optional, default 2) >=2 to continue the baton; 0 to end it\n" + "\nResult:\n{ \"txid\": \"hex\" }\n" + "\nExamples:\n" + + HelpExampleCli("zslp_mint", "\"\" \"1000\"") + + HelpExampleRpc("zslp_mint", "\"\", \"1000\", 2")); + + CZSLPStore* store = GetZSLPStoreOrThrow(); + LOCK2(cs_main, pwalletMain->cs_wallet); + + uint256 tokenId = ParseHashV(params[0], "tokenid"); + CZSLPToken token; + if (!store->GetToken(tokenId, token)) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Token not found"); + + uint64_t amount = ParseQuantity(params[1], "amount"); + if (amount == 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "amount must be > 0"); + + int batonVout = 2; // default: continue the baton at vout[2] + if (params.size() > 2) + batonVout = params[2].get_int(); + if (batonVout == 1) + throw JSONRPCError(RPC_INVALID_PARAMETER, "baton_vout 1 collides with the mint output; use 0 (end) or >=2"); + + EnsureWalletIsUnlocked(); + + // Find the live baton UTXO in the wallet (anti-burn intersection). + std::vector batons; + std::string ferr; + if (!ZSLPFindWalletTokenUtxos(pwalletMain, tokenId, /*wantBaton=*/true, batons, ferr)) + throw JSONRPCError(RPC_WALLET_ERROR, ferr); + if (batons.empty()) + throw JSONRPCError(RPC_WALLET_ERROR, + "This wallet does not hold the mint baton for that token (cannot mint)"); + + uint8_t tidBE[32]; TokenIdToBE(tokenId, tidBE); + uint8_t batonVoutOut = (batonVout >= 2) ? 2 : 0; // continued baton at vout[2] + + std::vector opret = ZSLPBuildMint(tidBE, batonVoutOut, amount); + if (opret.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "failed to build MINT OP_RETURN"); + + ZSLPBuildReq req; + req.opret = CScript(opret.begin(), opret.end()); + ZSLPTokenOut recip; recip.dest = FreshWalletScript(); recip.dustSats = SLP_TOKEN_DUST; + req.tokenOuts.push_back(recip); // vout[1] = new supply + if (batonVoutOut >= 2) { + ZSLPTokenOut nb; nb.dest = FreshWalletScript(); nb.dustSats = SLP_TOKEN_DUST; + req.tokenOuts.push_back(nb); // vout[2] = continued baton + } + req.tokenInputs.push_back(batons[0].outpoint); // pin the baton input + req.selfValidateTokenId = tokenId; + req.isGenesis = false; + + CWalletTx wtx; + std::string err; + if (!BuildAndCommitZSLP(pwalletMain, req, wtx, err)) + throw JSONRPCError(RPC_WALLET_ERROR, err); + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("txid", wtx.GetHash().GetHex())); + return ret; +} + +UniValue zslp_send(const UniValue& params, bool fHelp) +{ + if (!EnsureWalletIsAvailable(fHelp)) + return NullUniValue; + if (fHelp || params.size() < 2 || params.size() > 4) + throw std::runtime_error( + "zslp_send \"tokenid\" \"to_address\" ( amount change_address )\n" + "\nTransfer ZSLP token amounts (an NFT gift defaults to amount 1). Selects\n" + "the wallet's token UTXOs of tokenid, conserves supply (token-change goes\n" + "to a fresh own t-address, or change_address if given), pins token inputs +\n" + "anti-burn-filters fee coins, and self-validates before broadcast. Rejects\n" + "(clear error) on insufficient token balance — it never burns the token.\n" + + HelpRequiringPassphrase() + + "\nArguments:\n" + "1. \"tokenid\" (string, required) the token id (hex)\n" + "2. \"to_address\" (string, required) recipient t-address\n" + "3. amount (string|numeric, optional, default 1) amount to send (<2^63)\n" + "4. \"change_address\" (string, optional) token-change t-address (default: fresh own address)\n" + "\nResult:\n{ \"txid\": \"hex\" }\n" + "\nExamples:\n" + + HelpExampleCli("zslp_send", "\"\" \"t1...\" 1") + + HelpExampleRpc("zslp_send", "\"\", \"t1...\", \"5\"")); + + CZSLPStore* store = GetZSLPStoreOrThrow(); + LOCK2(cs_main, pwalletMain->cs_wallet); + + uint256 tokenId = ParseHashV(params[0], "tokenid"); + CZSLPToken token; + if (!store->GetToken(tokenId, token)) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Token not found"); + + CScript toScript = ScriptForTAddr(params[1].get_str()); + + uint64_t amount = 1; + if (params.size() > 2) + amount = ParseQuantity(params[2], "amount"); + if (amount == 0) + throw JSONRPCError(RPC_INVALID_PARAMETER, "amount must be > 0"); + + bool haveChangeAddr = false; + CScript changeScript; + if (params.size() > 3 && !params[3].get_str().empty()) { + changeScript = ScriptForTAddr(params[3].get_str()); + haveChangeAddr = true; + } + + EnsureWalletIsUnlocked(); + + // Enumerate + greedily select token UTXOs (deterministic order). + std::vector utxos; + std::string ferr; + if (!ZSLPFindWalletTokenUtxos(pwalletMain, tokenId, /*wantBaton=*/false, utxos, ferr)) + throw JSONRPCError(RPC_WALLET_ERROR, ferr); + + int64_t availIn = 0; + std::vector chosen; + for (size_t i = 0; i < utxos.size(); ++i) { + chosen.push_back(utxos[i].outpoint); + availIn += utxos[i].amount; + if (availIn >= (int64_t)amount) + break; + } + if (availIn < (int64_t)amount) + throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, + strprintf("Insufficient token balance: have %d, need %d", availIn, (int64_t)amount)); + + int64_t tokenChange = availIn - (int64_t)amount; + + // Quantities array: recipient first, then (if any) token-change to self. + // recipients(1) + change(0/1) <= ZSLP_SEND_MAX_OUTPUTS. + std::vector quantities; + quantities.push_back(amount); + if (tokenChange > 0) + quantities.push_back((uint64_t)tokenChange); + if ((int)quantities.size() > ZSLP_MAX_SEND_OUTPUTS) + throw JSONRPCError(RPC_INVALID_PARAMETER, "too many SEND outputs"); + + uint8_t tidBE[32]; TokenIdToBE(tokenId, tidBE); + std::vector opret = ZSLPBuildSend(tidBE, quantities); + if (opret.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, "failed to build SEND OP_RETURN"); + + ZSLPBuildReq req; + req.opret = CScript(opret.begin(), opret.end()); + ZSLPTokenOut recip; recip.dest = toScript; recip.dustSats = SLP_TOKEN_DUST; + req.tokenOuts.push_back(recip); // vout[1] = recipient + if (tokenChange > 0) { + ZSLPTokenOut chg; + chg.dest = haveChangeAddr ? changeScript : FreshWalletScript(); + chg.dustSats = SLP_TOKEN_DUST; + req.tokenOuts.push_back(chg); // vout[2] = token-change to self + } + req.tokenInputs = chosen; + req.selfValidateTokenId = tokenId; + req.isGenesis = false; + + CWalletTx wtx; + std::string err; + if (!BuildAndCommitZSLP(pwalletMain, req, wtx, err)) + throw JSONRPCError(RPC_WALLET_ERROR, err); + + UniValue ret(UniValue::VOBJ); + ret.push_back(Pair("txid", wtx.GetHash().GetHex())); + return ret; +} + +#endif // ENABLE_WALLET + static const CRPCCommand commands[] = { // category name actor (function) okSafeMode // --------- -------------------- ------------------- ---------- @@ -267,6 +655,11 @@ static const CRPCCommand commands[] = { "zslp", "zslp_listtokens", &zslp_listtokens, true }, { "zslp", "zslp_listtransfers", &zslp_listtransfers, true }, { "zslp", "zslp_listmytokens", &zslp_listmytokens, true }, +#ifdef ENABLE_WALLET + { "zslp", "zslp_genesis", &zslp_genesis, false }, + { "zslp", "zslp_mint", &zslp_mint, false }, + { "zslp", "zslp_send", &zslp_send, false }, +#endif }; void RegisterZSLPRPCCommands(CRPCTable& tableRPC) diff --git a/src/wallet/asyncrpcoperation_senddatafile.cpp b/src/wallet/asyncrpcoperation_senddatafile.cpp new file mode 100644 index 00000000000..6e3b6f958d7 --- /dev/null +++ b/src/wallet/asyncrpcoperation_senddatafile.cpp @@ -0,0 +1,321 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include "asyncrpcoperation_senddatafile.h" +#include "asyncrpcqueue.h" +#include "amount.h" +#include "consensus/consensus.h" +#include "core_io.h" +#include "init.h" +#include "key_io.h" +#include "main.h" +#include "miner.h" +#include "rpc/protocol.h" +#include "rpc/server.h" +#include "util.h" +#include "utilmoneystr.h" +#include "wallet.h" + +#include +#include + +using namespace libzcash; + +extern UniValue sendrawtransaction(const UniValue& params, bool fHelp); + +// Each ZDC1 frame output carries a tiny "data dust" value. This is conceptual +// dust the sender pays to themselves (the from z-addr is also the data +// recipient in the common case); change returns to the from z-addr. We keep it +// well above the network dust threshold but small. +static const CAmount SENDDATAFILE_OUTPUT_VALUE = 1000; // 0.00001 ZCL per frame + +// ── Exact on-wire sizes for the single-tx broadcastability guard ───────────── +// These match primitives/transaction.h byte-for-byte; if a struct changes, the +// guard stays correct because it is conservative (over-estimates, never under). +// SpendDescription = cv32 + anchor32 + nullifier32 + rk32 + zkproof192 + +// spendAuthSig64 = 384 +// OutputDescription = cv32 + cm32 + ephemeralKey32 + encCiphertext580 + +// outCiphertext80 + zkproof192 = 948 +// Envelope: header4 + nVersionGroupId4 + locktime4 + expiryHeight4 + +// valueBalance8 + bindingSig64 + a generous slab for the three count +// varints, the empty vin/vout/vJoinSplit vectors, and serialization slack. +static const size_t ZDC_SPEND_DESC_BYTES = 384; +static const size_t ZDC_OUTPUT_DESC_BYTES = 948; +static const size_t ZDC_TX_ENVELOPE_BYTES = 256; // conservative fixed overhead + +// Conservatively project the serialized tx size for nSpends shielded inputs and +// nOutputs shielded outputs (data frames + the 1 change output). Over-estimates. +static size_t ZdcProjectedTxSize(size_t nSpends, size_t nOutputs) { + return ZDC_TX_ENVELOPE_BYTES + + nSpends * ZDC_SPEND_DESC_BYTES + + nOutputs * ZDC_OUTPUT_DESC_BYTES; +} + +AsyncRPCOperation_senddatafile::AsyncRPCOperation_senddatafile( + TransactionBuilder builder, + CMutableTransaction contextualTx, + std::string fromAddress, + std::string toAddress, + std::vector > frames, + uint64_t transferId, + std::string fingerprintHex, + int minDepth, + CAmount fee, + UniValue contextInfo) : + fee_(fee), mindepth_(minDepth), fromaddress_(fromAddress), toaddress_(toAddress), + frames_(frames), transferId_(transferId), fingerprintHex_(fingerprintHex), + builder_(builder), contextinfo_(contextInfo) +{ + assert(fee_ >= 0); + if (minDepth < 0) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Minconf cannot be negative"); + } + if (minDepth == 0) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Minconf cannot be zero when sending from a zaddr"); + } + if (fromAddress.size() == 0 || toAddress.size() == 0) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "From and to addresses are required"); + } + if (frames_.empty()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "No data frames to send"); + } + + // Resolve and validate the FROM Sapling address + spending key. The key is + // used only to spend/sign within the wallet; it is never exported. + auto fromAddr = DecodePaymentAddress(fromAddress); + if (!IsValidPaymentAddress(fromAddr) || + boost::get(&fromAddr) == nullptr) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "fromaddress must be a Sapling z-address (the data channel rides on Sapling memos)"); + } + auto fromSapling = boost::get(fromAddr); + if (!pwalletMain->GetSaplingExtendedSpendingKey(fromSapling, spendingKey_)) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "No spending key found for fromaddress (cannot send a data file from a watch-only address)"); + } + + // Resolve and validate the TO Sapling address. + auto toAddr = DecodePaymentAddress(toAddress); + if (!IsValidPaymentAddress(toAddr) || + boost::get(&toAddr) == nullptr) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "toaddress must be a Sapling z-address"); + } + toPaymentAddress_ = boost::get(toAddr); +} + +AsyncRPCOperation_senddatafile::~AsyncRPCOperation_senddatafile() { +} + +void AsyncRPCOperation_senddatafile::main() { + if (isCancelled()) + return; + + set_state(OperationStatus::EXECUTING); + start_execution_clock(); + + bool success = false; + +#ifdef ENABLE_MINING + GenerateBitcoins(false, 0, Params()); +#endif + + try { + success = main_impl(); + } catch (const UniValue& objError) { + int code = find_value(objError, "code").get_int(); + std::string message = find_value(objError, "message").get_str(); + set_error_code(code); + set_error_message(message); + } catch (const std::runtime_error& e) { + set_error_code(-1); + set_error_message("runtime error: " + std::string(e.what())); + } catch (const std::logic_error& e) { + set_error_code(-1); + set_error_message("logic error: " + std::string(e.what())); + } catch (const std::exception& e) { + set_error_code(-1); + set_error_message("general exception: " + std::string(e.what())); + } catch (...) { + set_error_code(-2); + set_error_message("unknown error"); + } + +#ifdef ENABLE_MINING + GenerateBitcoins(GetBoolArg("-gen", false), GetArg("-genproclimit", 1), Params()); +#endif + + stop_execution_clock(); + + if (success) { + set_state(OperationStatus::SUCCESS); + } else { + set_state(OperationStatus::FAILED); + } + + std::string s = strprintf("%s: z_senddatafile finished (status=%s", getId(), getStateAsString()); + if (success) { + s += strprintf(", txid=%s)\n", tx_.GetHash().ToString()); + } else { + s += strprintf(", error=%s)\n", getErrorMessage()); + } + LogPrintf("%s", s); +} + +bool AsyncRPCOperation_senddatafile::find_unspent_notes(CAmount target) { + std::vector sproutEntries; + std::vector saplingEntries; + { + LOCK2(cs_main, pwalletMain->cs_wallet); + pwalletMain->GetFilteredNotes(sproutEntries, saplingEntries, fromaddress_, mindepth_); + } + + // Sapling-only channel. + for (auto& entry : saplingEntries) { + z_sapling_inputs_.push_back(entry); + } + + if (z_sapling_inputs_.empty()) { + return false; + } + + // Biggest notes first, to minimise the spend count. + std::sort(z_sapling_inputs_.begin(), z_sapling_inputs_.end(), + [](SaplingNoteEntry i, SaplingNoteEntry j) -> bool { + return i.note.value() > j.note.value(); + }); + + return true; +} + +bool AsyncRPCOperation_senddatafile::main_impl() { + // Each frame is one Sapling output carrying the 512-byte ZDC1 frame as its + // memo. The total value we must fund = N * per-output value + fee. + const size_t nFrames = frames_.size(); + CAmount sendAmount = (CAmount)nFrames * SENDDATAFILE_OUTPUT_VALUE; + CAmount targetAmount = sendAmount + fee_; + + if (!find_unspent_notes(targetAmount)) { + throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, + "Insufficient shielded funds: no spendable Sapling notes found for fromaddress"); + } + + // Derive the keys: expsk to spend, ovk (its own outgoing viewing key) so the + // sender can later decrypt its own outputs (and so the from z-addr can read + // the memos it sent — needed for z_getdatatransfer on the sender side). + SaplingExpandedSpendingKey expsk = spendingKey_.expsk; + uint256 ovk = expsk.full_viewing_key().ovk; + + // Select notes until we cover the target. + std::vector ops; + std::vector notes; + CAmount sum = 0; + for (auto& t : z_sapling_inputs_) { + ops.push_back(t.op); + notes.push_back(t.note); + sum += t.note.value(); + if (sum >= targetAmount) { + break; + } + } + if (sum < targetAmount) { + throw JSONRPCError(RPC_WALLET_INSUFFICIENT_FUNDS, + strprintf("Insufficient shielded funds: have %s, need %s (%u frames * %s + fee %s)", + FormatMoney(sum), FormatMoney(targetAmount), (unsigned)nFrames, + FormatMoney(SENDDATAFILE_OUTPUT_VALUE), FormatMoney(fee_))); + } + + // Fetch the Sapling anchor + witnesses for the selected notes. + uint256 anchor; + std::vector> witnesses; + { + LOCK2(cs_main, pwalletMain->cs_wallet); + pwalletMain->GetSaplingNoteWitnesses(ops, witnesses, anchor); + } + + builder_.SetFee(fee_); + + // Add Sapling spends. + for (size_t i = 0; i < notes.size(); i++) { + if (!witnesses[i]) { + throw JSONRPCError(RPC_WALLET_ERROR, "Missing witness for Sapling note"); + } + builder_.AddSaplingSpend(expsk, notes[i], anchor, witnesses[i].get()); + } + + // Change returns to the FROM z-addr (privacy-preserving: stays shielded). + builder_.SendChangeTo( + boost::get(DecodePaymentAddress(fromaddress_)), ovk); + + // Add one Sapling output per ZDC1 frame, all to the SAME recipient z-addr. + // This is the whole reason z_sendmany cannot do it: it would reject the + // duplicate recipient. Here the builder happily emits N outputs to one addr. + for (size_t i = 0; i < frames_.size(); i++) { + if (frames_[i].size() != ZC_MEMO_SIZE) { + throw JSONRPCError(RPC_INTERNAL_ERROR, + strprintf("Internal: frame %u is not %u bytes", (unsigned)i, (unsigned)ZC_MEMO_SIZE)); + } + std::array memo; + std::copy(frames_[i].begin(), frames_[i].end(), memo.begin()); + builder_.AddSaplingOutput(ovk, toPaymentAddress_, SENDDATAFILE_OUTPUT_VALUE, memo); + } + + // SINGLE-TX BROADCASTABILITY GUARD (reject BEFORE proving, never after). + // + // The RPC layer already caps the file so the FRAME count fits one tx, but the + // tx also carries one SpendDescription per selected input note plus a change + // output. An unusual UTXO set (many small notes) could push an otherwise + // in-cap transfer over MAX_TX_SIZE_AFTER_SAPLING. TransactionBuilder::Build() + // does NOT check size — it would compute every Groth proof and only then fail + // at AcceptToMemoryPool with an opaque "bad-txns-oversize". So we project the + // serialized size from the ACTUAL spend + output counts here, conservatively, + // and throw a clear actionable error if it would not broadcast — before any + // proving work is done. + { + size_t nSpends = notes.size(); + size_t nOutputs = frames_.size() + 1; // +1 for the change output to fromaddr + size_t projected = ZdcProjectedTxSize(nSpends, nOutputs); + if (projected > MAX_TX_SIZE_AFTER_SAPLING) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("transfer would not broadcast: projected tx size %u bytes " + "(%u input notes + %u outputs) exceeds the consensus limit " + "%u. Send a smaller file, or consolidate small notes first.", + (unsigned)projected, (unsigned)nSpends, (unsigned)nOutputs, + (unsigned)MAX_TX_SIZE_AFTER_SAPLING)); + } + } + + // Build + broadcast. + auto buildResult = builder_.Build(); + auto tx = buildResult.GetTxOrThrow(); + tx_ = tx; + + if (!testmode) { + UniValue params = UniValue(UniValue::VARR); + params.push_back(EncodeHexTx(tx_)); + UniValue sendResultValue = sendrawtransaction(params, false); + if (sendResultValue.isNull()) { + throw JSONRPCError(RPC_WALLET_ERROR, "sendrawtransaction did not return an error or a txid."); + } + } + + UniValue o(UniValue::VOBJ); + o.push_back(Pair("txid", tx_.GetHash().ToString())); + o.push_back(Pair("transfer_id", strprintf("%016x", transferId_))); + o.push_back(Pair("fingerprint", fingerprintHex_)); + o.push_back(Pair("frames", (int)nFrames)); + set_result(o); + return true; +} + +UniValue AsyncRPCOperation_senddatafile::getStatus() const { + UniValue v = AsyncRPCOperation::getStatus(); + if (contextinfo_.isNull()) { + return v; + } + UniValue obj = v.get_obj(); + obj.push_back(Pair("method", "z_senddatafile")); + obj.push_back(Pair("params", contextinfo_)); + return obj; +} diff --git a/src/wallet/asyncrpcoperation_senddatafile.h b/src/wallet/asyncrpcoperation_senddatafile.h new file mode 100644 index 00000000000..3b425c5c870 --- /dev/null +++ b/src/wallet/asyncrpcoperation_senddatafile.h @@ -0,0 +1,93 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// +// AsyncRPCOperation_senddatafile — the dedicated async op behind z_senddatafile. +// +// WHY A DEDICATED OP (not z_sendmany): a data transfer chunks one file into N +// ZDC1 frames, each of which becomes ONE Sapling output MEMO to the SAME +// recipient z-addr. z_sendmany's RPC layer rejects duplicate recipients +// (rpcwallet.cpp "duplicated address"); the async op / TransactionBuilder do +// NOT, so we emit all N same-recipient AddSaplingOutput calls in ONE tx here. +// +// The op encodes the (already pre-validated, ENCRYPTED) ZDC1 frames into Sapling +// outputs, selects + spends the wallet's Sapling notes of the from z-addr, +// builds + broadcasts the tx, and reports {txid, transfer_id, fingerprint, +// frames}. The crypto and framing all live in the codec (datachannel/zdc); this +// op only maps frames -> memos and drives the existing Sapling send machinery. +// +// KEYS NEVER LEAVE THE WALLET: the per-transfer ZDC1 key is generated and +// consumed in the RPC layer (returned to the caller, who chose to create it); +// the wallet's Sapling spending key is used only to spend/sign, never exported. + +#ifndef ASYNCRPCOPERATION_SENDDATAFILE_H +#define ASYNCRPCOPERATION_SENDDATAFILE_H + +#include "asyncrpcoperation.h" +#include "amount.h" +#include "primitives/transaction.h" +#include "transaction_builder.h" +#include "zcash/Address.hpp" +#include "wallet.h" + +#include +#include +#include + +#include + +// Default transaction fee if caller does not specify one. Matches z_sendmany. +#define SENDDATAFILE_DEFAULT_MINERS_FEE 10000 + +class AsyncRPCOperation_senddatafile : public AsyncRPCOperation { +public: + AsyncRPCOperation_senddatafile( + TransactionBuilder builder, + CMutableTransaction contextualTx, + std::string fromAddress, + std::string toAddress, + // The already-encoded, already-encrypted 512-byte ZDC1 frames. Each + // frame becomes one Sapling output memo (1:1, 512 == ZC_MEMO_SIZE). + std::vector > frames, + uint64_t transferId, + std::string fingerprintHex, + int minDepth, + CAmount fee = SENDDATAFILE_DEFAULT_MINERS_FEE, + UniValue contextInfo = NullUniValue); + virtual ~AsyncRPCOperation_senddatafile(); + + AsyncRPCOperation_senddatafile(AsyncRPCOperation_senddatafile const&) = delete; + AsyncRPCOperation_senddatafile(AsyncRPCOperation_senddatafile&&) = delete; + AsyncRPCOperation_senddatafile& operator=(AsyncRPCOperation_senddatafile const&) = delete; + AsyncRPCOperation_senddatafile& operator=(AsyncRPCOperation_senddatafile&&) = delete; + + virtual void main(); + + virtual UniValue getStatus() const; + + bool testmode = false; // Set to true to disable sending txs and generating proofs + +private: + UniValue contextinfo_; + + uint32_t consensusBranchId_; + CAmount fee_; + int mindepth_; + std::string fromaddress_; + std::string toaddress_; + libzcash::SaplingPaymentAddress toPaymentAddress_; + libzcash::SaplingExtendedSpendingKey spendingKey_; + std::vector > frames_; + uint64_t transferId_; + std::string fingerprintHex_; + + std::vector z_sapling_inputs_; + + TransactionBuilder builder_; + CTransaction tx_; + + bool find_unspent_notes(CAmount target); + bool main_impl(); +}; + +#endif /* ASYNCRPCOPERATION_SENDDATAFILE_H */ diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 10602ebf5d6..9869036b33a 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -20,6 +20,8 @@ #include "script/sign.h" #include "timedata.h" #include "utilmoneystr.h" +#include "wallet/zslpwallet.h" // ZSLPIsProtectedTokenOutpoint (anti-burn) +#include "zslp/zslpindexer.h" // g_zslpIndexer (for the ZSLP store handle) #include "zcash/Note.hpp" #include "crypter.h" #include "zcash/zip32.h" @@ -3148,12 +3150,20 @@ CAmount CWallet::GetImmatureWatchOnlyBalance() const /** * populate vCoins with vector of available COutputs. */ -void CWallet::AvailableCoins(vector& vCoins, bool fOnlyConfirmed, const CCoinControl *coinControl, bool fIncludeZeroValue, bool fIncludeCoinBase) const +void CWallet::AvailableCoins(vector& vCoins, bool fOnlyConfirmed, const CCoinControl *coinControl, bool fIncludeZeroValue, bool fIncludeCoinBase, bool fExcludeZSLPTokens) const { vCoins.clear(); { LOCK2(cs_main, cs_wallet); + + // ZSLP anti-burn (R-WALLET-2/4/5): drop token UTXOs / mint batons and + // the wallet's own pending token-change so no automatic spend path + // burns a token riding ordinary t-dust. The store handle may be NULL + // (-zslpindex off) — ZSLPIsProtectedTokenOutpoint still protects the + // wallet's own pending ZSLP outputs in that case (fail-safe, R-WALLET-6). + CZSLPStore* zslpStore = (g_zslpIndexer != NULL) ? g_zslpIndexer->Store() : NULL; + for (map::const_iterator it = mapWallet.begin(); it != mapWallet.end(); ++it) { const uint256& wtxid = it->first; @@ -3180,7 +3190,15 @@ void CWallet::AvailableCoins(vector& vCoins, bool fOnlyConfirmed, const if (!(IsSpent(wtxid, i)) && mine != ISMINE_NO && !IsLockedCoin((*it).first, i) && (pcoin->vout[i].nValue > 0 || fIncludeZeroValue) && (!coinControl || !coinControl->HasSelected() || coinControl->fAllowOtherInputs || coinControl->IsSelected((*it).first, i))) - vCoins.push_back(COutput(pcoin, i, nDepth, (mine & ISMINE_SPENDABLE) != ISMINE_NO)); + { + // Never drop an outpoint the caller explicitly preset (the + // ZSLP builder pins its intended token inputs this way). + bool preset = coinControl && coinControl->IsSelected(wtxid, i); + if (fExcludeZSLPTokens && !preset && + ZSLPIsProtectedTokenOutpoint(this, zslpStore, COutPoint(wtxid, i))) + continue; + vCoins.push_back(COutput(pcoin, i, nDepth, (mine & ISMINE_SPENDABLE) != ISMINE_NO)); + } } } } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 84c081f09e1..2ffe08c42cd 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -770,13 +770,47 @@ class CAccountingEntry }; -/** +/** + * Request to the ZSLP OP_RETURN tx builder (src/wallet/zslpwallet.{h,cpp}). One + * struct drives GENESIS / MINT / SEND so all three write RPCs share a single, + * unit-tested code path. The builder forces the fixed layout vout[0]=OP_RETURN, + * vout[1..N]=token recipients, ZEC change strictly after them. See + * doc/nft/MINT_TRANSFER_SPEC.md §2. + */ +struct ZSLPTokenOut { + CScript dest; //!< P2PKH (or other) recipient script + CAmount dustSats; //!< dust value carried (546 by convention) +}; +struct ZSLPBuildReq { + CScript opret; //!< the OP_RETURN script (from ZSLPBuild*) + std::vector tokenOuts; //!< canonical order: maps qty j -> vout[1+j] + std::vector tokenInputs; //!< token/baton UTXOs to FORCE-include + //!< GENESIS: empty; MINT: the baton; SEND: chosen token UTXOs + uint256 selfValidateTokenId; //!< token the built tx must conserve (null for GENESIS) + bool isGenesis; //!< true for GENESIS (txid==tokenId; self-validate vs the new id) + ZSLPBuildReq() : isGenesis(false) { selfValidateTokenId.SetNull(); } +}; +/** + * Build, fund (anti-burn), sign, SELF-VALIDATE (real ParseTx + read-only + * conservation), and only then CommitTransaction the ZSLP tx described by req. + * Returns false (with err set) on ANY failure and broadcasts nothing. + * Declared a friend of CWallet to reach the private SelectCoins() seam. + */ +bool BuildAndCommitZSLP(CWallet* w, const ZSLPBuildReq& req, + CWalletTx& wtxOut, std::string& err); + +/** * A CWallet is an extension of a keystore, which also maintains a set of transactions and balances, * and provides the ability to create new transactions. */ class CWallet : public CCryptoKeyStore, public CValidationInterface { private: + // The ZSLP OP_RETURN builder needs the private SelectCoins() seam to fund + // the fee deterministically while pinning token inputs (anti-burn). + friend bool ::BuildAndCommitZSLP(CWallet* w, const ZSLPBuildReq& req, + CWalletTx& wtxOut, std::string& err); + bool SelectCoins(const CAmount& nTargetValue, std::set >& setCoinsRet, CAmount& nValueRet, bool& fOnlyCoinbaseCoinsRet, bool& fNeedCoinbaseCoinsRet, const CCoinControl *coinControl = NULL) const; CWalletDB *pwalletdbEncryption; @@ -1078,7 +1112,16 @@ class CWallet : public CCryptoKeyStore, public CValidationInterface //! check whether we are allowed to upgrade (or already support) to the named feature bool CanSupportFeature(enum WalletFeature wf) { AssertLockHeld(cs_wallet); return nWalletMaxVersion >= wf; } - void AvailableCoins(std::vector& vCoins, bool fOnlyConfirmed=true, const CCoinControl *coinControl = NULL, bool fIncludeZeroValue=false, bool fIncludeCoinBase=true) const; + // fExcludeZSLPTokens (default true): drop ZSLP token UTXOs / mint batons + // and the wallet's own pending (0-conf) token-change outputs from the + // returned coins, so NO automatic spend path (sendtoaddress, z_sendmany, + // z_shieldcoinbase/merge "*", send-max, sweeps) can ride a token dust UTXO + // into a burn (R-WALLET-2/4/5). Outpoints explicitly preset via coinControl + // are NEVER dropped (the ZSLP SEND/MINT builder pins its token inputs that + // way). The ZSLP builder and its enumeration helper pass false to SEE token + // coins. The filter consults the ZSLP store + the wallet's pending ZSLP + // outputs; with -zslpindex off it still protects pending own-token outputs. + void AvailableCoins(std::vector& vCoins, bool fOnlyConfirmed=true, const CCoinControl *coinControl = NULL, bool fIncludeZeroValue=false, bool fIncludeCoinBase=true, bool fExcludeZSLPTokens=true) const; bool SelectCoinsMinConf(const CAmount& nTargetValue, int nConfMine, int nConfTheirs, std::vector vCoins, std::set >& setCoinsRet, CAmount& nValueRet) const; bool IsSpent(const uint256& hash, unsigned int n) const; diff --git a/src/wallet/zslpwallet.cpp b/src/wallet/zslpwallet.cpp new file mode 100644 index 00000000000..c4af823bc62 --- /dev/null +++ b/src/wallet/zslpwallet.cpp @@ -0,0 +1,480 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP write path — shared OP_RETURN tx builder. See zslpwallet.h. + +#include "wallet/zslpwallet.h" + +#include "script/standard.h" // CTxDestination — must precede coincontrol.h +#include "coincontrol.h" +#include "consensus/upgrades.h" +#include "key_io.h" +#include "main.h" +#include "script/sign.h" +#include "wallet/wallet.h" +#include "zslp/zslpindexer.h" +#include "zslp/zslpmsg.h" +#include "zslp/zslpstore.h" + +#include + +// ── Anti-burn coin-lock RAII ──────────────────────────────────────── +// +// Lock every OTHER wallet token/baton outpoint for the duration of the build so +// AvailableCoins (which honors IsLockedCoin, wallet.cpp:3180) cannot auto-select +// it for fee/change. UNCONDITIONALLY released on every exit path (success, +// failure, exception) — a failed build must never leave the user's tokens +// locked (R-WALLET-6 / risk #6). Locks are in-memory only (setLockedCoins) so +// they also clear on restart, but unlock-on-throw is still mandatory. +namespace { +class ScopedTokenLock +{ +public: + ScopedTokenLock(CWallet* w) : wallet(w) {} + ~ScopedTokenLock() + { + // cs_wallet is held by the caller for the whole build; LockCoin/UnlockCoin + // assert it. We never throw here. + for (size_t i = 0; i < locked.size(); ++i) { + COutPoint op = locked[i]; + wallet->UnlockCoin(op); + } + } + void lock(const COutPoint& op) + { + COutPoint tmp = op; // LockCoin takes a non-const reference + wallet->LockCoin(tmp); + locked.push_back(op); + } +private: + CWallet* wallet; + std::vector locked; +}; +} // namespace + +// Would the parsed SLP message `parsed` create a token UTXO or a mint baton at +// output index `vout`? This mirrors EXACTLY which outputs CZSLPStore::Apply- +// Transaction creates (so the 0-conf protection matches the confirmed truth the +// store will later record): +// GENESIS: vout[1] iff initialQuantity>0 && voutCount>1; baton at mintBatonVout +// iff 2<=mintBatonVout0 && voutCount>1; baton likewise. +// SEND: vout[1+j] for each j in [0,numOutputs) with outputQuantities[j]>0 +// and 1+j 0 && voutCount > 1) + return true; + if (vout == parsed.mintBatonVout && parsed.mintBatonVout >= 2 && + parsed.mintBatonVout < voutCount) + return true; + return false; + case ZSLP_MSG_MINT: + if (vout == 1 && parsed.additionalQuantity > 0 && voutCount > 1) + return true; + if (vout == parsed.mintBatonVout && parsed.mintBatonVout >= 2 && + parsed.mintBatonVout < voutCount) + return true; + return false; + case ZSLP_MSG_SEND: { + int n = parsed.numOutputs; + for (int j = 0; j < n; ++j) { + if (parsed.outputQuantities[j] > 0 && (int32_t)(1 + j) == vout && + vout < voutCount) + return true; + } + return false; + } + default: + return false; + } +} + +bool ZSLPIsProtectedTokenOutpoint(const CWallet* w, CZSLPStore* store, + const COutPoint& op) +{ + AssertLockHeld(cs_main); + AssertLockHeld(w->cs_wallet); + + // (1) CONFIRMED truth — the live indexer recorded a token UTXO/baton here. + if (store != NULL) { + CZSLPTokenUtxo rec; + if (store->GetUtxo(op.hash, (int32_t)op.n, rec) && + (rec.amount > 0 || rec.isMintBaton)) + return true; + } + + // (2) PENDING/0-conf truth — this is a token-bearing output of a wallet tx + // whose vout[0] parses as SLP, but the (ChainTip-only) indexer has not + // recorded it yet. Protect it so a follow-up spend cannot burn it. + const CWalletTx* wtx = w->GetWalletTx(op.hash); + if (wtx == NULL) + return false; + if ((size_t)op.n >= wtx->vout.size()) + return false; + // Only the wallet's OWN created token outputs need this 0-conf cover; a + // received token UTXO becomes confirmed before it is spendable and is then + // covered by (1). Restricting to from-me also bounds the parse work. + if (!wtx->IsFromMe(ISMINE_ALL)) + return false; + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesisMeta = false; + if (!CZSLPIndexer::ParseTx(*wtx, /*height=*/0, parsed, genesisMeta, + haveGenesisMeta)) + return false; + return MsgWouldMakeTokenOutput(parsed, (int32_t)wtx->vout.size(), + (int32_t)op.n); +} + +bool ZSLPFindWalletTokenUtxos(CWallet* w, const uint256& tokenId, + bool wantBaton, + std::vector& out, + std::string& err) +{ + out.clear(); + AssertLockHeld(cs_main); + AssertLockHeld(w->cs_wallet); + + if (g_zslpIndexer == NULL || g_zslpIndexer->Store() == NULL) { + err = "ZSLP index is not enabled. Start zclassicd with -zslpindex."; + return false; + } + CZSLPStore* store = g_zslpIndexer->Store(); + + std::vector coins; + // fExcludeZSLPTokens=false: we are LOOKING for token UTXOs; the global + // anti-burn filter would otherwise drop the very coins we need. + w->AvailableCoins(coins, /*fOnlyConfirmed=*/true, /*coinControl=*/NULL, + /*fIncludeZeroValue=*/false, /*fIncludeCoinBase=*/true, + /*fExcludeZSLPTokens=*/false); + + for (size_t i = 0; i < coins.size(); ++i) { + const COutput& c = coins[i]; + if (!c.fSpendable) + continue; + CZSLPTokenUtxo rec; + if (!store->GetUtxo(c.tx->GetHash(), c.i, rec)) + continue; + if (rec.tokenId != tokenId) + continue; + if (wantBaton) { + if (!rec.isMintBaton) + continue; + } else { + if (rec.isMintBaton || rec.amount <= 0) + continue; + } + ZSLPWalletUtxo u; + u.outpoint = COutPoint(c.tx->GetHash(), c.i); + u.tokenId = rec.tokenId; + u.amount = rec.amount; + u.isMintBaton = rec.isMintBaton; + u.height = rec.height; + out.push_back(u); + } + + // Deterministic selection order (height, txid, vout). + std::sort(out.begin(), out.end(), + [](const ZSLPWalletUtxo& a, const ZSLPWalletUtxo& b) { + if (a.height != b.height) return a.height < b.height; + if (a.outpoint.hash != b.outpoint.hash) + return a.outpoint.hash < b.outpoint.hash; + return a.outpoint.n < b.outpoint.n; + }); + return true; +} + +bool BuildAndCommitZSLP(CWallet* w, const ZSLPBuildReq& req, + CWalletTx& wtxOut, std::string& err) +{ + AssertLockHeld(cs_main); + AssertLockHeld(w->cs_wallet); + + // (0) Fail CLOSED if the index is off: without the store the wallet cannot + // classify dust / self-validate (R-WALLET-6). + if (g_zslpIndexer == NULL || g_zslpIndexer->Store() == NULL) { + err = "ZSLP index is not enabled. Start zclassicd with -zslpindex."; + return false; + } + CZSLPStore* store = g_zslpIndexer->Store(); + + // (1) Sanity on the request. + if (req.opret.empty()) { + err = "internal: empty OP_RETURN script (metadata too large or invalid)"; + return false; + } + if (req.opret.size() > MAX_OP_RETURN_RELAY) { + err = "metadata too large for one OP_RETURN (max 223 bytes)"; + return false; + } + if (req.tokenOuts.empty()) { + err = "internal: a ZSLP tx needs at least one token output"; + return false; + } + + const CFeeRate& relayFee = ::minRelayTxFee; + + // (2) Anti-burn fence. Lock every OTHER wallet token/baton outpoint so the + // funding pool excludes them; pin the intended token inputs. + ScopedTokenLock tokenLock(w); + { + std::set intended(req.tokenInputs.begin(), req.tokenInputs.end()); + // Enumerate WITHOUT the global token filter so we can SEE the token + // coins we need to lock (fExcludeZSLPTokens=false); the global filter + // would otherwise have already dropped them. + std::vector allCoins; + w->AvailableCoins(allCoins, true, NULL, false, true, + /*fExcludeZSLPTokens=*/false); + for (size_t i = 0; i < allCoins.size(); ++i) { + COutPoint op(allCoins[i].tx->GetHash(), allCoins[i].i); + if (intended.count(op)) + continue; // pinned input — leave spendable + // Confirmed token UTXO/baton OR a wallet's own pending (0-conf) + // token output — both must be fenced off from fee/change selection. + if (ZSLPIsProtectedTokenOutpoint(w, store, op)) + tokenLock.lock(op); + } + } + + CCoinControl cc; + cc.fAllowOtherInputs = true; + for (size_t i = 0; i < req.tokenInputs.size(); ++i) + cc.Select(req.tokenInputs[i]); + + // (3) Sum of token-output dust (paid from fee coins) and a dust pre-check. + CAmount dustTotal = 0; + for (size_t i = 0; i < req.tokenOuts.size(); ++i) { + CAmount d = req.tokenOuts[i].dustSats; + CTxOut probe(d, req.tokenOuts[i].dest); + if (probe.IsDust(relayFee)) { + err = "internal: token output below the dust threshold"; + return false; + } + dustTotal += d; + } + + // Value the token inputs contribute (they are ZEC dust outputs, spent as + // inputs); SelectCoins counts them via the preset path, so the fee loop's + // target already nets them out. + + CReserveKey reservekey(w); + + int nextBlockHeight = chainActive.Height() + 1; + + // (4) Fee + funding loop. Mirrors CreateTransaction's loop (wallet.cpp:3539- + // 3788) but with the FIXED canonical layout and change appended LAST. + CMutableTransaction txNew; + CAmount nFeeRet = 0; + std::set > setCoins; + bool reservedChangeKey = false; + while (true) { + txNew = CreateNewContextualCMutableTransaction( + Params().GetConsensus(), nextBlockHeight); + + // Discourage fee sniping (same as CreateTransaction). + txNew.nLockTime = std::max(0, chainActive.Height() - 10); + assert(txNew.nLockTime <= (unsigned int)chainActive.Height()); + assert(txNew.nLockTime < LOCKTIME_THRESHOLD); + + // Target = dust outputs + fee (the OP_RETURN carries value 0). The token + // inputs are pinned (preset) so SelectCoins already credits their ~546 + // sat each toward the target. + CAmount nTarget = dustTotal + nFeeRet; + + setCoins.clear(); + CAmount nValueIn = 0; + bool fOnlyCoinbase = false, fNeedCoinbase = false; + if (!w->SelectCoins(nTarget, setCoins, nValueIn, fOnlyCoinbase, + fNeedCoinbase, &cc)) { + if (fOnlyCoinbase && Params().GetConsensus().fCoinbaseMustBeProtected) + err = "Coinbase funds can only be sent to a zaddr"; + else if (fNeedCoinbase) + err = "Insufficient funds (coinbase must be shielded first)"; + else + err = "Insufficient funds to pay the dust outputs + network fee"; + return false; + } + + // (4a) Build the canonical layout from scratch each pass. + txNew.vin.clear(); + txNew.vout.clear(); + + // vout[0] = OP_RETURN (value 0). + txNew.vout.push_back(CTxOut(0, req.opret)); + // vout[1..N] = token outputs in canonical order. + for (size_t i = 0; i < req.tokenOuts.size(); ++i) + txNew.vout.push_back(CTxOut(req.tokenOuts[i].dustSats, + req.tokenOuts[i].dest)); + + // (4b) ZEC change appended STRICTLY at the tail (never index 0, never + // between token outputs). + CAmount nChange = nValueIn - dustTotal - nFeeRet; + if (nChange > 0) { + CScript scriptChange; + CPubKey vchPubKey; + // Clean failure (NOT assert) on keypool exhaustion: GetReservedKey + // returns false when the keypool is empty (a locked wallet cannot + // refill it). assert() would crash the node, and is a no-op under + // NDEBUG (then proceeding with an invalid CPubKey). The RAII guard + // unlocks any locked tokens on this early return. + if (!reservekey.GetReservedKey(vchPubKey)) { + err = "Keypool ran out, call keypoolrefill first"; + return false; + } + reservedChangeKey = true; + scriptChange = GetScriptForDestination(vchPubKey.GetID()); + + CTxOut changeOut(nChange, scriptChange); + if (changeOut.IsDust(relayFee)) { + // Fold dust change into the fee (matches CreateTransaction + // wallet.cpp:3672-3676). No change output is added. + nFeeRet += nChange; + reservekey.ReturnKey(); + reservedChangeKey = false; + } else { + txNew.vout.push_back(changeOut); // tail position guaranteed + } + } else { + reservekey.ReturnKey(); + reservedChangeKey = false; + } + + // (4c) Fill vin (sequence max()-1 so nLockTime works), as + // CreateTransaction does. + for (std::set >::iterator + it = setCoins.begin(); it != setCoins.end(); ++it) { + txNew.vin.push_back(CTxIn(it->first->GetHash(), it->second, CScript(), + std::numeric_limits::max() - 1)); + } + + // (4d) Sign each input (mirror wallet.cpp:3712-3737). Signing is the + // LAST step because moving any output invalidates every sig. + uint32_t consensusBranchId = + CurrentEpochBranchId(chainActive.Height() + 1, Params().GetConsensus()); + CTransaction txConst(txNew); + int nIn = 0; + bool signOk = true; + for (std::set >::iterator + it = setCoins.begin(); it != setCoins.end(); ++it) { + const CScript& spk = it->first->vout[it->second].scriptPubKey; + CAmount amt = it->first->vout[it->second].nValue; + SignatureData sigdata; + if (!ProduceSignature( + TransactionSignatureCreator(w, &txConst, nIn, amt, SigHashType()), + spk, sigdata, consensusBranchId)) { + signOk = false; + break; + } + UpdateTransaction(txNew, nIn, sigdata); + nIn++; + } + if (!signOk) { + err = "Signing transaction failed"; + return false; + } + + unsigned int nBytes = ::GetSerializeSize(txNew, SER_NETWORK, PROTOCOL_VERSION); + CAmount nFeeNeeded = w->GetMinimumFee(nBytes, nTxConfirmTarget, mempool); + if (nFeeRet >= nFeeNeeded) + break; // enough fee — layout finalized. + nFeeRet = nFeeNeeded; + // Loop again: a higher fee changes the target/selection. If we had + // reserved a change key this pass, ReturnKey it so we don't leak keys. + if (reservedChangeKey) { + reservekey.ReturnKey(); + reservedChangeKey = false; + } + } + + const CTransaction finalTx(txNew); + + // (5) Defensive anti-burn post-check (R-WALLET-3): no FEE input is a live + // token/baton of any token (only the explicitly pinned token inputs may + // be token UTXOs). + { + std::set intended(req.tokenInputs.begin(), req.tokenInputs.end()); + for (size_t k = 0; k < finalTx.vin.size(); ++k) { + const COutPoint& op = finalTx.vin[k].prevout; + if (intended.count(op)) + continue; + // Catches both confirmed token UTXOs/batons and the wallet's own + // pending (0-conf) token-change a prior ZSLP build produced. + if (ZSLPIsProtectedTokenOutpoint(w, store, op)) { + err = "anti-burn: a funding input is a live token UTXO/baton — refusing to broadcast"; + return false; + } + } + } + + // (6) SELF-VALIDATE the FINAL signed tx with the REAL indexer parse + + // read-only conservation (R-WALLET-9). Refuse to broadcast on mismatch. + { + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; + bool haveGenesisMeta = false; + // Use the EXACT production parse seam (vout[0]-only). + if (!CZSLPIndexer::ParseTx(finalTx, nextBlockHeight, parsed, genesisMeta, + haveGenesisMeta)) { + err = "self-validate: built tx has no parsable SLP message at vout[0]"; + return false; + } + + // The parsed token must be the one we intended to act on. + const uint256 expectId = req.isGenesis ? finalTx.GetHash() + : req.selfValidateTokenId; + if (req.isGenesis) { + if (parsed.type != ZSLP_MSG_GENESIS) { + err = "self-validate: expected GENESIS message"; + return false; + } + // GENESIS tokenId == txid (the indexer sets parsed.tokenId = txid). + if (parsed.tokenId != expectId) { + err = "self-validate: GENESIS token id mismatch"; + return false; + } + } else { + if (parsed.type == ZSLP_MSG_GENESIS) { + err = "self-validate: unexpected GENESIS message"; + return false; + } + if (parsed.tokenId != expectId) { + err = "self-validate: message token id does not match the intended token"; + return false; + } + } + + std::vector vin; + vin.reserve(finalTx.vin.size()); + for (size_t k = 0; k < finalTx.vin.size(); ++k) + vin.push_back(finalTx.vin[k].prevout); + + std::string reason; + if (!store->WouldBeValid(vin, &parsed, finalTx.GetHash(), + haveGenesisMeta ? &genesisMeta : NULL, + (int32_t)finalTx.vout.size(), reason)) { + err = "self-validate: built tx would not be valid in the token ledger (" + + reason + ")"; + return false; + } + } + + // (7) Embed + commit. Only now do we broadcast. + *static_cast(&wtxOut) = finalTx; + wtxOut.BindWallet(w); + wtxOut.fFromMe = true; + wtxOut.fTimeReceivedIsTxTime = true; + + if (!w->CommitTransaction(wtxOut, reservekey)) { + err = "CommitTransaction failed (tx was signed + self-validated but rejected by mempool)"; + return false; + } + return true; +} diff --git a/src/wallet/zslpwallet.h b/src/wallet/zslpwallet.h new file mode 100644 index 00000000000..72e97254a1e --- /dev/null +++ b/src/wallet/zslpwallet.h @@ -0,0 +1,106 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// ZSLP write path — the shared OP_RETURN transaction builder. +// +// NON-consensus: this builds an ordinary transparent payment that carries one +// SLP OP_RETURN at vout[0]; unchanged ZClassic nodes relay and mine it. The +// builder's whole job is to make the *overlay* ledger effect deterministic and +// burn-proof: +// (a) fixed layout vout[0]=OP_RETURN(value 0), vout[1..N]=token recipients +// (dust), ZEC change STRICTLY after the token outputs (or none) — the +// stock CWallet::CreateTransaction inserts change at a RANDOM index +// (wallet.cpp:3680), which would land at vout[0] or between token outputs +// and burn/mis-credit the token; +// (b) anti-burn funding — token UTXOs and mint batons are pinned (intended +// inputs) or excluded (everything else) so no token rides a fee/change +// coin into a burn; +// (c) self-validate the FINAL signed tx with the REAL indexer parse +// (CZSLPIndexer::ParseTx) + the read-only CZSLPStore::WouldBeValid +// conservation check, and refuse to broadcast on any mismatch (R-WALLET-9). +// +// The single entry point BuildAndCommitZSLP and its ZSLPBuildReq live in +// wallet.h (BuildAndCommitZSLP is a friend of CWallet so it can reach the +// private SelectCoins seam). This header only carries the small dust constant +// and the wallet-side token-UTXO enumeration helper that the RPCs share. + +#ifndef BITCOIN_WALLET_ZSLPWALLET_H +#define BITCOIN_WALLET_ZSLPWALLET_H + +#include "amount.h" +#include "primitives/transaction.h" // COutPoint +#include "uint256.h" + +#include +#include + +class CWallet; +class CZSLPStore; + +// Standard SLP/BCH dust convention: 546 sat per token-bearing output. The +// 54-sat relay dust floor (transaction.h:452-467 with the default +// -minrelaytxfee) leaves ~10x headroom; the builder also asserts dynamically +// that 546 is not dust under the active fee rate before using it. +static const CAmount SLP_TOKEN_DUST = 546; + +/** + * On-chain (big-endian / display) byte order of a daemon uint256 — the inverse + * of the indexer's TokenIdToUint256. This is what the SLP MINT/SEND encoders + * (ZSLPBuildMint / ZSLPBuildSend) expect for a token id. Shared (DRY) by + * rpc/zslp.cpp (mint/send) and rpc/nftoffer.cpp (the sell template's vout[0]), + * so the reversal lives in exactly one place. + */ +inline void ZSLPTokenIdToBE(const uint256& tokenId, uint8_t out[32]) +{ + const unsigned char* p = tokenId.begin(); // internal little-endian + for (int i = 0; i < 32; ++i) + out[i] = p[31 - i]; +} + +/** A wallet token UTXO discovered by intersecting AvailableCoins with the store. */ +struct ZSLPWalletUtxo { + COutPoint outpoint; + uint256 tokenId; + int64_t amount; //!< 0 for a baton + bool isMintBaton; + int64_t height; //!< for deterministic (height,txid,vout) selection order +}; + +/** + * Enumerate the wallet's spendable, confirmed token UTXOs for `tokenId` + * (the §2.5 intersection: AvailableCoins ∩ store->GetUtxo). When `wantBaton` + * is true, returns ONLY the live mint baton(s); otherwise returns ONLY + * quantity-bearing UTXOs (amount>0, never the baton). Sorted deterministically + * by (height, txid, vout). Requires cs_main + the wallet's cs_wallet held and + * the ZSLP index enabled (returns false with `err` set otherwise). + */ +bool ZSLPFindWalletTokenUtxos(CWallet* w, const uint256& tokenId, + bool wantBaton, + std::vector& out, + std::string& err); + +/** + * The ONE token-UTXO classifier shared by the global anti-burn filter + * (CWallet::AvailableCoins, R-WALLET-2/4/5), the builder's pin/exclude fence + * and its pre-broadcast post-check (R-WALLET-3). Returns true IFF `op` carries + * (or would carry) a live ZSLP token quantity or a mint baton, so it MUST NOT + * be spent as an ordinary fee/change coin. + * + * It is the union of TWO sources, because the indexer's confirmed store alone + * misses just-created (0-conf) token-change: + * (1) CONFIRMED truth — the store reports a live token UTXO/baton at `op` + * (amount>0 || isMintBaton). + * (2) PENDING/0-conf truth — `op` is a token-bearing output of a wallet + * transaction whose vout[0] parses (via the REAL CZSLPIndexer::ParseTx, + * vout[0]-only) as an SLP GENESIS/MINT/SEND, and `op.n` is one of the + * output indices that message would create as a token UTXO or baton + * (computed exactly as CZSLPStore::ApplyTransaction would). This protects + * the token-change a zslp_send/mint/genesis just produced before it + * confirms (the indexer is ChainTip-only, so the store has no row yet). + * + * `store` may be NULL (index off): source (1) is skipped but source (2) still + * protects the wallet's own pending ZSLP outputs. Requires cs_main + w->cs_wallet. + */ +bool ZSLPIsProtectedTokenOutpoint(const CWallet* w, CZSLPStore* store, + const COutPoint& op); + +#endif // BITCOIN_WALLET_ZSLPWALLET_H diff --git a/src/zslp/slp.c b/src/zslp/slp.c index 5cba92e1b29..66877e2a065 100644 --- a/src/zslp/slp.c +++ b/src/zslp/slp.c @@ -22,6 +22,16 @@ static uint64_t be_to_u64(const uint8_t *data, size_t len) return val; } +/* R-INT-1 / R-10: a quantity whose uint64 value has the high bit set + * (>= 2^63) is INVALID for the WHOLE message. The downstream ledger model is + * signed int64; a high-bit quantity would cast to a negative amount and corrupt + * derived balances, and it is a signed/unsigned fork surface across + * implementations. We reject it here in the canonical parser so EVERY consumer + * (the bridge, the store, any third-party indexer) treats such a message as + * not-SLP identically. Applied to GENESIS initial_quantity, MINT + * additional_quantity, and every SEND output quantity. */ +#define SLP_QTY_HIGH_BIT_SET(q) (((uint64_t)(q)) & (UINT64_C(1) << 63)) + /* Write a big-endian uint64 (8 bytes). */ static void u64_to_be(uint8_t *out, uint64_t val) { @@ -87,9 +97,11 @@ bool slp_parse(const uint8_t *script, size_t script_len, msg->document_url[len] = 0; } - /* Field 6: document_hash (0 or 32 bytes) */ + /* Field 6: document_hash — push length MUST be exactly 0 or 32 + * (R-8 / R-SCRIPT-6). Any other length rejects the whole GENESIS so a + * 31-byte hash can't parse two ways across implementations. */ p = read_push(p, end, &data, &len); - if (!p) return false; + if (!p || (len != 0 && len != 32)) return false; if (len == 32) { memcpy(msg->document_hash, data, 32); msg->has_document_hash = true; @@ -100,18 +112,27 @@ bool slp_parse(const uint8_t *script, size_t script_len, if (!p || len != 1 || data[0] > 9) return false; msg->decimals = data[0]; - /* Field 8: mint_baton_vout (0 or 1 byte) */ + /* Field 8: mint_baton_vout — push length 0 (no baton) OR exactly 1 + * with value >= 2 (R-8 / R-SCRIPT-6). Length 1 value 0/1, and ANY + * length > 1, reject the whole message. */ p = read_push(p, end, &data, &len); - if (!p) return false; + if (!p || len > 1) return false; if (len == 1) { if (data[0] < 2) return false; /* vout must be >= 2 */ msg->mint_baton_vout = data[0]; } - /* Field 9: initial_token_mint_quantity (8 bytes) */ + /* Field 9: initial_token_mint_quantity (exactly 8 bytes) */ p = read_push(p, end, &data, &len); if (!p || len != 8) return false; msg->initial_quantity = be_to_u64(data, 8); + /* R-INT-1 / R-10: high-bit quantity => whole message INVALID. */ + if (SLP_QTY_HIGH_BIT_SET(msg->initial_quantity)) return false; + + /* R-7 / R-SCRIPT-5: GENESIS has a fixed field count — the script MUST + * be fully consumed. A trailing push makes it not-SLP (a strict parser + * rejects; a lenient one would accept => fork). */ + if (p != end) return false; return true; @@ -123,18 +144,24 @@ bool slp_parse(const uint8_t *script, size_t script_len, if (!p || len != 32) return false; memcpy(msg->token_id.data, data, 32); - /* Field 4: mint_baton_vout */ + /* Field 4: mint_baton_vout — push length 0 OR exactly 1 with value >= 2 + * (R-8). Length 1 value 0/1, and ANY length > 1, reject. */ p = read_push(p, end, &data, &len); - if (!p) return false; + if (!p || len > 1) return false; if (len == 1) { if (data[0] < 2) return false; msg->mint_baton_vout = data[0]; } - /* Field 5: additional_token_quantity (8 bytes) */ + /* Field 5: additional_token_quantity (exactly 8 bytes) */ p = read_push(p, end, &data, &len); if (!p || len != 8) return false; msg->additional_quantity = be_to_u64(data, 8); + /* R-INT-1 / R-10: high-bit quantity => whole message INVALID. */ + if (SLP_QTY_HIGH_BIT_SET(msg->additional_quantity)) return false; + + /* R-7 / R-SCRIPT-5: MINT has a fixed field count — fully consume. */ + if (p != end) return false; return true; @@ -146,17 +173,25 @@ bool slp_parse(const uint8_t *script, size_t script_len, if (!p || len != 32) return false; memcpy(msg->token_id.data, data, 32); - /* Fields 4+: output quantities (8 bytes each, 1-19 outputs) */ + /* Fields 4+: output quantities — each an exactly-8-byte BE push, + * 1..ZSLP_SEND_MAX_OUTPUTS of them (R-SEND-1 / R-12). Read greedily; + * the loop body rejects (returns false) on any malformed/short push or + * high-bit quantity. After the cap, the script MUST be fully consumed: + * a (ZSLP_SEND_MAX_OUTPUTS+1)-th 8-byte push is trailing data => + * INVALID (NOT "first N win"), and any non-8-byte trailing push is + * likewise INVALID. */ msg->num_outputs = 0; - while (msg->num_outputs < 19) { - const uint8_t *saved = p; + while (p != end) { + if (msg->num_outputs >= ZSLP_SEND_MAX_OUTPUTS) + return false; /* a 20th quantity push => whole SEND INVALID */ p = read_push(p, end, &data, &len); - if (!p || len != 8) { - p = saved; /* restore for check below */ - break; - } - msg->output_quantities[msg->num_outputs++] = be_to_u64(data, 8); + if (!p || len != 8) return false; /* malformed/short push */ + uint64_t q = be_to_u64(data, 8); + /* R-INT-1 / R-10: high-bit output quantity => whole SEND INVALID. */ + if (SLP_QTY_HIGH_BIT_SET(q)) return false; + msg->output_quantities[msg->num_outputs++] = q; } + /* R-SEND-1 / R-12: a 0-quantity SEND is INVALID. */ if (msg->num_outputs < 1) return false; return true; @@ -267,7 +302,7 @@ size_t slp_build_send(uint8_t *out, size_t out_len, const struct uint256 *token_id, const uint64_t *quantities, int num_outputs) { - if (num_outputs < 1 || num_outputs > 19) return 0; + if (num_outputs < 1 || num_outputs > ZSLP_SEND_MAX_OUTPUTS) return 0; if (out_len < 1) return 0; size_t off = 0; diff --git a/src/zslp/slp.h b/src/zslp/slp.h index 6ebf824b881..77a761683f5 100644 --- a/src/zslp/slp.h +++ b/src/zslp/slp.h @@ -26,6 +26,14 @@ extern "C" { /* Token type */ #define SLP_TOKEN_TYPE_1 1 +/* Canonical SEND output-quantity cap (R-SEND-1 / R-12). A SEND MUST carry + * 1..ZSLP_SEND_MAX_OUTPUTS quantity pushes; a list of more than this (e.g. a + * 20th 8-byte push) makes the WHOLE message INVALID — NOT "first N win". This + * single constant is shared by the parser, the C++ message bridge, the store, + * and every array bound so all layers agree on the cap (a divergence here + * forks the ledger). */ +#define ZSLP_SEND_MAX_OUTPUTS 19 + /* Transaction types */ enum slp_tx_type { SLP_TX_GENESIS = 1, @@ -57,7 +65,7 @@ struct slp_message { /* SEND fields */ /* token_id reused */ - uint64_t output_quantities[20]; /* vout[1]..vout[19] + 1 extra */ + uint64_t output_quantities[ZSLP_SEND_MAX_OUTPUTS]; /* vout[1]..vout[19] */ int num_outputs; }; @@ -89,7 +97,8 @@ size_t slp_build_mint(uint8_t *out, size_t out_len, uint64_t additional_quantity); /* SEND: transfer token amounts. token_id and the quantities array are - * required; num_outputs must be in 1..19 (returns 0 otherwise). */ + * required; num_outputs must be in 1..ZSLP_SEND_MAX_OUTPUTS (returns 0 + * otherwise). */ size_t slp_build_send(uint8_t *out, size_t out_len, const struct uint256 *token_id, const uint64_t *quantities, int num_outputs); diff --git a/src/zslp/zslpindexer.cpp b/src/zslp/zslpindexer.cpp index 5e3ac9f2bf3..9851a0af066 100644 --- a/src/zslp/zslpindexer.cpp +++ b/src/zslp/zslpindexer.cpp @@ -3,19 +3,24 @@ // ZSLP indexer implementation. See zslpindexer.h. // // NON-consensus observer: reads connected/disconnected blocks off the -// validation signal bus and projects ZSLP OP_RETURN messages into the store. +// validation signal bus and projects ZSLP OP_RETURN messages into the store, +// enforcing the real SLP Token-Type-1 UTXO-bound rules (conservation). #include "zslp/zslpindexer.h" #include "chain.h" #include "key_io.h" +#include "main.h" #include "primitives/block.h" #include "primitives/transaction.h" #include "script/standard.h" +#include "sync.h" #include "util.h" #include "zslp/zslpmsg.h" #include "zslp/zslpstore.h" +#include + CZSLPIndexer* g_zslpIndexer = NULL; // LevelDB cache size for the ZSLP store (modest; this is auxiliary data). @@ -26,8 +31,17 @@ void StartZSLPIndexer() if (g_zslpIndexer != NULL) return; g_zslpIndexer = new CZSLPIndexer(); + // Open + migrate + catch up the historical chain BEFORE going live, so the + // replay does not race the validation bus and a re-delivered connect is + // caught by the per-block tip idempotence guard. + if (!g_zslpIndexer->Init()) { + LogPrintf("ZSLP: indexer init failed; disabling token index\n"); + delete g_zslpIndexer; + g_zslpIndexer = NULL; + return; + } RegisterValidationInterface(g_zslpIndexer); - LogPrintf("ZSLP: token indexer started (read-only OP_RETURN observation)\n"); + LogPrintf("ZSLP: token indexer started (UTXO-bound SLP conservation)\n"); } void StopZSLPIndexer() @@ -39,21 +53,94 @@ void StopZSLPIndexer() g_zslpIndexer = NULL; } -CZSLPIndexer::CZSLPIndexer() +CZSLPIndexer::CZSLPIndexer() {} + +CZSLPIndexer::~CZSLPIndexer() {} + +// ── Init / migration / catch-up ───────────────────────────────────── + +bool CZSLPIndexer::Init() { boost::filesystem::path path = GetDataDir() / "blocks" / "zslp"; + + // Open and check the on-disk format stamp. A stale/absent stamp (legacy + // credit-only index, or never written) triggers a wipe + full reindex; the + // index is fully derivable and behind -zslpindex, so a clean rebuild is the + // safe migration. store.reset(new CZSLPStore(path, ZSLP_DB_CACHE)); + uint32_t version = 0; + bool haveVersion = store->ReadIndexVersion(version); + bool wiped = false; + if (!haveVersion || version < ZSLP_INDEX_VERSION) { + LogPrintf("ZSLP: index format %s (have %u, want %u) — wiping + reindexing\n", + haveVersion ? "outdated" : "absent", + haveVersion ? version : 0u, ZSLP_INDEX_VERSION); + store.reset(); // close before reopening with fWipe + store.reset(new CZSLPStore(path, ZSLP_DB_CACHE, /*fMemory=*/false, + /*fWipe=*/true)); + // Stamp the version BEFORE reindexing so a crash mid-reindex resumes + // from the per-block tip rather than re-wiping. + store->WriteIndexVersion(ZSLP_INDEX_VERSION); + wiped = true; + } + (void)wiped; + + return CatchUp(); } -CZSLPIndexer::~CZSLPIndexer() {} +bool CZSLPIndexer::CatchUp() +{ + CZSLPStore* s = store.get(); + if (s == NULL) + return false; + + LOCK(cs_main); + + int64_t storedHeight = -1; + uint256 storedHash; + bool haveTip = s->ReadTip(storedHeight, storedHash); + + // Resume one past the stored tip (or from genesis after a wipe / no tip). + int resumeHeight = haveTip ? (int)storedHeight + 1 : 0; + if (resumeHeight < 0) + resumeHeight = 0; + + int tipHeight = chainActive.Height(); + if (tipHeight < 0) + return true; // empty chain: nothing to index yet + + for (int hh = resumeHeight; hh <= tipHeight; ++hh) { + const CBlockIndex* pindex = chainActive[hh]; + if (pindex == NULL) + break; + CBlock block; + if (!ReadBlockFromDisk(block, pindex)) { + LogPrintf("ZSLP: catch-up failed to read block at height %d\n", hh); + return false; + } + ConnectBlock(pindex, block); + } + if (tipHeight >= resumeHeight) + LogPrintf("ZSLP: catch-up indexed blocks %d..%d\n", resumeHeight, tipHeight); + return true; +} // ── Address extraction ───────────────────────────────────────────── // Decode the t-address paid by a given vout's scriptPubKey, or "" if it is // not a standard pay-to-address output (e.g. the OP_RETURN itself). -static std::string AddressOfVout(const CTransaction& tx, uint32_t voutIdx) +// +// DETERMINISM (part of F): the address that keys the derived balance / transfer +// rows MUST be canonical and deterministic so two implementations key the same +// holder. ExtractDestination (script/standard.cpp) is a pure function of the +// scriptPubKey bytes, and EncodeDestination (key_io.cpp) is the canonical +// base58check encoding under the active CChainParams (fixed per network). A +// non-standard / undecodable output deterministically yields "" — an empty +// address never receives a balance credit (recordBalanceDelta skips "") and is +// stored verbatim on the UTXO, so the result is bit-identical everywhere. +static std::string AddressOfVout(const CTransaction& tx, int32_t voutIdx) { - if (voutIdx >= tx.vout.size()) + if (voutIdx < 0 || (size_t)voutIdx >= tx.vout.size()) return std::string(); CTxDestination dest; if (!ExtractDestination(tx.vout[voutIdx].scriptPubKey, dest)) @@ -105,11 +192,103 @@ void CZSLPIndexer::ConnectBlock(const CBlockIndex* pindex, const CBlock& block) return; s->ConnectBlockBegin(blockHash); - for (size_t i = 0; i < block.vtx.size(); ++i) + // R-PARSE-3: skip the coinbase (vtx[0]) for SLP parsing. A coinbase's only + // input is the null prevout, which can never reference a token UTXO, so + // skipping it consumes/burns nothing and creates nothing — the skip is a + // no-op for conservation but is made explicit (and tested) so a coinbase + // whose vout[0] happens to begin OP_RETURN is never mistaken for SLP. + for (size_t i = 1; i < block.vtx.size(); ++i) IndexTransaction(block.vtx[i], pindex); s->ConnectBlockEnd(pindex->nHeight, blockHash); } +// R-PARSE-1/2 (BLOCKER): parse the SLP message from tx.vout[0] ONLY. A tx is an +// SLP candidate IFF vout[0] is OP_RETURN and parses as a valid SLP message; +// OP_RETURNs at vout>=1 are irrelevant by construction (this defeats the +// message-position fork and the multi-OP_RETURN fork). If vout[0] is not a valid +// SLP message, the tx has NO SLP message (returns false) — its spent token +// inputs are still burned by the caller. +// +// NOTE: we deliberately do NOT gate on Solver()/TX_NULL_DATA here. The SLP push +// grammar (read_push, R-SCRIPT-1) is the canonical accept set; it is stricter +// and policy-independent (R-PARSE-4) — TX_NULL_DATA's -datacarriersize check is +// consensus-relay policy and must never enter the ledger function. +// ZSLPParseScript already requires the leading 0x6a and rejects non-SLP scripts. +// +// Static + pure (no store / chain state) so the R-VECTORS corpus pins the +// position rules against this exact code. +bool CZSLPIndexer::ParseTx(const CTransaction& tx, int64_t height, + CZSLPParsedMsg& parsed, CZSLPToken& genesisMeta, + bool& haveGenesisMeta) +{ + haveGenesisMeta = false; + if (tx.vout.empty()) + return false; + + const uint256 txid = tx.GetHash(); + const CScript& spk = tx.vout[0].scriptPubKey; + std::vector raw(spk.begin(), spk.end()); + + ZSLPMessage msg; + if (raw.empty() || !ZSLPParseScript(raw.data(), raw.size(), msg)) + return false; + + switch (msg.type) { + case ZSLPMSG_GENESIS: { + parsed.type = ZSLP_MSG_GENESIS; + parsed.tokenId = txid; // canonical SLP: token id == genesis txid + parsed.initialQuantity = (int64_t)msg.initialQuantity; + parsed.mintBatonVout = (int32_t)msg.mintBatonVout; + + genesisMeta.tokenId = txid; + genesisMeta.ticker = msg.ticker; + genesisMeta.name = msg.name; + genesisMeta.documentUrl = msg.documentUrl; + genesisMeta.hasDocumentHash = msg.hasDocumentHash; + if (msg.hasDocumentHash) { + // document_hash is an arbitrary 32-byte hash (not a txid). + // uint256::GetHex() prints internal bytes reversed, so reverse here + // to display the on-chain byte order. + std::vector dh(32); + for (int b = 0; b < 32; ++b) + dh[b] = msg.documentHash[31 - b]; + genesisMeta.documentHash = uint256(dh); + } + genesisMeta.decimals = msg.decimals; + genesisMeta.mintBatonVout = msg.mintBatonVout; // store overwrites + genesisMeta.genesisHeight = height; + haveGenesisMeta = true; + return true; + } + case ZSLPMSG_MINT: { + parsed.type = ZSLP_MSG_MINT; + parsed.tokenId = TokenIdToUint256(msg.tokenId); + parsed.additionalQuantity = (int64_t)msg.additionalQuantity; + parsed.mintBatonVout = (int32_t)msg.mintBatonVout; + return true; + } + case ZSLPMSG_SEND: { + parsed.type = ZSLP_MSG_SEND; + parsed.tokenId = TokenIdToUint256(msg.tokenId); + // The parser guarantees 1..ZSLP_SEND_MAX_OUTPUTS (R-SEND-1); a larger + // list was already rejected as INVALID, so no clamp is needed — assert + // the single shared cap holds across layers. + static_assert(ZSLP_MAX_SEND_OUTPUTS == ZSLP_SEND_MAX_OUTPUTS_STORE, + "ZSLP SEND cap mismatch (bridge vs store)"); + int n = msg.numOutputs; + if (n < 0) n = 0; + if (n > ZSLP_SEND_MAX_OUTPUTS_STORE) + n = ZSLP_SEND_MAX_OUTPUTS_STORE; // defensive; parser-bounded + parsed.numOutputs = n; + for (int j = 0; j < n; ++j) + parsed.outputQuantities[j] = (int64_t)msg.outputQuantities[j]; + return true; + } + default: + return false; + } +} + void CZSLPIndexer::IndexTransaction(const CTransaction& tx, const CBlockIndex* pindex) { @@ -117,78 +296,27 @@ void CZSLPIndexer::IndexTransaction(const CTransaction& tx, const int64_t height = pindex->nHeight; const uint256 txid = tx.GetHash(); - // Find the first OP_RETURN (TX_NULL_DATA) output and try to parse it. - for (size_t vo = 0; vo < tx.vout.size(); ++vo) { - const CScript& spk = tx.vout[vo].scriptPubKey; - txnouttype whichType; - std::vector > solutions; - if (!Solver(spk, whichType, solutions) || whichType != TX_NULL_DATA) - continue; - - // Raw script bytes for the SLP parser. - std::vector raw(spk.begin(), spk.end()); - if (raw.empty()) - continue; - - ZSLPMessage msg; - if (!ZSLPParseScript(raw.data(), raw.size(), msg)) - continue; // not an SLP message; keep scanning other vouts - - switch (msg.type) { - case ZSLPMSG_GENESIS: { - // Token id == the genesis transaction id (canonical SLP rule). - // Minted quantity is paid to vout[1]; baton (if any) to its vout. - CZSLPToken token; - token.tokenId = txid; - token.ticker = msg.ticker; - token.name = msg.name; - token.documentUrl = msg.documentUrl; - token.hasDocumentHash = msg.hasDocumentHash; - if (msg.hasDocumentHash) { - // document_hash is an arbitrary 32-byte hash (not a txid). - // uint256::GetHex() prints internal bytes reversed, so reverse - // here to make the RPC display the on-chain byte order. - std::vector dh(32); - for (int b = 0; b < 32; ++b) - dh[b] = msg.documentHash[31 - b]; - token.documentHash = uint256(dh); - } - token.decimals = msg.decimals; - token.mintBatonVout = msg.mintBatonVout; - token.genesisHeight = height; - - std::string recipient = AddressOfVout(tx, 1); - s->ApplyGenesis(token, recipient, txid, 1, - (int64_t)msg.initialQuantity); - return; // one SLP message per tx - } - case ZSLPMSG_MINT: { - uint256 tokenId = TokenIdToUint256(msg.tokenId); - std::string recipient = AddressOfVout(tx, 1); - bool batonMoved = (msg.mintBatonVout >= 2); - s->ApplyMint(tokenId, recipient, txid, height, 1, - (int64_t)msg.additionalQuantity, batonMoved, - msg.mintBatonVout); - return; - } - case ZSLPMSG_SEND: { - uint256 tokenId = TokenIdToUint256(msg.tokenId); - // outputQuantities[j] is paid to vout[1+j]. - for (int j = 0; j < msg.numOutputs; ++j) { - uint64_t qty = msg.outputQuantities[j]; - if (qty == 0) - continue; - uint32_t voutIdx = (uint32_t)(j + 1); - std::string recipient = AddressOfVout(tx, voutIdx); - s->ApplySend(tokenId, recipient, txid, height, - (int32_t)voutIdx, (int64_t)qty); - } - return; - } - default: - return; - } - } + // (1) Token inputs spent by this tx (prevouts). Even a non-SLP tx must burn + // any token UTXO it spends, so we gather these for EVERY tx. + std::vector vin; + vin.reserve(tx.vin.size()); + for (size_t k = 0; k < tx.vin.size(); ++k) + vin.push_back(tx.vin[k].prevout); + + // (2) Parse the SLP message from vout[0] only (R-PARSE-1/2). See ParseTx. + CZSLPParsedMsg parsed; + CZSLPToken genesisMeta; // only filled for GENESIS + bool haveGenesisMeta = false; + bool msgPresent = ParseTx(tx, height, parsed, genesisMeta, haveGenesisMeta); + + // (3) Apply conservation. addrOfVout closes over this tx so the store needs + // no script knowledge of its own. + std::function addrOfVout = + [&tx](int32_t n) -> std::string { return AddressOfVout(tx, n); }; + + s->ApplyTransaction(vin, msgPresent ? &parsed : NULL, txid, height, + haveGenesisMeta ? &genesisMeta : NULL, + addrOfVout, (int32_t)tx.vout.size()); } // ── Disconnect (reorg) ───────────────────────────────────────────── diff --git a/src/zslp/zslpindexer.h b/src/zslp/zslpindexer.h index 24cf4128cf1..38997e5a27f 100644 --- a/src/zslp/zslpindexer.h +++ b/src/zslp/zslpindexer.h @@ -13,9 +13,10 @@ #include "validationinterface.h" +#include "zslp/zslpstore.h" // CZSLPParsedMsg, CZSLPToken (for the testable parse seam) + #include -class CZSLPStore; class CBlock; class CBlockIndex; class CTransaction; @@ -37,6 +38,24 @@ class CZSLPIndexer : public CValidationInterface /** Accessor for the read RPCs. May be NULL if the index is disabled. */ CZSLPStore* Store() { return store.get(); } + // Canonical, side-effect-free per-transaction PARSE seam (R-PARSE-1/2): + // parse the SLP message from tx.vout[0] ONLY (vout>=1 OP_RETURNs are + // ignored; coinbase is skipped by ConnectBlock, not here). Fills `parsed` + // (+ `genesisMeta`/`haveGenesisMeta` for GENESIS) and returns true IFF + // vout[0] is a valid SLP message. Static + pure so the vector corpus can + // pin the exact ledger-fork-critical rules against the REAL indexer code + // (no CBlockIndex / chain state needed). IndexTransaction is its sole + // production caller. + static bool ParseTx(const CTransaction& tx, int64_t height, + CZSLPParsedMsg& parsed, CZSLPToken& genesisMeta, + bool& haveGenesisMeta); + + // Open the store, run the version-stamp migration (wipe + reindex when the + // on-disk format is stale/absent), then replay any blocks the live tip is + // ahead of the stored tip. Must run BEFORE RegisterValidationInterface so + // the replay doesn't race live connects. Returns true on success. + bool Init(); + protected: // CValidationInterface hook: added=true on connect, false on disconnect. // Provides the (dis)connected CBlock directly, so no disk read is needed. @@ -50,8 +69,13 @@ class CZSLPIndexer : public CValidationInterface void ConnectBlock(const CBlockIndex* pindex, const CBlock& block); void DisconnectBlock(const CBlockIndex* pindex, const CBlock& block); - // Per-transaction scan: find the OP_RETURN, parse SLP, persist. + // Per-transaction scan: gather token inputs, find the OP_RETURN, parse SLP, + // apply conservation. Runs for EVERY tx (a non-SLP tx still burns any token + // UTXOs it spends). void IndexTransaction(const CTransaction& tx, const CBlockIndex* pindex); + + // Replay missing blocks from the stored tip up to chainActive (under cs_main). + bool CatchUp(); }; #endif // BITCOIN_ZSLP_ZSLPINDEXER_H diff --git a/src/zslp/zslpmsg.cpp b/src/zslp/zslpmsg.cpp index 084fcc469ed..c90c9ac7119 100644 --- a/src/zslp/zslpmsg.cpp +++ b/src/zslp/zslpmsg.cpp @@ -13,6 +13,12 @@ extern "C" { #include "zslp/slp.h" } +// The bridge's daemon-side cap MUST equal the C parser's canonical cap, or the +// two layers would disagree on the SEND output bound and fork the ledger +// (R-SEND-1 / R-12). This is the only TU that sees both constants. +static_assert(ZSLP_MAX_SEND_OUTPUTS == ZSLP_SEND_MAX_OUTPUTS, + "ZSLP SEND output cap mismatch between bridge and parser"); + bool ZSLPParseScript(const uint8_t* script, size_t scriptLen, ZSLPMessage& out) { struct slp_message msg; @@ -43,11 +49,79 @@ bool ZSLPParseScript(const uint8_t* script, size_t scriptLen, ZSLPMessage& out) case SLP_TX_SEND: out.type = ZSLPMSG_SEND; memcpy(out.tokenId, msg.token_id.data, 32); + // The parser guarantees 1..ZSLP_SEND_MAX_OUTPUTS; copy them all (the + // bound is identical on both sides per the static_assert above). out.numOutputs = msg.num_outputs; - for (int i = 0; i < msg.num_outputs && i < 20; ++i) + for (int i = 0; i < msg.num_outputs && i < ZSLP_MAX_SEND_OUTPUTS; ++i) out.outputQuantities[i] = msg.output_quantities[i]; return true; default: return false; } } + +// ── Build direction ───────────────────────────────────────────────── + +// Relay cap mirrored locally: this TU cannot include script/standard.h (it +// pulls in the daemon's class uint256, which clashes with slp.h's struct +// uint256). Kept equal to MAX_OP_RETURN_RELAY (script/standard.h:34). +static const size_t ZSLP_BRIDGE_MAX_OP_RETURN_RELAY = 223; + +// A 256-byte scratch buffer comfortably exceeds the 223-byte relay cap, so an +// over-cap GENESIS is detected by the length check below rather than truncating. +static const size_t ZSLP_BUILD_BUF = 256; + +// Wrap the raw encoder output (which already includes the leading 0x6a) into a +// daemon-side vector, returning EMPTY on encoder failure (return 0) or when the +// produced script would exceed the relay cap. +static std::vector FinishBuild(const uint8_t* buf, size_t n) +{ + if (n == 0) + return std::vector(); // encoder failure / invalid input + if (n > ZSLP_BRIDGE_MAX_OP_RETURN_RELAY) + return std::vector(); // too large for one relayed OP_RETURN + return std::vector(buf, buf + n); +} + +std::vector ZSLPBuildGenesis( + const std::string& ticker, const std::string& name, + const std::string& documentUrl, + const uint8_t* documentHash, + uint8_t decimals, uint8_t mintBatonVout, uint64_t initialQuantity) +{ + uint8_t buf[ZSLP_BUILD_BUF]; + size_t n = slp_build_genesis(buf, sizeof(buf), + ticker.empty() ? NULL : ticker.c_str(), + name.empty() ? NULL : name.c_str(), + documentUrl.empty() ? NULL : documentUrl.c_str(), + documentHash, decimals, mintBatonVout, + initialQuantity); + return FinishBuild(buf, n); +} + +std::vector ZSLPBuildMint( + const uint8_t* tokenIdBE, uint8_t mintBatonVout, uint64_t additionalQuantity) +{ + struct uint256 tid; + memcpy(tid.data, tokenIdBE, 32); + uint8_t buf[ZSLP_BUILD_BUF]; + size_t n = slp_build_mint(buf, sizeof(buf), &tid, mintBatonVout, + additionalQuantity); + return FinishBuild(buf, n); +} + +std::vector ZSLPBuildSend( + const uint8_t* tokenIdBE, const std::vector& quantities) +{ + // slp_build_send rejects <1 or >ZSLP_SEND_MAX_OUTPUTS itself (returns 0), + // which FinishBuild maps to an empty result — but guard here too so we never + // pass a stray pointer for an empty vector. + if (quantities.empty() || (int)quantities.size() > ZSLP_SEND_MAX_OUTPUTS) + return std::vector(); + struct uint256 tid; + memcpy(tid.data, tokenIdBE, 32); + uint8_t buf[ZSLP_BUILD_BUF]; + size_t n = slp_build_send(buf, sizeof(buf), &tid, quantities.data(), + (int)quantities.size()); + return FinishBuild(buf, n); +} diff --git a/src/zslp/zslpmsg.h b/src/zslp/zslpmsg.h index 2cb2fc91817..c701f4f0c39 100644 --- a/src/zslp/zslpmsg.h +++ b/src/zslp/zslpmsg.h @@ -16,6 +16,7 @@ #include #include #include +#include /** SLP message kinds (mirror enum slp_tx_type, but daemon-side). */ enum ZSLPMsgType { @@ -25,6 +26,12 @@ enum ZSLPMsgType { ZSLPMSG_SEND = 3, }; +/** Canonical SEND output-quantity cap (R-SEND-1 / R-12). Daemon-side mirror of + * slp.h's ZSLP_SEND_MAX_OUTPUTS, kept in sync by a static_assert in + * zslpmsg.cpp (which is the one place that includes the C header). One number + * in the parser, this bridge, and the store; >this => message INVALID. */ +static const int ZSLP_MAX_SEND_OUTPUTS = 19; + /** Parsed SLP message in a daemon-friendly POD form (no struct uint256). */ struct ZSLPMessage { ZSLPMsgType type; @@ -44,7 +51,7 @@ struct ZSLPMessage { uint64_t additionalQuantity; // MINT // SEND - uint64_t outputQuantities[20]; + uint64_t outputQuantities[ZSLP_MAX_SEND_OUTPUTS]; int numOutputs; ZSLPMessage() : type(ZSLPMSG_INVALID), hasDocumentHash(false), @@ -52,7 +59,7 @@ struct ZSLPMessage { additionalQuantity(0), numOutputs(0) { for (int i = 0; i < 32; ++i) { documentHash[i] = 0; tokenId[i] = 0; } - for (int i = 0; i < 20; ++i) outputQuantities[i] = 0; + for (int i = 0; i < ZSLP_MAX_SEND_OUTPUTS; ++i) outputQuantities[i] = 0; } }; @@ -62,4 +69,42 @@ struct ZSLPMessage { */ bool ZSLPParseScript(const uint8_t* script, size_t scriptLen, ZSLPMessage& out); +// ── Build direction (write path) ──────────────────────────────────── +// +// Thin C++ wrappers around slp_build_genesis / slp_build_mint / slp_build_send. +// They live HERE (the one TU that may include the C header) for the same reason +// the parse bridge does: callers (wallet/zslpwallet.cpp, rpc/zslp.cpp) must never +// include slp.h (struct uint256 vs class uint256 clash). +// +// Each returns the COMPLETE OP_RETURN script bytes (leading 0x6a included), or +// an EMPTY vector on ANY failure (encoder returned 0 = buffer/limit/invalid, or +// the produced script exceeds the MAX_OP_RETURN_RELAY 223-byte relay cap). An +// empty return MUST be treated by the caller as "metadata too large / invalid" +// and the build aborted (R-WALLET, doc/nft/MINT_TRANSFER_SPEC.md §2.6). +// +// `tokenId` for MINT/SEND is the 32-byte token id in ON-CHAIN big-endian / +// display order (i.e. the daemon uint256's bytes REVERSED — the inverse of the +// indexer's TokenIdToUint256). The caller is responsible for that reversal; the +// bridge passes the 32 bytes straight through to slp_build_*. + +/** GENESIS: ticker/name/documentUrl may be empty (encoded as empty pushes). + * documentHash, if non-NULL, points at exactly 32 raw bytes (on-chain order, + * NOT reversed). mintBatonVout is emitted only when >= 2. */ +std::vector ZSLPBuildGenesis( + const std::string& ticker, const std::string& name, + const std::string& documentUrl, + const uint8_t* documentHash /* 32 bytes or NULL */, + uint8_t decimals, uint8_t mintBatonVout, uint64_t initialQuantity); + +/** MINT: tokenIdBE is exactly 32 bytes (on-chain BE order). */ +std::vector ZSLPBuildMint( + const uint8_t* tokenIdBE /* 32 bytes */, + uint8_t mintBatonVout, uint64_t additionalQuantity); + +/** SEND: tokenIdBE is 32 bytes (BE); quantities are positional (qty[j] -> + * vout[1+j]); 1..ZSLP_MAX_SEND_OUTPUTS entries (else empty result). */ +std::vector ZSLPBuildSend( + const uint8_t* tokenIdBE /* 32 bytes */, + const std::vector& quantities); + #endif // BITCOIN_ZSLP_ZSLPMSG_H diff --git a/src/zslp/zslpstore.cpp b/src/zslp/zslpstore.cpp index dffc9199f70..4947e1072e4 100644 --- a/src/zslp/zslpstore.cpp +++ b/src/zslp/zslpstore.cpp @@ -4,6 +4,12 @@ // SLP token data model. See zslpstore.h for the schema and contract. // // NON-consensus: read-only observation. Never touches validation/PoW/wallet. +// +// The model is real SLP Token-Type-1, UTXO-bound: a (txid,vout)->UTXO map is +// the source of truth, the per-address balance is a derived view kept in sync +// by signed deltas, and conservation is enforced per transaction — a SEND can +// only move tokens carried by spent inputs, a MINT needs the baton on a spent +// input. Forgery (crediting yourself, duplicating an NFT) is impossible. #include "zslp/zslpstore.h" @@ -13,6 +19,7 @@ #include #include #include +#include #include // Record-type discriminators (first key byte). @@ -21,6 +28,8 @@ static const char DB_TRANSFER = 'x'; static const char DB_BALANCE = 'b'; static const char DB_UNDO = 'r'; static const char DB_TIP = 'T'; +static const char DB_UTXO = 'u'; +static const char DB_META = 'M'; namespace { @@ -132,6 +141,35 @@ struct TransferPrefix { } }; +// Token-UTXO key: 'u' + txid + BE(vout). Direct-serialized like TransferKey so +// db.Read(UtxoKey(prevout.hash, prevout.n)) is an O(1) point read and a Seek to +// 'u'+txid is a true byte-prefix of all that tx's outputs. +struct UtxoKey { + char prefix; + uint256 txid; + int32_t vout; + UtxoKey() : prefix(DB_UTXO), vout(0) {} + UtxoKey(const uint256& t, int32_t v) : prefix(DB_UTXO), txid(t), vout(v) {} + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(prefix); + READWRITE(txid); + if (ser_action.ForRead()) { + uint8_t vb[4]; + for (int i = 0; i < 4; ++i) READWRITE(vb[i]); + uint32_t vu = 0; for (int i = 0; i < 4; ++i) vu = (vu << 8) | vb[i]; + vout = (int32_t)vu; + } else { + uint32_t vu = (uint32_t)vout; + uint8_t vb[4]; + for (int i = 3; i >= 0; --i) { vb[i] = (uint8_t)(vu & 0xff); vu >>= 8; } + for (int i = 0; i < 4; ++i) READWRITE(vb[i]); + } + } +}; + } // namespace CZSLPStore::CZSLPStore(const boost::filesystem::path& path, size_t nCacheSize, @@ -159,7 +197,19 @@ bool CZSLPStore::ReadTip(int64_t& height, uint256& blockHash) const return true; } -// ── Token helpers ────────────────────────────────────────────────── +// ── Format version stamp ──────────────────────────────────────────── + +bool CZSLPStore::ReadIndexVersion(uint32_t& out) const +{ + return db.Read(std::make_pair(DB_META, (char)0), out); +} + +bool CZSLPStore::WriteIndexVersion(uint32_t version) +{ + return db.Write(std::make_pair(DB_META, (char)0), version); +} + +// ── Token / UTXO / balance helpers ────────────────────────────────── bool CZSLPStore::readToken(const uint256& tokenId, CZSLPToken& out) const { @@ -171,6 +221,18 @@ bool CZSLPStore::GetToken(const uint256& tokenId, CZSLPToken& out) const return readToken(tokenId, out); } +bool CZSLPStore::readUtxo(const uint256& txid, int32_t vout, + CZSLPTokenUtxo& out) const +{ + return db.Read(UtxoKey(txid, vout), out); +} + +bool CZSLPStore::GetUtxo(const uint256& txid, int32_t vout, + CZSLPTokenUtxo& out) const +{ + return readUtxo(txid, vout, out); +} + void CZSLPStore::writeTokenBatch(CDBBatch& batch, const CZSLPToken& token) { batch.Write(std::make_pair(DB_TOKEN, token.tokenId), token); @@ -203,6 +265,19 @@ int64_t CZSLPStore::TokenCount() const return n; } +int64_t CZSLPStore::UtxoCount() const +{ + int64_t n = 0; + boost::scoped_ptr it(const_cast(db).NewIterator()); + for (it->Seek(UtxoKey(uint256(), 0)); it->Valid(); it->Next()) { + UtxoKey key; + if (!it->GetKey(key) || key.prefix != DB_UTXO) + break; + ++n; + } + return n; +} + // ── Undo log ─────────────────────────────────────────────────────── void CZSLPStore::appendUndo(CDBBatch& batch, const CZSLPUndoOp& op) @@ -211,181 +286,318 @@ void CZSLPStore::appendUndo(CDBBatch& batch, const CZSLPUndoOp& op) nUndoSeq++; } -// ── Connect path ─────────────────────────────────────────────────── - -void CZSLPStore::ConnectBlockBegin(const uint256& blockHash) -{ - hashConnecting = blockHash; - nUndoSeq = 0; -} - -bool CZSLPStore::ApplyGenesis(const CZSLPToken& tokenIn, - const std::string& recipient, - const uint256& txid, int32_t vout, - int64_t initialQty) +// ── DRY mutation helpers — the ONLY places that touch 'u', 'b', 'x' ── + +// Create a token UTXO, credit its derived balance (if it carries quantity at a +// decodable address), and append the matching transfer row + undo ops. The +// transfer row is written ONLY for valid (created) outputs, so listtransfers +// never shows a forged/failed transfer. +void CZSLPStore::createUtxo(CDBBatch& batch, BalDeltaMap& bal, + const uint256& txid, int32_t vout, + const uint256& tokenId, int64_t amount, + bool isMintBaton, const std::string& address, + int64_t height, uint8_t txType) { - // GENESIS for an already-known token is a no-op (first genesis wins), - // mirroring the reference's INSERT OR IGNORE on the token row. - CZSLPToken existing; - if (readToken(tokenIn.tokenId, existing)) - return true; - - CDBBatch batch(db); - - CZSLPToken token = tokenIn; - token.totalMinted = initialQty; - writeTokenBatch(batch, token); - - CZSLPUndoOp tok; - tok.kind = UNDO_TOKEN_PUT; - tok.tokenId = token.tokenId; - appendUndo(batch, tok); - - // Transfer record for the genesis mint output. + CZSLPTokenUtxo rec; + rec.tokenId = tokenId; + rec.amount = isMintBaton ? 0 : amount; // baton bears no quantity + rec.isMintBaton = isMintBaton; + rec.address = address; + rec.height = height; + batch.Write(UtxoKey(txid, vout), rec); + + CZSLPUndoOp cu; + cu.kind = UNDO_UTXO_CREATE; + cu.txid = txid; + cu.utxoVout = vout; + appendUndo(batch, cu); + + if (!isMintBaton && amount > 0) + recordBalanceDelta(batch, bal, tokenId, address, amount); + + // Audit-log row for the valid output (batons too — amount 0, type MINT/GENESIS). CZSLPTransfer xfer; - xfer.tokenId = token.tokenId; + xfer.tokenId = tokenId; xfer.txid = txid; xfer.blockHash = hashConnecting; - xfer.blockHeight = token.genesisHeight; - xfer.txType = ZSLP_TX_GENESIS; - xfer.amount = initialQty; + xfer.blockHeight = height; + xfer.txType = txType; + xfer.amount = rec.amount; xfer.vout = vout; - xfer.address = recipient; + xfer.address = address; + batch.Write(TransferKey(tokenId, height, txid, vout), xfer); - batch.Write(TransferKey(token.tokenId, token.genesisHeight, txid, vout), xfer); CZSLPUndoOp xu; xu.kind = UNDO_TRANSFER_PUT; - xu.tokenId = token.tokenId; + xu.tokenId = tokenId; xu.txid = txid; - xu.blockHeight = token.genesisHeight; + xu.blockHeight = height; xu.vout = vout; appendUndo(batch, xu); - - // Credit the recipient balance (skip empty/undecodable addresses). - if (!recipient.empty() && initialQty > 0) { - int64_t bal = readBalance(token.tokenId, recipient); - if (bal <= std::numeric_limits::max() - initialQty) { - batch.Write(BalanceKey(token.tokenId, recipient), bal + initialQty); - CZSLPUndoOp bu; - bu.kind = UNDO_BALANCE_ADD; - bu.tokenId = token.tokenId; - bu.address = recipient; - bu.amount = initialQty; - appendUndo(batch, bu); - } - } - - return db.WriteBatch(batch); } -bool CZSLPStore::ApplyMint(const uint256& tokenId, const std::string& recipient, - const uint256& txid, int64_t blockHeight, - int32_t vout, int64_t addQty, bool batonMoved, - uint8_t newBatonVout) +// Consume (erase) a token UTXO: remove the 'u' record, reverse its derived +// balance credit, and log a CONSUME undo that carries the FULL record so a +// disconnect can reconstruct it. +void CZSLPStore::consumeUtxo(CDBBatch& batch, BalDeltaMap& bal, + const uint256& txid, int32_t vout, + const CZSLPTokenUtxo& rec) { - CZSLPToken token; - if (!readToken(tokenId, token)) - return false; // MINT of an unknown token: ignore (reference no-ops too) + batch.Erase(UtxoKey(txid, vout)); + + CZSLPUndoOp op; + op.kind = UNDO_UTXO_CONSUME; + op.txid = txid; + op.utxoVout = vout; + op.tokenId = rec.tokenId; + op.amount = rec.amount; + op.isMintBaton = rec.isMintBaton; + op.address = rec.address; + op.blockHeight = rec.height; + appendUndo(batch, op); + + if (!rec.isMintBaton && rec.amount > 0) + recordBalanceDelta(batch, bal, rec.tokenId, rec.address, -rec.amount); +} - CDBBatch batch(db); +// Accumulate a SIGNED derived-balance delta in memory and append the matching +// signed UNDO_BALANCE_ADD undo op immediately. The undo op is per-delta (the +// disconnect path nets them); the committed 'b' write is deferred to +// flushBalances() so several deltas to one address in a single tx net first. +void CZSLPStore::recordBalanceDelta(CDBBatch& batch, BalDeltaMap& bal, + const uint256& tokenId, + const std::string& address, int64_t delta) +{ + if (address.empty() || delta == 0) + return; + bal[std::make_pair(tokenId, address)] += delta; + + CZSLPUndoOp bu; + bu.kind = UNDO_BALANCE_ADD; + bu.tokenId = tokenId; + bu.address = address; + bu.amount = delta; // SIGNED + appendUndo(batch, bu); +} - // total_minted += addQty (overflow-guarded). - if (addQty > 0 && - token.totalMinted <= std::numeric_limits::max() - addQty) { - token.totalMinted += addQty; - CZSLPUndoOp mu; - mu.kind = UNDO_MINTED_ADD; - mu.tokenId = tokenId; - mu.amount = addQty; - appendUndo(batch, mu); +// Commit the netted per-address balance deltas: read each committed balance +// ONCE, apply the net delta, and write (or erase at <= 0). The single place +// that mutates the committed 'b' record on the connect path. +void CZSLPStore::flushBalances(CDBBatch& batch, const BalDeltaMap& bal) +{ + for (BalDeltaMap::const_iterator it = bal.begin(); it != bal.end(); ++it) { + int64_t delta = it->second; + if (delta == 0) + continue; + int64_t cur = readBalance(it->first.first, it->first.second); + // Overflow guard on the positive direction; negatives only subtract a + // value previously added, so they cannot underflow below 0 in practice. + if (delta > 0 && cur > std::numeric_limits::max() - delta) + continue; + int64_t newBal = cur + delta; + if (newBal > 0) + batch.Write(BalanceKey(it->first.first, it->first.second), newBal); + else + batch.Erase(BalanceKey(it->first.first, it->first.second)); } +} - if (batonMoved && token.mintBatonVout != newBatonVout) { - CZSLPUndoOp bsu; - bsu.kind = UNDO_BATON_SET; - bsu.tokenId = tokenId; - bsu.prevBaton = token.mintBatonVout; - appendUndo(batch, bsu); - token.mintBatonVout = newBatonVout; - } +// ── Connect path ─────────────────────────────────────────────────── - writeTokenBatch(batch, token); +void CZSLPStore::ConnectBlockBegin(const uint256& blockHash) +{ + hashConnecting = blockHash; + nUndoSeq = 0; +} - CZSLPTransfer xfer; - xfer.tokenId = tokenId; - xfer.txid = txid; - xfer.blockHash = hashConnecting; - xfer.blockHeight = blockHeight; - xfer.txType = ZSLP_TX_MINT; - xfer.amount = addQty; - xfer.vout = vout; - xfer.address = recipient; - batch.Write(TransferKey(tokenId, blockHeight, txid, vout), xfer); - CZSLPUndoOp xu; - xu.kind = UNDO_TRANSFER_PUT; - xu.tokenId = tokenId; - xu.txid = txid; - xu.blockHeight = blockHeight; - xu.vout = vout; - appendUndo(batch, xu); +bool CZSLPStore::ApplyTransaction( + const std::vector& vin, + const CZSLPParsedMsg* msg, + const uint256& txid, int64_t height, + const CZSLPToken* genesisMeta, + const std::function& addrOfVout, + int32_t voutCount) +{ + // One batch per tx, committed before the indexer's next tx, so a later tx + // in the SAME block sees the 'u'/'b' rows an earlier tx wrote (CDBBatch is + // write-only; db.Read sees only committed data — dbwrapper.h). + CDBBatch batch(db); - if (!recipient.empty() && addQty > 0) { - int64_t bal = readBalance(tokenId, recipient); - if (bal <= std::numeric_limits::max() - addQty) { - batch.Write(BalanceKey(tokenId, recipient), bal + addQty); - CZSLPUndoOp bu; - bu.kind = UNDO_BALANCE_ADD; - bu.tokenId = tokenId; - bu.address = recipient; - bu.amount = addQty; - appendUndo(batch, bu); - } + // Net the derived-balance changes of THIS tx in memory; flush once at the + // end. Required because readBalance() sees only committed data, so a consume + // (-amount) followed by a same-address change credit (+amount) within one tx + // would otherwise read the same committed value twice and lose one delta. + BalDeltaMap balDeltas; + + // (a) GATHER + CONSUME the token UTXOs spent by this tx (every tx, SLP or + // not). Any consumed input not re-assigned by a valid SLP message of + // its tokenId is thereby BURNED. + std::map availByToken; // summed input quantity per token + std::map batonInputPresent; // a baton input for this token? + for (size_t k = 0; k < vin.size(); ++k) { + CZSLPTokenUtxo rec; + if (!readUtxo(vin[k].hash, (int32_t)vin[k].n, rec)) + continue; // not a token-carrying input + if (rec.isMintBaton) + batonInputPresent[rec.tokenId] = true; + else + availByToken[rec.tokenId] += rec.amount; + consumeUtxo(batch, balDeltas, vin[k].hash, (int32_t)vin[k].n, rec); } - return db.WriteBatch(batch); -} - -bool CZSLPStore::ApplySend(const uint256& tokenId, const std::string& recipient, - const uint256& txid, int64_t blockHeight, - int32_t vout, int64_t amount) -{ - // Only record SENDs of a known token (genesis must have been seen). - if (!db.Exists(std::make_pair(DB_TOKEN, tokenId))) - return false; + // (b) DISPATCH on the parsed message (NULL => non-SLP tx; inputs already + // burned, nothing created). + if (msg != NULL) { + switch (msg->type) { + case ZSLP_MSG_GENESIS: { + const uint256 tokenId = txid; // canonical SLP: token id == genesis txid + // First-genesis-wins: only INSERT the token row if absent. The + // input-consume in (a) already happened regardless. + CZSLPToken existing; + if (genesisMeta != NULL && !readToken(tokenId, existing)) { + CZSLPToken token = *genesisMeta; + token.tokenId = tokenId; + // R-GEN-3 (FIX): totalMinted counts ONLY quantity ACTUALLY + // created as a UTXO, not declared-but-uncreated. The initial + // quantity is created at vout[1] iff vout[1] exists; a GENESIS + // with no vout[1] creates nothing and reports supply 0. (The + // high-bit / >=2^63 case is already rejected at parse, so + // initialQuantity here is a non-negative int64.) + bool createInitial = (msg->initialQuantity > 0 && voutCount > 1); + token.totalMinted = createInitial ? msg->initialQuantity : 0; + // Baton display-mirror reflects the live baton UTXO below. + token.mintBatonVout = 0; + bool batonIssued = (msg->mintBatonVout >= 2 && + msg->mintBatonVout < voutCount); + if (batonIssued) + token.mintBatonVout = (uint8_t)msg->mintBatonVout; + writeTokenBatch(batch, token); + CZSLPUndoOp tok; + tok.kind = UNDO_TOKEN_PUT; + tok.tokenId = tokenId; + appendUndo(batch, tok); + + // Mint output at vout[1] (only when it exists — R-GEN-3). + if (createInitial) { + createUtxo(batch, balDeltas, txid, 1, tokenId, msg->initialQuantity, + false, addrOfVout(1), height, ZSLP_TX_GENESIS); + } + // Baton output at its declared vout. + if (batonIssued) { + createUtxo(batch, balDeltas, txid, msg->mintBatonVout, tokenId, 0, true, + addrOfVout(msg->mintBatonVout), height, + ZSLP_TX_GENESIS); + } + } + break; + } + case ZSLP_MSG_MINT: { + const uint256 tokenId = msg->tokenId; + CZSLPToken token; + if (!readToken(tokenId, token)) + break; // MINT of an unknown token: invalid, no outputs. + // VALID iff a mint baton for this token was on a spent input. + if (!batonInputPresent.count(tokenId)) + break; // no baton => create nothing; consumed inputs stay burned. + + // R-MINT-3 (FIX): total_minted += additionalQuantity ONLY for + // quantity ACTUALLY created as a UTXO (i.e. vout[1] exists), to + // match R-GEN-3 — a MINT with no vout[1] creates nothing and must + // not bump supply. Overflow-guarded; the high-bit / >=2^63 case is + // already rejected at parse. The undo op is appended IFF the bump + // happens, so DisconnectBlock reverses exactly what was applied. + bool createAdditional = + (msg->additionalQuantity > 0 && voutCount > 1); + if (createAdditional && + token.totalMinted <= + std::numeric_limits::max() - msg->additionalQuantity) { + token.totalMinted += msg->additionalQuantity; + CZSLPUndoOp mu; + mu.kind = UNDO_MINTED_ADD; + mu.tokenId = tokenId; + mu.amount = msg->additionalQuantity; + appendUndo(batch, mu); + } else { + // Overflow (or nothing to create): supply unchanged, no UTXO. + createAdditional = false; + } - CDBBatch batch(db); + bool batonContinues = (msg->mintBatonVout >= 2 && + msg->mintBatonVout < voutCount); + uint8_t newBaton = batonContinues ? (uint8_t)msg->mintBatonVout : 0; + if (token.mintBatonVout != newBaton) { + CZSLPUndoOp bsu; + bsu.kind = UNDO_BATON_SET; + bsu.tokenId = tokenId; + bsu.prevBaton = token.mintBatonVout; + appendUndo(batch, bsu); + token.mintBatonVout = newBaton; + } + writeTokenBatch(batch, token); - CZSLPTransfer xfer; - xfer.tokenId = tokenId; - xfer.txid = txid; - xfer.blockHash = hashConnecting; - xfer.blockHeight = blockHeight; - xfer.txType = ZSLP_TX_SEND; - xfer.amount = amount; - xfer.vout = vout; - xfer.address = recipient; - batch.Write(TransferKey(tokenId, blockHeight, txid, vout), xfer); - CZSLPUndoOp xu; - xu.kind = UNDO_TRANSFER_PUT; - xu.tokenId = tokenId; - xu.txid = txid; - xu.blockHeight = blockHeight; - xu.vout = vout; - appendUndo(batch, xu); + if (createAdditional) { + createUtxo(batch, balDeltas, txid, 1, tokenId, msg->additionalQuantity, + false, addrOfVout(1), height, ZSLP_TX_MINT); + } + if (batonContinues) { + createUtxo(batch, balDeltas, txid, msg->mintBatonVout, tokenId, 0, true, + addrOfVout(msg->mintBatonVout), height, ZSLP_TX_MINT); + } + break; + } + case ZSLP_MSG_SEND: { + const uint256 tokenId = msg->tokenId; + std::map::const_iterator ait = + availByToken.find(tokenId); + int64_t availIn = (ait == availByToken.end()) ? 0 : ait->second; + + // requiredOut = Σ outputQuantities (overflow-guarded => INVALID). + // The parser already enforced 1..ZSLP_SEND_MAX_OUTPUTS_STORE + // (R-SEND-1 / R-12); clamp defensively to the SAME single cap (was + // 20, a fork bug vs the parser's 19) so the loop never reads past + // the array bound even if a future caller supplies a stray count. + int64_t requiredOut = 0; + bool overflow = false; + int n = msg->numOutputs; + if (n < 0) n = 0; + if (n > ZSLP_SEND_MAX_OUTPUTS_STORE) + n = ZSLP_SEND_MAX_OUTPUTS_STORE; + for (int j = 0; j < n; ++j) { + int64_t q = msg->outputQuantities[j]; + if (q < 0) { overflow = true; break; } + if (requiredOut > std::numeric_limits::max() - q) { + overflow = true; break; + } + requiredOut += q; + } - if (!recipient.empty() && amount > 0) { - int64_t bal = readBalance(tokenId, recipient); - if (bal <= std::numeric_limits::max() - amount) { - batch.Write(BalanceKey(tokenId, recipient), bal + amount); - CZSLPUndoOp bu; - bu.kind = UNDO_BALANCE_ADD; - bu.tokenId = tokenId; - bu.address = recipient; - bu.amount = amount; - appendUndo(batch, bu); + if (!overflow && availIn >= requiredOut) { + // Positional mapping j -> vout[1+j] preserved across zero-qty + // outputs (a zero-qty output consumes a slot, creates nothing). + for (int j = 0; j < n; ++j) { + int64_t qty = msg->outputQuantities[j]; + if (qty <= 0) + continue; + int32_t voutIdx = 1 + j; + if (voutIdx >= voutCount) + continue; // output vout doesn't exist => that qty burned + createUtxo(batch, balDeltas, txid, voutIdx, tokenId, qty, false, + addrOfVout(voutIdx), height, ZSLP_TX_SEND); + } + // (availIn - requiredOut) is burned implicitly (never created). + } + // availIn < requiredOut (or overflow) => INVALID: create nothing; + // all that-token inputs already burned in (a). + break; + } + default: + break; } } + // Commit the netted derived-balance deltas once each. + flushBalances(batch, balDeltas); + return db.WriteBatch(batch); } @@ -396,6 +608,150 @@ bool CZSLPStore::ConnectBlockEnd(int64_t height, const uint256& blockHash) return WriteTip(height, blockHash); } +// READ-ONLY mirror of ApplyTransaction's accept/conservation logic. Every check +// and overflow guard below is intentionally identical to the corresponding line +// in ApplyTransaction (a divergence here would let the wallet broadcast a tx the +// live indexer rejects). It only ever calls readUtxo()/readToken() (point reads) +// and NEVER stages a batch write. +bool CZSLPStore::WouldBeValid( + const std::vector& vin, + const CZSLPParsedMsg* msg, + const uint256& txid, + const CZSLPToken* genesisMeta, + int32_t voutCount, + std::string& reason) const +{ + if (msg == NULL) { + reason = "no SLP message at vout[0]"; + return false; + } + + // (a) Recompute availIn / baton presence from spent inputs (mirror + // ApplyTransaction step (a); batons contribute 0, non-token inputs are + // skipped). Read-only: readUtxo() instead of consumeUtxo(). + std::map availByToken; + std::map batonInputPresent; + for (size_t k = 0; k < vin.size(); ++k) { + CZSLPTokenUtxo rec; + if (!readUtxo(vin[k].hash, (int32_t)vin[k].n, rec)) + continue; // not a token-carrying input + if (rec.isMintBaton) + batonInputPresent[rec.tokenId] = true; + else + availByToken[rec.tokenId] += rec.amount; + } + + switch (msg->type) { + case ZSLP_MSG_GENESIS: { + const uint256 tokenId = txid; // canonical SLP: token id == genesis txid + if (genesisMeta == NULL) { + reason = "GENESIS without metadata"; + return false; + } + // First-genesis-wins: a GENESIS whose txid already names a token would + // create NOTHING (the indexer only inserts when absent) — that is a + // builder bug (it cannot happen for a freshly-built tx, txid is the + // genesis hash), so treat a collision as invalid. + CZSLPToken existing; + if (readToken(tokenId, existing)) { + reason = "GENESIS token id already exists"; + return false; + } + // The mint output is created at vout[1] IFF vout[1] exists (R-GEN-3); + // an NFT/fungible mint that declares a quantity but provides no vout[1] + // silently creates supply 0 — never what a builder intends. + if (msg->initialQuantity < 0) { reason = "GENESIS quantity negative/overflow"; return false; } + if (msg->initialQuantity > 0 && voutCount <= 1) { + reason = "GENESIS declares quantity but has no vout[1] to carry it"; + return false; + } + // A declared baton (mintBatonVout>=2) must reference an existing output. + if (msg->mintBatonVout >= 2 && msg->mintBatonVout >= voutCount) { + reason = "GENESIS baton vout out of range"; + return false; + } + return true; + } + case ZSLP_MSG_MINT: { + const uint256 tokenId = msg->tokenId; + CZSLPToken token; + if (!readToken(tokenId, token)) { + reason = "MINT of unknown token"; + return false; + } + if (!batonInputPresent.count(tokenId)) { + reason = "MINT requires the live mint baton as an input"; + return false; + } + if (msg->additionalQuantity < 0) { reason = "MINT quantity negative/overflow"; return false; } + if (msg->additionalQuantity > 0 && voutCount <= 1) { + reason = "MINT declares quantity but has no vout[1] to carry it"; + return false; + } + // Overflow of the issued-supply counter (mirror ApplyTransaction's guard) + if (msg->additionalQuantity > 0 && + token.totalMinted > std::numeric_limits::max() - msg->additionalQuantity) { + reason = "MINT would overflow total minted supply"; + return false; + } + if (msg->mintBatonVout >= 2 && msg->mintBatonVout >= voutCount) { + reason = "MINT baton vout out of range"; + return false; + } + return true; + } + case ZSLP_MSG_SEND: { + const uint256 tokenId = msg->tokenId; + std::map::const_iterator ait = availByToken.find(tokenId); + int64_t availIn = (ait == availByToken.end()) ? 0 : ait->second; + + // requiredOut = Σ outputQuantities with the SAME overflow guard as + // ApplyTransaction. Also assert every nonzero quantity lands on an + // existing output (so the builder never burns a declared quantity by + // omitting its recipient vout — that is the silent-burn class R-WALLET-9 + // must catch). + int64_t requiredOut = 0; + int n = msg->numOutputs; + if (n < 1) { reason = "SEND has no output quantities"; return false; } + if (n > ZSLP_SEND_MAX_OUTPUTS_STORE) { + reason = "SEND exceeds the maximum output count"; + return false; + } + for (int j = 0; j < n; ++j) { + int64_t q = msg->outputQuantities[j]; + if (q < 0) { reason = "SEND quantity negative/overflow"; return false; } + if (requiredOut > std::numeric_limits::max() - q) { + reason = "SEND output total overflows"; + return false; + } + requiredOut += q; + // A nonzero quantity whose target vout doesn't exist is an + // unintended burn of that quantity. + if (q > 0 && (int32_t)(1 + j) >= voutCount) { + reason = "SEND quantity maps to a nonexistent output (would burn)"; + return false; + } + } + if (availIn < requiredOut) { + reason = "SEND inputs do not cover outputs (would burn the token)"; + return false; + } + // availIn > requiredOut is allowed by the ledger (surplus burned), but + // the BUILDER must add a token-change output so nothing is silently + // burned; we therefore require exact conservation here. A surplus means + // the builder forgot the change output. + if (availIn != requiredOut) { + reason = "SEND would burn token surplus (missing token-change output)"; + return false; + } + return true; + } + default: + reason = "unknown SLP message type"; + return false; + } +} + // ── Disconnect path (reorg) ──────────────────────────────────────── bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, @@ -422,15 +778,18 @@ bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, CDBBatch batch(db); - // Accumulate per-token and per-balance changes in memory and write each - // record exactly ONCE. A single block can log multiple undo ops against the - // same record (a MINT logs both UNDO_MINTED_ADD and UNDO_BATON_SET; several - // mints can credit one address). readToken/readBalance see only the committed - // DB — not this pending batch — so a per-op read-modify-write would clobber - // a sibling op's change (e.g. the baton revert lost behind the minted revert). + // Accumulate per-token / per-balance / per-utxo changes in memory and write + // each record exactly ONCE. A single block can log multiple undo ops against + // the same record (a MINT logs UNDO_MINTED_ADD + UNDO_BATON_SET; many deltas + // can hit one address balance; a UTXO can be created then consumed in the + // same block). readToken/readBalance/readUtxo see only the committed DB — + // not this pending batch — so a per-op read-modify-write would clobber a + // sibling op's change. std::map tokenMods; std::set tokenErased; std::map, int64_t> balMods; + // boost::none => erase the 'u' record; a value => write that full record. + std::map, boost::optional > utxoMods; for (int i = (int)ops.size() - 1; i >= 0; --i) { const CZSLPUndoOp& op = ops[i]; @@ -448,7 +807,7 @@ bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, std::map, int64_t>::iterator bit = balMods.find(bk); if (bit == balMods.end()) bit = balMods.insert(std::make_pair(bk, readBalance(op.tokenId, op.address))).first; - bit->second -= op.amount; + bit->second -= op.amount; // op.amount is a SIGNED delta; reversal is sign-agnostic break; } case UNDO_MINTED_ADD: { @@ -479,6 +838,25 @@ bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, mit->second.mintBatonVout = op.prevBaton; break; } + case UNDO_UTXO_CREATE: { + // Reverse of create: erase the 'u' record. (Reverse-order replay + // means a same-block create+consume nets to erase: CONSUME is seen + // first and stages a write, then CREATE stages an erase — last + // writer per key in this loop wins, and CREATE has the lower seq so + // it is processed later here, leaving the erase.) + utxoMods[std::make_pair(op.txid, op.utxoVout)] = boost::none; + break; + } + case UNDO_UTXO_CONSUME: { + CZSLPTokenUtxo rec; + rec.tokenId = op.tokenId; + rec.amount = op.amount; + rec.isMintBaton = op.isMintBaton; + rec.address = op.address; + rec.height = op.blockHeight; + utxoMods[std::make_pair(op.txid, op.utxoVout)] = rec; + break; + } default: break; } @@ -498,6 +876,15 @@ bool CZSLPStore::DisconnectBlock(const uint256& blockHash, int64_t prevHeight, batch.Write(BalanceKey(it3->first.first, it3->first.second), it3->second); } + // Write each accumulated UTXO modification once. + for (std::map, boost::optional >::const_iterator + it4 = utxoMods.begin(); it4 != utxoMods.end(); ++it4) { + if (!it4->second) + batch.Erase(UtxoKey(it4->first.first, it4->first.second)); + else + batch.Write(UtxoKey(it4->first.first, it4->first.second), *it4->second); + } + // Drop the undo log for this block. for (size_t i = 0; i < seqs.size(); ++i) batch.Erase(UndoKey(blockHash, seqs[i])); @@ -551,9 +938,32 @@ int CZSLPStore::ListTransfers(const uint256& tokenId, int from, int count, if (from < 0) from = 0; - // Keys are 'x'+tokenId+BE(height)+txid+BE(vout): ascending height. We want - // newest-first, so gather all for this token then reverse and window. - std::vector all; + // R-24 (FIX): bound PEAK MEMORY to O(count) — INDEPENDENT of `from` — and never + // O(total transfers for the token). Keys are 'x'+tokenId+BE(height)+txid+BE(vout) + // in ASCENDING (oldest-first) order; the result is newest-first, skipping `from` + // then taking `count`. + // + // A previous ring-buffer attempt sized its window to (from+count). Because the + // RPC clamps `from` only to >=0 (UniValue get_int() admits up to INT_MAX), a + // single `zslp_listtransfers "tid" 1 2000000000` would allocate ~2e9 rows and + // OOM the daemon — WORSE than the chain-bounded O(total) CPU it replaced. The + // intended cap there was a no-op (count<=ZSLP_LIST_MAX makes from+count never + // exceed from+ZSLP_LIST_MAX), so `from` flowed straight into the allocation. + // + // CDBIterator exposes no Prev()/SeekToLast(), so instead we make TWO forward + // passes: + // pass 1 counts N rows for this token (keys only, no value deserialization); + // pass 2 deserializes ONLY the (<= count) rows in the target ascending window + // [lo, hi] = [max(0, N-from-count), N-1-from], then emits newest-first. + // Peak allocation is O(count) regardless of `from` or N, fully closing the + // one-cheap-tx -> expensive-RPC amplification (T15). Both passes run under + // cs_main, where the index mutates only on ChainTip, so N is stable between + // them. CPU stays O(N) (chain-bounded; pass 2 stops at `hi`), and value + // deserialization drops from O(N) to O(count). R-RPC-2 ordering (height- + // ascending key order, reversed to newest-first) is preserved bit-for-bit. + + // Pass 1: count rows in this token's contiguous keyspace (keys only). + int64_t total = 0; { boost::scoped_ptr it(const_cast(db).NewIterator()); for (it->Seek(TransferPrefix(tokenId)); it->Valid(); it->Next()) { @@ -564,17 +974,48 @@ int CZSLPStore::ListTransfers(const uint256& tokenId, int from, int count, break; if (key.tokenId != tokenId) break; + ++total; + } + } + if (total == 0 || (int64_t)from >= total) + return 0; + + // Ascending-index window to collect: [lo, hi], at most `count` rows. hi>=0 + // here because from < total. lo floors at 0 when fewer than `count` remain. + const int64_t hi = total - 1 - (int64_t)from; // newest row we emit (first out) + int64_t lo = hi - (int64_t)count + 1; // oldest row we emit (last out) + if (lo < 0) + lo = 0; + const size_t take = (size_t)(hi - lo + 1); // <= count + + // Pass 2: deserialize ONLY ascending rows in [lo, hi]; stop past hi. + std::vector asc; + asc.reserve(take); + { + int64_t idx = 0; + boost::scoped_ptr it(const_cast(db).NewIterator()); + for (it->Seek(TransferPrefix(tokenId)); it->Valid(); it->Next(), ++idx) { + TransferKey key; + if (!it->GetKey(key) || key.prefix != DB_TRANSFER) + break; + if (key.tokenId != tokenId) + break; + if (idx < lo) + continue; + if (idx > hi) + break; CZSLPTransfer xfer; if (!it->GetValue(xfer)) break; - all.push_back(xfer); + asc.push_back(xfer); } } - // Newest-first. - std::reverse(all.begin(), all.end()); - for (size_t i = (size_t)from; i < all.size() && (int)out.size() < count; ++i) - out.push_back(all[i]); + // Emit newest-first: reverse the ascending window (hi..lo). + out.reserve(asc.size()); + for (std::vector::reverse_iterator rit = asc.rbegin(); + rit != asc.rend(); ++rit) + out.push_back(*rit); return (int)out.size(); } diff --git a/src/zslp/zslpstore.h b/src/zslp/zslpstore.h index cd7cc3d60b8..6cde20563d7 100644 --- a/src/zslp/zslpstore.h +++ b/src/zslp/zslpstore.h @@ -8,23 +8,34 @@ // // Re-implements the data model from the zclassic-c reference // (app/models/src/zslp.c + adapters/.../zslp_store_sqlite.c) over LevelDB -// (CDBWrapper) instead of sqlite. The on-chain semantics are identical: -// - token genesis records (metadata + total_minted + baton state) -// - transfer records (one per token-bearing vout) -// - per-(token,address) balances (ZSLP rides transparent dust) +// (CDBWrapper) instead of sqlite. The on-chain semantics are the *real* SLP +// Token-Type-1 UTXO-bound rules: +// - token genesis records (metadata + total_minted + baton display mirror) +// - token-carrying UTXOs keyed by (txid, vout) — THE SOURCE OF TRUTH +// - per-(token,address) balances — a DERIVED view, kept conservation-correct +// by signed +/- deltas as token UTXOs are created/consumed +// - transfer records (one per VALID token-bearing vout) — a human audit log +// A SEND can only move tokens that exist on spent inputs; a MINT requires the +// mint baton on a spent input; an unfunded/over-budget SEND or baton-less MINT +// creates NOTHING (and burns any consumed inputs). Forgery is impossible. +// // Records are tagged by block hash + height + txid so a reorg can delete -// exactly the records a given block added, and a tip marker enables -// crash-resume. +// exactly the records a given block added (and restore the UTXOs it consumed), +// and a tip marker enables crash-resume. #ifndef BITCOIN_ZSLP_ZSLPSTORE_H #define BITCOIN_ZSLP_ZSLPSTORE_H #include "dbwrapper.h" +#include "primitives/transaction.h" // COutPoint #include "serialize.h" #include "uint256.h" #include +#include +#include #include +#include #include #include @@ -37,6 +48,26 @@ static const uint8_t ZSLP_TX_SEND = 3; /** Default upper bound for the count argument of the list RPCs. */ static const int ZSLP_LIST_MAX = 1000; +/** Canonical SEND output-quantity cap (R-SEND-1 / R-12). MUST equal the C + * parser's ZSLP_SEND_MAX_OUTPUTS and the bridge's ZSLP_MAX_SEND_OUTPUTS; the + * indexer asserts this at the bridge boundary. A SEND with more than this many + * quantities is rejected by the parser (whole message INVALID), so the store + * never sees a larger count — this is both the array bound and the loop cap. */ +static const int ZSLP_SEND_MAX_OUTPUTS_STORE = 19; + +/** On-disk index format version. Bump when the schema/semantics change so the + * indexer wipes + rebuilds (the index is fully derivable and behind -zslpindex). + * v1 = legacy credit-only (absent stamp). v2 = UTXO-bound conservation. */ +static const uint32_t ZSLP_INDEX_VERSION = 2; + +/** Parsed SLP message kind, matching ZSLPMsgType, but usable by the store + * without pulling in the message bridge header. */ +enum ZSLPStoreMsgType : uint8_t { + ZSLP_MSG_GENESIS = 1, + ZSLP_MSG_MINT = 2, + ZSLP_MSG_SEND = 3, +}; + /** Persisted token genesis / metadata record. */ class CZSLPToken { @@ -48,9 +79,9 @@ class CZSLPToken uint256 documentHash; //!< 0 when absent bool hasDocumentHash; uint8_t decimals; - uint8_t mintBatonVout; //!< 0 = no/spent baton + uint8_t mintBatonVout; //!< 0 = no/spent baton (DISPLAY mirror of the live baton UTXO) int64_t genesisHeight; - int64_t totalMinted; //!< running sum of genesis + mint quantities + int64_t totalMinted; //!< running sum of genesis + mint quantities (issued supply) CZSLPToken() { SetNull(); } @@ -86,7 +117,48 @@ class CZSLPToken } }; -/** Persisted transfer record — one per token-bearing event/vout. */ +/** + * Persisted token-carrying UTXO record — THE SOURCE OF TRUTH for ownership. + * Keyed by (txid, vout). A token quantity (or a mint baton) lives at exactly + * one UTXO; SEND/MINT consume inputs and create new UTXOs conservatively. + * + * Invariant: isMintBaton == true => amount == 0 (a baton bears no quantity; + * it is never counted in a SEND's availIn). + */ +class CZSLPTokenUtxo +{ +public: + uint256 tokenId; + int64_t amount; //!< 0 for a baton + bool isMintBaton; + std::string address; //!< owner t-address, "" if undecodable + int64_t height; //!< block height the UTXO was created at + + CZSLPTokenUtxo() { SetNull(); } + + void SetNull() + { + tokenId.SetNull(); + amount = 0; + isMintBaton = false; + address.clear(); + height = 0; + } + + ADD_SERIALIZE_METHODS; + + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + READWRITE(tokenId); + READWRITE(amount); + READWRITE(isMintBaton); + READWRITE(address); + READWRITE(height); + } +}; + +/** Persisted transfer record — one per VALID token-bearing output. */ class CZSLPTransfer { public: @@ -129,20 +201,45 @@ class CZSLPTransfer } }; +/** Minimal parsed-message view the store needs to apply a transaction. The + * indexer fills this from the ZSLPMessage it parsed; the store needs no + * script knowledge (consumed-input addresses come from stored UTXOs). */ +struct CZSLPParsedMsg { + ZSLPStoreMsgType type; + uint256 tokenId; //!< MINT/SEND: target token. GENESIS: ignored (txid is id). + int64_t initialQuantity; //!< GENESIS + int64_t additionalQuantity; //!< MINT + int32_t mintBatonVout; //!< GENESIS/MINT: >=2 to (re)issue a baton, else 0/end + int numOutputs; //!< SEND + int64_t outputQuantities[ZSLP_SEND_MAX_OUTPUTS_STORE]; //!< SEND: outputQuantities[j] -> vout[1+j] + + CZSLPParsedMsg() + : type(ZSLP_MSG_SEND), initialQuantity(0), additionalQuantity(0), + mintBatonVout(0), numOutputs(0) + { + tokenId.SetNull(); + for (int i = 0; i < ZSLP_SEND_MAX_OUTPUTS_STORE; ++i) + outputQuantities[i] = 0; + } +}; + /** * LevelDB token store. * * Key schema (first byte is a record-type discriminator): - * 't' + tokenId -> CZSLPToken (token by id) - * 'x' + tokenId + height + txid + vout -> CZSLPTransfer (ordered transfers) - * 'b' + tokenId + address -> int64 balance (balances) - * 'r' + blockHash + seq -> CZSLPUndoOp (reorg undo log) - * 'T' -> (height, blockHash) (tip marker) + * 't' + tokenId -> CZSLPToken (token by id) + * 'u' + txid + BE(vout) -> CZSLPTokenUtxo (token UTXO — TRUTH) + * 'x' + tokenId + BE(height) + txid + BE(vout) -> CZSLPTransfer (ordered transfers) + * 'b' + tokenId + address -> int64 balance (DERIVED view) + * 'r' + blockHash + BE(seq) -> CZSLPUndoOp (reorg undo log) + * 'T' -> (height, blockHash) (tip marker) + * 'M' + 0 -> uint32 version (format stamp) * - * The undo log records, per block, exactly which puts/credits were applied so - * that DisconnectBlock can reverse them precisely (genesis/transfer deletion + - * balance decrement + total_minted decrement), restoring the store to its - * pre-connect state. + * The undo log records, per block, exactly which UTXO creates/consumes and + * derived-balance deltas were applied so that DisconnectBlock can reverse them + * precisely (restore consumed UTXOs, erase created ones, reverse balance/total_ + * minted/baton changes), restoring the store byte-for-byte to its pre-connect + * state. */ class CZSLPStore { @@ -150,31 +247,38 @@ class CZSLPStore CDBWrapper db; // The reorg undo log appends ops under 'r'+blockHash+seq while a block is - // being connected; ConnectBlockBegin resets the running sequence. + // being connected; ConnectBlockBegin resets the running sequence. The seq + // runs across ALL txs in the block (not per-tx). uint32_t nUndoSeq; uint256 hashConnecting; //!< block currently being connected (for undo keys) public: /** Undo-op kinds appended while connecting a block. */ enum UndoKind : uint8_t { - UNDO_TOKEN_PUT = 1, //!< a genesis token record was created - UNDO_TRANSFER_PUT = 2, //!< a transfer record was created - UNDO_BALANCE_ADD = 3, //!< balance(token,address) was credited by amount - UNDO_MINTED_ADD = 4, //!< token.totalMinted was increased by amount - UNDO_BATON_SET = 5, //!< token.mintBatonVout changed (old value stored) + UNDO_TOKEN_PUT = 1, //!< a genesis token record was created + UNDO_TRANSFER_PUT = 2, //!< a transfer record was created + UNDO_BALANCE_ADD = 3, //!< balance(token,address) changed by SIGNED amount + UNDO_MINTED_ADD = 4, //!< token.totalMinted was increased by amount + UNDO_BATON_SET = 5, //!< token.mintBatonVout changed (old value stored) + UNDO_UTXO_CREATE = 6, //!< a token UTXO was created (disconnect: erase it) + UNDO_UTXO_CONSUME = 7, //!< a token UTXO was consumed (disconnect: re-write it) }; struct CZSLPUndoOp { uint8_t kind; uint256 tokenId; - uint256 txid; //!< for UNDO_TRANSFER_PUT key reconstruction + uint256 txid; //!< UNDO_TRANSFER_PUT / UNDO_UTXO_* key reconstruction int64_t blockHeight; - int32_t vout; - std::string address; //!< for UNDO_BALANCE_ADD - int64_t amount; //!< for UNDO_BALANCE_ADD / UNDO_MINTED_ADD - uint8_t prevBaton; //!< for UNDO_BATON_SET - - CZSLPUndoOp() : kind(0), blockHeight(0), vout(0), amount(0), prevBaton(0) + int32_t vout; //!< UNDO_TRANSFER_PUT key (transfer vout) + std::string address; //!< UNDO_BALANCE_ADD / UNDO_UTXO_CONSUME (restore) + int64_t amount; //!< UNDO_BALANCE_ADD (SIGNED delta) / UNDO_MINTED_ADD / UTXO amount + uint8_t prevBaton; //!< UNDO_BATON_SET + int32_t utxoVout; //!< UNDO_UTXO_CREATE / UNDO_UTXO_CONSUME: the 'u' key vout + bool isMintBaton; //!< UNDO_UTXO_CONSUME: restore baton flag + + CZSLPUndoOp() + : kind(0), blockHeight(0), vout(0), amount(0), prevBaton(0), + utxoVout(0), isMintBaton(false) { tokenId.SetNull(); txid.SetNull(); @@ -192,6 +296,8 @@ class CZSLPStore READWRITE(address); READWRITE(amount); READWRITE(prevBaton); + READWRITE(utxoVout); + READWRITE(isMintBaton); } }; @@ -208,42 +314,85 @@ class CZSLPStore bool WriteTip(int64_t height, const uint256& blockHash); bool ReadTip(int64_t& height, uint256& blockHash) const; + // ── Format version stamp (migration) ─────────────────────────── + bool ReadIndexVersion(uint32_t& out) const; + bool WriteIndexVersion(uint32_t version); + // ── Connect-side mutation (called by the indexer per block) ──── // - // ConnectBlockBegin() starts a fresh undo log for blockHash; the Apply* - // calls append both the data record and a matching undo op; ConnectBlockEnd - // advances the tip marker. The whole block is staged in one CDBBatch by the - // caller-less helpers below for crash-atomicity. + // ConnectBlockBegin() starts a fresh undo log for blockHash; ApplyTransaction + // (called per tx, in block order) commits its own batch so a later tx in the + // same block can spend a UTXO an earlier tx created; ConnectBlockEnd advances + // the tip marker. The undo seq runs across the whole block. void ConnectBlockBegin(const uint256& blockHash); - /** Create (or no-op if exists) a token genesis record + seed its - * total_minted with the initial quantity and the recipient balance. */ - bool ApplyGenesis(const CZSLPToken& token, const std::string& recipient, - const uint256& txid, int32_t vout, int64_t initialQty); - - /** Increase total_minted (and credit recipient) for an existing token. */ - bool ApplyMint(const uint256& tokenId, const std::string& recipient, - const uint256& txid, int64_t blockHeight, int32_t vout, - int64_t addQty, bool batonMoved, uint8_t newBatonVout); - - /** Record a SEND output: credit the recipient + transfer record. */ - bool ApplySend(const uint256& tokenId, const std::string& recipient, - const uint256& txid, int64_t blockHeight, int32_t vout, - int64_t amount); + /** + * Apply one transaction to the store under the real SLP UTXO-bound rules. + * Runs for EVERY tx (SLP or not): first consume/burn the token UTXOs the + * tx spends, then (if msg != NULL) create new UTXOs only as far as the + * consumed inputs (or, for GENESIS, the genesis itself) permit. + * + * @param vin tx.vin prevouts (token inputs are looked up by these) + * @param msg parsed SLP message, or NULL for a non-SLP tx + * @param txid this transaction's id + * @param height block height + * @param genesisMeta GENESIS only: prebuilt token metadata (else NULL) + * @param addrOfVout maps an output index to its t-address ("" if none) + * @param voutCount tx.vout.size() (for output-index bounds checks) + * @returns true on a successful commit + */ + bool ApplyTransaction(const std::vector& vin, + const CZSLPParsedMsg* msg, + const uint256& txid, int64_t height, + const CZSLPToken* genesisMeta, + const std::function& addrOfVout, + int32_t voutCount); bool ConnectBlockEnd(int64_t height, const uint256& blockHash); + /** + * READ-ONLY dry-run validator for a built (but not yet broadcast) tx. + * + * Mirrors ApplyTransaction's conservation/baton/layout checks EXACTLY using + * only point reads (GetUtxo/GetToken) — it NEVER writes leveldb. The wallet + * write path (R-WALLET-9, doc/nft/MINT_TRANSFER_SPEC.md §4.3) calls this on + * the final signed tx and REFUSES to broadcast unless it returns true, so a + * builder bug can never publish a tx the live indexer would burn/mis-credit. + * + * The `msg` is the SAME CZSLPParsedMsg the indexer would produce from the + * tx's vout[0] (callers pass what CZSLPIndexer::ParseTx returned); `vin`, + * `genesisMeta`, `voutCount` mirror ApplyTransaction's arguments. The + * non-burn caller intent (no input may be a token of a DIFFERENT token, no + * baton may be spent unintentionally) is enforced by the wallet builder, not + * here — this predicate answers strictly "does the overlay ledger accept and + * fully credit this tx as the message intends?". + * + * @param reason human-readable failure cause (set only when returning false) + * @returns true iff the overlay would create EXACTLY the intended outputs + * (GENESIS/MINT mint output + baton present; SEND fully conserved, + * every quantity landing on an existing output; no implicit burn of + * the message's own declared quantities). + */ + bool WouldBeValid(const std::vector& vin, + const CZSLPParsedMsg* msg, + const uint256& txid, + const CZSLPToken* genesisMeta, + int32_t voutCount, + std::string& reason) const; + // ── Disconnect-side (reorg) ──────────────────────────────────── // - // Replays the block's undo log in reverse, deleting exactly the records - // ConnectBlock* added and decrementing balances / total_minted, then sets - // the tip marker back to (prevHeight, prevHash). Idempotent: a block with - // no undo log is a no-op. + // Replays the block's undo log in reverse, restoring consumed UTXOs, + // erasing created ones, reversing balance/total_minted/baton changes and + // deleting the transfer/token records the block added, then sets the tip + // marker back to (prevHeight, prevHash). Yields byte-identical pre-state. bool DisconnectBlock(const uint256& blockHash, int64_t prevHeight, const uint256& prevHash); // ── Read API (used by the RPCs and tests) ───────────────────── bool GetToken(const uint256& tokenId, CZSLPToken& out) const; + /** Look up a token UTXO by (txid, vout). */ + bool GetUtxo(const uint256& txid, int32_t vout, CZSLPTokenUtxo& out) const; /** Bounded, deterministic token list (skip `from`, take up to `count`). */ int ListTokens(int from, int count, std::vector& out) const; /** Bounded transfer list for one token, newest height first. */ @@ -256,14 +405,37 @@ class CZSLPStore std::vector >& out) const; int64_t TokenCount() const; + /** Count of live token UTXOs (test/diagnostic helper). */ + int64_t UtxoCount() const; private: - // Internal helpers (single-record writes; the batch variants are used by - // the Apply* path so a block connects/disconnects atomically). + // Internal helpers. bool readToken(const uint256& tokenId, CZSLPToken& out) const; + bool readUtxo(const uint256& txid, int32_t vout, CZSLPTokenUtxo& out) const; void writeTokenBatch(CDBBatch& batch, const CZSLPToken& token); int64_t readBalance(const uint256& tokenId, const std::string& address) const; void appendUndo(CDBBatch& batch, const CZSLPUndoOp& op); + + // Per-(token,address) balance delta accumulator. CDBBatch is write-only and + // readBalance() sees only committed data, so several deltas to one address in + // a single tx (e.g. consume the input then credit the change back to it) + // must net in memory before a single committed read + write at flush time. + typedef std::map, int64_t> BalDeltaMap; + + // The ONLY mutation sites for 'u', 'b', and 'x' (kept DRY). createUtxo / + // consumeUtxo stage 'u'/'x' writes and the signed UNDO_BALANCE_ADD undo op + // into the batch immediately, but route the derived-balance change through + // the in-memory accumulator; flushBalances() commits it once per address. + void createUtxo(CDBBatch& batch, BalDeltaMap& bal, const uint256& txid, + int32_t vout, const uint256& tokenId, int64_t amount, + bool isMintBaton, const std::string& address, + int64_t height, uint8_t txType); + void consumeUtxo(CDBBatch& batch, BalDeltaMap& bal, const uint256& txid, + int32_t vout, const CZSLPTokenUtxo& rec); + void recordBalanceDelta(CDBBatch& batch, BalDeltaMap& bal, + const uint256& tokenId, const std::string& address, + int64_t delta); + void flushBalances(CDBBatch& batch, const BalDeltaMap& bal); }; #endif // BITCOIN_ZSLP_ZSLPSTORE_H From f9c143c353cf68a5715ff2f6a6e8e6b38d8edaf8 Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 11:52:31 +0000 Subject: [PATCH 5/7] =?UTF-8?q?nft(shield):=20cross-wallet=20receive=20?= =?UTF-8?q?=E2=80=94=20reconstruct=20z=5Fgetdatatransfer=20from=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make a true cross-wallet/cross-node recipient able to retrieve and verify-before-decrypt a private data transfer, fixing the sender-session-only limitation flagged in review. z_getdatatransfer (src/rpc/datachannel.cpp, +107/-61): the hard "transfer not found in this node's registry" throw is replaced by a registry-free fallback. On a registry miss it scans this wallet's Sapling notes (GetFilteredNotes, requireSpendingKey=false so a viewing-key-only wallet works), groups in-wallet ZDC frames by transfer_id (and, for a fingerprint query, selects the group whose recomputed ciphertext fingerprint matches via an explicit foundId flag), and feeds them to the Decoder — which consumes the on-chain KEY frame automatically, so the per-transfer key is NEVER required from the in-memory registry. The same verify-before-decrypt gate runs first. Also fixes the post-restart retrieval gap for both recipient and sender (each reconstructs from its own ivk/ovk-decrypted memos). Codec unchanged (src/datachannel/zdc.* byte-identical); no consensus edit; all gates preserved (default-OFF -datachannel, acknowledge_permanent, DoS caps, verify-before-decrypt -> ERR_HASH_MISMATCH/no plaintext on mismatch, distinct ERR_NO_KEY when no KEY frame is visible); no key/ivk returned or logged. Test: committed two-node regtest qa/zslp/zdc-xwallet-regtest.sh (separate datadirs) — node B (recipient spending key, EMPTY registry) reassembles + decrypts to byte-identical original by transfer_id AND by fingerprint; wrong verify_fingerprint refused; an unrelated wallet gets no plaintext. 25/25 ZDC gtests pass. Both reviewers approved (0 blockers). Follow-up (out of scope): Sapling viewing-key import (z_importviewingkey is Sprout-only) would let a vk-only wallet retrieve without a spending key. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/zslp/zdc-xwallet-regtest.sh | 337 +++++++++++++++++++++++++++++++++ src/rpc/datachannel.cpp | 168 ++++++++++------ 2 files changed, 444 insertions(+), 61 deletions(-) create mode 100755 qa/zslp/zdc-xwallet-regtest.sh diff --git a/qa/zslp/zdc-xwallet-regtest.sh b/qa/zslp/zdc-xwallet-regtest.sh new file mode 100755 index 00000000000..7362ba05f97 --- /dev/null +++ b/qa/zslp/zdc-xwallet-regtest.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# ============================================================================ +# SHIELD data-channel CROSS-WALLET end-to-end LIVE regtest (#117). +# +# Proves a TRUE cross-wallet recipient can retrieve + verify-before-decrypt a +# z_senddatafile transfer with NO in-memory registry record, using only what is +# on chain + its own viewing/spending key — the registry-free reconstruct path +# added to z_getdatatransfer (src/rpc/datachannel.cpp). +# +# TWO SEPARATE NODES, SEPARATE DATADIRS (the load-bearing setup): +# node A funds a Sapling z-addr, z_senddatafile -> node B's z-addr. +# node B holds the recipient SPENDING key (no registry record), runs +# z_getdatatransfer and reconstructs from chain. +# node V a viewing-key-only variant: B's z-addr imported via z_importviewingkey. +# node C a THIRD unrelated wallet with neither the notes nor the key. +# +# ASSERTIONS: +# (1) B (spending key) reassembles + decrypts to the EXACT original bytes +# (sha256 of returned hexdata == sha256 of the original file). +# (1v) V (VIEWING key only) does the same — proves requireSpendingKey=false. +# (2) B with a WRONG verify_fingerprint is REFUSED: error contains +# "hash mismatch", verified=false, and NO hexdata is returned. +# (3) C (no notes, no key) gets not-found / no-key and NO hexdata. +# (3b) C asked by fingerprint -> "transfer not found" and NO hexdata. +# +# NON-CONSENSUS: -datachannel default OFF; both A and B run with -datachannel. +# Sapling on regtest needs -nuparams=5ba81b19:1 (Overwinter) + +# -nuparams=76b809bb:1 (Sapling). +# +# Usage: +# qa/zslp/zdc-xwallet-regtest.sh [ZCLASSICD] [ZCLASSIC_CLI] +# Resolution: positional $1/$2, then env ZCLASSICD/ZCLASSIC_CLI, then +# /src/zclassicd and /src/zclassic-cli. params: env +# ZCASH_PARAMS_DIR, else ~/.zcash-params. +# +# proot/params GOTCHA (prun runs in-proot via `env -i`): pass binaries +# POSITIONALLY, inject params with `prun env`: +# EXTRA_BINDS="-b /home/rhett/.zcash-params:/root/.zcash-params -b /tmp:/tmp" \ +# /home/rhett/zclbuild/prun env ZCASH_PARAMS_DIR=/root/.zcash-params \ +# bash /src/daemon/qa/zslp/zdc-xwallet-regtest.sh \ +# /build/daemon/src/zclassicd /build/daemon/src/zclassic-cli +# +# Exit: 0 = all assertions green; non-zero = a failure. +# ============================================================================ +set -u + +# ---- Resolve binaries + params (repo-discoverable) ------------------------ +if SRCTOP=$(git -C "$(dirname "$0")" rev-parse --show-toplevel 2>/dev/null); then + SRCDIR="$SRCTOP/src" +else + SRCDIR="$(cd "$(dirname "$0")/../../src" && pwd)" +fi +DAEMON="${1:-${ZCLASSICD:-$SRCDIR/zclassicd}}" +CLI="${2:-${ZCLASSIC_CLI:-$SRCDIR/zclassic-cli}}" +PARAMS="${ZCASH_PARAMS_DIR:-$HOME/.zcash-params}" + +NUPARAMS="-nuparams=5ba81b19:1 -nuparams=76b809bb:1" + +# Unique ports/datadirs per run. +BASEPORT=$(( 19200 + (RANDOM % 600) )) +A_PORT=$BASEPORT; A_RPC=$(( BASEPORT + 1 )) +B_PORT=$(( BASEPORT + 2 )); B_RPC=$(( BASEPORT + 3 )) +C_PORT=$(( BASEPORT + 4 )); C_RPC=$(( BASEPORT + 5 )) +A_DIR=$(mktemp -d "${TMPDIR:-/tmp}/zdc-A.XXXXXX") +B_DIR=$(mktemp -d "${TMPDIR:-/tmp}/zdc-B.XXXXXX") +C_DIR=$(mktemp -d "${TMPDIR:-/tmp}/zdc-C.XXXXXX") +PLAIN=$(mktemp "${TMPDIR:-/tmp}/zdc-plain.XXXXXX") +RPCUSER=rt; RPCPASS=rt + +FAILS=0 +pass() { echo " PASS $*"; } +fail() { echo " FAIL $*"; FAILS=$((FAILS+1)); } +skip() { echo " SKIP $*"; } +hdr() { echo; echo "================ $* ================"; } +# Extract a top-level JSON string field by key from a value blob (quote-stripped). +jget() { echo "$1" | tr -d ' ",' | grep -m1 "$2:" | sed "s/.*$2://"; } +# sha256 of a hex string's decoded bytes (no xxd dependency). +hexsha() { printf "%s" "$1" | python3 -c 'import sys,hashlib;h=sys.stdin.read().strip();print(hashlib.sha256(bytes.fromhex(h)).hexdigest() if h else "")'; } +# extract a JSON field robustly via python (returns "" if absent / parse error). +jfield() { python3 -c 'import sys,json +try: print(json.load(sys.stdin).get(sys.argv[1],"")) +except Exception: print("")' "$2" <<<"$1"; } + +echo "ZDC cross-wallet regtest" +echo " daemon = $DAEMON" +echo " cli = $CLI" +echo " params = $PARAMS" +[ -x "$DAEMON" ] || { echo "FATAL: zclassicd not executable at $DAEMON"; exit 2; } +[ -x "$CLI" ] || { echo "FATAL: zclassic-cli not executable at $CLI"; exit 2; } + +A_PID=""; B_PID=""; C_PID="" +cliA() { "$CLI" -regtest -datadir="$A_DIR" -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$A_RPC" "$@"; } +cliB() { "$CLI" -regtest -datadir="$B_DIR" -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$B_RPC" "$@"; } +cliC() { "$CLI" -regtest -datadir="$C_DIR" -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$C_RPC" "$@"; } + +cleanup() { + echo; echo "---- teardown ----" + cliA stop >/dev/null 2>&1 || true + cliB stop >/dev/null 2>&1 || true + cliC stop >/dev/null 2>&1 || true + for d in A B C; do + pid_var="${d}_PID"; pid="${!pid_var}" + if [ -n "$pid" ]; then + for _ in $(seq 1 20); do kill -0 "$pid" 2>/dev/null || break; sleep 1; done + kill -KILL "$pid" 2>/dev/null || true + fi + done + pkill -KILL -f "zclassicd -regtest .*-datadir=$A_DIR" 2>/dev/null || true + pkill -KILL -f "zclassicd -regtest .*-datadir=$B_DIR" 2>/dev/null || true + pkill -KILL -f "zclassicd -regtest .*-datadir=$C_DIR" 2>/dev/null || true + rm -rf "$A_DIR" "$B_DIR" "$C_DIR" "$PLAIN" + echo "removed datadirs + plaintext"; echo "daemons stopped" +} +trap cleanup EXIT INT TERM + +start_node() { # $1=name $2=datadir $3=port $4=rpcport $5=extra + "$DAEMON" -regtest -datadir="$2" -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" \ + -rpcport="$4" -port="$3" -listen=1 -txindex $NUPARAMS $5 \ + > "$2/daemon.log" 2>&1 & + eval "${1}_PID=$!" +} +wait_rpc() { # $1=cli-fn $2=datadir $3=pidvar + local pid="${!3}" + for i in $(seq 1 90); do + if ! kill -0 "$pid" 2>/dev/null; then + echo " daemon died during warmup; log tail:"; tail -25 "$2/daemon.log"; return 1 + fi + local h; h=$("$1" getblockcount 2>/dev/null) + if [[ "$h" =~ ^[0-9]+$ ]]; then return 0; fi + sleep 1 + done + echo " RPC never came up; log tail:"; tail -25 "$2/daemon.log"; return 1 +} + +# ---- Bring up A, B, C ----------------------------------------------------- +hdr "(0) BRING-UP A(rpc $A_RPC) B(rpc $B_RPC) C(rpc $C_RPC)" +start_node A "$A_DIR" "$A_PORT" "$A_RPC" "-datachannel" +start_node B "$B_DIR" "$B_PORT" "$B_RPC" "-datachannel" +start_node C "$C_DIR" "$C_PORT" "$C_RPC" "-datachannel" +wait_rpc cliA "$A_DIR" A_PID || { fail "A RPC up"; exit 1; }; pass "A up" +wait_rpc cliB "$B_DIR" B_PID || { fail "B RPC up"; exit 1; }; pass "B up" +wait_rpc cliC "$C_DIR" C_PID || { fail "C RPC up"; exit 1; }; pass "C up" + +# Connect A<->B<->C so A's tx propagates to B and C. +cliA addnode "127.0.0.1:$B_PORT" onetry >/dev/null 2>&1 || true +cliA addnode "127.0.0.1:$C_PORT" onetry >/dev/null 2>&1 || true +cliB addnode "127.0.0.1:$C_PORT" onetry >/dev/null 2>&1 || true +sleep 2 + +# ---- Fund A and activate Sapling ------------------------------------------ +hdr "(1) FUND A + activate Sapling" +cliA generate 110 >/dev/null +HA=$(cliA getblockcount); pass "A height=$HA" +# Sync B and C up to A's tip. +for _ in $(seq 1 30); do + HB=$(cliB getblockcount 2>/dev/null); HC=$(cliC getblockcount 2>/dev/null) + [ "$HB" = "$HA" ] && [ "$HC" = "$HA" ] && break; sleep 1 +done +pass "B height=$(cliB getblockcount) C height=$(cliC getblockcount)" + +# A: shield coinbase into a Sapling z-addr so we have a private funding note. +ZA=$(cliA z_getnewaddress sapling); echo " A z-addr = $ZA" +SHOP=$(cliA z_shieldcoinbase "*" "$ZA") +echo " shield op: $(echo "$SHOP" | head -c 120)" +for _ in $(seq 1 60); do + st=$(cliA z_getoperationstatus 2>/dev/null) + echo "$st" | grep -q '"status":"success"' && break + echo "$st" | grep -q '"status":"failed"' && { echo "SHIELD FAILED: $st"; break; } + cliA generate 1 >/dev/null; sleep 1 +done +cliA generate 3 >/dev/null +ZBAL=$(cliA z_getbalance "$ZA") +echo " A z-balance = $ZBAL" + +# ---- B's recipient z-addr (spending key in B) + export keys --------------- +ZB=$(cliB z_getnewaddress sapling); echo " B z-addr = $ZB" +ZB_SPEND=$(cliB z_exportkey "$ZB" 2>/dev/null) +# z_exportviewingkey is Sprout-only in this daemon (rpcdump.cpp:830 "TODO: Add +# Sapling support"). Capture stderr so we can SKIP the viewing-key-only sub-test +# honestly rather than mis-report it as a fallback failure. +ZB_VIEW=$(cliB z_exportviewingkey "$ZB" 2>&1) +SAPLING_VK_SUPPORTED=1 +echo "$ZB_VIEW" | grep -qi "only Sprout\|Invalid\|error" && SAPLING_VK_SUPPORTED=0 + +# ---- (2) A sends a private file to B's z-addr ------------------------------ +hdr "(2) A z_senddatafile -> B" +# Deterministic ~1.5KB payload (multi-frame), then its sha256. +head -c 1500 /dev/urandom > "$PLAIN" +PLAIN_SHA=$(sha256sum "$PLAIN" | cut -d' ' -f1) +echo " plaintext sha256 = $PLAIN_SHA ($(wc -c < "$PLAIN") bytes)" + +SEND=$(cliA z_senddatafile "{\"fromaddress\":\"$ZA\",\"toaddress\":\"$ZB\",\"filepath\":\"$PLAIN\",\"acknowledge_permanent\":true}") +echo "$SEND" +SOPID=$(jget "$SEND" operationid) +TID=$(jget "$SEND" transfer_id) +FP=$(jget "$SEND" fingerprint) +echo " transfer_id = $TID fingerprint = $FP" +[ -n "$TID" ] && [ -n "$FP" ] && pass "send accepted" || { fail "send rejected"; echo "$SEND"; exit 1; } + +# Wait for the async op to broadcast, then confirm + propagate to B and C. +for _ in $(seq 1 60); do + st=$(cliA z_getoperationstatus 2>/dev/null) + echo "$st" | grep -q '"status":"success"' && break + echo "$st" | grep -q '"status":"failed"' && { echo "DATAFILE OP FAILED:"; echo "$st"; fail "send op failed"; exit 1; } + sleep 1 +done +cliA generate 3 >/dev/null +HA=$(cliA getblockcount) +for _ in $(seq 1 40); do + HB=$(cliB getblockcount 2>/dev/null); HC=$(cliC getblockcount 2>/dev/null) + [ "$HB" = "$HA" ] && [ "$HC" = "$HA" ] && break; sleep 1 +done +pass "tx confirmed; B height=$(cliB getblockcount) C height=$(cliC getblockcount)" + +# ============================================================================ +# (3) ASSERTION 1 — B (SPENDING key, NO registry record) reconstructs +# ============================================================================ +hdr "(3) ASSERT 1: B reconstructs + decrypts (spending key)" +# Sanity: B has NO registry record (it never sent; z_listdatatransfers is empty). +LST=$(cliB z_listdatatransfers 2>/dev/null) +if echo "$LST" | tr -d ' \n' | grep -q '^\[\]$'; then pass "B registry empty (true cross-wallet)"; else echo " B list: $LST"; fail "B registry should be empty"; fi + +GB=$(cliB z_getdatatransfer "{\"transfer_id\":\"$TID\"}") +echo "$GB" | head -c 400; echo +GB_HEX=$(echo "$GB" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hexdata",""))' 2>/dev/null) +GB_VERIFIED=$(echo "$GB" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("verified",""))' 2>/dev/null) +if [ -n "$GB_HEX" ]; then + GOT_SHA=$(hexsha "$GB_HEX") + if [ "$GOT_SHA" = "$PLAIN_SHA" ]; then pass "B decrypted EXACT bytes (sha256 match, verified=$GB_VERIFIED)" + else fail "B sha256 mismatch: got=$GOT_SHA want=$PLAIN_SHA"; fi +else + fail "B returned no hexdata"; echo "$GB" +fi + +# Also by FINGERPRINT (registry-free id resolution from chain). +GBF=$(cliB z_getdatatransfer "{\"fingerprint\":\"$FP\"}") +GBF_HEX=$(echo "$GBF" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hexdata",""))' 2>/dev/null) +if [ -n "$GBF_HEX" ]; then + GOT_SHA2=$(hexsha "$GBF_HEX") + [ "$GOT_SHA2" = "$PLAIN_SHA" ] && pass "B by-fingerprint decrypted EXACT bytes" || fail "B by-fingerprint sha256 mismatch" +else fail "B by-fingerprint returned no hexdata"; echo "$GBF"; fi + +# Recipient's out-of-band trust anchor (the on-chain fingerprint) accepted. +GBV=$(cliB z_getdatatransfer "{\"transfer_id\":\"$TID\",\"verify_fingerprint\":\"$FP\"}") +GBV_HEX=$(echo "$GBV" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hexdata",""))' 2>/dev/null) +[ -n "$GBV_HEX" ] && pass "B with CORRECT verify_fingerprint decrypts" || { fail "B correct verify_fingerprint refused"; echo "$GBV"; } + +# ============================================================================ +# (3v) ASSERTION 1v — VIEWING-KEY-ONLY wallet (node V via import into C-style +# fresh datadir is overkill; import the viewing key into node C? No — C must +# stay key-less. Use a fresh transient datadir node V.) +# ============================================================================ +hdr "(3v) ASSERT 1v: viewing-key-only wallet reconstructs" +if [ "$SAPLING_VK_SUPPORTED" != 1 ]; then + skip "Sapling viewing-key export/import is NOT implemented in this daemon" + skip " (rpcdump.cpp:830 'TODO: Add Sapling support'). The fallback already" + skip " uses GetFilteredNotes(requireSpendingKey=false) — the SAME ivk-decrypt" + skip " path Assertion 1 exercised — so a vk-only wallet WOULD work once the" + skip " daemon can import a Sapling vk. Not a defect in the #117 fallback." +else +V_DIR=$(mktemp -d "${TMPDIR:-/tmp}/zdc-V.XXXXXX") +V_PORT=$(( BASEPORT + 6 )); V_RPC=$(( BASEPORT + 7 )); V_PID="" +cliV() { "$CLI" -regtest -datadir="$V_DIR" -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" -rpcport="$V_RPC" "$@"; } +"$DAEMON" -regtest -datadir="$V_DIR" -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" \ + -rpcport="$V_RPC" -port="$V_PORT" -listen=1 -txindex $NUPARAMS -datachannel \ + > "$V_DIR/daemon.log" 2>&1 & +V_PID=$! +VUP=0 +for i in $(seq 1 90); do + kill -0 "$V_PID" 2>/dev/null || { echo "V died"; tail -20 "$V_DIR/daemon.log"; break; } + h=$(cliV getblockcount 2>/dev/null); [[ "$h" =~ ^[0-9]+$ ]] && { VUP=1; break; }; sleep 1 +done +if [ "$VUP" = 1 ]; then + pass "V up" + cliV addnode "127.0.0.1:$A_PORT" onetry >/dev/null 2>&1 || true + # Import B's VIEWING key (no spending key) with rescan so V sees the note. + cliV z_importviewingkey "$ZB_VIEW" yes 0 >/dev/null 2>&1 || cliV z_importviewingkey "$ZB_VIEW" >/dev/null 2>&1 + HA=$(cliA getblockcount) + for _ in $(seq 1 40); do [ "$(cliV getblockcount 2>/dev/null)" = "$HA" ] && break; sleep 1; done + GV=$(cliV z_getdatatransfer "{\"transfer_id\":\"$TID\",\"address\":\"$ZB\"}") + echo "$GV" | head -c 300; echo + GV_HEX=$(echo "$GV" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hexdata",""))' 2>/dev/null) + if [ -n "$GV_HEX" ]; then + GOT_SHAV=$(hexsha "$GV_HEX") + [ "$GOT_SHAV" = "$PLAIN_SHA" ] && pass "VIEWING-KEY-ONLY wallet decrypted EXACT bytes" || fail "V sha256 mismatch: $GOT_SHAV" + else fail "V (viewing key) returned no hexdata"; echo "$GV"; fi +else + fail "V did not come up" +fi +[ -n "$V_PID" ] && { cliV stop >/dev/null 2>&1 || true; for _ in $(seq 1 15); do kill -0 "$V_PID" 2>/dev/null || break; sleep 1; done; kill -KILL "$V_PID" 2>/dev/null || true; } +rm -rf "$V_DIR" +fi # SAPLING_VK_SUPPORTED + +# ============================================================================ +# (4) ASSERTION 2 — WRONG verify_fingerprint REFUSED, no plaintext +# ============================================================================ +hdr "(4) ASSERT 2: wrong verify_fingerprint -> ERR_HASH_MISMATCH, no plaintext" +BADFP="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +GBAD=$(cliB z_getdatatransfer "{\"transfer_id\":\"$TID\",\"verify_fingerprint\":\"$BADFP\"}") +echo "$GBAD" | head -c 400; echo +GBAD_HEX=$(echo "$GBAD" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hexdata",""))' 2>/dev/null) +GBAD_VER=$(echo "$GBAD" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("verified",""))' 2>/dev/null) +GBAD_ERR=$(echo "$GBAD" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("error",""))' 2>/dev/null) +if [ -z "$GBAD_HEX" ] && [ "$GBAD_VER" = "False" ] && echo "$GBAD_ERR" | grep -qi "hash mismatch"; then + pass "wrong verify_fingerprint REFUSED (verified=false, no hexdata, err='$GBAD_ERR')" +else + fail "wrong verify_fingerprint NOT properly refused (hex='${GBAD_HEX:0:16}' verified=$GBAD_VER err='$GBAD_ERR')" +fi + +# ============================================================================ +# (5) ASSERTION 3 — third unrelated wallet C: no notes, no key -> no plaintext +# ============================================================================ +hdr "(5) ASSERT 3: unrelated wallet C gets not-found/no-key, no plaintext" +# C has the BLOCKS (it's a peer) but NOT B's ivk, so it cannot decrypt the memo +# into a ZDC frame at all -> it has no matching notes. +GC=$(cliC z_getdatatransfer "{\"transfer_id\":\"$TID\"}") +echo " C by-id: $(echo "$GC" | head -c 250)" +GC_HEX=$(echo "$GC" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hexdata",""))' 2>/dev/null) +GC_ERR=$(echo "$GC" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("error",""))' 2>/dev/null) +if [ -z "$GC_HEX" ]; then pass "C by-id: NO plaintext (err='$GC_ERR')"; else fail "C by-id LEAKED plaintext"; fi + +# By fingerprint, C has no in-wallet frames hashing to it -> hard not-found throw. +GCF=$(cliC z_getdatatransfer "{\"fingerprint\":\"$FP\"}" 2>&1) +echo " C by-fingerprint: $(echo "$GCF" | head -c 250)" +GCF_HEX=$(echo "$GCF" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("hexdata",""))' 2>/dev/null) +if [ -z "$GCF_HEX" ] && echo "$GCF" | grep -qi "not found"; then + pass "C by-fingerprint: transfer not found, NO plaintext" +elif [ -z "$GCF_HEX" ]; then + pass "C by-fingerprint: NO plaintext (msg='$(echo "$GCF" | head -c 80)')" +else + fail "C by-fingerprint LEAKED plaintext" +fi + +# ============================================================================ +hdr "RESULT" +if [ "$FAILS" -eq 0 ]; then echo "ALL GREEN — cross-wallet registry-free retrieval works"; exit 0 +else echo "$FAILS assertion(s) FAILED"; exit 1; fi diff --git a/src/rpc/datachannel.cpp b/src/rpc/datachannel.cpp index f9d810c04dd..ab1b9b3ef20 100644 --- a/src/rpc/datachannel.cpp +++ b/src/rpc/datachannel.cpp @@ -457,91 +457,134 @@ UniValue z_getdatatransfer(const UniValue& params, bool fHelp) (verifyFingerprintHex.size() != 64 || !IsHex(verifyFingerprintHex))) throw JSONRPCError(RPC_INVALID_PARAMETER, "verify_fingerprint must be 64 hex chars"); - // Resolve the recorded transfer (holds the authoritative anchor + key). + // ── (A) FAST-PATH: a registry record from THIS session (sender) ───────── + // The record holds the authoritative anchor + the per-transfer key, so we can + // serve verify-before-decrypt even when (rarely) no KEY frame rode on chain. ZdcTransferRecord rec; bool haveRec = false; uint64_t wantId = 0; - { + if (!transferIdHex.empty()) { + if (transferIdHex.size() != 16 || !IsHex(transferIdHex)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "transfer_id must be 16 hex chars"); + wantId = strtoull(transferIdHex.c_str(), NULL, 16); LOCK(cs_zdc); ZdcExpireOld(); - if (!transferIdHex.empty()) { - if (transferIdHex.size() != 16 || !IsHex(transferIdHex)) - throw JSONRPCError(RPC_INVALID_PARAMETER, "transfer_id must be 16 hex chars"); - wantId = strtoull(transferIdHex.c_str(), NULL, 16); - std::map::const_iterator it = g_zdcTransfers.find(wantId); - if (it != g_zdcTransfers.end()) { rec = it->second; haveRec = true; } - } else { - if (fingerprintHex.size() != 64 || !IsHex(fingerprintHex)) - throw JSONRPCError(RPC_INVALID_PARAMETER, "fingerprint must be 64 hex chars"); - for (std::map::const_iterator it = g_zdcTransfers.begin(); - it != g_zdcTransfers.end(); ++it) { - if (it->second.fingerprintHex == fingerprintHex) { - rec = it->second; haveRec = true; wantId = it->first; break; - } + std::map::const_iterator it = g_zdcTransfers.find(wantId); + if (it != g_zdcTransfers.end()) { rec = it->second; haveRec = true; } + } else { + if (fingerprintHex.size() != 64 || !IsHex(fingerprintHex)) + throw JSONRPCError(RPC_INVALID_PARAMETER, "fingerprint must be 64 hex chars"); + LOCK(cs_zdc); + ZdcExpireOld(); + for (std::map::const_iterator it = g_zdcTransfers.begin(); + it != g_zdcTransfers.end(); ++it) { + if (it->second.fingerprintHex == fingerprintHex) { + rec = it->second; haveRec = true; wantId = it->first; break; } } } - if (!haveRec) - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, - "transfer not found in this node's registry (it tracks transfers sent this session)"); - if (address.empty()) address = rec.toAddress; + // The address to scan: explicit param > recorded toaddress > all in-wallet + // z-addrs (empty string => GetFilteredNotes scans every address we can view). + if (address.empty() && haveRec) address = rec.toAddress; - // Scan the wallet's Sapling notes at the recipient address; feed memos to the - // decoder, keeping only frames whose transfer_id matches. + // Pull the wallet's Sapling notes. requireSpendingKey=false so a VIEWING-KEY- + // ONLY wallet (the recipient with only an ivk) still sees its frames; the ivk + // path inside GetFilteredNotes decrypts each Sapling memo to plaintext for us. + // No spending key / ivk ever leaves the wallet; we only read decrypted memos. std::vector saplingEntries; std::vector sproutEntries; { LOCK2(cs_main, pwalletMain->cs_wallet); - pwalletMain->GetFilteredNotes(sproutEntries, saplingEntries, address, /*minDepth=*/0, false, false); + pwalletMain->GetFilteredNotes(sproutEntries, saplingEntries, address, + /*minDepth=*/0, /*ignoreSpent=*/false, + /*requireSpendingKey=*/false); + } + + // ── (B) REGISTRY-MISS FALLBACK: reconstruct purely from chain ──────────── + // No session record for this id/fingerprint (a cross-wallet recipient, or the + // sender after a restart). We resolve wantId from the on-chain frames alone: + // * transfer_id query -> wantId already parsed above. + // * fingerprint query -> group in-wallet ZDC frames by transfer_id, recompute + // each group's ciphertext fingerprint, pick the match. + // Everything after this point is identical to the fast-path; the ONLY secret we + // ever rely on is the on-chain KEY frame (decrypted to us by our own ivk), which + // the Decoder consumes automatically in add_frame — we never need rec.key. + if (!haveRec && transferIdHex.empty()) { + // fingerprint-only, no record: find the transfer_id whose DATA frames hash + // to the requested fingerprint. (Bounded by this wallet's note count.) + std::map > > byId; + for (size_t i = 0; i < saplingEntries.size(); ++i) { + std::vector memo(saplingEntries[i].memo.begin(), saplingEntries[i].memo.end()); + zdc::FrameHeader h; + if (zdc::parse_header(&memo[0], h) != zdc::OK) continue; // skip non-ZDC memos + byId[h.transfer_id].push_back(memo); + } + bool foundId = false; + for (std::map > >::const_iterator + it = byId.begin(); it != byId.end(); ++it) { + uint8_t fp[zdc::CONTENT_HASH_LEN]; + if (zdc::ciphertext_fingerprint(it->second, fp) != zdc::OK) continue; + if (BytesToHex(fp, zdc::CONTENT_HASH_LEN) == fingerprintHex) { + wantId = it->first; foundId = true; break; + } + } + if (!foundId) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "transfer not found: no in-wallet Sapling frames hash to that fingerprint " + "(this wallet is not the recipient, or the frames have not arrived)"); } + // Feed matching frames to the decoder (it locks to the first transfer_id it + // accepts; the on-chain KEY frame, if visible to our ivk, populates the key). zdc::Decoder dec; uint32_t fed = 0; for (size_t i = 0; i < saplingEntries.size(); ++i) { std::vector memo(saplingEntries[i].memo.begin(), saplingEntries[i].memo.end()); - // Peek the transfer_id before adding so foreign/text memos are skipped - // cleanly (decoder locks to the first transfer_id it accepts). zdc::FrameHeader h; - if (zdc::parse_header(&memo[0], h) != zdc::OK) continue; + if (zdc::parse_header(&memo[0], h) != zdc::OK) continue; // foreign/text memo if (h.transfer_id != wantId) continue; - zdc::Status as = dec.add_frame(memo); - if (as == zdc::OK) ++fed; + if (dec.add_frame(memo) == zdc::OK) ++fed; } UniValue ret(UniValue::VOBJ); ret.push_back(Pair("transfer_id", strprintf("%016x", wantId))); - ret.push_back(Pair("fingerprint", rec.fingerprintHex)); + // Authoritative anchor: registry record if we have one, else the on-chain + // recomputed fingerprint (filled below once frames are complete). + if (haveRec) ret.push_back(Pair("fingerprint", rec.fingerprintHex)); ret.push_back(Pair("frames_received", (int)fed)); ret.push_back(Pair("complete", dec.is_complete())); - // Gather the frames actually on chain (in seq order) to recompute the anchor. - // We recompute over the DATA frames we received and compare to the recorded - // anchor BEFORE any decrypt. This is the verify-before-decrypt gate. + // VERIFY-BEFORE-DECRYPT: recompute the anchor over the DATA frames we hold and + // compare to the EXPECTED anchor BEFORE any decrypt. Expected = + // verify_fingerprint (caller out-of-band) if supplied; + // else rec.fingerprintHex if we have a record; + // else (cross-wallet, no record) the on-chain recompute IS the anchor — there + // is nothing independent to compare to, so "verified" means the frames form a + // self-consistent transfer; a caller wanting a trust anchor passes + // verify_fingerprint (e.g. the published NFT document_hash). bool verified = false; + std::string onchain; if (dec.is_complete()) { - // Re-collect DATA frames for fingerprinting. The decoder doesn't expose - // raw frames, so re-scan the same memos into a frame vector. std::vector > frames; - { - LOCK2(cs_main, pwalletMain->cs_wallet); - for (size_t i = 0; i < saplingEntries.size(); ++i) { - std::vector memo(saplingEntries[i].memo.begin(), saplingEntries[i].memo.end()); - zdc::FrameHeader h; - if (zdc::parse_header(&memo[0], h) != zdc::OK) continue; - if (h.transfer_id != wantId) continue; - frames.push_back(memo); - } + for (size_t i = 0; i < saplingEntries.size(); ++i) { + std::vector memo(saplingEntries[i].memo.begin(), saplingEntries[i].memo.end()); + zdc::FrameHeader h; + if (zdc::parse_header(&memo[0], h) != zdc::OK) continue; + if (h.transfer_id != wantId) continue; + frames.push_back(memo); } uint8_t fp[zdc::CONTENT_HASH_LEN]; if (zdc::ciphertext_fingerprint(frames, fp) == zdc::OK) { - std::string onchain = BytesToHex(fp, zdc::CONTENT_HASH_LEN); - // The anchor we verify the on-chain ciphertext against: the caller's - // out-of-band expectation if supplied, else our recorded anchor. - const std::string& expected = - verifyFingerprintHex.empty() ? rec.fingerprintHex : verifyFingerprintHex; - verified = (onchain == expected); + onchain = BytesToHex(fp, zdc::CONTENT_HASH_LEN); + if (!verifyFingerprintHex.empty()) + verified = (onchain == verifyFingerprintHex); + else if (haveRec) + verified = (onchain == rec.fingerprintHex); + else + verified = true; // no independent anchor to compare against ret.push_back(Pair("onchain_fingerprint", onchain)); + if (!haveRec) ret.push_back(Pair("fingerprint", onchain)); if (!verifyFingerprintHex.empty()) ret.push_back(Pair("expected_fingerprint", verifyFingerprintHex)); } @@ -554,29 +597,32 @@ UniValue z_getdatatransfer(const UniValue& params, bool fHelp) return ret; } if (!verified) { - // VERIFY-BEFORE-DECRYPT refusal: NEVER attempt decrypt or return plaintext - // when the on-chain anchor does not match the expected fingerprint (the - // caller-asserted out-of-band anchor if given, else the recorded anchor). + // VERIFY-BEFORE-DECRYPT refusal: NEVER decrypt or return plaintext when the + // on-chain anchor does not match the expected fingerprint. ret.push_back(Pair("error", std::string(zdc::status_str(zdc::ERR_HASH_MISMATCH)) + - (verifyFingerprintHex.empty() - ? " (on-chain fingerprint != recorded anchor; refusing to decrypt)" - : " (on-chain fingerprint != caller-asserted verify_fingerprint; refusing to decrypt)"))); + (!verifyFingerprintHex.empty() + ? " (on-chain fingerprint != caller-asserted verify_fingerprint; refusing to decrypt)" + : " (on-chain fingerprint != recorded anchor; refusing to decrypt)"))); return ret; } - // Anchor verified. Supply the key out-of-band from the registry (the on-chain - // KEY frame also works, but the registry key is authoritative for the sender) - // and decrypt. - if (!rec.key.empty()) + // Anchor verified. The key comes from the on-chain KEY frame the Decoder already + // consumed (cross-wallet recipient, or sender-after-restart via its own ovk- + // decrypted outgoing memo). If we ALSO hold a registry key, set it as a belt-and- + // suspenders authoritative source; assemble() then AEAD-decrypts. No key is ever + // returned or logged. + if (haveRec && !rec.key.empty()) dec.set_key(rec.key); std::vector out; zdc::TransferMeta gotMeta; zdc::Status ds = dec.assemble(out, gotMeta); if (ds != zdc::OK) { - // Surface the DISTINCT codec error honestly (ERR_NO_KEY / ERR_AEAD_FAIL / - // ERR_HASH_MISMATCH) — never return plaintext on failure. + // Distinct codec error, honestly surfaced (never plaintext on failure): + // ERR_NO_KEY -> no KEY frame visible to this wallet (not the recipient) + // ERR_AEAD_FAIL -> tamper / wrong key + // ERR_HASH_MISMATCH -> reassembled plaintext != END content hash ret.push_back(Pair("error", zdc::status_str(ds))); return ret; } From e2c77770317c8646fb93f438abd4108ecbcde297 Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sat, 6 Jun 2026 21:31:46 +0000 Subject: [PATCH 6/7] docs(nft): capability vision + privacy-tech + native-display UX + API/UX/DRY audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four canonical, honesty-checked NFT docs (workflow w63171z7j: 4 parallel surveyors -> 4 authors -> honesty/completeness critic), each capability tagged BUILT+TESTED / BUILT-CLI-ONLY / DESIGNED-NOT-BUILT / FUTURE-IDEA and verified against both working trees: - NFT_CAPABILITIES.md — what NFTs enable (the four pillars + collections/ gifting/provenance), CLI+GUI usage, honest status matrix, roadmap. - PRIVACY_TECH.md — the ONE privacy tech (ZDC1 shielded data-channel): ownership is always PUBLIC; only file CONTENT is private (ChaCha20-Poly1305 over Sapling memos). Full RPC reference + threat model + leaked metadata. - NATIVE_DISPLAY_UX.md — native (no browser) gallery/detail/set-board design + the implementation-ready SHIELD send/receive UI spec (the next GUI build). - API_UX_DRY_AUDIT.md — prioritized (P0/P1/P2) refactor punch list: API consistency, daemon+GUI DRY, error mapping, test-coverage gaps, dead code. Critic-caught fixes applied pre-commit: corrected the GUI branch to feature/nft-gallery in NATIVE_DISPLAY_UX.md; reworded the 40000-byte/90-frame cap as a chosen headroom ceiling (not a hard quotient). Coin is ZCL throughout (zero ZEC leaks). Non-consensus overlay; no fork. Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/nft/API_UX_DRY_AUDIT.md | 229 +++++++++++++ doc/nft/NATIVE_DISPLAY_UX.md | 637 +++++++++++++++++++++++++++++++++++ doc/nft/NFT_CAPABILITIES.md | 323 ++++++++++++++++++ doc/nft/PRIVACY_TECH.md | 422 +++++++++++++++++++++++ 4 files changed, 1611 insertions(+) create mode 100644 doc/nft/API_UX_DRY_AUDIT.md create mode 100644 doc/nft/NATIVE_DISPLAY_UX.md create mode 100644 doc/nft/NFT_CAPABILITIES.md create mode 100644 doc/nft/PRIVACY_TECH.md diff --git a/doc/nft/API_UX_DRY_AUDIT.md b/doc/nft/API_UX_DRY_AUDIT.md new file mode 100644 index 00000000000..7ffac6e5a6a --- /dev/null +++ b/doc/nft/API_UX_DRY_AUDIT.md @@ -0,0 +1,229 @@ +# NFT API / UX / DRY Audit + Refactor Punch List + +Status: read-only audit converted to an actionable, prioritized punch list. No code changed by this document. + +Scope: +- Daemon: `/home/rhett/github/zclassic` (C++11), branch `feature/zslp-nft-indexer` — carries ALL ZSLP/NFT code in the working tree. +- GUI: `/home/rhett/github/zcl-qt-wallet` (C++14), branch `feature/nft-gallery`. + +Hard model (true, unchanged): +- The coin is **ZClassic / ZCL**. Zcash-lineage code identifiers (`zclassicd`, `z_sendmany`, `.zcash-params`, Sapling, `ivk`) keep their names, but the money a user holds, sends, or sells for is always **ZCL**. +- **ZSLP NFT ownership is ALWAYS PUBLIC.** Tokens ride transparent 546-sat dust UTXOs; who-owns-what and every transfer is fully visible on-chain. No item below makes ownership private, shielded, anonymous, or confidential. +- The **only** privacy technology in this feature is the **ZDC1 shielded data-channel**. It encrypts **FILE CONTENT** (the bytes) with ChaCha20-Poly1305 AEAD and ships the ciphertext inside Sapling shielded memos. It hides the payload and the data-transfer linkage — it does **not** hide token ownership. +- The NFT layer is a **non-consensus OP_RETURN overlay**. Old unmodified nodes relay and mine the OP_RETURN with no rule change; security comes from every honest wallet deterministically re-validating confirmed history. There is no consensus fork. + +Built-vs-designed legend (each capability tagged exactly one): +**BUILT+TESTED** / **BUILT-CLI-ONLY** / **DESIGNED-NOT-BUILT** / **FUTURE-IDEA**. + +Pillar status (for context — see the capability survey for detail): +- MINT / VIEW (`zslp_*`): **BUILT+TESTED** (RPC built, primitives gtested, GUI dialogs + L1 tests exist). +- SELL / TRADE (`nft_*`): primitives **BUILT+TESTED**; RPC dispatchers **BUILT-CLI-ONLY** (no direct gtest); GUI dialogs + L1 tests exist. +- SHIELD / PRIVACY (ZDC1 `z_*datafile` / `z_*datatransfer`): daemon **BUILT-CLI-ONLY** (codec gtested as ZDC); GUI **DESIGNED-NOT-BUILT** (zero GUI surface; `RPC::isPrivateMintWired()` hard-returns `false`). + +Honesty review of the code as read: **clean.** User-facing copy says ZCL (never ZEC/Zcash); ownership-is-public is stated repeatedly and correctly; ZDC privacy is scoped to file content only ("public ciphertext", permanent); the non-consensus overlay framing is consistent. The findings below are engineering hygiene, not honesty defects. + +--- + +## Priority key + +- **P0** — correctness/coherence risk a user or integrator can hit, or a load-bearing safety guard that is copy-pasted (drift risk). Do first. +- **P1** — meaningful DRY/consistency/test-coverage win with clear payoff; no immediate user harm. +- **P2** — cleanup, dead-param removal, polish, aspirational-helper pruning. + +Each item lists: the problem, the concrete fix, the file(s). Line numbers are as of this audit and may shift. + +--- + +## Area A — API consistency (daemon RPC) + +### P0 + +**A-1. `nft_listoffers` `mine` param is dead end-to-end.** *(was DEAD-1; verified both ends)* +- Problem: the daemon parses `onlyMine` then discards it — `(void)onlyMine;` with comment "every record in the local store is mine" (`src/rpc/nftoffer.cpp:1000-1002`), and the fetched `store` is also `(void)store;` (`:1042`, `:1064`). The GUI wrapper accepts `bool mineOnly`, does `(void)mineOnly;` (`zcl-qt-wallet/src/rpc.cpp:1384`), yet still sends `{"mine": mineOnly}` over the wire (`:1390`). So a filter is advertised in help and on the wire that does nothing — it implies a capability that does not exist. +- Fix: pick one. Either (a) honor the filter (record send/receive provenance and filter on it), or (b) drop `mine` from the RPC `help`, stop sending it from the GUI, and remove the `mineOnly` arg from `RPC::nftListOffers`. Given the receive path is not built (see A-7 / D-3), **(b) remove** is the honest choice for now. +- Files: `zclassic/src/rpc/nftoffer.cpp` (~`:993-1064`, help text); `zcl-qt-wallet/src/rpc.cpp:1371-1392`. + +### P1 + +**A-2. Naming-family split: object-param vs positional-param surface.** *(API-1)* +- Problem: three prefixes, two param conventions. `zslp_genesis` takes an object (`src/rpc/zslp.cpp:328`); `zslp_mint`/`zslp_send`/`zslp_listtokens`/`zslp_listtransfers` take POSITIONAL params (`zslp.cpp:464,543,107,142`). All `nft_*` take a single object (`src/rpc/nftoffer.cpp`). All `z_*datafile`/`z_*datatransfer` take a single object (`src/rpc/datachannel.cpp`). A reader cannot predict the shape from the name. +- Fix: document the split prominently in each RPC `help` and in `doc/nft/README.md`; longer-term, accept an optional object form for `zslp_mint`/`zslp_send` so the write surface is uniformly object-style. Do NOT break the existing positional form (CLI users depend on it) — add the object form, keep positional. +- Files: `zclassic/src/rpc/zslp.cpp` (mint `:464`, send `:543`); `zclassic/src/rpc/client.cpp:136-158` (would need new conversion entries if object form is added); `zclassic/doc/nft/README.md`. + +**A-3. `zslp_listmytokens` returns a different, smaller object than `zslp_listtokens`/`zslp_gettoken`.** *(API-4 — pairs with C-3)* +- Problem: `zslp_listtokens`/`zslp_gettoken` emit the full token via `TokenToJSON` (`documenturl`, `documenthash`, `genesisheight`, `totalminted`, `mintbatonvout`, `hasmintbaton`), but `zslp_listmytokens` hand-rolls a smaller object — only `tokenid,ticker,name,decimals,balance,addresses[]` (`zslp.cpp:~250-258`, verified). Crucially it omits `documenthash`, which is exactly why the GUI fires a second `zslp_gettoken` per token (see C-3). Two divergent shapes for "a token" is a drift hazard. +- Fix: have `zslp_listmytokens` embed the `TokenToJSON(token)` object (it already does `store->GetToken(...)` in the loop) plus the per-wallet `balance`/`addresses[]`. This makes the shapes consistent and is the server half of deleting the GUI's per-token batch (C-3). +- Files: `zclassic/src/rpc/zslp.cpp` (~`:245-262`; reuse the `TokenToJSON` helper in the same file). + +**A-4. Inconsistent "not found" error mapping.** *(API-5 wart)* +- Problem: a missing token in `zslp_gettoken` throws `RPC_INVALID_ADDRESS_OR_KEY` (`zslp.cpp:103`) — semantically odd (the arg is a token id, neither an address nor a key). The `nft_*` cancel/not-found paths use `RPC_INVALID_PARAMETER`. Everything else is coherent (`RPC_MISC_ERROR` for index-off, `RPC_WALLET_INSUFFICIENT_FUNDS` for balance, `RPC_VERIFY_REJECTED` for a failed offer). +- Fix: map "token not found" to one consistent code across `zslp_*` and `nft_*`. `RPC_INVALID_PARAMETER` (the arg names a thing that does not exist) is the most defensible; align `zslp_gettoken:103` to it (or pick `RPC_INVALID_ADDRESS_OR_KEY` everywhere — just be consistent). +- Files: `zclassic/src/rpc/zslp.cpp:103`; cross-check `zclassic/src/rpc/nftoffer.cpp` not-found throws. + +### P2 + +**A-5. `nft_takeoffer` `changeAddr` is a reserved no-op param.** *(DEAD-2)* +- Problem: help says "reserved for a pre-size prep tx (unused here)" (`nftoffer.cpp:758`); the param is accepted but ignored. Honest in help, but it is a no-op surface that can mislead integrators into thinking change routing is controllable. +- Fix: either implement the pre-size prep tx, or drop the param until it is. If kept as a forward-compat placeholder, keep the explicit "(unused here)" note. +- Files: `zclassic/src/rpc/nftoffer.cpp` (~`:758`, takeoffer help + parse). + +**A-6. `client.cpp` arg-conversion map must stay lock-step with dispatcher signatures.** *(API-6 — note, not a bug)* +- Problem: `src/rpc/client.cpp:136-158` correctly converts arg0 for every object-param RPC and the numeric positional args of `zslp_mint`/`zslp_send`/`zslp_listtokens`/`zslp_listtransfers`; `z_listdatatransfers` (0 args) and string-only positionals correctly have no entry. No bug today — but it is a second place that silently breaks if a signature changes. +- Fix: add a one-line comment block at the table head pointing to the dispatcher files, and (if A-2 adds object forms) update entries in the same commit. Optional: a regtest assertion that each registered NFT RPC round-trips its documented arg shape. +- Files: `zclassic/src/rpc/client.cpp:136-158`. + +**A-7. `z_listdatatransfers` advertises a `direction`/`status` vocabulary it never varies.** *(DEAD-3)* +- Problem: `ZdcDirToStr` is an identity passthrough — `static const char* ZdcDirToStr(const char* d){return d;}` (`datachannel.cpp:152`); `direction` is always literal `"sent"` (`:356`) and `status` always `"recorded"` (`:397`). There is no "received"/"complete" state because the receive path is not built. The RPC implies a richer state machine than exists. +- Fix: until a receive path lands, either drop the `direction`/`status` fields, or document them in help as "always 'sent'/'recorded' in this build; 'received'/'complete' are reserved for the unbuilt receive path." Remove the no-op `ZdcDirToStr` wrapper. Tag this surface **BUILT-CLI-ONLY** with reserved fields. +- Files: `zclassic/src/rpc/datachannel.cpp` (`:152`, `:356`, `:395-397`). + +**A-8. Stale constant comment: ZDC file cap says "64 KB", code enforces 40000 bytes.** +- Problem: the top-of-file comment describes a 64 KB cap, but the built constant is `ZDC_MAX_FILE_BYTES = 40000` (`datachannel.cpp:84`), enforced at `:243`, `:261`, `:315`. The comment is stale and will mislead. +- Fix: correct the comment to match `40000`, or hoist the number into the comment via the constant so they cannot drift again. +- Files: `zclassic/src/rpc/datachannel.cpp` (header comment block; `:84`). + +--- + +## Area B — daemon DRY (shared helpers) + +### P0 + +**B-1. Duplicated quantity/zat parsers — 3 copies, subtly divergent.** *(API-2)* +- Problem: three near-identical "string|int → unsigned, digits-only, overflow-guarded" parsers: `ParseQuantity` (`src/rpc/zslp.cpp:284`, rejects `>= 2^63`), `NftParseZat` (`src/rpc/nftoffer.cpp:103`, rejects `> MAX_MONEY`), and the GUI's `RPC::zclToZat` (`zcl-qt-wallet/src/rpc.cpp:1216`, the only float→zat path). The first two are byte-for-byte the same logic with one different bound and one different error string. Divergent bounds/messages in money parsers are a P0 drift hazard. +- Fix: extract one daemon helper (e.g. `ParseAmountField(v, field, maxInclusive, what)`) into `wallet/zslpwallet.h`/`.cpp` next to the already-shared `ZSLPTokenIdToBE`; have `ParseQuantity` and `NftParseZat` call it with their respective bounds. Leave the GUI's `zclToZat` as the single GUI-side path (it is tested — see C-2) but cross-reference it in a comment. +- Files: `zclassic/src/rpc/zslp.cpp:284`; `zclassic/src/rpc/nftoffer.cpp:103`; new home in `zclassic/src/wallet/zslpwallet.{h,cpp}`. + +**B-2. Duplicated address/script + store-or-throw helpers, re-declared only to dodge a name clash.** *(API-3)* +- Problem: `ScriptForTAddr`+`FreshWalletScript` (`zslp.cpp:309,319`) are re-declared verbatim as `NftScriptForTAddr`+`NftFreshWalletScript`+`NftAddrFromScript` (`nftoffer.cpp:75,93,85`) purely to avoid a symbol clash in the same TU set. `GetZSLPStoreOrThrow` (`zslp.cpp:38`) and `NftGetStoreOrThrow` (`nftoffer.cpp:66`) are byte-identical. These are load-bearing (they build the actual scriptPubKeys for token carriers and the offer template) and copy-pasted. +- Fix: move one canonical set into `wallet/zslpwallet.h` (alongside `ZSLPTokenIdToBE`): `ZSLPStoreOrThrow()`, `ZSLPScriptForTAddr()`, `ZSLPFreshWalletScript()`, `ZSLPAddrFromScript()`. Delete both prefixed copies and call the shared ones. Confirmed call sites to repoint: `zslp.cpp` (`:38,98,121,158,205,309,319,361,422-423,446,485,523,526,566,574,585,631`) and `nftoffer.cpp` (`:66,75,85,93,360,366,474,487,494,496-497,689,764,867,994,1063,1096,1139,1159-1160`). +- Files: `zclassic/src/rpc/zslp.cpp`; `zclassic/src/rpc/nftoffer.cpp`; new home `zclassic/src/wallet/zslpwallet.{h,cpp}`. + +--- + +## Area C — GUI DRY (the refactor core) + +### P0 + +**C-1. In-flight / QPointer / Done-state async-dialog scaffold is copy-pasted 4-5x — the biggest DRY + safety win.** *(DRY-1)* +- Problem: the exact pattern { set `m_inFlight`; disable button + relabel "…ing"; `QPointer` guard across the async callback; on success switch the button to a terminal "Done" via `disconnect(SIGNAL(clicked()))` + reconnect to `onDoneClicked`; on error "Try again" + red result line; `closeEvent` swallow while in flight } is duplicated across `nftmintdialog.cpp:227-306`, `nftsenddialog.cpp:137-204`, `nftselldialog.cpp:190-389` (twice — make AND cancel), `nftbuydialog.cpp:355-425`. The `QPointer` guard is the **load-bearing UAF protection**; copy-pasting it means a future dialog can forget it (this exact UAF class has bitten before per project history). +- Fix: introduce a small `NftAsyncDialog` base (or a free helper taking the button + result label + two lambdas `{startFn, onResult}`) under `src/` that centralizes: in-flight latch, button relabel/disable, the `QPointer` guard, success→Done transition, error→Try-again, and the `closeEvent` swallow. Migrate all five flows onto it. Removes ~150 lines and makes the UAF guard impossible to omit. +- Files: new `zcl-qt-wallet/src/nftasyncdialog.{h,cpp}`; refactor `zcl-qt-wallet/src/nftmintdialog.cpp:227-306`, `nftsenddialog.cpp:137-204`, `nftselldialog.cpp:190-389`, `nftbuydialog.cpp:355-425`. (Note: `nftdetaildialog.cpp` also uses `QPointer` — evaluate whether it benefits.) +- Build constraint: the GUI is **C++14** (`zcl-qt-wallet.pro` `CONFIG += c++14`). Do NOT use `std::optional`/`std::string_view`; declare any header-signature types' includes in the header. + +### P1 + +**C-2. `humanZcl` is a private, untested, bespoke money formatter living next to a tested canonical one.** *(DRY-2; pairs with E-3)* +- Problem: `NFTBuyDialog::humanZcl` (static; impl `nftbuydialog.cpp:408`, decl `nftbuydialog.h:76`) is a zat→display formatter used at `nftbuydialog.cpp:258,268,379`. It exists nowhere else and has **no test**. Meanwhile the canonical, L0-tested formatter is `Settings::getDecimalString` (`src/settings.cpp:377`), and the inverse `RPC::zclToZat` (`src/rpc.cpp:1216`) is L1-tested (`tst_widget.cpp:2450`). A bespoke untested money formatter next to a tested one is exactly the kind of drift that produces wrong on-screen amounts. +- Fix: delete `humanZcl`; format via the tested `Settings::getDecimalString` path (convert zat→ZCL through the same arithmetic `zclToZat` uses, or add a tiny tested `Settings::zatToDecimalString(qint64)` and route both the buy dialog and any future caller through it). +- Files: `zcl-qt-wallet/src/nftbuydialog.cpp` (`:258,268,379,408`), `nftbuydialog.h:76`; canonical home `zcl-qt-wallet/src/settings.cpp`. + +**C-3. Per-token `zslp_gettoken` fan-out in `refreshNFTs` — a symptom of A-3.** *(DRY-4)* +- Problem: `refreshNFTs` calls `zslp_listmytokens` (`rpc.cpp:880`) then fans out one `zslp_gettoken` per listed token (Stage 2, `rpc.cpp:895-917`+) purely to recover `documenthash`/`genesisheight` that the list call omits. This doubles RPC round-trips per gallery refresh. +- Fix: land A-3 (server returns the full `TokenToJSON` object in `zslp_listmytokens`), then delete the Stage-2 batch and read the metadata straight off the list response. +- Files: `zcl-qt-wallet/src/rpc.cpp:824-984` (Stage 2 deletion); depends on `zclassic/src/rpc/zslp.cpp` A-3. + +**C-4. Address-validator + public-trade copy duplicated across send/sell/buy.** *(DRY-3)* +- Problem: `nftsenddialog.cpp:109-126` and `nftselldialog.cpp:162-177` are the same 4-state validator ("That doesn't look like a ZClassic address." / "Looks good — public (transparent)" / needs-public / valid-z-but-unsupported) with identical strings and identical `Settings::isTAddress` gating in their `refresh*Enabled` (`nftsenddialog.cpp:114,116,132,141`; `nftselldialog.cpp:166,168,185,198`). Separately, the public-trade sentence "This trade settles publicly on-chain — the price and both addresses are visible…" is verbatim in `nftselldialog.cpp:115` and `nftbuydialog.cpp:114`. Duplicated strings double the translation entry and risk drift between dialogs. +- Fix: add one shared `nftValidateTAddrInto(QLabel* status, const QString& addr) -> bool` helper and one `QString nftPublicTradeNote()`; call from all three dialogs. +- Files: new helper in `zcl-qt-wallet/src/` (e.g. `nftcommon.{h,cpp}` or fold into the C-1 base); refactor `nftsenddialog.cpp:109-141`, `nftselldialog.cpp:115,162-198`, `nftbuydialog.cpp:114`. + +### Note (no action — for the record) + +**C-5. GUI does NOT duplicate daemon security logic — correct.** *(DRY-5)* +- The GUI never re-implements offer verification (delegates to `nft_verifyoffer`), conservation, or anti-burn; `zclToZat` is the only arithmetic it owns. No GUI-vs-daemon duplication of security logic. Keep it this way. + +--- + +## Area D — error mapping + missing GUI wrappers + +### Note (no action — clean) + +**D-1. Honest error mapping verified clean.** `zclToZat`/`zslpCalmError` (`zcl-qt-wallet/src/rpc.cpp:1016`) maps -13/-6/-1 to calm sentences and passes the daemon's own message through otherwise — never fabricating success. Success callbacks check for empty txid/blob and surface "unexpected reply" rather than fake a result (`mintNFT` `:1092`, `nftMakeOffer` `:1270`). `nftJsonStr/Int/Bool` (`:846-869`) are SIGABRT-safe readers (const `operator[]` asserts under the shipped build's active C-asserts — the comment documents this correctly). No change needed. + +### P1 + +**D-2. `nft_requestbuy` has no GUI wrapper and no GUI test.** *(COV-4)* +- Problem: `nft_requestbuy` is registered and CLI-mapped (`zclassic/src/rpc/client.cpp:158`) but there is no `nftRequestBuy` wrapper in `zcl-qt-wallet/src/rpc.cpp` (grep-confirmed absent). The buyer-address handshake is daemon/CLI-only. Tag: **BUILT-CLI-ONLY**. +- Fix: either (a) add a `RPC::nftRequestBuy` wrapper + a GUI affordance if the buyer-address-request flow is wanted in v1, or (b) document it as intentionally CLI-only and ensure no GUI copy implies a one-click request-to-buy exists. Decide based on whether open/handshake listings are in scope (they are FUTURE-IDEA per the capability survey). +- Files: `zcl-qt-wallet/src/rpc.cpp` (new wrapper if pursued); `zclassic/src/rpc/nftoffer.cpp` (requestbuy dispatcher, for reference). + +### P2 + +**D-3. No GUI caller for `zslp_listtransfers` — provenance is unreachable from the GUI.** *(capability gap; honesty-adjacent)* +- Problem: the daemon `zslp_listtransfers` exists (`zclassic/src/rpc/zslp.cpp:142`, newest-first, reorg-safe) but no GUI code calls it (grep-confirmed empty in `zcl-qt-wallet/src/`). The detail dialog presents an NFT's identity but a user cannot view the public chain-of-custody history in-app. Status: **DESIGNED-NOT-BUILT (GUI)**. Make sure the detail dialog does not advertise provenance it cannot show. +- Fix: add a provenance list in `NFTDetailDialog` backed by a new `RPC::nftListTransfers` wrapper over `zslp_listtransfers`; until then, ensure no GUI string promises in-app history. (Public-by-design: history is fully visible on-chain — labeling it "public transfer history" is correct and required.) +- Files: `zcl-qt-wallet/src/nftdetaildialog.{cpp,h}`, `zcl-qt-wallet/src/rpc.cpp` (new wrapper); `zclassic/src/rpc/zslp.cpp:142` (source RPC). + +--- + +## Area E — test coverage gaps + +Primitive coverage is excellent: `test_zslp`(29), `test_zslp_vectors`(43), `test_zslp_indexer`(16), `test_zslp_wallet`(21), `test_zdc`(25), `test_nftoffer`(6); GUI L1 covers all five dialogs (mint/send/sell/buy/detail: success + gate + mismatch + close-swallow). The gaps below are at the **RPC-dispatcher** and **bespoke-helper** layers. + +### P1 + +**E-1. NFT-offer RPC dispatchers are not directly tested — and the test re-implements prod logic.** *(COV-1)* +- Problem: `test_nftoffer.cpp` rebuilds the 3-output template and re-runs `WouldBeValid`/`VerifyScript` **by hand** (`test_nftoffer.cpp:107,122,245`) because the real `NftVerify` is `static` in `nftoffer.cpp:309`, hence untestable. So the actual `nft_makeoffer`/`nft_verifyoffer`/`nft_takeoffer` dispatcher paths — param parsing, expiry-bound checks, the overshoot-ack gate, the `fundingInputs` anti-burn loop, the registry/listoffers status recompute — have **zero direct coverage**, and the test itself duplicates prod logic (a DRY violation inside the test). Same shape for `zslp_genesis`/`zslp_mint`/`zslp_send`: only `ZSLPBuild*` + `BuildAndCommitZSLP` are tested, not the RPC layer with its nft-preset conflict rejections (`zslp.cpp:402-411`). +- Fix: either (a) de-`static` + expose `NftVerify` (and the offer template builder) so the gtest calls the real function instead of a hand-rolled copy, or (b) add an RPC-level harness (regtest / `CallRPC`) exercising the dispatchers end-to-end. (a) is the smaller change and also kills the in-test duplication. The `qa/zslp/nft-sell-regtest.sh` regtest covers the happy path + sig-tamper/forged/overshoot refusals, but it is not a unit gate. +- Files: `zclassic/src/rpc/nftoffer.cpp:309` (de-static `NftVerify`); `zclassic/src/gtest/test_nftoffer.cpp:107,122,245`; `zclassic/src/rpc/zslp.cpp:402-411` (preset-conflict paths to cover). + +**E-2. Datachannel (ZDC) RPC layer is untested above the codec.** *(COV-2)* +- Problem: `test_zdc.cpp`(25) thoroughly tests the ZDC1 codec, but `z_senddatafile`/`z_listdatatransfers`/`z_getdatatransfer` have no test — the registry, TTL expiry (`ZdcExpireOld`), rate guard (`ZdcRateGuard`), the verify-before-decrypt branch logic, and the registry-miss fingerprint-grouping fallback (`datachannel.cpp:513-536`) are all uncovered. This is the privacy surface (file-content confidentiality via ZDC1), so its registry/verify branches deserve a gate. Note this is **BUILT-CLI-ONLY**, default-OFF behind `-experimentalfeatures -datachannel`. +- Fix: add an RPC/regtest harness (or unit tests around the extractable registry/TTL/rate-guard functions) exercising send→list→get, TTL expiry, rate-guard rejection, and the verify-before-decrypt failure modes (`ERR_NO_KEY`/`ERR_AEAD_FAIL`/`ERR_HASH_MISMATCH`). The cross-wallet round-trip in `qa/zslp/zdc-xwallet-regtest.sh` exists but is not a unit gate. +- Files: `zclassic/src/rpc/datachannel.cpp` (`ZdcExpireOld`, `ZdcRateGuard`, `:407` getdatatransfer, `:513-536` fallback); new gtest or regtest. + +**E-3. `humanZcl` is untested.** *(COV-3 — resolved by C-2)* +- Problem: the bespoke buy-dialog money formatter (`nftbuydialog.cpp:408`) has no test. Resolved automatically by C-2 (delete it, route through the tested `Settings`/`zclToZat` path). If C-2 is deferred, add a direct test for `humanZcl` covering rounding, zero, and large values. +- Files: see C-2. + +### P2 + +**E-4. The non-consensus no-fork guarantee is under-tested.** *(cross-cutting risk)* +- Problem: the HARD constraint — the NFT OP_RETURN must pass mainnet `IsStandard`/`-datacarriersize` policy unchanged so unmodified nodes relay and mine it — is currently backed only by a 223-byte builder-length assert, not by any test tied to mainnet policy. The "no fork" claim is not test-proven against real relay policy. +- Fix: add a test that constructs a representative ZSLP genesis/send/offer OP_RETURN and asserts it passes `IsStandardTx`/`AreInputsStandard` under default mainnet `-datacarriersize`. This converts the no-fork claim from asserted to proven. +- Files: new gtest in `zclassic/src/gtest/`; policy under `zclassic/src/policy/policy.cpp`. + +--- + +## Area F — dead / placeholder code + +### P2 + +**F-1. `NFTSellDialog::trimmedExpiryLabel` is unused/aspirational.** *(DEAD-4)* +- Problem: `trimmedExpiryLabel` (`nftselldialog.cpp:379`) builds a "~N days" label, but the expiry combo has only the single 7-day row (`:88`) and the user-facing label is hard-coded "expires in ~7 days." elsewhere (`:242`). The helper is unreachable; the combo itself is a single-choice placeholder (arbitrary expiry is a documented follow-up at `:86-90`). +- Fix: remove `trimmedExpiryLabel` until multi-choice expiry lands; or wire it up if a second expiry option is added now. Keep the "arbitrary expiry is a follow-up" comment so the placeholder intent stays honest. +- Files: `zcl-qt-wallet/src/nftselldialog.cpp` (`:86-90`, `:242`, `:379`). + +(See also A-1 `nft_listoffers mine`, A-5 `nft_takeoffer changeAddr`, A-7 `ZdcDirToStr`/never-varied status — all dead-param/placeholder items filed under Area A because the fix lives in the RPC surface.) + +--- + +## Suggested refactor order (highest leverage first) + +1. **C-1** — shared async-dialog scaffold (biggest LOC win + centralizes the load-bearing UAF guard). **P0** +2. **B-1 / B-2** — shared daemon helpers (amount parser, store-or-throw, t-addr script/addr) into `zslpwallet.h`. **P0/P0** +3. **A-3 + C-3** — embed the full `TokenToJSON` object in `zslp_listmytokens`; delete the GUI per-token batch. **P1/P1** +4. **E-1 / E-2** — RPC-dispatcher tests; de-`static` `NftVerify` so the test stops duplicating prod logic. **P1/P1** +5. **C-2 / C-4 / E-3** — fold `humanZcl` into the tested `Settings` path; share the t-addr validator + public-trade note. **P1/P1** +6. **A-1** — honor or remove the dead `nft_listoffers` `mine` param (and the GUI side). **P0** (small; can be batched with step 1.) + +Remaining P2 items (A-5, A-6, A-7, A-8, D-3, E-4, F-1) are cleanup/coverage to schedule opportunistically. + +--- + +## Appendix — verification notes + +Every load-bearing claim above was spot-checked against the working tree on the stated branches (daemon `feature/zslp-nft-indexer`, GUI `feature/nft-gallery`): +- A-1: `(void)onlyMine;`/`(void)store;` confirmed at `nftoffer.cpp:1000-1064`; GUI sends `{"mine": mineOnly}` at `rpc.cpp:1390` while `(void)mineOnly;` at `:1384` — dead both ends. +- A-3: `zslp_listmytokens` hand-rolled object (no `documenthash`) confirmed at `zslp.cpp:~250-258`. +- A-4: `RPC_INVALID_ADDRESS_OR_KEY` for "Token not found" confirmed at `zslp.cpp:103`; `RPC_INVALID_PARAMETER` used elsewhere in the file. +- A-7/A-8: `ZdcDirToStr` identity wrapper `:152`, `direction="sent"` `:356`, `status="recorded"` `:397`, `ZDC_MAX_FILE_BYTES=40000` `:84`. +- B-1: `ParseQuantity` `zslp.cpp:284` vs `NftParseZat` `nftoffer.cpp:103` confirmed. +- B-2: prefixed-duplicate helpers and call sites confirmed in both `zslp.cpp` and `nftoffer.cpp` (line lists above). +- C-1: `m_inFlight`/`QPointer` pattern present in all of `nftmint/send/sell/buy` (and `nftdetail`) dialogs. +- C-2: `humanZcl` only in `nftbuydialog.{cpp,h}`; `Settings::getDecimalString` `settings.cpp:377`; `RPC::zclToZat` `rpc.cpp:1216`. +- C-3: two-stage `zslp_listmytokens` → per-token `zslp_gettoken` confirmed at `rpc.cpp:871-917`. +- C-4: duplicated validator strings + `isTAddress` gating + duplicated public-trade sentence confirmed across `nftsend/sell/buy`. +- D-2: no `nftRequestBuy`/`nft_requestbuy` in GUI `src/` (grep empty). +- D-3: no `zslp_listtransfers` caller in GUI `src/` (grep empty). +- SHIELD GUI gate: `isPrivateMintWired()` returns `false` (`rpc.h:326`); "coming in this release"/"coming soon" copy at `nftmintdialog.cpp:86-90`, `nftsenddialog.cpp:79`. diff --git a/doc/nft/NATIVE_DISPLAY_UX.md b/doc/nft/NATIVE_DISPLAY_UX.md new file mode 100644 index 00000000000..e6a273ba2b6 --- /dev/null +++ b/doc/nft/NATIVE_DISPLAY_UX.md @@ -0,0 +1,637 @@ +# ZClassic Native NFT Display & Shield UX — Canonical Design + +*The single canonical design doc for the **native** (no web browser) NFT display surface in +the ZClassic wallet: how an owner views, verifies, and privately ships the **file content** +behind a ZSLP NFT. Status-accurate against the GUI on `feature/nft-gallery` +(`zcl-qt-wallet`) and the daemon data-channel on `feature/zslp-nft-indexer` (`zclassic`).* + +> **The coin is ZClassic / ZCL.** Every balance, fee, dust output, and price in this +> document is denominated in **ZCL**. Zcash-lineage code identifiers (`zclassicd`, +> `z_sendmany`, `.zcash-params`, Sapling, ivk) keep their upstream names, but the money a +> user holds, sends, and sells for is always **ZCL**. + +--- + +## 0. The two truths this whole surface must tell honestly + +Read these before any pixel. They are load-bearing and every screen must obey them. + +1. **Ownership is ALWAYS public.** A ZSLP NFT rides a **transparent dust UTXO** (0.00001 + ZCL). Who owns which token, and every transfer of it, is **fully visible on-chain** to + anyone, forever. There is **no shielded ownership, no anonymous holder, no confidential + transfer.** The wallet must never say or imply otherwise. The honest one-liner, used + verbatim across gallery and detail, is: + > "● Public — anyone can verify this on the public ledger." + +2. **The only privacy here is file content.** The **ZDC1 shielded data-channel** makes the + **bytes of a file private** by encrypting them (ChaCha20-Poly1305 AEAD) and shipping the + ciphertext inside Sapling shielded memos. It hides the **payload** and the data-transfer + linkage — it does **not** hide who owns the token. And even the bytes are stored as + **public ciphertext on every full node permanently** (encrypted-but-undeletable): + "private" means confidential, **not** undetectable and **not** erasable. + +3. **A green ✓ means one narrow thing.** "The local bytes match the on-chain fingerprint." + It does **not** mean genuine, official, authorized, or one-of-a-kind. Anyone can mint a + copy that reuses the same picture. Only the **mint id** is unique. The verify copy must + never overstate. + +4. **Non-consensus overlay.** ZSLP NFTs and the data-channel are an OP_RETURN / Sapling-memo + overlay. Old, unmodified nodes relay and mine the outputs unchanged; there is **no + consensus fork.** Security comes from **every honest wallet deterministically + re-validating confirmed history**, not from miners enforcing it. + +### Honesty tags used throughout + +Every capability below is tagged exactly one of: + +- **BUILT+TESTED** — code exists, has automated tests (gtest/L0/L1), proven. +- **BUILT-CLI-ONLY** — daemon code exists and works from the RPC console; **no GUI surface** + and no automated end-to-end seam test. +- **DESIGNED-NOT-BUILT** — specified here/in the guide; no code yet. +- **FUTURE-IDEA** — directional only; not specified to build. + +--- + +## 1. Design principles + +The owner is not a developer. They double-click a wallet, click a Collections tab, and want +to *see* their stuff and *send* a file to a friend without learning a vocabulary. Five +principles, in priority order: + +### 1.1 In-app rendering only — never a browser, never an auto-fetch +There is **no QtWebEngine, no QtMultimedia, no embedded browser** anywhere in this surface +(`zcl-qt-wallet.pro` declares only `QT += svg widgets`). Every render entry point takes a +**local filesystem path or a `:/resource` only**. A remote `http(s)`/`ftp`/`ipfs`/`zdc1`/UNC +/ protocol-relative path is **rejected** by `ContentEngine::isRemoteUrl()` and resolves to an +honest null/pending fallback. **There is zero network code in the render layer.** This is a +security stance, not a limitation we apologize for: an NFT image can never phone home, never +leak that you opened it, never pull a tracking pixel. **BUILT+TESTED.** + +### 1.2 Never auto-fetch; the owner is always in control of bytes +The wallet will not silently download or resolve a file from anywhere. The bytes that render +are bytes the owner already has on this device (via **Attach the file you have…**, the +content-addressed blob store, or — designed — an arrived ZDC1 transfer). The default live +state for a freshly received NFT is therefore **"Image not on this device,"** and that is the +*correct, honest* state, not a bug. (See §2.3 and §5.) + +### 1.3 Instant / fast — never block the GUI thread +All hashing, classification, and decoding run on a **bounded 4-thread `QThreadPool`**. +Workers touch only `QByteArray`/`QCryptographicHash`/`QImageReader`/`QImage` — **never +`QPixmap`** (that is GUI-thread-only, built in `deliver`). Files stream through one reused +**1 MiB buffer**, so a 2 GB file hashes in ~1 MiB of RAM; an in-flight multi-GB hash aborts +cleanly on shutdown (`cancelAll()` + `waitForDone()`). The paint hot path allocates **no +pixmaps**. **BUILT+TESTED.** (Full architecture in §5.) + +### 1.4 Honest verification, always visible, never overstated +Three badge states — pending / verified / mismatch — plus a **fourth neutral terminal state** +for "no local bytes to check" (the common production state). The green check is scoped to +"bytes match the fingerprint" and the public-ownership line sits next to it on every card and +detail view. Honesty copy is uniform and load-bearing (§2.6). **BUILT+TESTED.** + +### 1.5 Zero jargon +No "UTXO", "Merkle root", "AEAD", "ivk", "ciphertext" in primary copy. Use **"fingerprint"** +for the content hash, **"mint id"** for the token id, **"public ledger"** for the chain, +**"file is private"** for the data-channel encryption. Technical terms live only in tooltips, +an expandable "Details" disclosure, or the RPC console — never in the first thing the owner +reads. + +--- + +## 2. The gallery — Collections tab + +The Collections tab is a `QStackedWidget`. **Index 0 = gallery** (BUILT+TESTED). **Index 1 = +set/collection board** (DESIGNED-NOT-BUILT, §4). + +### 2.1 Layout (index 0) — BUILT+TESTED + +- A **`QListView` in IconMode**: `setResizeMode(Adjust)` (cards reflow on window resize), + `setUniformItemSizes(true)`, `setSpacing(8)`, single-selection, static movement, + mouse-tracking for hover. +- **`activated`** (double-click *and* Enter) opens the detail dialog — connected exactly once + (no double-open bug). +- Model: **`NFTGalleryModel : QAbstractListModel`** of `NFTItem` POD rows + (`name / collection / txid / docHashHex / cachePath / receivedHeight / isPrivate / + verifyState`). Thumbnails live in a parallel `_thumbs` `QPixmap` vector index-aligned with + `_items`. +- Delegate: **`NFTGalleryDelegate`**, a fixed **168×208** card (DPR-scaled): rounded card + `#15171c` + hairline `#2a2d35`; a square cover-fit thumbnail + (`KeepAspectRatioByExpanding` + crop); a verify badge on a dark disc top-right; a **"Public" + pill — always amber `#d9822b`, never a green "Private"** (issue #119 honesty); a bold elided + name and a dim elided collection caption. +- **Churn-free polling:** `setItems()` is fingerprint-guarded (a SHA-1 over all + render-affecting fields). An identical re-feed each poll = **zero churn** (no flicker, no + reflow). `onImageReady` updates **every** row whose `docHashHex` matches and emits a tight + per-row `dataChanged`. + +### 2.2 Gallery states + +| State | When | What the owner sees | Status | +|---|---|---|---| +| **Empty / first run** | No tokens held | Friendly intro card ("Your NFTs will appear here…"); hides once rows arrive | BUILT+TESTED | +| **Index off** | `zslp` index disabled | State line with a **copyable `zslpindex=1` hint** | BUILT+TESTED | +| **Loading (first poll)** | RPC in flight | Today: static state line. **Designed:** skeleton shimmer cards | line BUILT; skeletons DESIGNED-NOT-BUILT | +| **Populated, no local bytes** | Live production default | A wall of cards each showing **"Image not on this device"** + neutral "–" badge | BUILT+TESTED (and is the #1 UX gap, §2.3) | +| **Populated, bytes attached** | After Attach / blob hit | Real thumbnails, green ✓ badges | BUILT+TESTED | + +### 2.3 The #1 "don't-make-me-think" gap: no production thumbnails + +**This is the most important thing to fix.** In shipped builds, `RPC::refreshNFTs()` +(`rpc.cpp:871-1010`, via `zslp_listmytokens` + batched `zslp_gettoken`) sets +**`it.cachePath = QString()` for every item by privacy design**, so every live card is +`verifyState 0` with no thumbnail — a wall of identical gray "Image not on this device" cards. +The blob store and **Attach** exist, but there is **no first-class flow to populate posters**. + +Designed remedies, in priority order (all **DESIGNED-NOT-BUILT**): + +1. **Inline "Attach image" affordance on the card** — a card with no local bytes shows a + subtle "+ Add image" hover action that opens the same file-picker → `hashFile` → + match-gate → `cachePut` → re-request poster flow that the detail dialog uses. +2. **Drag-and-drop a folder of held files onto the gallery** → bulk-hash every file once, + auto-match each against held tokens' `docHashHex`, and populate every matching poster in + one pass. (Bounded by the same 4-thread pool; show a non-modal progress chip.) +3. **Auto-resolve from an arrived ZDC1 transfer** — once a private data-channel content for a + token you hold has been received and verified (§6), the verified plaintext is written to + the blob store and the poster appears with **no manual Attach.** This is the long-term + "it just works" path and it depends on the Shield receive flow shipping. + +### 2.4 Search / filter / group / sort — DESIGNED-NOT-BUILT + +`NATIVE_NFT_GUIDE.md §2.0/§2.2` specifies live search over name+collection, a Filter +(All / Verified / Needs-attention / …), a Group (By collection / By privacy), and Sort. **None +is built** — there is no `QSortFilterProxyModel`; the model is a flat, unsorted list in raw +RPC order. + +**Designed implementation:** insert a `QSortFilterProxyModel` between `NFTGalleryModel` and +the view. A debounced (~150 ms) search box drives `setFilterFixedString` over a synthesized +name+collection role. Filter/Group/Sort are toolbar combo-boxes mapping to proxy predicates +and a sort role. Keep the model's fingerprint guard intact: the proxy filters, the source +model still no-churns on identical re-feeds. "Needs-attention" filter = `verifyState == 2` +(mismatch) plus the no-local-bytes terminal state, surfacing exactly the cards that want the +owner's action. + +### 2.5 Zoom tiers — DESIGNED-NOT-BUILT + +Gallery currently hard-codes `nftThumbPx = 152` (`mainwindow.h:373`). A denser/larger zoom +would re-decode rather than reuse. **Designed:** a 3-stop zoom (compact / standard / large) +that requests new poster sizes; because posters are content-addressed by `"@"`, each +stop caches independently and the RAM `QPixmapCache` keeps recently-used sizes warm. + +### 2.6 Honesty copy on every card (uniform, BUILT+TESTED) + +- **"Public" pill** — always amber, always present. +- **Badge meaning** never appears as "genuine/official." Green ✓ tooltip: *"The image on this + device matches the fingerprint recorded on the public ledger. It does not mean the NFT is + official — anyone can mint a copy using the same picture."* +- The no-local-bytes neutral state reads **"Image not on this device"** (gallery) — not a + perpetual amber spinner. + +--- + +## 3. The detail / verify view + +`NFTDetailDialog` (`src/nftdetaildialog.{cpp,h}`) — **BUILT+TESTED**. + +### 3.1 Layout + +- **760×560.** Left = an image **stage** (`kStagePx = 380`, requests `kPosterPx = 512`). + Right = an info panel: verify line + badge, **Public pill**, mint id, received block/date, + set line, fingerprint shown abbreviated **8…8**, and the honesty footnote. +- **Footnote (verbatim, load-bearing):** *"Only the mint id is one of a kind."* +- **Ownership line (verbatim):** *"● Public — anyone can verify this on the public ledger."* + Never "private/shielded." + +### 3.2 Actions + +| Action | Behavior | Notes | +|---|---|---| +| **Send / Gift** | Opens transparent transfer of the token | Token transfer is **public** — copy must say so | +| **Sell** | Opens sell dialog | **Disabled on mismatch** (`verifyState == 2`) | +| **Save image…** | Writes the local image to disk | Only when bytes are present | +| **Copy id** / **Copy fingerprint** | Clipboard | — | +| **Re-check image** | Re-runs verify on the local bytes | — | +| **View in explorer** | **Confirm-dialog gated** (leaving the app) | Honest "this opens a website" warning | +| **Prev / Next** | Walks the gallery | A fresh token retires a stale neighbor's late reply (§5.4) | +| **Attach the file you have…** | The one path a received NFT reaches green ✓ | See §3.3 | + +### 3.3 "Attach the file you have…" — the verification handshake (BUILT+TESTED) + +This is the **only** path by which a received NFT's image reaches the green badge today: + +1. Owner explicitly picks a **local file**. +2. `ContentEngine::hashFile()` computes the anchor. +3. **Match-gate** against the token's `docHashHex` — accepts **either** a bare whole-file + SHA-256 (small/single-leaf) **or** a chunked Merkle root (`anchorHexFor` / `verify()` rule, + §5.5). +4. On match: `cachePut` writes the verified bytes to the content blob store, then + `requestPoster()` re-renders → green ✓. +5. **Disabled honestly** for a hash-less NFT (nothing to check against). + +### 3.4 Verify states in detail (consistent with gallery) + +- **0 pending** → amber "?" (`question.svg`). +- **1 verified** → green "✓" (`check.svg`, `#1f7a1f` / `#2a9d2a`). +- **2 mismatch** → red "✗" (`x.svg`, `#c0392b`) — Sell disabled. +- **No-local-bytes terminal** → neutral dim "–" + *"Can't check this image — its file isn't on + this computer."* (**not** a perpetual spinner). This is the common production state. + +### 3.5 Detail-view gaps — DESIGNED-NOT-BUILT + +- **Neighbor prefetch.** Prev/Next re-requests a poster each step with no look-ahead; fast + scrubbing tears down/rebuilds. **Designed:** prefetch 1-ahead / 1-behind so scrubbing feels + instant. Posters are content-addressed, so the prefetched neighbor is a warm-cache hit. +- **Resize re-fit.** `onPosterReady` scales once to the current stage size; `m_sourcePixmap` + is retained but there is no `resizeEvent` re-fit, so a maximized dialog shows a small image. + **Designed:** re-scale `m_sourcePixmap` to the stage on `resizeEvent` (no re-decode). +- **Provenance back-fill is a stub.** `nftProvenance` / `txReceivedDate` RPC back-fill runs + with QPointer lifetime guards, but provenance is a **no-op stub** (the chain records no + creator). Creator / set / series rows are **honest defaults only** today. + +--- + +## 4. The collection / set board (Collections tab, index 1) — DESIGNED-NOT-BUILT + +`NATIVE_NFT_GUIDE.md §2.7` specifies a stacked page for card-sets/groups: a **set header**, a +**completion meter** ("3 of 7 collected"), and a per-set card board. **None is built.** Today +"collection" is only a one-line dim caption; tokens are not grouped, and **`nft.h` has no +`groupId` / `childOf`** field. + +**Designed implementation:** + +1. **Data model.** Add `groupId` (the ZSLP group/parent token id) and `childOf` to `NFTItem`, + populated by `refreshNFTs()` from the ZSLP group→child relationship (group GENESIS + child + tokens). A second model groups child rows under their group row. +2. **Navigation.** Clicking a set caption on a card, or a "View set" action in detail, + switches the `QStackedWidget` to index 1 with that set selected; a back button returns to + the gallery preserving scroll position. +3. **Set header.** Set name, a **completion meter** ("collected M of N") where N is the set's + declared child count and M is the count this wallet holds, and the same **Public** honesty + pill (set membership is on-chain and public). +4. **Card board.** The same delegate, but cards the owner does **not** hold render as dim + "not yet collected" placeholders (no fabricated image, honest empty glyph) — never implying + ownership the ledger doesn't show. +5. **Honesty.** "Completion" is *what this wallet holds of a publicly-declared set* — it is + not a private or exclusive status. Membership and completion are derivable by anyone from + the public ledger. + +--- + +## 5. Rendering + caching architecture (the engine we already have) + +The whole render surface runs on **one shared engine**. This section is the contract any new +display feature builds on. + +### 5.1 One engine, one instance — BUILT+TESTED + +- **`ContentEngine`** (`src/contentengine.{h,cpp}`) is the single class. + **`NFTImageCache`** (`src/nftimagecache.{h,cpp}`) is now a **thin back-compat alias-subclass** + — no duplicate logic. +- **One instance per `MainWindow`** (`nftImgCache`, `mainwindow.cpp:3132`), passed to the + gallery, detail, mint, send, sell, and buy dialogs (everywhere as + `nftImgCache /*ContentEngine*/`). New surfaces reuse this instance — never spin up a second + engine. +- It turns any **local** file's bytes into: (a) a `verifyState` (0 pending / 1 verified / + 2 mismatch), (b) a poster/thumbnail `QImage`, (c) a chunked Merkle root. + +### 5.2 Three async ops, one runnable — BUILT+TESTED + +One `ContentTask : QRunnable` carries `Op_Poster` / `Op_Hash` / `Op_Verify`: + +| Method | Caller | Inflight key | +|---|---|---| +| `request()` / `posterFor()` | gallery (token == 0) | `"@"` (hash-addressed) | +| `posterForToken()` | detail (token > 0) | `"poster#"` | +| `hashFile()` | Attach handshake | `"hashjob#"` | +| `verify()` | re-check | `"verify#"` | + +### 5.3 The two delivery paths + +- **Gallery (`request`/`posterFor`, token == 0):** worker classifies (MIME header sniff), + streams + verifies, decodes the image **downscaled at decode**, writes an **atomic PNG** to + the on-disk poster cache (`AppData/nft_posters/_.png`), then crosses back via + `QMetaObject::invokeMethod(..., QueuedConnection)` to **`deliver()` on the GUI thread**, + which builds the `QPixmap`, seeds `QPixmapCache`, and calls + `NFTGalleryModel::onImageReady(hash, pm, verifyState)`. +- **Detail (`posterForToken`, token > 0):** the **same** decode/verify, but delivers the large + `QImage` via the **`posterReady(token, img, verifyState)`** signal to **one** caller + (`NFTDetailDialog::onPosterReady`). `token == 0` / empty-path / bad-size / remote-URL all + emit `posterReady(token, null, CE_Pending)` so the dialog **never hangs**. + +### 5.4 Two-tier (really three-store) cache — BUILT+TESTED + +- **RAM `QPixmapCache` (128 MB)** keyed by the inflight key. +- **On-disk poster PNG cache** (`AppData/nft_posters/`) — decoded thumbnails. +- **Separate content-addressed blob store** (`AppData/nft_content/`) — **verified raw + bytes**, opt-in via `cachePut`. +- **Critical correctness rule:** `request()` short-circuits to `CE_Verified` **without + rehash** *only* when the **trusted-hash blob exists** — correctly **not** gated on mere + poster-PNG existence (which would mis-report a mismatch as verified). New code must preserve + this. +- **Path-traversal safe:** `safeKey()` keeps only `[0-9a-f]`, length-capped 80; `cachePut` + re-asserts the destination stays inside `blobCacheDir()`. +- **Stale-reply retirement (detail):** a fresh `token` retires a stale neighbor's late reply + on fast prev/next, so scrubbing never shows the wrong image. + +### 5.5 How it stays fast (perf characteristics) — BUILT+TESTED + +- **Bounded `QThreadPool` = 4 workers.** Workers never touch `QPixmap`. +- **Streaming, bounded RAM:** never `readAll()`; one reused **1 MiB** buffer + (`kHashBufBytes`); `std::atomic` cancel checked every block; dtor `cancelAll()` + + `waitForDone()`. +- **Decode guards:** source wider than 4096 px or file > 10 MB is `setScaledSize()`-downscaled + at decode (`kDecodeCapPx = 1024`), then `scaledToWidth(sizePx)`. +- **Dedupe:** identical in-flight key dropped; every non-delivering exit (cancel / null owner) + releases the key via the `ContentTask` dtor safety net (inflight-key leak fix). +- **Anchor rule (`anchorHexFor`):** multi-chunk → Merkle root; small/single-leaf → bare + whole-file SHA-256. `verify()` accepts **either** form. `computeVerify` does a fast + single-pass bare-SHA first and only falls back to the full Merkle pass on a non-match + (double-hash fix). **This is why "Attach" accepts both hash shapes.** + +### 5.6 What renders, and what is honestly *not* faked — BUILT+TESTED + +- **Image** (`CK_Image`, `image/*`): real `QImageReader` decode → thumbnail/poster (also + attempted when MIME sniff is ambiguous, so an odd-extension image still gets a thumb). +- **Video / Document / Bytes:** **no decode, no fake frame.** `renderTypedPoster()` paints a + native `QPainter` glyph on the dark inset (`#1d2027`): film-strip + play triangle (video), + folded-corner ruled sheet (document), box + tick (bytes). **Honest** — the static bundle has + no codec, so we never fake a video frame. +- **Format coverage caveat:** PNG always works; **JPEG/GIF/WEBP/SVG depend on which Qt + `imageformats` plugins are compiled into the static bundle.** The `.pro` lists only platform + plugins, so **format-plugin coverage must be confirmed in the actual shipped bundle** before + promising a format to the owner. + +### 5.7 Placeholder vs. real (so no one ships a fixture) + +- **Real/honest:** the engine, verify math, threading, all three caches, badge states, + no-bytes terminal copy, typed glyph posters, public-ownership copy. +- **Fixtures only:** thumbnails from `loadNFTFixtures()` (`:/nft/sample*.png`) are gated behind + `NFT_GALLERY_FIXTURES` and are **not in shipped builds.** In the shipped path every + `cachePath` is empty by design (§2.3), `collection` is just the ZSLP ticker (fallback + "ZSLP"), and `isPrivate` is always false. + +--- + +## 6. SHIELD — private send / receive of file content (THE NEXT BUILD) + +This is the next thing to build, so this section is **implementation-ready**. It is the **GUI +surface over the ZDC1 shielded data-channel**. The daemon side is **BUILT-CLI-ONLY**; the GUI +is **DESIGNED-NOT-BUILT**. Build the GUI to the as-built daemon contract below — not to the +older, stale docs. + +> **Honesty banner the Shield UI must show, verbatim, before the first send:** +> *"This makes the **file's contents** private — it is encrypted so only the person you send +> it to can open it. It does **not** make ownership of your NFT private: who holds the token +> is always public on the ledger. The encrypted file is stored **permanently and publicly** on +> every node — it can never be deleted, only kept unreadable to others."* + +### 6.1 What's actually built underneath (the contract) + +- **ZDC1 codec — BUILT+TESTED** (25 gtests, `src/gtest/test_zdc.cpp`; standalone harness + `src/datachannel/test/zdc_test.cpp`; compiled into the daemon). + - **AEAD:** libsodium `crypto_aead_chacha20poly1305_ietf` combined mode, the **only** cipher + (`CIPHER_CHACHA20POLY1305 = 0x01`). + - **Per-transfer key:** 32 random bytes (`randombytes_buf`), **fresh per transfer, never + reused, never logged**, zeroized on destruct/replace/TTL-expire. **No KDF** — the key is + raw CSPRNG, independent of any wallet secret and of the Sapling ivk. + - **Nonce:** `transfer_id(8 BE) ‖ nonce_ctr(4 BE)`; reserved-counter band (START = 0xFFFFFFFF, + END = 0xFFFFFFFE) fixes a real prior nonce-reuse bug; locked by `TEST(ZDC, NonceUniqueness)`. + - **document_hash = ciphertext fingerprint** (`ciphertext_fingerprint`, `zdc.cpp:523`): + SHA-256 over DATA-frame ciphertexts in seq order — deterministic, **key-independent** + (stable before/after key reveal). This is the on-chain anchor that a ZSLP NFT's + `document_hash` commits to, **binding the public token to the private bytes without + revealing them.** + - **Verify-before-decrypt:** `z_getdatatransfer` recomputes the anchor and compares it to + the expected anchor **before any decrypt**; it **never returns plaintext on failure.** +- **Three daemon RPCs — BUILT-CLI-ONLY** (`src/rpc/datachannel.cpp`), registered **only** under + `-datachannel` (default OFF, `init.cpp:527`). When off, the dispatcher returns + `RPC_METHOD_NOT_FOUND (-32601)`. +- **Cross-wallet receive — BUILT-CLI-ONLY, unproven by automated E2E.** The current daemon has + the registry-free reconstruct-from-chain path (`datachannel.cpp:504-536`, + `GetFilteredNotes(requireSpendingKey=false)` so a **viewing-key-only** wallet can read its + frames; the ivk decrypts the L1 memos, the on-chain KEY frame populates the L3 key, + `verify_fingerprint` gates the open). **No ivk/spending key ever leaves the wallet.** The + stale "#117 structurally impossible" line in `NFT_FINAL_REVIEW.md` / `PRIVACY.md` describes + an **older** revision — **the code is ahead of those docs.** The remaining real caveat is + **key delivery**: the in-band KEY frame works (`include_key_frame = true`, + `datachannel.cpp:288`); out-of-band/reveal-later is **DESIGNED-NOT-BUILT**. +- **Selective disclosure via `z_exportviewingkey` (Sapling ivk) — DESIGNED-NOT-BUILT.** + `rpcdump.cpp` throws *"Currently, only Sprout zaddrs are supported"* (line 832); the data + channel is Sapling-only, so the ivk cannot be exported today. **As-built disclosure is via + sharing the per-transfer L3 key** returned by `z_senddatafile` plus `verify_fingerprint`. + The GUI must offer **only** the L3-key path until the Sapling ivk export ships. + +### 6.2 Exact daemon RPCs the GUI calls + +**`z_senddatafile`** (async; poll with `z_getoperationresult`): +- IN: `{ fromaddress (Sapling z), toaddress (Sapling z), filepath | hexdata (exactly one, + ≤ 40000 B), acknowledge_permanent: true (REQUIRED), filename?, content_type? }`. +- OUT: `{ operationid, transfer_id (16-hex), fingerprint (64-hex = NFT document_hash), frames, + key (hex per-transfer key for selective disclosure) }`. Async result also yields + `{ txid, transfer_id, fingerprint, frames }`. +- Enforces, at the daemon: **permanence ack**, **Sapling from-addr with spending key in + wallet** (no watch-only send), **shielded change**, **40000-byte cap**, **90-frame single-tx + guard**. The 40000-byte cap is principled, not arbitrary: a single shielded tx fits + ~99 frames under the post-Sapling tx-size budget, and the codec then picks a **chosen + ceiling of 90 frames** (`ZDC_MAX_FRAMES_PER_TX`) for headroom — 87 DATA frames × 464 + usable bytes ≈ the 40000-byte file cap. + +**`z_listdatatransfers`** — IN: none. OUT: array of +`{ transfer_id, fingerprint, direction ("sent"), frames, status ("recorded"), fromaddress, +toaddress, filename }`. **Session/in-memory only** — lists only what *this* node sent *this* +session; expired by 72h TTL. **The GUI must not present this as a durable history.** + +**`z_getdatatransfer`** — reassemble + verify-before-decrypt. IN: +`{ transfer_id (16-hex) | fingerprint (64-hex), address? (defaults to recorded toaddress, +else scans all viewable addrs), verify_fingerprint? (64-hex out-of-band anchor) }`. OUT: +`{ transfer_id, fingerprint, verified, complete, frames_received, onchain_fingerprint?, +expected_fingerprint?, hexdata (plaintext — only if verified+decrypted), size, filename, +content_type, error }`. + +**The four honest error codes the receive UI must distinguish** (never collapse to a generic +"failed"): +- `ERR_HASH_MISMATCH` — on-chain fingerprint ≠ expected anchor → **refuses to decrypt.** +- `ERR_NO_KEY` — frames complete but no KEY frame visible to this wallet (not the recipient / + sealed transfer). +- `ERR_AEAD_FAIL` — tamper or wrong key. +- `ERR_INCOMPLETE` — frames still missing (post-decrypt, an END-plaintext SHA-256 cross-check + can also raise `ERR_HASH_MISMATCH`). + +### 6.3 Enabling the channel (prerequisite gate) — DESIGNED-NOT-BUILT + +`-datachannel` defaults **OFF**. The GUI needs a **Settings → Privacy → "Enable private file +sending"** toggle that adds `-datachannel=1` to the daemon config and prompts a restart +(mirror the existing `zclassicd` config-edit + restart pattern). When off, every Shield +action is disabled with an honest *"Turn on private file sending in Settings to use this."* +(probe by attempting an RPC and treating `-32601` as "off"). No `-experimentalfeatures` +requirement in the as-built daemon. + +### 6.4 SHIELD — Send flow (DESIGNED-NOT-BUILT, implementation-ready) + +Entry points: detail dialog **"Send file privately…"** action, and a Collections-tab +**"Send a private file"** button. A modal wizard, never a free-form form. + +**Step 1 — Pick the file.** +- File picker, local only. Show the chosen filename + size. +- **Hard 40000-byte cap, enforced in the UI up front** (the daemon also re-projects actual + serialized size including real spend count, so an unusual UTXO set can still be rejected — + surface that as a clear "this file is too large to send privately in one transaction," + never a raw `bad-txns-oversize`). +- Optional `content_type` is inferred from extension; let the owner override. + +**Step 2 — Choose from / to (Sapling z-addresses).** +- **From:** a combo of the wallet's Sapling z-addresses that hold a spending key and enough + ZCL for **N × 0.00001 ZCL dust + fee** (N = frame count, shown live as the file is picked). + Watch-only addresses are excluded (the daemon rejects them). +- **To:** the recipient's Sapling z-address (paste/scan). Validate live (green/red, mirroring + the send-tab address validation). The recipient **must** be a Sapling z-addr. +- Change returns **shielded** to the from-address — state this so the owner isn't surprised. + +**Step 3 — Consent (mandatory, the daemon enforces it too).** +- A checkbox the owner must tick: **"I understand this encrypted file is stored permanently + and publicly, and can never be deleted."** This maps to `acknowledge_permanent = true`. The + Send button stays disabled until ticked. +- Restate the ownership-is-public truth (§6 banner). Show the live cost estimate + (N dust + fee, in ZCL) and frame count. + +**Step 4 — Sending (async).** +- Call `z_senddatafile`, then poll `z_getoperationresult` on the returned `operationid`. Show + a non-modal progress chip ("Encrypting and sending…"), never a frozen dialog. +- On success, show: the **fingerprint** (= the NFT `document_hash`, labeled "Content + fingerprint — this is what links the file to your NFT"), the **txid** (with the gated "View + in explorer"), and the **per-transfer key**. + +**Step 5 — Deliver the key (the load-bearing honesty step).** +- The in-band KEY frame is already on-chain (as-built default), so a recipient holding the + matching ivk can open the file **without** you sharing anything. **But** the GUI must be + honest that on-chain in-band reveal commits the key at send time and its confidentiality + rests entirely on Sapling encryption to the recipient's ivk — a compromised ivk exposes the + key. +- Offer **"Copy disclosure key"** (the L3 key) + **"Copy content fingerprint"** for explicit, + out-of-band selective disclosure to a third party who is not the recipient. Label it plainly: + *"Anyone with this key and fingerprint can open and verify this file — share it only with + people you want to read it."* +- **Do not** offer ivk-export-based disclosure in the GUI yet (Sapling + `z_exportviewingkey` is DESIGNED-NOT-BUILT). + +### 6.5 SHIELD — Receive flow (DESIGNED-NOT-BUILT, implementation-ready) + +The cross-wallet receive path is **BUILT-CLI-ONLY** in the daemon (registry-free, +`datachannel.cpp:504`); the GUI is what's missing. Two entry modes: + +**Mode A — Open a file linked to an NFT you hold.** +- From the detail view of a token whose `docHashHex` is a known data-channel fingerprint, a + **"Open private file"** action calls `z_getdatatransfer { fingerprint, address? }` with the + token's fingerprint as the expected anchor. +- The daemon **verifies before decrypt**; on `verified == true && complete == true`, write the + returned plaintext to the **content blob store** and re-request the poster → the gallery and + detail thumbnails populate automatically (§2.3 path 3). This is the "it just works" loop. + +**Mode B — Open by transfer id / fingerprint (paste).** +- A **"Receive a private file"** dialog accepts a `transfer_id` **or** `fingerprint`, an + optional receiving z-addr (defaults to scanning all viewable addrs), and an optional + out-of-band `verify_fingerprint`. +- If the sender shared an out-of-band L3 disclosure key (the as-built selective-disclosure + path), provide a field for it; otherwise rely on the in-band KEY frame + the recipient's ivk. + +**Receive states (map 1:1 to the daemon error taxonomy, §6.2):** + +| Daemon result | UI state | Copy | +|---|---|---| +| `verified && complete` | Success | "File verified and opened." Save / preview / (if it's an image) attach-to-NFT | +| `ERR_INCOMPLETE` / `complete==false` | Still arriving | "Some pieces haven't confirmed yet. Try again in a few minutes." | +| `ERR_NO_KEY` | Can't open | "This file isn't addressed to you, or the key isn't on-chain for this wallet." | +| `ERR_HASH_MISMATCH` | Refused | "The file on-chain doesn't match the expected fingerprint. Not opened." (never show plaintext) | +| `ERR_AEAD_FAIL` | Refused | "Couldn't decrypt — the file may be tampered or the key is wrong. Not opened." | + +**Cross-wallet caveat to surface honestly:** receive is **unproven by automated E2E** (no +regtest/cross-RPC seam test exists yet). Until that test lands, treat the GUI receive flow as +**beta** in release notes and keep the honest error states above sharp. + +### 6.6 Metadata leakage the Shield UI must not hide + +This is a **confidentiality** channel, **not steganographic / not undetectable.** The number +of outputs ≈ transfer size, burst timing, and the mere existence of a shielded tx are +observable; an all-max-memo output run hints "data channel." The send confirmation should +include a one-line, plain-language disclosure: *"Sending a private file is itself visible on +the ledger (the encrypted contents are not). Private does not mean undetectable."* Never imply +the transfer is invisible. + +### 6.7 Shield — built-vs-designed summary + +| Capability | Status | +|---|---| +| ZDC1 codec (AEAD, nonce, AAD, fingerprint, reassembly, error taxonomy) | **BUILT+TESTED** (25 gtests) | +| `z_senddatafile` / `z_listdatatransfers` / `z_getdatatransfer` | **BUILT-CLI-ONLY** | +| Cross-wallet verify-then-open via recipient ivk (registry-free) | **BUILT-CLI-ONLY**, no E2E | +| In-band KEY frame (as-built default) | **BUILT-CLI-ONLY** | +| Send wizard / Receive dialog / Settings enable toggle (the whole GUI) | **DESIGNED-NOT-BUILT** | +| Selective disclosure via Sapling `z_exportviewingkey` (ivk) | **DESIGNED-NOT-BUILT** | +| Seal-then-reveal / out-of-band key (`z_revealkey`, `keymode`), `zslp_mint_private` | **DESIGNED-NOT-BUILT** | +| Off-chain ciphertext + on-chain fingerprint for files > 40000 B; one-ciphertext-N-recipients fan-out | **FUTURE-IDEA** | + +--- + +## 7. Accessibility + performance budget + +### 7.1 Accessibility (DESIGNED — verify against shipped widgets) + +- **Color is never the only signal.** Verify badges carry a glyph (✓ / ✗ / ? / –) *and* color + *and* a text label, so red/green color-blindness never hides "mismatch." +- **Keyboard-first.** Gallery `activated` already fires on **Enter** (not just double-click); + detail Prev/Next, Attach, and all actions must be Tab-reachable with visible focus rings; + the Shield wizard advances on Enter and cancels on Esc. +- **Screen-reader labels.** Each card exposes an accessible name ("``, ``, + ``, Public"). Buttons get accessible descriptions matching the honesty copy. +- **Text scaling / DPI.** Cards are DPR-scaled; copy must not clip at 125–200% scaling (the + delegate elides name/collection — confirm elision, not truncation, at large fonts). +- **Reduced motion.** Skeleton shimmer (when built) honors a "reduce motion" preference by + falling back to a static placeholder. + +### 7.2 Performance budget (measured against the as-built engine) + +| Target | Budget | Backed by | +|---|---|---| +| GUI thread never blocks on render work | 0 ms blocking; all hash/decode off-thread | 4-thread `QThreadPool`, GUI-thread only builds `QPixmap` — **BUILT+TESTED** | +| Peak RAM per in-flight hash | ~1 MiB regardless of file size | reused `kHashBufBytes` 1 MiB buffer, no `readAll()` — **BUILT+TESTED** | +| RAM thumbnail cache ceiling | 128 MB | `QPixmapCache` cap — **BUILT+TESTED** | +| Decode cap | ≤ 1024 px working size; > 4096 px or > 10 MB downscaled at decode | `kDecodeCapPx`, `setScaledSize()` — **BUILT+TESTED** | +| Gallery re-poll churn | zero repaint on identical data | SHA-1 fingerprint guard in `setItems()` — **BUILT+TESTED** | +| Paint hot path | zero pixmap allocation | delegate uses pre-built thumbs — **BUILT+TESTED** | +| Detail prev/next | should feel instant | **needs** 1-ahead/1-behind prefetch — **DESIGNED-NOT-BUILT** | +| Shutdown with multi-GB hash in flight | clean abort | `cancelAll()` + `waitForDone()` — **BUILT+TESTED** | +| Private send (Shield) | non-blocking async; ≤ 40000 B/file; ≤ 90 frames/tx | daemon `z_senddatafile` async + caps — **BUILT-CLI-ONLY** | + +--- + +## 8. Key file map + +**GUI (`/home/rhett/github/zcl-qt-wallet`):** +- `src/contentengine.{h,cpp}` — the one shared engine (`posterForToken`→`posterReady`; + gallery `request`→`onImageReady`; `isRemoteUrl`). +- `src/nftimagecache.{h,cpp}` — back-compat alias shim. +- `src/nftgallery{model,delegate}.{cpp,h}`, `src/nft.h` (no `groupId`/`childOf` yet). +- `src/nftdetaildialog.{cpp,h}`. +- `src/mainwindow.cpp:3013-3367` (setupNFTTab / openNFTDetail / setNFTItems / + loadNFTFixtures), `src/mainwindow.h:373` (`nftThumbPx=152`). +- `src/rpc.cpp:871-1010` (`refreshNFTs` → `setNFTItems`; `cachePath` always empty by design). + +**Daemon (`/home/rhett/github/zclassic`):** +- `src/datachannel/zdc.{h,cpp}` (codec, AEAD, nonce, `ciphertext_fingerprint` at zdc.cpp:523). +- `src/rpc/datachannel.cpp` (the 3 RPCs; registry-free cross-wallet path at 504-536; + `include_key_frame=true` at 288; permanence ack at 206-213; 40000-byte cap at 84). +- `src/wallet/asyncrpcoperation_senddatafile.{h,cpp}` (one shielded tx, N same-recipient + outputs; size re-projection guard). +- `src/wallet/rpcdump.cpp:832` (Sapling `z_exportviewingkey` Sprout-only TODO). +- `src/init.cpp:527` (`-datachannel` default 0). +- `src/gtest/test_zdc.cpp`, `src/datachannel/test/zdc_test.cpp` (codec tests). + +**Docs:** +- `doc/nft/NATIVE_NFT_GUIDE.md` §2.0–2.7 (display spec; search/filter/group/sort + set board + DESIGNED-NOT-BUILT), §3.3 (as-built data-channel contract). +- `doc/nft/PRIVACY.md` (honesty banner; stale on cross-wallet #117 — code is ahead). +- `doc/nft/NFT_FINAL_REVIEW.md` (whole-feature status; also stale on #117). + +--- + +*Canonical as-built contract for the data channel: `NATIVE_NFT_GUIDE.md §3.3`. Whole-feature +status: `NFT_FINAL_REVIEW.md`. The coin is ZClassic / ZCL throughout. NFT ownership is always +public; only file content is private.* diff --git a/doc/nft/NFT_CAPABILITIES.md b/doc/nft/NFT_CAPABILITIES.md new file mode 100644 index 00000000000..58e5e38f8dd --- /dev/null +++ b/doc/nft/NFT_CAPABILITIES.md @@ -0,0 +1,323 @@ +# ZClassic NFTs — Capabilities & Vision + +> Canonical capability/vision doc. Where this disagrees with `NATIVE_NFT_GUIDE.md` or +> `NFT_FEATURE_CHECKLIST.md`, **this doc and the code win** — those two are stale-conservative +> (they still call the Sell GUI greenfield, call image-verify the worst gap, and call the +> write path untested; all three are now built and tested). Survey-verified against the live +> working trees: daemon on `feature/zslp-nft-indexer`, GUI on `feature/nft-gallery`. + +--- + +## North star + +ZClassic NFTs let anyone turn a file into a one-of-a-kind, on-chain collectible whose +ownership is **public and verifiable forever** — minted, viewed, gifted, and sold for **ZCL** +directly from the wallet, with **zero changes to consensus**. An NFT is not a new coin type: +it is a thin, non-consensus overlay (a ZSLP `OP_RETURN` riding a 546-sat transparent dust +output) that old, unmodified nodes relay and mine without knowing it exists, while every +honest wallet deterministically re-derives the same token state from confirmed history. The +money you hold, gift, and sell for is always **ZCL**. Ownership and every transfer are +**always public** — they live on transparent UTXOs that anyone can read. The single privacy +feature is the **ZDC1 shielded data-channel**, which keeps the *file's bytes* private (encrypted +inside Sapling memos) without ever hiding *who owns the token*. + +**One thing to internalize:** privacy here means *private file content*, never *private +ownership*. If you mint a "private" NFT, the world still sees that you own token X and sees +every time it changes hands — they just can't read the sealed bytes behind it. + +--- + +## The four pillars + +### Pillar 1 — Mint (create an NFT) + +**What it is.** Take any file, fingerprint it locally with SHA-256 (the bytes never leave your +machine), and broadcast a baton-less ZSLP GENESIS: decimals 0, quantity 1, no mint baton — a +true 1-of-1. The fingerprint (`document_hash`) is what makes the collectible *verifiable* later. + +**How you do it (CLI).** +``` +zslp_genesis '{"nft":true,"name":"My Piece","document_hash":"","ticker":"SET","document_url":"https://...","to":""}' +``` +Returns `{ "txid", "tokenid" }`. The daemon self-validates the build with `WouldBeValid` +before it ever broadcasts. (`src/rpc/zslp.cpp:328`) + +**How you do it (GUI).** `NftMintDialog`: drag a file → it streams a fingerprint → fill name +and details → choose public (private is gated off, see Pillar 3) → review → **Create**. +Create is gated on having both a name and a fingerprint; pasted web links are rejected as the +anchor; and the dialog shows a permanence warning ("this goes on the public ledger +permanently…") before you commit. Wired via `MainWindow::openMintDialog` → `RPC::mintNFT` +(`rpc.cpp:1045`, warning at `nftmintdialog.cpp:105`). + +**Status: BUILT+TESTED.** The full write path (genesis → gettoken → send → listmytokens) runs +in `qa/zslp/zslp-nft-regtest.sh`; GUI mint has 4 widget tests (`nftMint_*`). This was the +single biggest doc-claimed hole — it is **closed**. + +**Adjacent mint capabilities:** + +- **Fungible / divisible tokens** (decimals 0–9, quantity, optional re-issue mint baton at + `mint_baton_vout >= 2`) — same `zslp_genesis` without the `nft` preset. + **BUILT-CLI-ONLY** (no GUI; covered by the regtest GOLD case). +- **Re-issue supply** of a fungible token by spending its live mint baton: + `zslp_mint "tokenid" amount (baton_vout)` (`zslp.cpp:464`). NFTs never use this. + **BUILT-CLI-ONLY.** +- **Limited / numbered editions ("N of 100")** — `zslp_genesis` with quantity=N and the baton + off, or N separate 1-of-1s. **BUILT-CLI-ONLY** (RPC supports it; no GUI affordance; + edition numbering is a manifest convention, not enforced on-chain). +- **Anti-burn holder safety** — ordinary send/shield/sweep operations will never accidentally + spend a token's carrier dust. Two independent guards: the builder self-validate gate + (`BuildAndCommitZSLP` → `WouldBeValid`) and coin selection that drops protected outpoints + (`AvailableCoins(fExcludeZSLPTokens=true)` via `ZSLPIsProtectedTokenOutpoint`). + **BUILT+TESTED** (self-validate gate is gtested; the decision is also exercised end-to-end + in the regtest and sell flows). +- **Deliberate burn / retire-an-edition** — there is no sanctioned destroy primitive. + Anti-burn protects against accidents; intentional burning has no RPC or GUI. + **DESIGNED-NOT-BUILT.** + +--- + +### Pillar 2 — View / Verify (discover, inspect, prove) + +**What it is.** See the NFTs your wallet owns in a native dark-themed gallery (a real Qt +`QListView` in IconMode — no embedded browser), and *prove* that the file you hold matches the +fingerprint recorded on-chain. Verification is local-only: the wallet never fetches the +`document_url` to check it. + +**How you do it (CLI).** +- `zslp_listmytokens` → tokens with a positive balance at your addresses + (per-address roll-up, `zslp.cpp:191`). +- `zslp_gettoken "id"` → full public metadata for one token (`zslp.cpp:73`). +- `zslp_listtokens (count from)` → browse all tokens, clamped to `ZSLP_LIST_MAX=1000` + (`zslp.cpp:107`). +- `zslp_listtransfers "id" (count from)` → full public provenance, newest-first and + reorg-safe (`zslp.cpp:142`). + +**How you do it (GUI).** The gallery (`NFTGalleryModel` / `NFTGalleryDelegate`, fed by +`RPC::refreshNFTs`, `rpc.cpp:824`) shows each owned NFT with a verify badge and a +public/private pill. Opening one gives `NFTDetailDialog`: a large verified render, the mint id, +fingerprint, received date, copy actions, prev/next navigation, an explorer deep-link, and +Send / Sell / Attach buttons. The badge honestly reads "these bytes match the on-chain +fingerprint" — never "genuine," "official," or "original." + +The image check runs in `ContentEngine`, which streams SHA-256 / Merkle hashing on a worker +pool and returns ✓ match / ✗ mismatch / ? pending (`contentengine.cpp`). If you *received* an +NFT but don't have the bytes yet, the detail dialog's "Attach the file you have…" button lets +you point at a local file; a match flips the badge green and caches the path +(`nftdetaildialog.cpp:165,569`). + +**Status: BUILT+TESTED.** Gallery model/delegate, content engine (14 `ce*`/cache tests), +attach-to-verify (`nftDetail_attachFileVerifiesBadge` / `attachNonMatchStaysUnverified`), and +the detail dialog's verified / mismatch / no-bytes badge-copy paths all have widget tests. The +attach-and-verify flow was the doc's "confirmed worst gap" — it is **closed**. + +**Honest limits in View/Verify:** + +- **Browse-all from the GUI** — `zslp_listtokens` exists but the GUI only renders tokens you + own. **BUILT-CLI-ONLY** for whole-chain browsing. +- **Provenance in the GUI** — `zslp_listtransfers` exists and is reorg-safe, but **no GUI code + calls it** (grep-confirmed empty). The detail dialog advertises provenance, yet the + chain-of-custody history is currently only reachable via CLI. **GUI: DESIGNED-NOT-BUILT.** +- **Freshness** — `refreshNFTs` repaints mainly on a new block, and ownership shows PENDING + below `DEFAULT_MAX_REORG_DEPTH=10` confirmations. A same-block re-open can momentarily look + empty. **BUILT+TESTED (partial)** — minor. +- **Hash-less NFTs** — `document_hash` is *optional* for `nft=true`, so a CLI- or + foreign-minted NFT with no anchor is permanently unverifiable (badge stays neutral). The + GUI mint always requires an anchor; a daemon-side "require an anchor" rule is still a pending + decision. **DESIGNED-NOT-BUILT.** + +--- + +### Pillar 3 — Shield (private FILE CONTENT via ZDC1) — *bytes private, ownership still public* + +**What it is.** The ZDC1 shielded data-channel makes the *content* of a file private without +touching token ownership. Bytes are sealed with a per-transfer key +(ChaCha20-Poly1305 AEAD), framed, and shipped as Sapling shielded-memo outputs inside one +shielded transaction. This rides the existing `z_sendmany`-style memo path with **no consensus +change**. It hides the payload and the data-transfer linkage; it does **not** hide who owns the +token. A "private NFT" is just a normal public NFT whose `document_hash` points at the sealed +ciphertext instead of a plaintext file. + +**How you do it (CLI).** +- Send: `z_senddatafile '{"fromaddress":"","toaddress":"","filepath":"...","acknowledge_permanent":true}'` + → `{ operationid, transfer_id, fingerprint, frames, key }` (`datachannel.cpp:157`). The + daemon enforces a required-true `acknowledge_permanent`, shielded from/to addresses, a + **per-file cap of 40000 bytes** (`ZDC_MAX_FILE_BYTES`; the file's top comment saying "64 KB" + is stale), a 90-frame single-tx ceiling, 256 max in-flight, a 72h TTL, and a basic rate + guard. +- List: `z_listdatatransfers` (`datachannel.cpp:372`). +- Receive: `z_getdatatransfer '{"transfer_id":"...","verify_fingerprint":true}'` + (`datachannel.cpp:407`) — verify-before-decrypt; it refuses to hand back plaintext if the + on-chain ciphertext doesn't match the expected anchor, with honest `ERR_NO_KEY` / + `ERR_AEAD_FAIL` / `ERR_HASH_MISMATCH` errors. +- Private NFT (the as-built 2-step recipe): `z_senddatafile` for the sealed bytes, then an + ordinary `zslp_genesis` whose `document_hash` is the ciphertext fingerprint. +- Selective disclosure to an auditor or buyer: reuse the per-transfer `key`, or hand over an + incoming viewing key via `z_exportviewingkey` (read/prove only, never spend). + +**Status: BUILT-CLI-ONLY**, default-OFF behind `-experimentalfeatures -datachannel` +(dev/testnet only). The ZDC1 codec itself (frame / reassemble / AEAD / +ciphertext-fingerprint / verify-before-decrypt) is **BUILT+TESTED** (25 `test_zdc.cpp` +blocks), and a full cross-wallet private round-trip runs in `qa/zslp/zdc-xwallet-regtest.sh`. + +**How you do it (GUI).** You don't, yet. Private mint / send / receive are hard-gated off: +`RPC::isPrivateMintWired()` returns `false` (`rpc.h:326`). The mint dialog shows "Private +collectibles are coming in this release" (`nftmintdialog.cpp:90`) and the send dialog shows +"Private gift — coming soon" (`nftsenddialog.cpp:80`). The binary-safe memo-read fix (sniff +the `ZDC1` magic on the raw 512-byte memo before constructing a `QString`) is **not** applied +on the GUI side. **GUI: DESIGNED-NOT-BUILT.** + +**Also not built / out of scope:** +- `z_revealkey` (seal-now, reveal-key-later) and `zslp_mint_private` (one-shot private mint) + are not in the command table. **DESIGNED-NOT-BUILT.** +- Shielding token *value/ownership* through Sapling is impossible on existing consensus + (shielded notes carry no script). **Correctly out of scope.** + +--- + +### Pillar 4 — Sell / Trade (NFT ⇄ ZCL) + +**What it is.** A transparent, single-transaction atomic swap of an NFT for ZCL. The template +is a fixed 3-output transaction signed `SIGHASH_ALL | ANYONECANPAY`: +vout[0] is the ZSLP SEND `OP_RETURN`, vout[1] is the buyer's NFT dust, vout[2] is the seller's +ZCL payout. The seller signs only their NFT input (vin[0]); the buyer appends funding inputs. +The coin legs are **consensus-atomic** — either the whole swap confirms or none of it does. +Token *attribution* is an indexer convention, so this is **trust-minimized, not trustless**, +and it is **never private** — price and both addresses settle publicly on-chain. + +**How you do it (CLI).** (`src/rpc/nftoffer.cpp:1178-1186`) +- `nft_makeoffer` — compose an offer (locks the seller's outpoint in the wallet). +- `nft_verifyoffer` — mandatory pre-pay check; runs `VerifyScript` on vin[0]. +- `nft_takeoffer` — buyer funds and broadcasts (anti-burn applied to buyer funding; no + `fundrawtransaction`). +- `nft_listoffers`, `nft_canceloffer` (releases the lock), `nft_requestbuy`. + +**How you do it (GUI).** From the detail dialog's **Sell** button, `NFTSellDialog` lets you set +price / expiry / buyer address, click **List**, and get a shareable offer blob with +Copy / Save (`*.znftoffer`) / Cancel (`nftdetaildialog.cpp:146,462`). From the gallery's +**Buy an NFT** button, `NFTBuyDialog` lets you paste or open an offer, auto-runs +`nft_verifyoffer` for a green/amber verdict, and gates **Buy** on a verified offer plus an +overshoot acknowledgement before calling `nft_takeoffer` +(`mainwindow.cpp:3041,3205`). RPC wrappers `nftMakeOffer / nftVerifyOffer / nftTakeOffer / +nftListOffers / nftCancelOffer` plus `zclToZat` live at `rpc.cpp:1261-1438`. The buy dialog +states honestly that the swap "settles publicly on-chain — price and both addresses are +visible" (`nftbuydialog.cpp:114`); a fingerprint-mismatch item disables List. + +**Status: BUILT+TESTED.** 6 `test_nftoffer.cpp` gtests plus `qa/zslp/nft-sell-regtest.sh` +exercise the atomic swap and its refusals (signature-tamper, forged, overshoot). The GUI has +`nftSell_*` (×4) and `nftBuy_*` (×5) widget tests. **Caveat: the sell/buy dialog files are +UNCOMMITTED** (untracked `nftselldialog.*` / `nftbuydialog.*` in the GUI tree) — commit them +before they're lost. The docs still call this greenfield; that is stale — it is built. + +**Sell — honest limits / not built:** +- **Open / floor listings and marketplace browse** — v1 requires a specific buyer address + (`buyerNftAddr` mandatory); offers are shared offline as base64 blobs through a local + `nftoffers.json` store; there is no in-app marketplace. **FUTURE-IDEA** (documented follow-up). +- **Any shielded leg atomic** — impossible in-codebase (a Sapling binding signature is + single-party). **Out of scope.** +- **Escrowed / disputed sale (2-of-3 P2SH multisig)** — the script primitives exist and are + tested (P2SH / CLTV / multisig), but there's no flow, RPC, or trusted-arbiter design. + **FUTURE-IDEA.** + +--- + +## Collections, gifting, and provenance + +**Gift / transfer an NFT (one-way, always public).** `zslp_send "tokenid" "to_address" +(amount change_address)` (`zslp.cpp:543`); GUI `NFTSendDialog` via `RPC::sendNFT` +(`rpc.cpp:1108`), where a fingerprint-mismatch item hard-disables Send. +**BUILT+TESTED** (regtest send leg + `nftSend_*` ×4 widget tests). Every transfer is +transparent and visible on-chain. + +**Airdrop / batch.** The builder can fan out up to `ZSLP_SEND_MAX_OUTPUTS=19` token outputs in +one `zslp_send`, but the RPC argument surface is single-recipient. +**BUILT-CLI-ONLY (builder-capable).** + +**Collections / card-sets ("collect them all").** A shared `ticker` groups tokens into a set, +and the GUI maps `ticker` → collection so the gallery can group owned items. But there is **no +on-chain full slot list** — the daemon only knows the tokens *you* hold — so any "set board" +must show owned slots plus "manifest not available" and must never invent a slot count. The +set-board model/delegate is spec'd (guide §2.7) but not built; today the gallery groups by +collection only. **DESIGNED-NOT-BUILT.** + +**Provenance.** Full public chain-of-custody is available via `zslp_listtransfers` (newest +-first, reorg-safe), but **no GUI surfaces it yet**. **CLI: BUILT-CLI-ONLY. GUI: +DESIGNED-NOT-BUILT.** + +**The no-fork guarantee.** The whole overlay depends on the ZSLP `OP_RETURN` passing +`IsStandard` unchanged so old nodes relay and mine it. Today this is enforced only by a +223-byte builder-length assert and is **not** tied by any test to mainnet +`-datacarriersize` / policy. The claim is sound by construction but **BUILT but UNDER-TESTED** — +the "no consensus fork" property is not yet test-proven against real mainnet policy. + +--- + +## Status matrix + +| Capability | CLI | GUI | Tested | Status | +|---|---|---|---|---| +| Mint public 1-of-1 NFT | `zslp_genesis` | `NftMintDialog` | regtest + 4 widget | **BUILT+TESTED** | +| Mint fungible/divisible token | `zslp_genesis` | — | regtest GOLD | BUILT-CLI-ONLY | +| Re-issue supply (mint baton) | `zslp_mint` | — | regtest exercises | BUILT-CLI-ONLY | +| Numbered editions ("N of 100") | `zslp_genesis` | — | (RPC-supported) | BUILT-CLI-ONLY | +| Anti-burn holder safety | builder + coin-select | (automatic) | gtest + regtest | **BUILT+TESTED** | +| Deliberate burn / retire | — | — | — | DESIGNED-NOT-BUILT | +| See NFTs I own (gallery) | `zslp_listmytokens` | `NFTGalleryModel` | L0 model/delegate | **BUILT+TESTED** | +| Verify image vs fingerprint | (local) | `ContentEngine` | 14 ce/cache tests | **BUILT+TESTED** | +| Attach received file → verify | (local) | `NFTDetailDialog` | 2 widget tests | **BUILT+TESTED** | +| NFT detail dialog | `zslp_gettoken` | `NFTDetailDialog` | badge widget tests | **BUILT+TESTED** | +| Browse all tokens | `zslp_listtokens` | — | — | BUILT-CLI-ONLY | +| Provenance / transfer history | `zslp_listtransfers` | — (uncalled) | — | CLI: BUILT-CLI-ONLY / GUI: DESIGNED-NOT-BUILT | +| ZDC1 codec (seal/verify bytes) | (lib) | — | 25 zdc tests | **BUILT+TESTED** | +| Send private file (ZDC1) | `z_senddatafile` | — | xwallet regtest | BUILT-CLI-ONLY (default-OFF) | +| List/receive private transfer | `z_listdatatransfers` / `z_getdatatransfer` | — | xwallet regtest | BUILT-CLI-ONLY (default-OFF) | +| Selective disclosure (key/IVK) | `z_exportviewingkey` | — | — | BUILT-CLI-ONLY | +| Private NFT (sealed bytes) | 2-step (`z_senddatafile`+`zslp_genesis`) | — | — | BUILT-CLI-ONLY (default-OFF) | +| GUI private mint/send/receive | — | gated off (`isPrivateMintWired()==false`) | — | DESIGNED-NOT-BUILT | +| `z_revealkey` / `zslp_mint_private` | — | — | — | DESIGNED-NOT-BUILT | +| Transfer / gift NFT (public) | `zslp_send` | `NFTSendDialog` | regtest + 4 widget | **BUILT+TESTED** | +| Airdrop / batch (≤19 outputs) | `zslp_send` (builder) | — | — | BUILT-CLI-ONLY | +| Atomic NFT⇄ZCL swap | `nft_makeoffer`/`verifyoffer`/`takeoffer` | `NFTSellDialog`/`NFTBuyDialog` | 6 gtest + regtest + 9 widget | **BUILT+TESTED** (UNCOMMITTED) | +| Collections / card-set board | (`ticker` group) | gallery groups only | — | DESIGNED-NOT-BUILT | +| Open listings / marketplace | — | — | — | FUTURE-IDEA | +| Escrowed / disputed sale | (primitives only) | — | P2SH/CLTV tested | FUTURE-IDEA | +| No-fork (IsStandard) guarantee | builder assert | — | length-assert only | BUILT but UNDER-TESTED | + +Flags: `-zslpindex` defaults **ON**; `-datachannel` defaults **OFF** behind +`-experimentalfeatures`. + +--- + +## What's next / designed-not-built / future ideas + +**Closest to shipping (designed, partly wired, just needs GUI/finish):** +1. **GUI provenance** — wire `zslp_listtransfers` into `NFTDetailDialog` so chain-of-custody is + visible, not just advertised. The daemon side is done. +2. **GUI Shield** — flip `isPrivateMintWired()`, apply the binary-safe `ZDC1`-magic memo read + in `rpc.cpp`, and surface private mint / send / receive. The codec and CLI are done and + tested; this is GUI work only. +3. **Card-set board** — build the spec'd set model/delegate (guide §2.7), strictly honest about + unknown slots ("manifest not available," never an invented count). + +**Designed but not built (daemon/RPC):** +4. **`zslp_mint_private`** one-shot private mint and **`z_revealkey`** (seal-now, + reveal-key-later) — collapse today's manual 2-step private-NFT recipe into one command. +5. **Deliberate burn / retire** — a sanctioned destroy primitive (anti-burn already prevents + accidents). +6. **Daemon-side "require an anchor" rule** — decide whether `nft=true` should mandate a + `document_hash` so no NFT is born permanently unverifiable. + +**Future ideas (no flow yet):** +7. **Open / floor listings + in-app marketplace browse** — drop the mandatory buyer address; + share and discover offers natively instead of via offline blobs. +8. **Escrowed / disputed sale** — a 2-of-3 P2SH multisig flow over the existing tested script + primitives. + +**Hardening that gates "ship to mainnet":** +9. **Commit everything.** All ZSLP/ZDC/offer code is uncommitted (daemon working tree; + untracked GUI sell/buy dialogs). This is the highest-priority risk. +10. **Test the no-fork constraint** against real mainnet `IsStandard` / `-datacarriersize` + policy, not just the 223-byte builder assert. + +**Permanent non-goals (correctly out of scope):** +- Private *ownership/value* of tokens through Sapling — shielded notes carry no script. +- An atomic swap with a shielded leg — a Sapling binding signature is single-party. diff --git a/doc/nft/PRIVACY_TECH.md b/doc/nft/PRIVACY_TECH.md new file mode 100644 index 00000000000..0aaf84850e7 --- /dev/null +++ b/doc/nft/PRIVACY_TECH.md @@ -0,0 +1,422 @@ +# Privacy Technology in the ZClassic NFT Feature + +*What privacy technology are we enabling, and exactly what does it protect?* + +This document is the canonical answer. It describes **one** privacy technology — the +**ZDC1 shielded data-channel** — and is scrupulously honest about what it does, what +it does **not** do, and what is built versus merely designed. + +The coin is **ZClassic (ZCL)**. Every fee, dust output, and balance referenced here is +denominated in **ZCL**. (Some code identifiers carry their upstream Sapling/zk lineage +— `zclassicd`, `z_sendmany`, `.zcash-params`, `Sapling`, `ivk` — but the money a user +holds, sends, or sells for is always ZCL.) + +Capability honesty tags used throughout: + +| Tag | Meaning | +|-----|---------| +| **BUILT+TESTED** | Implemented and covered by automated tests. | +| **BUILT-CLI-ONLY** | Implemented and reachable from `zclassic-cli`, but no GUI and no automated end-to-end test. | +| **DESIGNED-NOT-BUILT** | Specified in design docs; no working code path today. | +| **FUTURE-IDEA** | Direction only; not specified or scheduled. | + +--- + +## 1. What is private, and what is NOT (read this first) + +There are exactly two things to keep separate. Do not let them blur. + +### 1a. NFT ownership is PUBLIC. Always. No exceptions. + +A ZSLP NFT is a token that rides on an ordinary **transparent dust UTXO** +(0.00001 ZCL). Consequently: + +- **Who owns which NFT is fully visible on-chain.** +- **Every transfer of an NFT is fully visible on-chain** — the sending address, the + receiving address, the token id, the time, all of it. +- Provenance (the complete chain of past owners) is public and permanent. + +The data-channel described in this document does **not** shield ownership, does **not** +shield transfers, and does **not** anonymize who-holds-what. There is no +private/confidential/anonymous mode for NFT ownership in this feature, and there is no +plan to claim one. If anyone tells you ZSLP NFT ownership is private, they are wrong. + +### 1b. The file CONTENT (the bytes) can be PRIVATE. + +What the privacy technology actually protects is the **content of a file or message** — +the asset bytes behind an NFT, or any arbitrary payload you choose to send. Those bytes +are encrypted and can only be opened by a holder of the per-transfer key. This is +**BUILT+TESTED** at the codec layer. + +So the one-sentence truth is: + +> **The token says, in public, "this exists and this address owns it." +> The data-channel keeps the file's *bytes* confidential — and nothing else.** + +### 1c. A subtlety you must not miss: the ciphertext is public forever + +"Private content" means **encrypted content that nobody without the key can read**. It +does **not** mean the content is hidden from existence or deletable. The encrypted bytes +(the ciphertext) are stored by **every full node, permanently, undeletably**. Privacy +here is confidentiality of the plaintext, guaranteed by encryption — not erasure, not +unobservability, not deniability. See §4 for the metadata that still leaks. + +--- + +## 2. How ZDC1 works, end to end + +ZDC1 ("ZClassic Data Channel, version 1") moves an encrypted byte stream across many +**512-byte Sapling shielded memos**, one ZDC1 frame per memo. The codec lives in +`src/datachannel/zdc.{h,cpp}` and is pure logic (depends only on libsodium + the C++ +standard library; no chain, no globals, no Qt). + +### 2.1 The four-layer stack + +ZDC1 deliberately stacks two independent encryption layers on top of the Sapling pool: + +| Layer | What it is | Whose code | Crypto | +|-------|-----------|-----------|--------| +| **L0** | Sapling shielded pool | consensus (NOT this code) | zk-SNARKs hide sender/recipient/amount | +| **L1** | Per-output 512-byte memo | consensus (NOT this code) | ChaCha20-Poly1305 to the recipient's `ivk` | +| **L2** | ZDC1 transport | this code | framing / chunking / reassembly | +| **L3** | ZDC1 application AEAD | this code | ChaCha20-Poly1305 IETF, per-transfer 32-byte key | + +Two encryption layers (L1 and L3) are intentional. They enable seal-then-reveal +(publish ciphertext now, hand out the key later), layer isolation (a break in one layer +does not cascade into the other), and one-ciphertext-to-many-recipients fan-out. + +### 2.2 The crypto, named (BUILT+TESTED — 25 gtests in `src/gtest/test_zdc.cpp`) + +- **AEAD cipher:** libsodium `crypto_aead_chacha20poly1305_ietf` in combined mode + (ciphertext = plaintext ‖ 16-byte Poly1305 tag). Its size constants are + `static_assert`ed against sodium at compile time (`zdc.cpp:22-25`). It is the *only* + cipher implemented (`CIPHER_CHACHA20POLY1305 = 0x01`). +- **Per-transfer key:** 32 bytes from `randombytes_buf()`, fresh for every transfer, + never reused, never logged (`ZdcAead::generate_key`). The decoder zeroizes it with + `sodium_memzero` on destruct and on replace; the RPC registry wipes keys on + TTL-expire. +- **Key derivation:** there is **no KDF**. The key is raw CSPRNG bytes, not derived from + any wallet secret. This keeps L3 fully independent of the L1 Sapling `ivk` by design. +- **Nonce (the security-critical part):** 12 bytes = `transfer_id(8 BE) ‖ counter(4 BE)`. + The counter is a per-frame value distinct from the wire `seq`: DATA chunk *i* → *i*; + START → `0xFFFFFFFF`; END → `0xFFFFFFFE`; the KEY frame is not L3-encrypted and + consumes no counter. Fresh-per-transfer key + a unique counter ⇒ every `(key, nonce)` + pair is unique by construction. The reserved-counter band fixes a real prior + nonce-reuse bug (START `seq` 0 vs DATA[0] `seq` 0 would otherwise collide). Locked by + `TEST(ZDC, NonceUniqueness)`. +- **AAD:** the 32-byte frame header with `crc32` and `payload_len` zeroed. This binds + magic/version/type/flags/cipher/`transfer_id`/`seq`/`chunk_count`, so a reordered, + retyped, or cross-transfer-grafted frame fails Poly1305. + +**Three integrity mechanisms — do not conflate them:** + +1. **Per-chunk Poly1305 tag** — the *real* security check. Tamper or wrong key ⇒ fail. +2. **Content binding via two different hashes** — the END frame carries a SHA-256 over + the **plaintext** (re-verified after decrypt), and the `ciphertext_fingerprint` is a + SHA-256 over the concatenated **DATA ciphertexts** in `seq` order. The latter is the + key-independent on-chain anchor (the ZSLP `document_hash`). +3. **`crc32`** — transport-corruption detection only. It is attacker-forgeable and is + **not** security; tests prove a CRC-refixed tampered byte still fails the AEAD. + +**The document_hash / ciphertext fingerprint anchor** (`ciphertext_fingerprint`, +`zdc.cpp:523`) hashes only the DATA frames' ciphertext in ascending `seq` order. It is +deterministic, order-independent, and **key-independent** — stable before and after the +key is revealed. This is what a ZSLP NFT `document_hash` commits to: it cryptographically +binds the **public** token to the **private** bytes without revealing them. + +### 2.3 Send path (encrypt → frame → Sapling memos → on-chain) + +A single dedicated async operation, `AsyncRPCOperation_senddatafile` +(`src/wallet/asyncrpcoperation_senddatafile.{h,cpp}`), does the whole send: + +1. **Encode + encrypt.** The plaintext is chunked and each DATA chunk is AEAD-encrypted + under the per-transfer L3 key (464 usable plaintext bytes per DATA frame: 480 payload + − 16 tag). Frame order is START, DATA×N, END, then KEY. +2. **Frame → memo.** Each 512-byte ZDC1 frame becomes exactly one Sapling output memo + (`512 == ZC_MEMO_SIZE`): a 32-byte header + 480-byte payload. +3. **One shielded transaction.** All N same-recipient Sapling outputs are emitted in a + *single* shielded tx. (This needs the dedicated op because `z_sendmany`'s RPC layer + rejects duplicate recipients; the underlying `TransactionBuilder` does not.) Each + output carries 0.00001 ZCL dust; change returns shielded to the from-address. The + tx is signed with the wallet's Sapling spending key — used only to spend and sign, + **never exported**. +4. **Random transfer id.** The 8-byte `transfer_id` is random (libsodium), in-flight + collision-checked. It is **not** the txid and **not** a token id. + +### 2.4 Receive path (reconstruct → verify-before-decrypt) + +A recipient wallet reconstructs the transfer **from the chain alone**, even with no +local session record (`datachannel.cpp:504-548`): + +1. `GetFilteredNotes(requireSpendingKey=false)` lets a viewing-key-only wallet (one + holding just an `ivk`) read its frames. +2. The `ivk` decrypts the L1 Sapling memos, exposing the ZDC1 frames. +3. The on-chain KEY frame populates the L3 key in the decoder. +4. **Verify before decrypt** gates the open (see §2.5). + +**No `ivk` and no spending key ever leaves the wallet.** (Historical note: older docs — +`NFT_FINAL_REVIEW.md` and the honesty banner in `PRIVACY.md` — say cross-wallet receive +is "structurally impossible (#117)." That described a prior revision that hard-threw +"transfer not found in registry." The current code has applied the exact recommended +fix — a registry-free reconstruct path — so cross-wallet verify-then-open now works via +the recipient `ivk`. **The docs are stale on this point; the code is ahead of them.** +The remaining caveat is key *delivery*: the in-band KEY frame works today; out-of-band / +reveal-later is **DESIGNED-NOT-BUILT**.) + +### 2.5 Verify-before-decrypt (the safety property) — and its honest failure modes + +`z_getdatatransfer` recomputes the ciphertext fingerprint over the received DATA frames +and compares it to the expected anchor **before any decrypt happens**. It never returns +plaintext on failure (`datachannel.cpp:558-628`). The distinct, honest error codes: + +| Error | Meaning | +|-------|---------| +| `ERR_HASH_MISMATCH` | On-chain ciphertext fingerprint ≠ expected anchor (caller's `verify_fingerprint`, or the recorded anchor). Refuses to decrypt; returns no plaintext. Also raised post-decrypt if the END plaintext SHA-256 cross-check fails. | +| `ERR_NO_KEY` | Frames complete, but no KEY frame is visible to this wallet (the caller is not the recipient, or the transfer is sealed). | +| `ERR_AEAD_FAIL` | Poly1305 failed — tamper or wrong key. | +| `ERR_INCOMPLETE` | Frames are still missing. | + +### 2.6 End-to-end maturity + +The **codec** (AEAD, nonce domain, AAD, fingerprint, reassembly, error taxonomy) is +**BUILT+TESTED**. The **end-to-end send/receive over a live chain** is **BUILT-CLI-ONLY**: +it works from `zclassic-cli`, but there is no GUI and no automated cross-RPC / regtest +seam test yet (`qa/` contains zero `z_senddatafile` usage). Treat live end-to-end as +working-but-unproven-by-automation. + +--- + +## 3. RPC API reference (BUILT-CLI-ONLY) + +All three RPCs are registered **only** when the daemon runs with `-datachannel`. When the +flag is off (the default — see §4), the RPCs are not registered at all, so the dispatcher +returns `RPC_METHOD_NOT_FOUND (-32601)` — indistinguishable from a method that never +existed (`datachannel.cpp:649-658`). `-experimentalfeatures` is **not** required by the +as-built code (adding a second gate is a logged hardening option). + +CLI argument mapping (so `zclassic-cli` sends a JSON object, not a raw string) lives at +`src/rpc/client.cpp:138-139`. + +### z_senddatafile *(async — returns immediately; poll with `z_getoperationresult`)* + +Defined at `datachannel.cpp:157`. + +**Params** — a single object: + +| Field | Type | Notes | +|-------|------|-------| +| `fromaddress` | string | Sapling z-address; spending key must be in this wallet (no watch-only send). | +| `toaddress` | string | Sapling z-address (the recipient). | +| `filepath` *or* `hexdata` | string | Exactly one. The payload, ≤ 40000 bytes. | +| `acknowledge_permanent` | bool | **Required, must be `true`.** Daemon refuses otherwise. | +| `filename` | string | Optional metadata. | +| `content_type` | string | Optional metadata. | + +**Returns** (the immediate object): + +| Field | Notes | +|-------|-------| +| `operationid` | Poll this with `z_getoperationresult`. | +| `transfer_id` | 16-hex random id. | +| `fingerprint` | 64-hex ciphertext anchor — the same value an NFT `document_hash` commits to. | +| `frames` | Number of Sapling outputs emitted. | +| `key` | Hex per-transfer L3 key, returned to the sender for selective disclosure. | + +The async result later yields `{txid, transfer_id, fingerprint, frames}`. + +**Enforces:** permanence ack; Sapling from-address with spending key in wallet; shielded +change; the 40000-byte cap; and a 90-frame single-tx guard. The cap is *derived*, not +wished: a transfer is one shielded tx, each frame is a 948-byte Sapling +`OutputDescription`, and consensus `MAX_TX_SIZE_AFTER_SAPLING = 102000` ⇒ ≤ 90 frames +(`ZDC_MAX_FRAMES_PER_TX`) = 87 DATA × 464 = 40368, advertised as 40000. Oversize is +rejected up front before any proving; a second guard in the async op re-projects the real +serialized size (including the actual spend count) so an unusual UTXO set never hits a +late `bad-txns-oversize`. + +**Errors:** `RPC_INVALID_PARAMETER` (missing/false `acknowledge_permanent`, oversize +payload, both/neither of `filepath`/`hexdata`, encode failure); address/key errors for a +non-Sapling or watch-only from-address; `RPC_METHOD_NOT_FOUND (-32601)` when +`-datachannel` is off. + +### z_listdatatransfers *(okSafeMode)* + +Defined at `datachannel.cpp:372`. + +**Params:** none. + +**Returns:** an array of `{transfer_id, fingerprint, direction ("sent"), frames, status +("recorded"), fromaddress, toaddress, filename}`. + +**Scope caveat:** this is **session / in-memory only**. It lists what *this* node sent +*this session*; the registry is not persisted and entries expire on a 72-hour TTL. It is +**not** a chain query and **not** a list of what you have received. + +### z_getdatatransfer *(okSafeMode — reassemble + verify-before-decrypt)* + +Defined at `datachannel.cpp:407`. + +**Params** — a single object: + +| Field | Type | Notes | +|-------|------|-------| +| `transfer_id` *or* `fingerprint` | string | 16-hex id, or 64-hex anchor. Identifies the transfer. | +| `address` | string | Optional. The z-address that received it; defaults to the recorded `toaddress`, else scans all viewable addresses. | +| `verify_fingerprint` | string | Optional 64-hex out-of-band anchor. If given, the on-chain ciphertext **must** hash to it or the call refuses. | + +**Returns:** + +| Field | Notes | +|-------|-------| +| `transfer_id`, `fingerprint` | Identity. | +| `verified` | bool — anchor matched. | +| `complete` | bool — all frames present. | +| `frames_received` | count | +| `onchain_fingerprint`, `expected_fingerprint` | Present for mismatch diagnosis. | +| `hexdata` | The plaintext — returned **only** if verified and decrypted. | +| `size`, `filename`, `content_type` | Payload metadata. | +| `error` | The honest codec error, if any (see §2.5). | + +**Errors:** the §2.5 taxonomy (`ERR_HASH_MISMATCH`, `ERR_NO_KEY`, `ERR_AEAD_FAIL`, +`ERR_INCOMPLETE`); `RPC_METHOD_NOT_FOUND (-32601)` when `-datachannel` is off. + +--- + +## 4. Guarantees, limits, threat model, and what still leaks + +### Guarantees + +- **Plaintext confidentiality** of file/message bytes, under a fresh per-transfer AEAD + key (BUILT+TESTED). +- **Tamper-evidence** via per-chunk Poly1305 over header-bound AAD (BUILT+TESTED). +- **Verify-before-decrypt:** plaintext is never returned unless the on-chain ciphertext + fingerprint matches the expected anchor (BUILT-CLI-ONLY). +- **Keys never leave the wallet:** the only secret returned to a caller is the + per-transfer L3 key it asked the daemon to create. No `ivk` or spending key is exported. + +### Limits / caps (policy, not consensus) + +- **40000-byte file cap** per transfer (`ZDC_MAX_FILE_BYTES`). The structural codec + ceiling is ~29 MB (`MAX_CHUNK_COUNT = 65535`) but must never be approached, for + governance and permanence reasons. +- **256** max in-flight tracked transfers; **72-hour** in-flight TTL; a basic rate guard + (~4 calls/sec). +- **Default OFF** behind `-datachannel` (`init.cpp:527`, default `0`). +- **Permanence consent enforced at the daemon** (`datachannel.cpp:206-213`), not the GUI: + `z_senddatafile` refuses unless `acknowledge_permanent=true`. + +### Non-consensus overlay (why this is safe to ship) + +ZDC1 frames ride inside ordinary Sapling output memos. **Old, unmodified nodes relay and +mine these transactions unchanged** — no validation, PoW, or consensus rule is touched, so +there is **no consensus fork**. Security comes from the application layer: every honest +wallet deterministically re-validates confirmed history (verify-before-decrypt over the +key-independent on-chain anchor). There is **no DRM, no anti-copy, and no consensus +enforcement** — it is pure wallet/application policy. + +### Threat model and what STILL leaks + +This is a **confidentiality** channel, **not** a steganographic or undetectable one. +*"Private" ≠ "undetectable."* Even with perfect L3 encryption, the following metadata is +observable on-chain (`zdc.h:30-37`): + +- **Transfer size (approximate):** the number of Sapling outputs ≈ the transfer size. +- **Timing:** the burst of outputs is observable. +- **Existence:** that *a* shielded tx occurred is observable. +- **Fingerprinting:** a run of all-max-size memos in one tx hints "data channel here." +- **Permanence:** the ciphertext is stored by every full node **forever** — encrypted but + undeletable. Caps exist for responsibility, not just performance. + +**KEY-frame caveat (important).** The in-band KEY frame ships the raw 32-byte L3 key in +cleartext *at L3*; its on-chain confidentiality rests entirely on the L1 Sapling +encryption to the recipient `ivk`. A compromised `ivk` therefore exposes the key, and +in-band reveal commits the key on-chain at send time. The as-built daemon **always** +includes the KEY frame (`datachannel.cpp:287`, `include_key_frame=true`) and also returns +the key to the sender. Out-of-band / reveal-later key delivery is **DESIGNED-NOT-BUILT**. + +**Selective disclosure via viewing-key export — known gap.** The design (`PRIVACY.md §1.3`) +envisions selective disclosure by sharing the Sapling `ivk` so a third party can prove +content/receipt without spend authority. **That Sapling path is not implemented.** +`z_exportviewingkey` (`src/wallet/rpcdump.cpp:830`) carries a `// TODO: Add Sapling +support` and throws *"Currently, only Sprout zaddrs are supported"* for any non-Sprout +address. Since the data channel is Sapling-only, `z_exportviewingkey` **cannot export the +`ivk` for a data-channel z-address today** — so tag ivk-export selective disclosure as +**DESIGNED-NOT-BUILT**. Disclosure *does* work in practice the as-built way: share the +per-transfer L3 `key` returned by `z_senddatafile` together with `verify_fingerprint`. + +--- + +## 5. GUI / UX plan for private send + receive (DESIGNED-NOT-BUILT) + +There is no GUI surface for the data-channel today; the entire send/receive UX below is +**DESIGNED-NOT-BUILT**. The principles it must honor: + +### 5.1 Be honest in the label about what is private + +The UI must never imply the NFT or its ownership is private. The framing is: + +> "Your NFT and its owner are public on the blockchain. This option keeps the **file's +> contents** private — only someone you give the key to can open it." + +### 5.2 Consent for permanence, in plain language + +The daemon already refuses without `acknowledge_permanent=true`; the GUI must earn that +consent honestly, not bury it. Required copy, blunt: + +> "This sends the encrypted file onto the blockchain **forever**. It cannot be deleted or +> recalled. The encrypted bytes will be **public ciphertext stored by every node, for +> all time** — private only as long as the key stays private. Continue?" + +A user cannot proceed until they actively confirm this. No pre-checked boxes. + +### 5.3 Private send flow + +1. Pick the Sapling from-address (must hold the spending key) and the recipient Sapling + z-address. +2. Choose the file (≤ 40000 bytes; the UI enforces and explains the cap up front). +3. Show the permanence consent (§5.2). +4. On send, surface the `transfer_id`, `fingerprint` (= NFT `document_hash`), frame count, + and — clearly marked as the secret to safeguard/share for disclosure — the per-transfer + `key`. + +### 5.4 Private receive flow + +1. The recipient wallet reconstructs from chain (binary-safe memo read — a path that is + **DESIGNED-NOT-BUILT** in the GUI; the CLI does it). +2. Run verify-before-decrypt; show `verified` and `complete` plainly. +3. On success, present the file. On failure, surface the honest codec error (§2.5) + verbatim-in-spirit, never a fake "try again." + +### 5.5 The framing to repeat everywhere + +Public token, private bytes, permanent ciphertext. If a screen can only show one +sentence, it is: + +> "Anyone can see you own this NFT. Only key-holders can open its file. The encrypted +> file lives on-chain forever." + +--- + +## 6. Built-vs-designed summary + +| Capability | Status | +|-----------|--------| +| ZDC1 codec — AEAD, nonce domain, AAD, fingerprint, reassembly, error taxonomy | **BUILT+TESTED** (25 gtests, `src/gtest/test_zdc.cpp`; standalone harness `src/datachannel/test/zdc_test.cpp`; compiled into the daemon) | +| `z_senddatafile` / `z_listdatatransfers` / `z_getdatatransfer` incl. cross-wallet verify-then-open via recipient `ivk` and `verify_fingerprint` gating | **BUILT-CLI-ONLY** (no GUI, no automated E2E) | +| Seal-then-reveal / out-of-band key (`z_revealkey`, `keymode`) | **DESIGNED-NOT-BUILT** | +| `zslp_mint_private` single RPC | **DESIGNED-NOT-BUILT** | +| GUI binary-safe memo read + native SHIELD/receive UX (`PRIVACY.md §3.3/§4`) | **DESIGNED-NOT-BUILT** | +| Sapling `z_exportviewingkey` for selective disclosure | **DESIGNED-NOT-BUILT** (Sprout-only TODO, `rpcdump.cpp:830`) | +| Off-chain ciphertext + on-chain fingerprint for files > 40000 bytes | **FUTURE-IDEA** | +| One-ciphertext-N-recipients key fan-out | **FUTURE-IDEA** | + +--- + +## 7. Source files + +- `src/datachannel/zdc.h`, `src/datachannel/zdc.cpp` — the ZDC1 codec (crypto, framing, fingerprint). +- `src/rpc/datachannel.cpp` — the three RPCs, safety gates, verify-before-decrypt. +- `src/wallet/asyncrpcoperation_senddatafile.{h,cpp}` — single-tx multi-output shielded send. +- `src/gtest/test_zdc.cpp` (25 tests); `src/datachannel/test/zdc_test.cpp` (standalone harness). +- `src/wallet/rpcdump.cpp:830` — `z_exportviewingkey` Sprout-only gap. +- `src/rpc/client.cpp:138-139` — CLI arg mapping; `src/init.cpp:527` — `-datachannel` default-off. +- `doc/nft/PRIVACY.md`, `doc/nft/NFT_FINAL_REVIEW.md` — design docs (stale on cross-wallet #117; this doc is current). From 0ce2d81f6d43b10df70654f4011fc1093b920cc0 Mon Sep 17 00:00:00 2001 From: Rhett Creighton Date: Sun, 7 Jun 2026 02:14:15 +0000 Subject: [PATCH 7/7] nft(daemon): DRY de-dup + API consistency + dispatcher/ZDC/no-fork gtests (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit P0/P1 hardening of the ZSLP/NFT daemon code. Behavior-preserving (NFT-family gtests stay 140/140); full suite 382/0; all 3 live regtests green. DRY (shared, drift-proof): - B-1: one ZSLPParseAmountField in wallet/zslpwallet; ParseQuantity (2^63 bound) and NftParseZat (MAX_MONEY bound) repointed, bounds + wording preserved. - B-2: ZSLPStoreOrThrow/ScriptForTAddr/FreshWalletScript/AddrFromScript canonicalized in wallet/zslpwallet; both prefixed copies deleted, 35 call sites repointed (these build the real token-carrier + offer scriptPubKeys). API consistency / honesty: - A-1: drop the dead nft_listoffers `mine`/onlyMine param (parse, void casts, help, client.cpp entry). - A-3: zslp_listmytokens embeds the full TokenToJSON (documenthash, etc.) + balance + addresses[] — enables the GUI to drop its per-token fan-out. - A-4: zslp_gettoken token-not-found -> RPC_INVALID_PARAMETER (consistent). - A-7: remove the no-op ZdcDirToStr; help documents the fixed direction/status vocabulary instead of implying an unbuilt receive state machine. - A-8: stale "64KB" comment -> references ZDC_MAX_FILE_BYTES=40000. Tests: - de-static NftVerify behind a new src/rpc/nftoffer.h so the gtest exercises the REAL offer-verify safety core (E-1) instead of a hand-rolled copy: decode, output-shape, vout[0] SEND/token-id, vout[1]/[2] re-derivation, live-UTXO + SIGHASH_ALL|ANYONECANPAY backstop, expiry bound, conservation. - E-2: ZDC TTL prune, rate guard, fingerprint-grouping fallback via a minimal test-only seam (src/rpc/datachannel.h) over the real registry; prod paths unchanged. - E-4: genesis/send OP_RETURN carriers proven IsStandardTx on mainnet under the default -datacarriersize (=223) — the non-consensus no-fork claim is now test-proven, not just asserted. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Makefile.am | 1 + src/gtest/test_nftoffer.cpp | 464 ++++++++++++++++++++++++++++++++++++ src/gtest/test_zdc.cpp | 127 ++++++++++ src/rpc/client.cpp | 3 +- src/rpc/datachannel.cpp | 70 +++++- src/rpc/datachannel.h | 44 ++++ src/rpc/nftoffer.cpp | 206 +++++----------- src/rpc/nftoffer.h | 107 +++++++++ src/rpc/zslp.cpp | 80 +++---- src/wallet/zslpwallet.cpp | 75 ++++++ src/wallet/zslpwallet.h | 53 ++++ 11 files changed, 1040 insertions(+), 190 deletions(-) create mode 100644 src/rpc/datachannel.h create mode 100644 src/rpc/nftoffer.h diff --git a/src/Makefile.am b/src/Makefile.am index 75687e6a173..6ad384691ed 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -181,6 +181,7 @@ BITCOIN_CORE_H = \ random.h \ reverselock.h \ rpc/client.h \ + rpc/nftoffer.h \ rpc/protocol.h \ rpc/server.h \ rpc/register.h \ diff --git a/src/gtest/test_nftoffer.cpp b/src/gtest/test_nftoffer.cpp index 4081a2c4ceb..ce8a7985870 100644 --- a/src/gtest/test_nftoffer.cpp +++ b/src/gtest/test_nftoffer.cpp @@ -30,7 +30,9 @@ #include #include "chainparams.h" +#include "coins.h" #include "consensus/upgrades.h" +#include "core_io.h" #include "key.h" #include "key_io.h" #include "keystore.h" @@ -46,6 +48,10 @@ #include "zslp/zslpmsg.h" #include "zslp/zslpstore.h" +#ifdef ENABLE_WALLET +#include "rpc/nftoffer.h" // CNftOfferBlob / NftVerifyResult / NftVerify (E-1) +#endif + #include #include #include @@ -388,3 +394,461 @@ TEST(NftOfferTemplate, DustFloorNeverBelow546) CAmount D = std::max((CAmount)SLP_TOKEN_DUST, floor); EXPECT_GE(D, (CAmount)SLP_TOKEN_DUST); } + +#ifdef ENABLE_WALLET +// ════════════════════════════════════════════════════════════════════════ +// 4. THE REAL nft_verifyoffer CORE (E-1): drive the now-exposed NftVerify +// instead of hand-rolling the checks. NftVerify reads the live UTXO set +// (pcoinsTip) + chainActive + a ZSLP store under cs_main, so we install a +// synthetic coins view holding the NFT prevout, a fake tip, and a store +// seeded with the qty-1 NFT, then assemble + SIGN the exact 3-output +// ALL|ANYONECANPAY template (MakeSellTemplate, which already uses the REAL +// ZSLPBuildSend encoder) and assert the verifier's real verdict. +// +// COVERED here (unit-reachable): the decode, output-shape, vout[0] SEND parse + +// token-id match, vout[1]/vout[2] address+price re-derivation, the LIVE-UTXO + +// cryptographic ALL|ANYONECANPAY backstop on vin[0], the expiry-bound check, +// and the WouldBeValid conservation arm — i.e. the entire nft_verifyoffer +// safety core, exercised through the REAL function (no hand-rolled copy). +// +// NOT reachable as a unit (covered by qa/zslp/nft-sell-regtest.sh, noted in +// coverageHonesty): nft_makeoffer's wallet-side template ASSEMBLY (keypool / +// ZSLPFindWalletTokenUtxos), nft_takeoffer's overshoot-ack gate + fundingInputs +// selection (both need a live CWallet/keystore + EnsureWalletIsUnlocked), and +// the offer-blob (de)serializer over a live store. The fundingInputs anti-burn +// PREDICATE (ZSLPIsProtectedTokenOutpoint) is already unit-gated in +// test_zslp_wallet.cpp (ZslpAntiBurnPredicate.*), so it is not duplicated here. +// ════════════════════════════════════════════════════════════════════════ + +namespace { + +// Minimal empty coins backing store; the cache layered above holds our coin. +class NftFakeCoinsView : public CCoinsView { +public: + bool GetSproutAnchorAt(const uint256&, SproutMerkleTree&) const { return false; } + bool GetSaplingAnchorAt(const uint256&, SaplingMerkleTree&) const { return false; } + bool GetNullifier(const uint256&, ShieldedType) const { return false; } + bool GetCoins(const uint256&, CCoins&) const { return false; } + bool HaveCoins(const uint256&) const { return false; } + uint256 GetBestBlock() const { return uint256(); } + uint256 GetBestAnchor(ShieldedType) const { return uint256(); } + bool BatchWrite(CCoinsMap&, const uint256&, const uint256&, const uint256&, + CAnchorsSproutMap&, CAnchorsSaplingMap&, CNullifiersMap&, + CNullifiersMap) { return false; } + bool GetStats(CCoinsStats&) const { return false; } +}; + +// RAII: install pcoinsTip = &cache and a synthetic max-work tip for the test; +// restore both on teardown so sibling tests see a clean global state. +class NftChainStateGuard { +public: + NftChainStateGuard(CCoinsViewCache* cache, int height) { + savedTip_ = pcoinsTip; + pcoinsTip = cache; + fakeTip_.nChainWork = ~arith_uint256(0); + fakeTip_.nTime = GetTime(); + fakeTip_.nHeight = height; + chainActive.SetTip(&fakeTip_); + } + ~NftChainStateGuard() { + chainActive.SetTip(NULL); + pcoinsTip = savedTip_; + } +private: + CCoinsViewCache* savedTip_; + CBlockIndex fakeTip_; +}; + +// Seed one spendable coin (the NFT dust UTXO) at outpoint `op` into `cache`. +void SeedCoin(CCoinsViewCache& cache, const COutPoint& op, const CScript& spk, + CAmount value, int height) { + CCoinsModifier c = cache.ModifyCoins(op.hash); + c->fCoinBase = false; + c->nHeight = height; + c->nVersion = 1; + if ((size_t)op.n >= c->vout.size()) + c->vout.resize(op.n + 1); + c->vout[op.n] = CTxOut(value, spk); +} + +// Build the seller-signed offer blob exactly as nft_makeoffer ships it: +// the 3-output template, vin[0] signed ALL|ANYONECANPAY, serialized to offerHex. +CNftOfferBlob MakeSignedOffer(const CKeyStore& ks, const CScript& nftSpk, + CAmount nftValue, uint32_t branchId, + const uint256& tokenId, + const COutPoint& nftOp, + const std::string& buyerAddr, + const std::string& payoutAddr, CAmount price, + uint32_t expiryHeight) { + CScript buyerScript = ZSLPScriptForTAddr(buyerAddr); + CScript payoutScript = ZSLPScriptForTAddr(payoutAddr); + CMutableTransaction mtx = MakeSellTemplate( + tokenId, nftOp, buyerScript, SLP_TOKEN_DUST, payoutScript, price, {}); + mtx.nExpiryHeight = expiryHeight; + EXPECT_TRUE(SignSignature(ks, nftSpk, mtx, 0, nftValue, + SigHashType(SIGHASH_ALL | SIGHASH_ANYONECANPAY), + branchId)); + CNftOfferBlob blob; + blob.tokenId = tokenId; + blob.priceZat = price; + blob.payoutAddr = payoutAddr; + blob.buyerNftAddr = buyerAddr; + blob.expiryHeight = expiryHeight; + blob.offerHex = EncodeHexTx(CTransaction(mtx)); + return blob; +} + +bool HasReason(const NftVerifyResult& r, const std::string& needle) { + for (size_t i = 0; i < r.reasons.size(); ++i) + if (r.reasons[i].find(needle) != std::string::npos) return true; + return false; +} + +// Mint a qty-1 NFT into a store + return (tokenId, nftScript so the carrier UTXO +// is a P2PKH the seller key controls, and the genesis NFT outpoint). +struct SeededNft { CZSLPStore* store; uint256 tokenId; COutPoint nftOp; }; + +SeededNft SeedNftStore(const CKey& sellerKey, const CScript& nftSpk) { + SeededNft out; + out.store = NewStore(); + std::vector gen = + ZSLPBuildGenesis("", "Art #1", "", NULL, 0, 0, /*qty=*/1); + CMutableTransaction gmtx; + gmtx.vout.push_back(CTxOut(0, CScript(gen.begin(), gen.end()))); + gmtx.vout.push_back(CTxOut(SLP_TOKEN_DUST, nftSpk)); // vout[1] = NFT carrier + CTransaction gtx(gmtx); + out.tokenId = ApplyRealTx(out.store, gtx, 1); + out.nftOp = COutPoint(gtx.GetHash(), 1); + return out; +} + +} // namespace + +TEST(NftVerifyReal, AcceptsAWellFormedSignedOffer) +{ + SelectParams(CBaseChainParams::REGTEST); + // Sign with the SAME branch id NftVerify recomputes for nextHeight (tip 10 + // => nextHeight 11), so the seller's signature validly verifies under the + // verifier's epoch (REGTEST has Sapling at NO_ACTIVATION by default). + uint32_t branchId = CurrentEpochBranchId(11, Params().GetConsensus()); + + CBasicKeyStore ks; + CKey sellerKey; sellerKey.MakeNewKey(true); + ks.AddKeyPubKey(sellerKey, sellerKey.GetPubKey()); + CScript nftSpk = GetScriptForDestination(sellerKey.GetPubKey().GetID()); + + SeededNft nft = SeedNftStore(sellerKey, nftSpk); + + // Real buyer/payout t-addrs (so NftAddrFromScript round-trips through the + // SAME script<->addr helper the verifier uses). + CKey buyerKey; buyerKey.MakeNewKey(true); + CKey payoutKey; payoutKey.MakeNewKey(true); + std::string buyerAddr = EncodeDestination(buyerKey.GetPubKey().GetID()); + std::string payoutAddr = EncodeDestination(payoutKey.GetPubKey().GetID()); + + CNftOfferBlob blob = MakeSignedOffer( + ks, nftSpk, SLP_TOKEN_DUST, branchId, nft.tokenId, nft.nftOp, + buyerAddr, payoutAddr, /*price=*/100000000, /*expiry=*/0); + + NftFakeCoinsView base; + CCoinsViewCache cache(&base); + SeedCoin(cache, nft.nftOp, nftSpk, SLP_TOKEN_DUST, 1); + + NftChainStateGuard guard(&cache, /*tipHeight=*/10); + LOCK(cs_main); + NftVerifyResult r; + NftVerify(nft.store, blob, r); + EXPECT_TRUE(r.ok) << (r.reasons.empty() ? std::string("(no reasons)") + : r.reasons[0]); + EXPECT_EQ(r.tokenId, nft.tokenId); + EXPECT_EQ(r.priceZat, (int64_t)100000000); + EXPECT_EQ(r.buyerNftAddr, buyerAddr); + EXPECT_EQ(r.payoutAddr, payoutAddr); + + delete nft.store; +} + +TEST(NftVerifyReal, RejectsPriceTamperViaSellerSignature) +{ + SelectParams(CBaseChainParams::REGTEST); + // Sign with the SAME branch id NftVerify recomputes for nextHeight (tip 10 + // => nextHeight 11), so the seller's signature validly verifies under the + // verifier's epoch (REGTEST has Sapling at NO_ACTIVATION by default). + uint32_t branchId = CurrentEpochBranchId(11, Params().GetConsensus()); + CBasicKeyStore ks; + CKey sellerKey; sellerKey.MakeNewKey(true); + ks.AddKeyPubKey(sellerKey, sellerKey.GetPubKey()); + CScript nftSpk = GetScriptForDestination(sellerKey.GetPubKey().GetID()); + SeededNft nft = SeedNftStore(sellerKey, nftSpk); + CKey buyerKey; buyerKey.MakeNewKey(true); + CKey payoutKey; payoutKey.MakeNewKey(true); + std::string buyerAddr = EncodeDestination(buyerKey.GetPubKey().GetID()); + std::string payoutAddr = EncodeDestination(payoutKey.GetPubKey().GetID()); + + CNftOfferBlob blob = MakeSignedOffer( + ks, nftSpk, SLP_TOKEN_DUST, branchId, nft.tokenId, nft.nftOp, + buyerAddr, payoutAddr, 100000000, 0); + + // Tamper the payout DOWN after signing: re-decode, shave vout[2], re-encode. + CMutableTransaction mtx; + { CTransaction t; ASSERT_TRUE(DecodeHexTx(t, blob.offerHex)); mtx = CMutableTransaction(t); } + mtx.vout[2].nValue -= 1; + blob.offerHex = EncodeHexTx(CTransaction(mtx)); + // blob.priceZat still advertises the ORIGINAL price -> both the value-match + // arm AND the cryptographic backstop must fire. + + NftFakeCoinsView base; + CCoinsViewCache cache(&base); + SeedCoin(cache, nft.nftOp, nftSpk, SLP_TOKEN_DUST, 1); + NftChainStateGuard guard(&cache, 10); + LOCK(cs_main); + NftVerifyResult r; + NftVerify(nft.store, blob, r); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(HasReason(r, "seller signature does not validly bind")) + << "the ALL|ANYONECANPAY backstop must reject a payout edit"; + + delete nft.store; +} + +TEST(NftVerifyReal, RejectsRecipientRedirectViaSellerSignature) +{ + SelectParams(CBaseChainParams::REGTEST); + // Sign with the SAME branch id NftVerify recomputes for nextHeight (tip 10 + // => nextHeight 11), so the seller's signature validly verifies under the + // verifier's epoch (REGTEST has Sapling at NO_ACTIVATION by default). + uint32_t branchId = CurrentEpochBranchId(11, Params().GetConsensus()); + CBasicKeyStore ks; + CKey sellerKey; sellerKey.MakeNewKey(true); + ks.AddKeyPubKey(sellerKey, sellerKey.GetPubKey()); + CScript nftSpk = GetScriptForDestination(sellerKey.GetPubKey().GetID()); + SeededNft nft = SeedNftStore(sellerKey, nftSpk); + CKey buyerKey; buyerKey.MakeNewKey(true); + CKey payoutKey; payoutKey.MakeNewKey(true); + std::string buyerAddr = EncodeDestination(buyerKey.GetPubKey().GetID()); + std::string payoutAddr = EncodeDestination(payoutKey.GetPubKey().GetID()); + + CNftOfferBlob blob = MakeSignedOffer( + ks, nftSpk, SLP_TOKEN_DUST, branchId, nft.tokenId, nft.nftOp, + buyerAddr, payoutAddr, 100000000, 0); + + // Redirect the NFT recipient (vout[1]) to an attacker after signing. + CMutableTransaction mtx; + { CTransaction t; ASSERT_TRUE(DecodeHexTx(t, blob.offerHex)); mtx = CMutableTransaction(t); } + mtx.vout[1].scriptPubKey = P2PKH(0xEE); + blob.offerHex = EncodeHexTx(CTransaction(mtx)); + + NftFakeCoinsView base; + CCoinsViewCache cache(&base); + SeedCoin(cache, nft.nftOp, nftSpk, SLP_TOKEN_DUST, 1); + NftChainStateGuard guard(&cache, 10); + LOCK(cs_main); + NftVerifyResult r; + NftVerify(nft.store, blob, r); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(HasReason(r, "seller signature does not validly bind")); + + delete nft.store; +} + +TEST(NftVerifyReal, RejectsExpiredOrExpiringSoonOffer) +{ + SelectParams(CBaseChainParams::REGTEST); + // Sign with the SAME branch id NftVerify recomputes for nextHeight (tip 10 + // => nextHeight 11), so the seller's signature validly verifies under the + // verifier's epoch (REGTEST has Sapling at NO_ACTIVATION by default). + uint32_t branchId = CurrentEpochBranchId(11, Params().GetConsensus()); + CBasicKeyStore ks; + CKey sellerKey; sellerKey.MakeNewKey(true); + ks.AddKeyPubKey(sellerKey, sellerKey.GetPubKey()); + CScript nftSpk = GetScriptForDestination(sellerKey.GetPubKey().GetID()); + SeededNft nft = SeedNftStore(sellerKey, nftSpk); + CKey buyerKey; buyerKey.MakeNewKey(true); + CKey payoutKey; payoutKey.MakeNewKey(true); + std::string buyerAddr = EncodeDestination(buyerKey.GetPubKey().GetID()); + std::string payoutAddr = EncodeDestination(payoutKey.GetPubKey().GetID()); + + // tip is height 10 (=> nextHeight 11). An expiry at 12 is within + // nextHeight+TX_EXPIRING_SOON_THRESHOLD (=14) -> "expiring too soon". + const uint32_t expiry = 12; + CNftOfferBlob blob = MakeSignedOffer( + ks, nftSpk, SLP_TOKEN_DUST, branchId, nft.tokenId, nft.nftOp, + buyerAddr, payoutAddr, 100000000, expiry); + + NftFakeCoinsView base; + CCoinsViewCache cache(&base); + SeedCoin(cache, nft.nftOp, nftSpk, SLP_TOKEN_DUST, 1); + NftChainStateGuard guard(&cache, /*tipHeight=*/10); + LOCK(cs_main); + NftVerifyResult r; + NftVerify(nft.store, blob, r); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(HasReason(r, "expired or expiring too soon")); + + delete nft.store; +} + +TEST(NftVerifyReal, RejectsWhenNftOutpointIsNotLive) +{ + SelectParams(CBaseChainParams::REGTEST); + // Sign with the SAME branch id NftVerify recomputes for nextHeight (tip 10 + // => nextHeight 11), so the seller's signature validly verifies under the + // verifier's epoch (REGTEST has Sapling at NO_ACTIVATION by default). + uint32_t branchId = CurrentEpochBranchId(11, Params().GetConsensus()); + CBasicKeyStore ks; + CKey sellerKey; sellerKey.MakeNewKey(true); + ks.AddKeyPubKey(sellerKey, sellerKey.GetPubKey()); + CScript nftSpk = GetScriptForDestination(sellerKey.GetPubKey().GetID()); + SeededNft nft = SeedNftStore(sellerKey, nftSpk); + CKey buyerKey; buyerKey.MakeNewKey(true); + CKey payoutKey; payoutKey.MakeNewKey(true); + std::string buyerAddr = EncodeDestination(buyerKey.GetPubKey().GetID()); + std::string payoutAddr = EncodeDestination(payoutKey.GetPubKey().GetID()); + + CNftOfferBlob blob = MakeSignedOffer( + ks, nftSpk, SLP_TOKEN_DUST, branchId, nft.tokenId, nft.nftOp, + buyerAddr, payoutAddr, 100000000, 0); + + // Coins view is EMPTY -> vin[0] prevout is not live (spent / never existed). + NftFakeCoinsView base; + CCoinsViewCache cache(&base); + NftChainStateGuard guard(&cache, 10); + LOCK(cs_main); + NftVerifyResult r; + NftVerify(nft.store, blob, r); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(HasReason(r, "not a live")); + + delete nft.store; +} + +TEST(NftVerifyReal, RejectsBuyerAddrAndPriceFieldLies) +{ + // The header is ADVISORY: NftVerify re-derives buyerNftAddr/priceZat from + // offerHex and must flag a header that lies about them (independently of the + // signature, which still validly binds the REAL outputs). + SelectParams(CBaseChainParams::REGTEST); + // Sign with the SAME branch id NftVerify recomputes for nextHeight (tip 10 + // => nextHeight 11), so the seller's signature validly verifies under the + // verifier's epoch (REGTEST has Sapling at NO_ACTIVATION by default). + uint32_t branchId = CurrentEpochBranchId(11, Params().GetConsensus()); + CBasicKeyStore ks; + CKey sellerKey; sellerKey.MakeNewKey(true); + ks.AddKeyPubKey(sellerKey, sellerKey.GetPubKey()); + CScript nftSpk = GetScriptForDestination(sellerKey.GetPubKey().GetID()); + SeededNft nft = SeedNftStore(sellerKey, nftSpk); + CKey buyerKey; buyerKey.MakeNewKey(true); + CKey payoutKey; payoutKey.MakeNewKey(true); + CKey otherKey; otherKey.MakeNewKey(true); + std::string buyerAddr = EncodeDestination(buyerKey.GetPubKey().GetID()); + std::string payoutAddr = EncodeDestination(payoutKey.GetPubKey().GetID()); + + CNftOfferBlob blob = MakeSignedOffer( + ks, nftSpk, SLP_TOKEN_DUST, branchId, nft.tokenId, nft.nftOp, + buyerAddr, payoutAddr, 100000000, 0); + // Lie in the advisory header (tx itself untouched + still validly signed). + blob.buyerNftAddr = EncodeDestination(otherKey.GetPubKey().GetID()); + blob.priceZat = 999; + + NftFakeCoinsView base; + CCoinsViewCache cache(&base); + SeedCoin(cache, nft.nftOp, nftSpk, SLP_TOKEN_DUST, 1); + NftChainStateGuard guard(&cache, 10); + LOCK(cs_main); + NftVerifyResult r; + NftVerify(nft.store, blob, r); + EXPECT_FALSE(r.ok); + EXPECT_TRUE(HasReason(r, "NFT recipient")); + EXPECT_TRUE(HasReason(r, "payout) value")); + + delete nft.store; +} +#endif // ENABLE_WALLET + +// ════════════════════════════════════════════════════════════════════════ +// 5. E-4 (no-fork PROOF): each representative ZSLP/NFT OP_RETURN carrier is +// RELAY-STANDARD on MAINNET under the DEFAULT -datacarriersize, so old +// unmodified nodes relay + mine it. This converts the "no consensus fork" +// claim from a 223-byte builder assert to a policy-level proof against the +// real IsStandardTx path. (The OFFER carrier is covered above by +// NftOfferStandardness.SwapTxIsStandardOnMainnet; here we add GENESIS + +// SEND and pin the datacarrier policy explicitly.) +// ════════════════════════════════════════════════════════════════════════ + +namespace { +// A representative ZSLP carrier tx (vout[0]=OP_RETURN op, then real outputs) with +// push-only dummy scriptSigs so per-input standardness passes without signing. +CTransaction MakeCarrierTx(const std::vector& opret, + const std::vector& tokenOuts, int nHeight) { + CMutableTransaction mtx = CreateNewContextualCMutableTransaction( + Params().GetConsensus(), nHeight); + CTxIn in(COutPoint(uint256S("01"), 0)); + in.scriptSig = CScript() << std::vector(72, 0); + mtx.vin.push_back(in); + mtx.vout.push_back(CTxOut(0, CScript(opret.begin(), opret.end()))); + for (size_t i = 0; i < tokenOuts.size(); ++i) + mtx.vout.push_back(tokenOuts[i]); + return CTransaction(mtx); +} +} // namespace + +TEST(NftNoForkStandardness, GenesisIsStandardOnMainnetDefaultDatacarrier) +{ + SelectParams(CBaseChainParams::MAIN); + // The policy knob is at its shipped default (223 = MAX_OP_RETURN_RELAY). + ASSERT_EQ(nMaxDatacarrierBytes, (unsigned)MAX_OP_RETURN_RELAY); + const int nHeight = 476969; // MAIN Sapling activation + + // A realistic NFT genesis: name + 32-byte document_hash (the heaviest common + // NFT mint), which is what zslp_genesis nft=true ships. + uint8_t docHash[32]; memset(docHash, 0x5A, 32); + std::vector gen = ZSLPBuildGenesis( + "ART", "My Photo #1 — a longish display name", "ipfs://Qm-some-cid", + docHash, /*decimals=*/0, /*baton=*/0, /*qty=*/1); + ASSERT_FALSE(gen.empty()); + // The OP_RETURN scriptPubKey must fit the relay cap (the no-fork premise). + CScript opScript(gen.begin(), gen.end()); + EXPECT_LE(opScript.size(), (size_t)nMaxDatacarrierBytes); + + std::vector outs; + outs.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0x01))); // vout[1] token recipient + CTransaction tx = MakeCarrierTx(gen, outs, nHeight); + + std::string reason; + EXPECT_TRUE(IsStandardTx(tx, reason, nHeight)) + << "genesis carrier not relay-standard: " << reason; + + SelectParams(CBaseChainParams::REGTEST); +} + +TEST(NftNoForkStandardness, SendIsStandardOnMainnetDefaultDatacarrier) +{ + SelectParams(CBaseChainParams::MAIN); + ASSERT_EQ(nMaxDatacarrierBytes, (unsigned)MAX_OP_RETURN_RELAY); + const int nHeight = 476969; + + uint8_t be[32]; memset(be, 0x42, 32); + // A max-fanout SEND (the largest SEND OP_RETURN the builder emits) still fits. + std::vector snd = ZSLPBuildSend(be, {1}); + ASSERT_FALSE(snd.empty()); + CScript opScript(snd.begin(), snd.end()); + EXPECT_LE(opScript.size(), (size_t)nMaxDatacarrierBytes); + + std::vector outs; + outs.push_back(CTxOut(SLP_TOKEN_DUST, P2PKH(0x02))); // vout[1] recipient + CTransaction tx = MakeCarrierTx(snd, outs, nHeight); + + std::string reason; + EXPECT_TRUE(IsStandardTx(tx, reason, nHeight)) + << "send carrier not relay-standard: " << reason; + + SelectParams(CBaseChainParams::REGTEST); +} + +// Belt-and-braces: the builder's own 223-byte ceiling matches the relay policy +// constant, so a carrier that the builder accepts can never exceed -datacarriersize. +TEST(NftNoForkStandardness, BuilderCeilingMatchesRelayPolicy) +{ + EXPECT_EQ((unsigned)MAX_OP_RETURN_RELAY, (unsigned)223); + EXPECT_EQ(nMaxDatacarrierBytes, (unsigned)MAX_OP_RETURN_RELAY); +} diff --git a/src/gtest/test_zdc.cpp b/src/gtest/test_zdc.cpp index a74fd52ea6d..a0b2ad711e8 100644 --- a/src/gtest/test_zdc.cpp +++ b/src/gtest/test_zdc.cpp @@ -14,10 +14,13 @@ #include "datachannel/zdc.h" #include "consensus/consensus.h" // MAX_TX_SIZE_AFTER_SAPLING +#include "rpc/datachannel.h" // E-2: ZDC RPC-layer test seams +#include "utiltime.h" // SetMockTime / GetTime #include #include +#include #include #include #include @@ -513,3 +516,127 @@ TEST(ZDC, CiphertextFingerprint) { EXPECT_EQ(ciphertext_fingerprint(tampered, fp4), OK); EXPECT_NE(std::memcmp(fp1, fp4, CONTENT_HASH_LEN), 0); } + +// ════════════════════════════════════════════════════════════════════════ +// E-2: the ZDC RPC LAYER above the codec — TTL pruning, the rate guard, and +// the registry-miss fingerprint-grouping fallback. These gate the DoS/registry +// decision logic that test_zdc's codec tests never reach. The send/list/get +// end-to-end paths need a live CWallet + Sapling notes and stay covered by +// qa/zslp/zdc-xwallet-regtest.sh (see coverageHonesty). +// ════════════════════════════════════════════════════════════════════════ + +// Matches ZDC_INFLIGHT_TTL_SEC in rpc/datachannel.cpp (72h). +static const int64_t kZdcTtlSec = 72 * 60 * 60; + +// TTL pruning: records strictly older than the TTL are dropped; fresh ones stay. +TEST(ZdcRpcLayer, ExpireOldDropsOnlyRecordsPastTtl) +{ + ZdcTestReset(); + SetMockTime(1000000); // deterministic "now" + + // Three records at increasing ages relative to the TTL boundary. + ZdcTestSeedTransfer(0xA1, /*createdAt=*/1000000 - (kZdcTtlSec + 5)); // expired + ZdcTestSeedTransfer(0xB2, /*createdAt=*/1000000 - (kZdcTtlSec - 5)); // fresh + ZdcTestSeedTransfer(0xC3, /*createdAt=*/1000000); // brand new + EXPECT_EQ(ZdcTestTransferCount(), (size_t)3); + + ZdcTestExpireOld(); + + EXPECT_EQ(ZdcTestTransferCount(), (size_t)2); + EXPECT_FALSE(ZdcTestHasTransfer(0xA1)); // pruned + EXPECT_TRUE(ZdcTestHasTransfer(0xB2)); + EXPECT_TRUE(ZdcTestHasTransfer(0xC3)); + + // Advance well past the TTL: everything is pruned. + SetMockTime(1000000 + kZdcTtlSec + 100); + ZdcTestExpireOld(); + EXPECT_EQ(ZdcTestTransferCount(), (size_t)0); + + SetMockTime(0); // restore real clock + ZdcTestReset(); +} + +// Rate guard: ZDC_RATE_MAX_PER_WIN (4) calls admitted per ZDC_RATE_WINDOW_SEC (1s) +// window; the 5th in the same window is rejected; a new window resets the count. +TEST(ZdcRpcLayer, RateGuardAdmitsUpToCapThenRejectsWithinWindow) +{ + ZdcTestReset(); + SetMockTime(2000000); + + // First 4 calls in the window are admitted... + for (int i = 0; i < 4; ++i) + EXPECT_TRUE(ZdcTestRateGuardAdmits()) << "call " << i << " must be admitted"; + // ...the 5th in the SAME window is rejected (the guard would throw). + EXPECT_FALSE(ZdcTestRateGuardAdmits()) + << "the 5th call within the window must trip the rate guard"; + + // A new window (>= ZDC_RATE_WINDOW_SEC later) resets the counter. + SetMockTime(2000002); + EXPECT_TRUE(ZdcTestRateGuardAdmits()) + << "a fresh window must admit again"; + + SetMockTime(0); + ZdcTestReset(); +} + +// Registry-miss fingerprint-grouping fallback (datachannel.cpp:518-541): given +// ONLY on-chain ZDC frames (no session record), group memos by transfer_id, and +// recompute each group's ciphertext fingerprint to recover the wanted id. We +// drive the REAL codec ops the fallback uses (parse_header + ciphertext_finger- +// print) against TWO interleaved transfers, proving the grouping disambiguates. +TEST(ZdcRpcLayer, FingerprintGroupingFallbackResolvesTransferId) +{ + // Build two distinct transfers with different transfer_ids + payloads. + std::vector keyA = make_key(); + std::vector keyB = make_key(); + TransferMeta meta; meta.filename = "a"; meta.content_type = ""; + meta.total_plaintext_size = 0; meta.chunk_count = 0; + + std::vector ptA = rand_bytes(900); // 2 DATA frames + std::vector ptB = rand_bytes(1300); // 3 DATA frames + const uint64_t idA = 0x1111111111111111ull; + const uint64_t idB = 0x2222222222222222ull; + + std::vector > framesA, framesB; + ASSERT_EQ(Encoder::encode(idA, keyA, ptA, meta, /*include_key_frame=*/true, framesA), OK); + ASSERT_EQ(Encoder::encode(idB, keyB, ptB, meta, /*include_key_frame=*/true, framesB), OK); + + // The authoritative anchors (what z_getdatatransfer would search for). + uint8_t fpA[CONTENT_HASH_LEN], fpB[CONTENT_HASH_LEN]; + ASSERT_EQ(ciphertext_fingerprint(framesA, fpA), OK); + ASSERT_EQ(ciphertext_fingerprint(framesB, fpB), OK); + + // Interleave both transfers' memos as they would appear in one wallet's + // GetFilteredNotes output (plus a foreign/text memo the loop must skip). + std::vector > wallet; + for (size_t i = 0; i < framesA.size(); ++i) wallet.push_back(framesA[i]); + std::vector textMemo(HEADER_SIZE, 0x00); // non-ZDC magic => skipped + wallet.push_back(textMemo); + for (size_t i = 0; i < framesB.size(); ++i) wallet.push_back(framesB[i]); + + // Replicate the prod fallback EXACTLY (datachannel.cpp:521-536): + // group memos by transfer_id, recompute each group's fingerprint, match. + auto resolve = [&](const uint8_t wantFp[CONTENT_HASH_LEN]) -> uint64_t { + std::map > > byId; + for (size_t i = 0; i < wallet.size(); ++i) { + FrameHeader h; + if (parse_header(&wallet[i][0], h) != OK) continue; // skip non-ZDC + byId[h.transfer_id].push_back(wallet[i]); + } + for (std::map > >::const_iterator + it = byId.begin(); it != byId.end(); ++it) { + uint8_t fp[CONTENT_HASH_LEN]; + if (ciphertext_fingerprint(it->second, fp) != OK) continue; + if (std::memcmp(fp, wantFp, CONTENT_HASH_LEN) == 0) return it->first; + } + return 0; // not found + }; + + EXPECT_EQ(resolve(fpA), idA) << "grouping must recover transfer A by its anchor"; + EXPECT_EQ(resolve(fpB), idB) << "grouping must recover transfer B by its anchor"; + + // A fingerprint that no group hashes to resolves to "not found" (the prod + // RPC throws RPC_INVALID_ADDRESS_OR_KEY in that case). + uint8_t bogus[CONTENT_HASH_LEN]; std::memset(bogus, 0x7E, CONTENT_HASH_LEN); + EXPECT_EQ(resolve(bogus), (uint64_t)0); +} diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 4af1447a09b..8488fe98b97 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -153,7 +153,8 @@ static const CRPCConvertParam vRPCConvertParams[] = { "nft_makeoffer", 0}, // params object { "nft_verifyoffer", 0}, // params object { "nft_takeoffer", 0}, // params object - { "nft_listoffers", 0}, // params object + // nft_listoffers takes NO args (A-1: the dead 'mine' filter was removed), so + // there is nothing to convert. { "nft_canceloffer", 0}, // params object { "nft_requestbuy", 0} // params object }; diff --git a/src/rpc/datachannel.cpp b/src/rpc/datachannel.cpp index ab1b9b3ef20..4d08a5d09a2 100644 --- a/src/rpc/datachannel.cpp +++ b/src/rpc/datachannel.cpp @@ -22,7 +22,8 @@ // * PERMANENCE CONSENT: z_senddatafile REQUIRES acknowledge_permanent=true. // * transfer_id is RANDOM (8 bytes from libsodium); the on-chain ANCHOR is the // ciphertext fingerprint (= what a ZSLP NFT document_hash would commit to). -// * DoS caps: per-file size cap (64 KB), inflight TTL (72h), max inflight +// * DoS caps: per-file size cap (ZDC_MAX_FILE_BYTES = 40000 bytes; derived +// from the single-tx frame budget below), inflight TTL (72h), max inflight // transfers (256), and a basic per-call rate guard. // * VERIFY-BEFORE-DECRYPT: z_getdatatransfer confirms the on-chain ciphertext // fingerprint matches the recorded anchor BEFORE any AEAD decrypt, and @@ -32,6 +33,7 @@ #include "rpc/server.h" +#include "rpc/datachannel.h" // E-2 test seams (de-hidden registry/DoS guards) #include "datachannel/zdc.h" #include "key_io.h" #include "rpc/protocol.h" @@ -149,7 +151,63 @@ static std::string BytesToHex(const uint8_t* p, size_t n) return s; } -static const char* ZdcDirToStr(const char* d) { return d; } +// ── E-2 test seams (declared in rpc/datachannel.h) ─────────────────────────── +// +// These call the REAL ZdcExpireOld/ZdcRateGuard and touch the REAL file-static +// registry + rate globals — so the gtest gates the exact production decision +// logic. They acquire cs_zdc the same way the RPCs do. No production code path +// changes; these are only reachable from the test binary. +void ZdcTestExpireOld() +{ + LOCK(cs_zdc); + ZdcExpireOld(); +} + +bool ZdcTestRateGuardAdmits() +{ + LOCK(cs_zdc); + try { + ZdcRateGuard(); + return true; + } catch (const UniValue&) { + return false; // JSONRPCError throws a UniValue + } +} + +void ZdcTestSeedTransfer(uint64_t transferId, int64_t createdAt) +{ + LOCK(cs_zdc); + ZdcTransferRecord rec; + rec.transferId = transferId; + rec.createdAt = createdAt; + rec.frames = 0; + g_zdcTransfers[transferId] = rec; +} + +size_t ZdcTestTransferCount() +{ + LOCK(cs_zdc); + return g_zdcTransfers.size(); +} + +bool ZdcTestHasTransfer(uint64_t transferId) +{ + LOCK(cs_zdc); + return g_zdcTransfers.find(transferId) != g_zdcTransfers.end(); +} + +void ZdcTestReset() +{ + LOCK(cs_zdc); + for (std::map::iterator it = g_zdcTransfers.begin(); + it != g_zdcTransfers.end(); ++it) { + if (!it->second.key.empty()) + sodium_memzero(&it->second.key[0], it->second.key.size()); + } + g_zdcTransfers.clear(); + g_zdcRateWindowStart = 0; + g_zdcRateCount = 0; +} #ifdef ENABLE_WALLET @@ -377,6 +435,10 @@ UniValue z_listdatatransfers(const UniValue& params, bool fHelp) throw std::runtime_error( "z_listdatatransfers\n" "\nList the data transfers this node knows about (sent this session).\n" + "\nIn this build \"direction\" is ALWAYS \"sent\" and \"status\" is ALWAYS\n" + "\"recorded\": only the send path is built. The values \"received\" and\n" + "\"complete\" are reserved for the unbuilt receive path and never appear\n" + "here yet.\n" "\nResult: [ { \"transfer_id\", \"fingerprint\", \"direction\",\n" " \"frames\", \"status\", \"toaddress\", \"filename\" }, ... ]\n" "\nExamples:\n" @@ -392,7 +454,9 @@ UniValue z_listdatatransfers(const UniValue& params, bool fHelp) UniValue obj(UniValue::VOBJ); obj.push_back(Pair("transfer_id", strprintf("%016x", r.transferId))); obj.push_back(Pair("fingerprint", r.fingerprintHex)); - obj.push_back(Pair("direction", ZdcDirToStr(r.direction.c_str()))); + // direction is always "sent" and status always "recorded" in this build + // (the receive path is not built; see the help). No identity wrapper. + obj.push_back(Pair("direction", r.direction.c_str())); obj.push_back(Pair("frames", (int)r.frames)); obj.push_back(Pair("status", "recorded")); obj.push_back(Pair("fromaddress", r.fromAddress)); diff --git a/src/rpc/datachannel.h b/src/rpc/datachannel.h new file mode 100644 index 00000000000..b59fd657b00 --- /dev/null +++ b/src/rpc/datachannel.h @@ -0,0 +1,44 @@ +// Copyright (c) 2026 The ZClassic developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// +// SHIELD pillar — test seams ONLY (E-2). +// +// The data-channel RPCs (z_senddatafile / z_listdatatransfers / +// z_getdatatransfer) live entirely in rpc/datachannel.cpp and need a live +// CWallet + Sapling notes to drive end-to-end (covered by the cross-wallet +// regtest qa/zslp/zdc-xwallet-regtest.sh). This header exposes the THREE +// wallet-independent registry/DoS-guard pieces so zcash-gtest can gate them: +// +// * ZdcExpireOld() — TTL pruning of the in-memory transfer registry. +// * ZdcRateGuard() — the basic per-window rate limit (throws when exceeded). +// * a minimal seam to seed / count / reset the registry + rate state. +// +// The functions' logic + production call sites are UNCHANGED; this header only +// un-hides them (they were file-static). Nothing here weakens a guard. The +// verify-before-decrypt failure modes (ERR_NO_KEY / ERR_AEAD_FAIL / +// ERR_HASH_MISMATCH) and the fingerprint-grouping fallback that calls +// GetFilteredNotes are NOT exposed — they are codec-tested (test_zdc.cpp) and +// regtest-driven respectively. + +#ifndef BITCOIN_RPC_DATACHANNEL_H +#define BITCOIN_RPC_DATACHANNEL_H + +#include +#include + +// Drop every registry record older than the TTL (ZDC_INFLIGHT_TTL_SEC), wiping +// each dropped key. Caller need not hold cs_zdc here; the seam locks internally. +void ZdcTestExpireOld(); + +// Run the per-window rate guard exactly as z_senddatafile does; returns true if +// the call is admitted, false if the guard would have thrown (rate exceeded). +bool ZdcTestRateGuardAdmits(); + +// Test helpers (seed/inspect/reset) — no-ops for production paths. +void ZdcTestSeedTransfer(uint64_t transferId, int64_t createdAt); +size_t ZdcTestTransferCount(); +bool ZdcTestHasTransfer(uint64_t transferId); +void ZdcTestReset(); + +#endif // BITCOIN_RPC_DATACHANNEL_H diff --git a/src/rpc/nftoffer.cpp b/src/rpc/nftoffer.cpp index ba339e5439c..583790c26b9 100644 --- a/src/rpc/nftoffer.cpp +++ b/src/rpc/nftoffer.cpp @@ -21,12 +21,13 @@ // nft_makeoffer {tokenId,priceZat,payoutAddr?,buyerNftAddr,expiryHeight?} // nft_verifyoffer {offerBlob} (read-only, mandatory) // nft_takeoffer {offerBlob,fundingInputs?,changeAddr?,acknowledge?} -// nft_listoffers {mine?} +// nft_listoffers () (no filter; all are yours) // nft_canceloffer {offerId} // nft_requestbuy {tokenId|offerId} #include "rpc/server.h" +#include "rpc/nftoffer.h" // CNftOfferBlob, NftVerifyResult, NftVerify (E-1) #include "base58.h" #include "consensus/upgrades.h" #include "consensus/validation.h" @@ -62,63 +63,36 @@ extern bool EnsureWalletIsAvailable(bool avoidException); #ifdef ENABLE_WALLET // ── shared helpers ────────────────────────────────────────────────── +// +// B-2: the store-or-throw + t-addr script/addr helpers are now ONE canonical +// copy in wallet/zslpwallet.{h,cpp} (alongside ZSLPTokenIdToBE), used by both +// this file and rpc/zslp.cpp. These thin shims keep the existing Nft*-prefixed +// call sites reading unchanged while the byte-for-byte behavior lives in one +// place (they build the real token-carrier + offer-template scriptPubKeys). -static CZSLPStore* NftGetStoreOrThrow() -{ - if (g_zslpIndexer == NULL || g_zslpIndexer->Store() == NULL) - throw JSONRPCError(RPC_MISC_ERROR, - "ZSLP index is not enabled. Start zclassicd with -zslpindex."); - return g_zslpIndexer->Store(); -} +static inline CZSLPStore* NftGetStoreOrThrow() { return ZSLPStoreOrThrow(); } // A t-address string -> P2PKH/P2SH script (throws on invalid). -static CScript NftScriptForTAddr(const std::string& addr) +static inline CScript NftScriptForTAddr(const std::string& addr) { - CTxDestination dest = DecodeDestination(addr); - if (!IsValidDestination(dest)) - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, - "Invalid transparent address: " + addr); - return GetScriptForDestination(dest); + return ZSLPScriptForTAddr(addr); } // Decode a script back to a t-address string ("" if not a standard address). -static std::string NftAddrFromScript(const CScript& spk) +static inline std::string NftAddrFromScript(const CScript& spk) { - CTxDestination dest; - if (ExtractDestination(spk, dest) && IsValidDestination(dest)) - return EncodeDestination(dest); - return std::string(); + return ZSLPAddrFromScript(spk); } -static CScript NftFreshWalletScript() -{ - CPubKey vchPubKey; - if (!pwalletMain->GetKeyFromPool(vchPubKey)) - throw JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT, - "Keypool ran out, call keypoolrefill first"); - return GetScriptForDestination(vchPubKey.GetID()); -} +static inline CScript NftFreshWalletScript() { return ZSLPFreshWalletScript(); } // Parse a non-negative zatoshi amount from a JSON string|integer. +// B-1: the parse/overflow logic is shared (ZSLPParseAmountField); this pins THIS +// family's bound (<= MAX_MONEY) and its exact wording ("exceeds MAX_MONEY", and +// the "(zatoshi)" digits-only note) so behavior is unchanged. static int64_t NftParseZat(const UniValue& v, const std::string& field) { - std::string s; - if (v.isStr()) s = v.get_str(); - else if (v.isNum()) s = v.getValStr(); - else throw JSONRPCError(RPC_TYPE_ERROR, field + " must be a string or integer"); - if (s.empty()) - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is empty"); - for (size_t i = 0; i < s.size(); ++i) - if (s[i] < '0' || s[i] > '9') - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " must be a non-negative integer (zatoshi)"); - errno = 0; - char* end = NULL; - unsigned long long q = strtoull(s.c_str(), &end, 10); - if (errno != 0 || end == NULL || *end != '\0') - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is not a valid integer"); - if (q > (unsigned long long)MAX_MONEY) - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " exceeds MAX_MONEY"); - return (int64_t)q; + return ZSLPParseAmountField(v, field, MAX_MONEY, "MAX_MONEY", " (zatoshi)"); } // Fee-rate-derived dust floor for a token-bearing output (§2.2). Never below the @@ -132,77 +106,41 @@ static CAmount NftTokenDust(const CScript& dest) // ── offer blob format (base64; §4) ────────────────────────────────── // -// Self-describing, versioned. The header is ADVISORY only — nft_verifyoffer -// always re-derives every field from offerHex and ignores a header that lies. -static const unsigned char NFT_OFFER_MAGIC[4] = { 'Z', 'N', 'F', 'T' }; -static const unsigned char NFT_OFFER_VERSION = 0x01; +// The class + SerializationOp + the magic/version constants live in +// rpc/nftoffer.h (so the gtest can construct one for the de-static NftVerify, +// E-1). Only these three non-template methods are defined here. -class CNftOfferBlob +std::string CNftOfferBlob::ToBase64() const { -public: - uint256 tokenId; //!< internal order (render reversed) - int64_t priceZat; - std::string payoutAddr; - std::string buyerNftAddr; - uint32_t expiryHeight; - std::string offerHex; //!< the partial ALL|ANYONECANPAY tx hex - - CNftOfferBlob() : priceZat(0), expiryHeight(0) { tokenId.SetNull(); } - - ADD_SERIALIZE_METHODS; - template - inline void SerializationOp(Stream& s, Operation ser_action) - { - for (int i = 0; i < 4; ++i) { - unsigned char m = NFT_OFFER_MAGIC[i]; - READWRITE(m); - if (ser_action.ForRead() && m != NFT_OFFER_MAGIC[i]) - throw std::ios_base::failure("offer blob: bad magic"); - } - unsigned char ver = NFT_OFFER_VERSION; - READWRITE(ver); - if (ser_action.ForRead() && ver != NFT_OFFER_VERSION) - throw std::ios_base::failure("offer blob: unsupported version"); - READWRITE(tokenId); - READWRITE(priceZat); - READWRITE(payoutAddr); - READWRITE(buyerNftAddr); - READWRITE(expiryHeight); - READWRITE(offerHex); - } - - std::string ToBase64() const - { - CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); - ss << *this; - return EncodeBase64((const unsigned char*)&ss[0], ss.size()); - } + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << *this; + return EncodeBase64((const unsigned char*)&ss[0], ss.size()); +} - // offerId = first 8 bytes of SHA256(blob) hex; stable content fingerprint. - std::string OfferId() const - { - CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); - ss << *this; - uint256 h = Hash((const unsigned char*)&ss[0], - (const unsigned char*)&ss[0] + ss.size()); - return h.GetHex().substr(0, 16); - } +// offerId = first 8 bytes of SHA256(blob) hex; stable content fingerprint. +std::string CNftOfferBlob::OfferId() const +{ + CDataStream ss(SER_NETWORK, PROTOCOL_VERSION); + ss << *this; + uint256 h = Hash((const unsigned char*)&ss[0], + (const unsigned char*)&ss[0] + ss.size()); + return h.GetHex().substr(0, 16); +} - bool FromBase64(const std::string& b64, std::string& err) - { - bool invalid = false; - std::vector raw = DecodeBase64(b64.c_str(), &invalid); - if (invalid) { err = "offer blob is not valid base64"; return false; } - try { - CDataStream ss(raw, SER_NETWORK, PROTOCOL_VERSION); - ss >> *this; - } catch (const std::exception& e) { - err = std::string("offer blob decode failed: ") + e.what(); - return false; - } - return true; +bool CNftOfferBlob::FromBase64(const std::string& b64, std::string& err) +{ + bool invalid = false; + std::vector raw = DecodeBase64(b64.c_str(), &invalid); + if (invalid) { err = "offer blob is not valid base64"; return false; } + try { + CDataStream ss(raw, SER_NETWORK, PROTOCOL_VERSION); + ss >> *this; + } catch (const std::exception& e) { + err = std::string("offer blob decode failed: ") + e.what(); + return false; } -}; + return true; +} // Strip a "znftoffer:" URI prefix if present, returning the bare base64. static std::string NftStripPrefix(const std::string& in) @@ -285,29 +223,14 @@ static bool NftFindOffer(const std::string& offerId, UniValue& recOut) // ── decode + verify the partial offer tx (the core safety logic) ──── // -// Re-derives every advertised field from offerHex and re-runs the real indexer -// parse + conservation check + a live-UTXO check on vin[0]. Used by both -// nft_verifyoffer (read-only) and nft_takeoffer (refuse-if-not-ok). -// -// Fills `reasons` with one string per failed check; ok == reasons.empty(). -// Also fills the derived (truth) fields so callers can echo them. -struct NftVerifyResult { - bool ok; - uint256 tokenId; - int64_t priceZat; - std::string payoutAddr; - std::string buyerNftAddr; - uint32_t expiryHeight; - CMutableTransaction tx; //!< the decoded partial tx (for takeoffer) - CScript nftPrevScript; //!< vin[0]'s prevout scriptPubKey (live) - CAmount nftPrevValue; //!< vin[0]'s prevout value (live) - std::vector reasons; - NftVerifyResult() : ok(false), priceZat(0), expiryHeight(0), nftPrevValue(0) - { tokenId.SetNull(); } -}; - -static void NftVerify(CZSLPStore* store, const CNftOfferBlob& blob, - NftVerifyResult& r) +// NftVerifyResult lives in rpc/nftoffer.h. NftVerify is now non-static (E-1) so +// test_nftoffer.cpp can drive the REAL verifier instead of hand-rolling a copy; +// its signature + logic are unchanged. It re-derives every advertised field from +// offerHex and re-runs the real indexer parse + conservation check + a live-UTXO +// check on vin[0]. Used by nft_verifyoffer (read-only), nft_takeoffer +// (refuse-if-not-ok), and nft_makeoffer's self-validate. +void NftVerify(CZSLPStore* store, const CNftOfferBlob& blob, + NftVerifyResult& r) { AssertLockHeld(cs_main); r.expiryHeight = blob.expiryHeight; @@ -982,25 +905,19 @@ UniValue nft_takeoffer(const UniValue& params, bool fHelp) UniValue nft_listoffers(const UniValue& params, bool fHelp) { - if (fHelp || params.size() > 1) + if (fHelp || params.size() > 0) throw std::runtime_error( - "nft_listoffers ( {\"mine\":true|false} )\n" + "nft_listoffers\n" "\nList offers from the local store; status recomputed live against\n" - "the UTXO set (open / filled / expired / canceled).\n" + "the UTXO set (open / filled / expired / canceled). Every record in\n" + "the local store is yours (sent), so there is no filter.\n" "\nResult:\n" "[ { \"offerId\", \"tokenId\", \"priceZat\", \"expiryHeight\",\n" " \"role\", \"status\" }, ... ]\n"); - CZSLPStore* store = NftGetStoreOrThrow(); + NftGetStoreOrThrow(); // fail CLOSED if the index is off LOCK(cs_main); - bool onlyMine = false; - if (params.size() == 1 && params[0].isObject()) { - const UniValue& v = find_value(params[0].get_obj(), "mine"); - if (v.isBool()) onlyMine = v.get_bool(); - } - (void)onlyMine; // every record in the local store is "mine" (sent/received) - int tip = chainActive.Height(); UniValue arr = NftLoadStore(); UniValue out(UniValue::VARR); @@ -1039,7 +956,6 @@ UniValue nft_listoffers(const UniValue& params, bool fHelp) o.push_back(Pair("status", status)); out.push_back(o); } - (void)store; return out; } diff --git a/src/rpc/nftoffer.h b/src/rpc/nftoffer.h new file mode 100644 index 00000000000..60020fb23c9 --- /dev/null +++ b/src/rpc/nftoffer.h @@ -0,0 +1,107 @@ +// Copyright 2026 Rhett Creighton - Apache License 2.0 +// +// NFT SELL pillar — public declarations so the gtest can drive the REAL offer +// verifier (E-1) instead of hand-rolling a copy of prod logic. The dispatchers +// and the local-store helpers stay file-static in rpc/nftoffer.cpp; only the +// load-bearing decode+verify core (CNftOfferBlob / NftVerifyResult / NftVerify) +// is exposed here. The verifier's signature + logic are UNCHANGED — this header +// only un-hides them. +// +// NON-consensus overlay; ENABLE_WALLET only (the verifier reads pcoinsTip and +// the wallet-side ZSLP store). + +#ifndef BITCOIN_RPC_NFTOFFER_H +#define BITCOIN_RPC_NFTOFFER_H + +#ifdef ENABLE_WALLET + +#include "amount.h" +#include "primitives/transaction.h" // CMutableTransaction, CScript via deps +#include "script/script.h" // CScript +#include "serialize.h" // ADD_SERIALIZE_METHODS / READWRITE +#include "uint256.h" + +#include +#include +#include + +class CZSLPStore; + +// ── offer blob format (base64; §4) ────────────────────────────────── +// +// Self-describing, versioned. The header is ADVISORY only — NftVerify always +// re-derives every field from offerHex and ignores a header that lies. +static const unsigned char NFT_OFFER_MAGIC[4] = { 'Z', 'N', 'F', 'T' }; +static const unsigned char NFT_OFFER_VERSION = 0x01; + +class CNftOfferBlob +{ +public: + uint256 tokenId; //!< internal order (render reversed) + int64_t priceZat; + std::string payoutAddr; + std::string buyerNftAddr; + uint32_t expiryHeight; + std::string offerHex; //!< the partial ALL|ANYONECANPAY tx hex + + CNftOfferBlob() : priceZat(0), expiryHeight(0) { tokenId.SetNull(); } + + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) + { + for (int i = 0; i < 4; ++i) { + unsigned char m = NFT_OFFER_MAGIC[i]; + READWRITE(m); + if (ser_action.ForRead() && m != NFT_OFFER_MAGIC[i]) + throw std::ios_base::failure("offer blob: bad magic"); + } + unsigned char ver = NFT_OFFER_VERSION; + READWRITE(ver); + if (ser_action.ForRead() && ver != NFT_OFFER_VERSION) + throw std::ios_base::failure("offer blob: unsupported version"); + READWRITE(tokenId); + READWRITE(priceZat); + READWRITE(payoutAddr); + READWRITE(buyerNftAddr); + READWRITE(expiryHeight); + READWRITE(offerHex); + } + + std::string ToBase64() const; + + // offerId = first 8 bytes of SHA256(blob) hex; stable content fingerprint. + std::string OfferId() const; + + bool FromBase64(const std::string& b64, std::string& err); +}; + +// The result of decoding + verifying a partial offer tx (the core safety logic). +// Fills `reasons` with one string per failed check; ok == reasons.empty(). Also +// fills the derived (truth) fields so callers can echo them. +struct NftVerifyResult { + bool ok; + uint256 tokenId; + int64_t priceZat; + std::string payoutAddr; + std::string buyerNftAddr; + uint32_t expiryHeight; + CMutableTransaction tx; //!< the decoded partial tx (for takeoffer) + CScript nftPrevScript; //!< vin[0]'s prevout scriptPubKey (live) + CAmount nftPrevValue; //!< vin[0]'s prevout value (live) + std::vector reasons; + NftVerifyResult() : ok(false), priceZat(0), expiryHeight(0), nftPrevValue(0) + { tokenId.SetNull(); } +}; + +// Re-derive every advertised field from offerHex and re-run the real indexer +// parse + conservation check + a live-UTXO check (incl. the seller's +// ALL|ANYONECANPAY signature) on vin[0]. Used by nft_verifyoffer (read-only), +// nft_takeoffer (refuse-if-not-ok), and nft_makeoffer's self-validate. Requires +// cs_main held. De-static (E-1) so test_nftoffer.cpp can exercise the REAL +// function; signature + logic unchanged. +void NftVerify(CZSLPStore* store, const CNftOfferBlob& blob, NftVerifyResult& r); + +#endif // ENABLE_WALLET + +#endif // BITCOIN_RPC_NFTOFFER_H diff --git a/src/rpc/zslp.cpp b/src/rpc/zslp.cpp index 1a1f0bdfe3f..64c7ef4d948 100644 --- a/src/rpc/zslp.cpp +++ b/src/rpc/zslp.cpp @@ -31,10 +31,20 @@ extern bool EnsureWalletIsAvailable(bool avoidException); #endif +#include #include #include -// Return the active store or throw a friendly error when the index is off. +// Store-or-throw + t-addr script/addr helpers + the amount parser are shared +// (B-1/B-2): ONE canonical copy in wallet/zslpwallet.{h,cpp} (alongside +// ZSLPTokenIdToBE), used by both this file and rpc/nftoffer.cpp. For builds +// WITHOUT a wallet the wallet header still declares ZSLPStoreOrThrow, but the +// implementation TU (zslpwallet.cpp) is wallet-only — so provide a local +// store-or-throw for the read-only (non-wallet) commands here. +#ifdef ENABLE_WALLET +// wallet/zslpwallet.h is included above (line ~27); it declares ZSLPStoreOrThrow. +static inline CZSLPStore* GetZSLPStoreOrThrow() { return ZSLPStoreOrThrow(); } +#else static CZSLPStore* GetZSLPStoreOrThrow() { if (g_zslpIndexer == NULL || g_zslpIndexer->Store() == NULL) @@ -42,6 +52,7 @@ static CZSLPStore* GetZSLPStoreOrThrow() "ZSLP index is not enabled. Start zclassicd with -zslpindex."); return g_zslpIndexer->Store(); } +#endif static UniValue TokenToJSON(const CZSLPToken& t) { @@ -100,7 +111,9 @@ UniValue zslp_gettoken(const UniValue& params, bool fHelp) CZSLPToken token; if (!store->GetToken(tokenId, token)) - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Token not found"); + // A-4: align with the nft_* not-found paths (the arg names a thing that + // does not exist) — RPC_INVALID_PARAMETER, not RPC_INVALID_ADDRESS_OR_KEY. + throw JSONRPCError(RPC_INVALID_PARAMETER, "Token not found"); return TokenToJSON(token); } @@ -196,8 +209,12 @@ UniValue zslp_listmytokens(const UniValue& params, bool fHelp) "\nLists ZSLP tokens with a positive balance at any of this\n" "wallet's transparent addresses (read-only). ZSLP rides\n" "transparent dust, so only t-addresses are considered.\n" - "\nResult: [ { \"tokenid\", \"ticker\", \"name\", \"decimals\",\n" - " \"balance\", \"addresses\": [ ... ] }, ... ]\n" + "\nEach entry is the FULL token object (identical shape to\n" + "zslp_gettoken / zslp_listtokens: tokenid, ticker, name,\n" + "documenturl, documenthash, decimals, genesisheight, totalminted,\n" + "mintbatonvout, hasmintbaton) PLUS this wallet's aggregate\n" + "\"balance\" and the per-address \"addresses\" breakdown.\n" + "\nResult: [ { ...full token..., \"balance\", \"addresses\": [ ... ] }, ... ]\n" "\nExamples:\n" + HelpExampleCli("zslp_listmytokens", "") + HelpExampleRpc("zslp_listmytokens", "")); @@ -249,11 +266,11 @@ UniValue zslp_listmytokens(const UniValue& params, bool fHelp) break; // bound the response size (wallet-size-bound), matching the other list RPCs CZSLPToken token; store->GetToken(it->first, token); - UniValue o(UniValue::VOBJ); - o.push_back(Pair("tokenid", it->first.GetHex())); - o.push_back(Pair("ticker", token.ticker)); - o.push_back(Pair("name", token.name)); - o.push_back(Pair("decimals", (int)token.decimals)); + // A-3: embed the FULL token object (same shape as zslp_gettoken / + // zslp_listtokens) so the GUI can drop its per-token zslp_gettoken + // fan-out, then append the per-wallet balance + addresses[]. tokenid/ + // ticker/name/decimals remain present (TokenToJSON emits them). + UniValue o = TokenToJSON(token); o.push_back(Pair("balance", it->second)); o.push_back(Pair("addresses", tokenAddrs[it->first])); arr.push_back(o); @@ -281,48 +298,29 @@ static inline void TokenIdToBE(const uint256& tokenId, uint8_t out[32]) // Parse a uint64 quantity from a JSON value that is a STRING or a small integer. // Rejects negatives, non-digits, overflow, and the high bit (>= 2^63) which the // SLP parser/store treat as INVALID (R-INT-1). Throws on any violation. +// +// B-1: the parse/overflow logic now lives ONCE in ZSLPParseAmountField; this is +// a thin shim that pins THIS family's bound (<= 2^63-1) and its exact over-bound +// message ("exceeds the maximum (2^63-1)"). INT64_MAX inclusive == rejecting the +// old `q >> 63` high bit, so behavior is unchanged. static uint64_t ParseQuantity(const UniValue& v, const std::string& field) { - std::string s; - if (v.isStr()) - s = v.get_str(); - else if (v.isNum()) - s = v.getValStr(); // exact integer text, no double rounding - else - throw JSONRPCError(RPC_TYPE_ERROR, field + " must be a string or integer"); - if (s.empty()) - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is empty"); - for (size_t i = 0; i < s.size(); ++i) - if (s[i] < '0' || s[i] > '9') - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " must be a non-negative integer"); - errno = 0; - char* end = NULL; - unsigned long long q = strtoull(s.c_str(), &end, 10); - if (errno != 0 || end == NULL || *end != '\0') - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is not a valid integer"); - if (q >> 63) - throw JSONRPCError(RPC_INVALID_PARAMETER, field + " exceeds the maximum (2^63-1)"); - return (uint64_t)q; + return (uint64_t)ZSLPParseAmountField( + v, field, std::numeric_limits::max(), "the maximum (2^63-1)"); } // Decode a t-address to a P2PKH/P2SH script, throwing on an invalid address. -static CScript ScriptForTAddr(const std::string& addr) +// (B-2) shared with rpc/nftoffer.cpp via wallet/zslpwallet.h. +static inline CScript ScriptForTAddr(const std::string& addr) { - CTxDestination dest = DecodeDestination(addr); - if (!IsValidDestination(dest)) - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, - "Invalid transparent address: " + addr); - return GetScriptForDestination(dest); + return ZSLPScriptForTAddr(addr); } // Reserve a fresh wallet t-address script (for default recipient / token-change). -static CScript FreshWalletScript() +// (B-2) shared with rpc/nftoffer.cpp via wallet/zslpwallet.h. +static inline CScript FreshWalletScript() { - CPubKey vchPubKey; - if (!pwalletMain->GetKeyFromPool(vchPubKey)) - throw JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT, - "Keypool ran out, call keypoolrefill first"); - return GetScriptForDestination(vchPubKey.GetID()); + return ZSLPFreshWalletScript(); } UniValue zslp_genesis(const UniValue& params, bool fHelp) diff --git a/src/wallet/zslpwallet.cpp b/src/wallet/zslpwallet.cpp index c4af823bc62..79217730721 100644 --- a/src/wallet/zslpwallet.cpp +++ b/src/wallet/zslpwallet.cpp @@ -7,15 +7,20 @@ #include "script/standard.h" // CTxDestination — must precede coincontrol.h #include "coincontrol.h" #include "consensus/upgrades.h" +#include "init.h" // pwalletMain (ZSLPFreshWalletScript) #include "key_io.h" #include "main.h" +#include "rpc/protocol.h" // JSONRPCError + RPC_* codes (shared helpers) #include "script/sign.h" #include "wallet/wallet.h" #include "zslp/zslpindexer.h" #include "zslp/zslpmsg.h" #include "zslp/zslpstore.h" +#include +#include #include +#include // ── Anti-burn coin-lock RAII ──────────────────────────────────────── // @@ -478,3 +483,73 @@ bool BuildAndCommitZSLP(CWallet* w, const ZSLPBuildReq& req, } return true; } + +// ── Shared RPC helpers (B-1/B-2) ──────────────────────────────────── +// +// ONE canonical copy, formerly duplicated as GetZSLPStoreOrThrow / ScriptForTAddr +// / FreshWalletScript in rpc/zslp.cpp and Nft*-prefixed twins in rpc/nftoffer.cpp. +// Behavior is byte-for-byte the pre-refactor logic (same error codes, same +// messages) — they build the real token-carrier scriptPubKeys + the offer +// template, so any drift would be load-bearing. + +CZSLPStore* ZSLPStoreOrThrow() +{ + if (g_zslpIndexer == NULL || g_zslpIndexer->Store() == NULL) + throw JSONRPCError(RPC_MISC_ERROR, + "ZSLP index is not enabled. Start zclassicd with -zslpindex."); + return g_zslpIndexer->Store(); +} + +CScript ZSLPScriptForTAddr(const std::string& addr) +{ + CTxDestination dest = DecodeDestination(addr); + if (!IsValidDestination(dest)) + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, + "Invalid transparent address: " + addr); + return GetScriptForDestination(dest); +} + +CScript ZSLPFreshWalletScript() +{ + CPubKey vchPubKey; + if (!pwalletMain->GetKeyFromPool(vchPubKey)) + throw JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT, + "Keypool ran out, call keypoolrefill first"); + return GetScriptForDestination(vchPubKey.GetID()); +} + +std::string ZSLPAddrFromScript(const CScript& spk) +{ + CTxDestination dest; + if (ExtractDestination(spk, dest) && IsValidDestination(dest)) + return EncodeDestination(dest); + return std::string(); +} + +int64_t ZSLPParseAmountField(const UniValue& v, const std::string& field, + int64_t maxInclusive, const char* what, + const char* unitNote) +{ + std::string s; + if (v.isStr()) + s = v.get_str(); + else if (v.isNum()) + s = v.getValStr(); // exact integer text, no double rounding + else + throw JSONRPCError(RPC_TYPE_ERROR, field + " must be a string or integer"); + if (s.empty()) + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is empty"); + for (size_t i = 0; i < s.size(); ++i) + if (s[i] < '0' || s[i] > '9') + throw JSONRPCError(RPC_INVALID_PARAMETER, + field + " must be a non-negative integer" + std::string(unitNote)); + errno = 0; + char* end = NULL; + unsigned long long q = strtoull(s.c_str(), &end, 10); + if (errno != 0 || end == NULL || *end != '\0') + throw JSONRPCError(RPC_INVALID_PARAMETER, field + " is not a valid integer"); + if (maxInclusive < 0 || q > (unsigned long long)maxInclusive) + throw JSONRPCError(RPC_INVALID_PARAMETER, + field + " exceeds " + std::string(what)); + return (int64_t)q; +} diff --git a/src/wallet/zslpwallet.h b/src/wallet/zslpwallet.h index 72e97254a1e..84cfbcb0571 100644 --- a/src/wallet/zslpwallet.h +++ b/src/wallet/zslpwallet.h @@ -28,13 +28,16 @@ #include "amount.h" #include "primitives/transaction.h" // COutPoint +#include "script/script.h" // CScript (helper return type) #include "uint256.h" +#include #include #include class CWallet; class CZSLPStore; +class UniValue; // Standard SLP/BCH dust convention: 546 sat per token-bearing output. The // 54-sat relay dust floor (transaction.h:452-467 with the default @@ -103,4 +106,54 @@ bool ZSLPFindWalletTokenUtxos(CWallet* w, const uint256& tokenId, bool ZSLPIsProtectedTokenOutpoint(const CWallet* w, CZSLPStore* store, const COutPoint& op); +// ── Shared RPC helpers (B-1/B-2: ONE canonical copy for both rpc/zslp.cpp and +// rpc/nftoffer.cpp) ──────────────────────────────────────────────────── +// +// These were previously duplicated in each RPC TU (verbatim, only re-prefixed to +// dodge a symbol clash). They are load-bearing: they build the real scriptPubKeys +// for token carriers + the sell template and gate every command on the index. +// Defined once in zslpwallet.cpp so the bound/behavior cannot drift. + +/** + * The ZSLP token store, or a friendly JSONRPCError(RPC_MISC_ERROR) when the + * NON-consensus index is off. Fails CLOSED: every ZSLP/NFT command starts here. + */ +CZSLPStore* ZSLPStoreOrThrow(); + +/** + * Decode a transparent (t-) address to its P2PKH/P2SH scriptPubKey. Throws + * JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY) on an invalid address. + */ +CScript ZSLPScriptForTAddr(const std::string& addr); + +/** + * Reserve a fresh wallet t-address scriptPubKey (default recipient / token- + * change / payout). Throws JSONRPCError(RPC_WALLET_KEYPOOL_RAN_OUT) if the + * keypool is empty. ENABLE_WALLET only. + */ +CScript ZSLPFreshWalletScript(); + +/** + * Decode a scriptPubKey back to a t-address string ("" if not a standard, + * extractable destination). + */ +std::string ZSLPAddrFromScript(const CScript& spk); + +/** + * Parse a non-negative integer money/quantity field from a JSON value that is a + * STRING or a small integer (digits-only, overflow-guarded). The ONE shared + * amount parser (B-1): callers pass their own inclusive upper bound and the + * "what" phrase used in the over-bound message, so each call site PRESERVES its + * historical bound + wording exactly. Throws JSONRPCError on any violation. + * + * maxInclusive the largest accepted value (inclusive) + * what the upper-bound phrase, e.g. "the maximum (2^63-1)" or + * "MAX_MONEY" — rendered as: exceeds + * unitNote optional suffix on the digits-only rejection, e.g. " (zatoshi)" + * so NftParseZat keeps its exact historical wording ("" by default) + */ +int64_t ZSLPParseAmountField(const UniValue& v, const std::string& field, + int64_t maxInclusive, const char* what, + const char* unitNote = ""); + #endif // BITCOIN_WALLET_ZSLPWALLET_H