diff --git a/.gitmodules b/.gitmodules index be27b2965..80d2c0696 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,3 +14,6 @@ [submodule "external/mx25519"] path = external/mx25519 url = https://github.com/tevador/mx25519 +[submodule "external/randomwow"] + path = external/randomwow + url = https://github.com/wownero/RandomWOW.git diff --git a/CMakeLists.txt b/CMakeLists.txt index fe5702b34..3e696f92c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -381,6 +381,15 @@ else() message(STATUS "Building without build tag") endif() +option(WOWNERO_FCMP_TESTNET "Build the isolated Wownero FCMP++ research network." OFF) +if(WOWNERO_FCMP_TESTNET) + if(NOT UNIX) + message(FATAL_ERROR "The Wownero FCMP testnet profile currently supports POSIX platforms only") + endif() + add_compile_definitions(WOWNERO_FCMP_TESTNET) + set(PER_BLOCK_CHECKPOINT OFF CACHE BOOL "Upstream checkpoints do not apply to this network" FORCE) +endif() + option(MANUAL_SUBMODULES "Skip submodule up-to-date checks" OFF) if(NOT MANUAL_SUBMODULES) find_package(Git) @@ -398,7 +407,11 @@ if(NOT MANUAL_SUBMODULES) message(STATUS "Checking submodules") check_submodule(external/rapidjson) - check_submodule(external/randomx) + if(WOWNERO_FCMP_TESTNET) + check_submodule(external/randomwow) + else() + check_submodule(external/randomx) + endif() check_submodule(external/supercop) check_submodule(external/mx25519) endif() diff --git a/external/CMakeLists.txt b/external/CMakeLists.txt index 08e910a0a..b1546d9f2 100644 --- a/external/CMakeLists.txt +++ b/external/CMakeLists.txt @@ -31,5 +31,11 @@ add_subdirectory(db_drivers) add_subdirectory(easylogging++) add_subdirectory(qrcodegen) -add_subdirectory(randomx EXCLUDE_FROM_ALL) +if(WOWNERO_FCMP_TESTNET) + set(RANDOMX_INCLUDE "${CMAKE_CURRENT_SOURCE_DIR}/randomwow/src" CACHE STRING "RandomWOW include path" FORCE) + add_subdirectory(randomwow EXCLUDE_FROM_ALL) +else() + set(RANDOMX_INCLUDE "${CMAKE_CURRENT_SOURCE_DIR}/randomx/src" CACHE STRING "RandomX include path" FORCE) + add_subdirectory(randomx EXCLUDE_FROM_ALL) +endif() add_subdirectory(mx25519) diff --git a/external/easylogging++/easylogging++.cc b/external/easylogging++/easylogging++.cc index d60b8e268..1a3bc7a34 100644 --- a/external/easylogging++/easylogging++.cc +++ b/external/easylogging++/easylogging++.cc @@ -16,6 +16,7 @@ #define EASYLOGGING_CC #include "easylogging++.h" +#include #include #include @@ -2502,11 +2503,11 @@ void DefaultLogDispatchCallback::handle(const LogDispatchData* data) { template -static inline void utf8canonical(std::string &s, Transform t = [](wint_t c)->wint_t { return c; }) +static inline void utf8canonical(std::string &s, Transform t = [](std::uint32_t c)->std::uint32_t { return c; }) { size_t avail = s.size(); const char *ptr = s.data(); - wint_t cp = 0; + std::uint32_t cp = 0; int rbytes = 1, bytes = -1; char wbuf[8], *wptr; size_t w_offset = 0; @@ -2585,7 +2586,7 @@ static inline void utf8canonical(std::string &s, Transform t = [](wint_t c)->win void sanitize(std::string &s) { - utf8canonical(s, [](wint_t c)->wint_t { + utf8canonical(s, [](std::uint32_t c)->std::uint32_t { if (c == 9 || c == 10 || c == 13) return c; if (c < 0x20) diff --git a/external/easylogging++/easylogging++.h b/external/easylogging++/easylogging++.h index e3814438c..c795e0127 100644 --- a/external/easylogging++/easylogging++.h +++ b/external/easylogging++/easylogging++.h @@ -2792,6 +2792,7 @@ class Storage : base::NoCopy, public base::threading::ThreadSafe { } }; extern ELPP_EXPORT base::type::StoragePointer elStorage; +ELPP_EXPORT void sanitize(std::string& s); #define ELPP el::base::Storage::getELPP() class DefaultLogDispatchCallback : public LogDispatchCallback { protected: diff --git a/external/randomwow b/external/randomwow new file mode 160000 index 000000000..ff4747966 --- /dev/null +++ b/external/randomwow @@ -0,0 +1 @@ +Subproject commit ff47479662593fa18295642752b4df8d04c21b66 diff --git a/src/blockchain_db/lmdb/db_lmdb.cpp b/src/blockchain_db/lmdb/db_lmdb.cpp index 54e0c4433..0b1fdf3c9 100644 --- a/src/blockchain_db/lmdb/db_lmdb.cpp +++ b/src/blockchain_db/lmdb/db_lmdb.cpp @@ -350,6 +350,7 @@ typedef struct mdb_tree_meta { } mdb_tree_meta; std::atomic mdb_txn_safe::num_active_txns{0}; +thread_local uint64_t mdb_txn_safe::num_active_txns_per_thread = 0; std::atomic_flag mdb_txn_safe::creation_gate = ATOMIC_FLAG_INIT; mdb_threadinfo::~mdb_threadinfo() @@ -367,9 +368,12 @@ mdb_txn_safe::mdb_txn_safe(const bool check) : m_txn(NULL), m_tinfo(NULL), m_che { if (check) { - while (creation_gate.test_and_set()); - num_active_txns++; - creation_gate.clear(); + const bool nested = num_active_txns_per_thread != 0; + if (!nested) + while (creation_gate.test_and_set()); + increment_txns(1); + if (!nested) + creation_gate.clear(); } } @@ -400,12 +404,12 @@ mdb_txn_safe::~mdb_txn_safe() } mdb_txn_abort(m_txn); } - num_active_txns--; + increment_txns(-1); } void mdb_txn_safe::uncheck() { - num_active_txns--; + increment_txns(-1); m_check = false; } @@ -460,7 +464,8 @@ void mdb_txn_safe::allow_new_txns() void mdb_txn_safe::increment_txns(int i) { - num_active_txns += i; + num_active_txns_per_thread += i; + num_active_txns += i; } #define TXN_PREFIX(flags); \ diff --git a/src/blockchain_db/lmdb/db_lmdb.h b/src/blockchain_db/lmdb/db_lmdb.h index 3d9f41520..99ffe0132 100644 --- a/src/blockchain_db/lmdb/db_lmdb.h +++ b/src/blockchain_db/lmdb/db_lmdb.h @@ -182,6 +182,7 @@ struct mdb_txn_safe bool m_batch_txn = false; bool m_check; static std::atomic num_active_txns; + static thread_local uint64_t num_active_txns_per_thread; // could use a mutex here, but this should be sufficient. static std::atomic_flag creation_gate; diff --git a/src/checkpoints/checkpoints.cpp b/src/checkpoints/checkpoints.cpp index 74531848e..978abd069 100644 --- a/src/checkpoints/checkpoints.cpp +++ b/src/checkpoints/checkpoints.cpp @@ -195,6 +195,9 @@ namespace cryptonote bool checkpoints::init_default_checkpoints(network_type nettype) { +#ifdef WOWNERO_FCMP_TESTNET + return nettype == TESTNET; +#endif if (nettype == TESTNET) { ADD_CHECKPOINT2(0, "48ca7cd3c8de5b6a4d53d2861fbdaedca141553559f9be9520068053cda8430b", "0x1"); @@ -313,6 +316,9 @@ namespace cryptonote bool checkpoints::load_checkpoints_from_dns(network_type nettype) { +#ifdef WOWNERO_FCMP_TESTNET + return nettype == TESTNET; +#endif std::vector records; // All four MoneroPulse domains have DNSSEC on and valid diff --git a/src/common/scoped_message_writer.h b/src/common/scoped_message_writer.h index 6b95ffe37..7bf8bc769 100644 --- a/src/common/scoped_message_writer.h +++ b/src/common/scoped_message_writer.h @@ -95,17 +95,20 @@ public: { m_flush = false; - MCLOG_FILE(m_log_level, "msgwriter", m_oss.str()); + std::string message = m_oss.str(); + MCLOG_FILE(m_log_level, "msgwriter", message); + try { el::base::sanitize(message); } + catch (const std::exception&) { message = ""; } PAUSE_READLINE(); if (epee::console_color_default == m_color) { - std::cout << m_oss.str(); + std::cout << message; } else { set_console_color(m_color, m_bright); - std::cout << m_oss.str(); + std::cout << message; epee::reset_console_color(); } std::cout << std::endl; diff --git a/src/crypto/CMakeLists.txt b/src/crypto/CMakeLists.txt index ddd962287..8ac2569fc 100644 --- a/src/crypto/CMakeLists.txt +++ b/src/crypto/CMakeLists.txt @@ -59,6 +59,10 @@ endif() include_directories(${RANDOMX_INCLUDE}) +if(WOWNERO_FCMP_TESTNET) + list(APPEND crypto_sources wow_miner_proof.cpp) +endif() + set(crypto_headers) monero_find_all_headers(crypto_private_headers "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/src/crypto/rx-slow-hash.c b/src/crypto/rx-slow-hash.c index 44565c7ef..768e55697 100644 --- a/src/crypto/rx-slow-hash.c +++ b/src/crypto/rx-slow-hash.c @@ -120,12 +120,14 @@ static inline int disabled_flags(void) { flags = 0; } +#ifndef WOWNERO_FCMP_TESTNET if (flags & RANDOMX_FLAG_V2) { mwarning(RX_LOGCAT, "MONERO_RANDOMX_UMASK would mask RandomX V2, ignoring that bit..."); flags &= ~RANDOMX_FLAG_V2; } +#endif } return flags; @@ -148,9 +150,11 @@ static randomx_flags get_variant_flags(const int variant) { { case RX_VARIANT_1: break; +#ifndef WOWNERO_FCMP_TESTNET case RX_VARIANT_LATEST: case RX_VARIANT_2: return RANDOMX_FLAG_V2; +#endif default: local_abort("Unrecognized RandomX variant"); } @@ -543,7 +547,11 @@ void rx_slow_hash(const char *seedhash, const int variant, const void *data, siz } void rx_commitment(const void *data, size_t length, const void *rx_hash, char *result_commitment) { +#ifdef WOWNERO_FCMP_TESTNET + local_abort("RandomX v2 commitments are unavailable in the RandomWOW testnet profile"); +#else randomx_calculate_commitment(data, length, rx_hash, result_commitment); +#endif } void rx_set_miner_thread(uint32_t value, size_t max_dataset_init_threads) { diff --git a/src/crypto/wow_miner_proof.cpp b/src/crypto/wow_miner_proof.cpp new file mode 100644 index 000000000..d83401725 --- /dev/null +++ b/src/crypto/wow_miner_proof.cpp @@ -0,0 +1,151 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include "wow_miner_proof.h" + +#include + +#include "generators.h" + +namespace wownero::research +{ +namespace +{ +constexpr char domain[] = "WOW-CARROT-MINER-PROOF-v1"; +constexpr unsigned char subgroup_order[32] = { + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, + 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10}; + +template const unsigned char *bytes(const T &value) +{ + return reinterpret_cast(value.data); +} + +template unsigned char *bytes(T &value) +{ + return reinterpret_cast(value.data); +} + +bool decode_public_key(const crypto::public_key &key, ge_p3 &point) +{ + if (ge_frombytes_vartime(&point, bytes(key)) != 0 || + ge_p3_is_point_at_infinity_vartime(&point)) + return false; + + crypto::public_key canonical; + ge_p3_tobytes(bytes(canonical), &point); + if (std::memcmp(key.data, canonical.data, sizeof(key.data)) != 0) + return false; + + // Multiplication uses the unreduced subgroup order to reject mixed-order points. + ge_p3 multiplied; + ge_scalarmult_p3(&multiplied, subgroup_order, &point); + return ge_p3_is_point_at_infinity_vartime(&multiplied); +} + +void representation(const unsigned char *x, const unsigned char *y, ge_p3 &out) +{ + ge_p3 xg, yt; + ge_scalarmult_base(&xg, x); + ge_scalarmult_p3(&yt, y, &crypto::get_T_p3()); + ge_cached cached; + ge_p3_to_cached(&cached, &yt); + ge_p1p1 sum; + ge_add(&sum, &xg, &cached); + ge_p1p1_to_p3(&out, &sum); +} + +crypto::ec_scalar challenge(const crypto::hash &network_genesis, + const crypto::hash &block_digest, const crypto::public_key &key, const ge_p3 &nonce) +{ + std::array transcript{}; + auto *cursor = transcript.data(); + std::memcpy(cursor, domain, sizeof(domain) - 1); + cursor += sizeof(domain) - 1; + for (const auto *field : {bytes(network_genesis), bytes(block_digest), bytes(key)}) + { + std::memcpy(cursor, field, 32); + cursor += 32; + } + ge_p3_tobytes(cursor, &nonce); + crypto::ec_scalar result; + crypto::hash_to_scalar(transcript.data(), transcript.size(), result); + return result; +} +} + +bool check_miner_public_key(const crypto::public_key &key) +{ + ge_p3 point; + return decode_public_key(key, point); +} + +bool generate_miner_proof(const crypto::hash &network_genesis, + const crypto::hash &block_digest, const crypto::public_key &key, + const crypto::secret_key &x, const crypto::secret_key &y, miner_proof &proof) +{ + proof.fill(0); + if (sc_check(bytes(x)) != 0 || sc_check(bytes(y)) != 0 || !check_miner_public_key(key)) + return false; + + ge_p3 expected; + representation(bytes(x), bytes(y), expected); + crypto::public_key encoded; + ge_p3_tobytes(bytes(encoded), &expected); + if (std::memcmp(key.data, encoded.data, sizeof(key.data)) != 0) + return false; + + // Secret scalars use the upstream locked and scrubbed key container. + crypto::secret_key a, b; + for (;;) + { + crypto::random32_unbiased(bytes(a)); + crypto::random32_unbiased(bytes(b)); + ge_p3 nonce; + representation(bytes(a), bytes(b), nonce); + if (ge_p3_is_point_at_infinity_vartime(&nonce)) + continue; + + const crypto::ec_scalar c = challenge(network_genesis, block_digest, key, nonce); + if (!sc_isnonzero(bytes(c))) + continue; + std::memcpy(proof.data(), c.data, 32); + sc_muladd(proof.data() + 32, bytes(c), bytes(x), bytes(a)); + sc_muladd(proof.data() + 64, bytes(c), bytes(y), bytes(b)); + return true; + } +} + +bool verify_miner_proof(const crypto::hash &network_genesis, + const crypto::hash &block_digest, const crypto::public_key &key, + epee::span proof) +{ + if (proof.size() != miner_proof{}.size()) + return false; + const auto *c = proof.data(); + const auto *sg = c + 32; + const auto *st = sg + 32; + if (sc_check(c) != 0 || sc_check(sg) != 0 || sc_check(st) != 0 || !sc_isnonzero(c)) + return false; + + ge_p3 public_point; + if (!decode_public_key(key, public_point)) + return false; + + ge_p3 response, ck; + representation(sg, st, response); + ge_scalarmult_p3(&ck, c, &public_point); + ge_cached cached; + ge_p3_to_cached(&cached, &ck); + ge_p1p1 difference; + ge_sub(&difference, &response, &cached); + ge_p3 nonce; + ge_p1p1_to_p3(&nonce, &difference); + if (ge_p3_is_point_at_infinity_vartime(&nonce)) + return false; + + const auto expected = challenge(network_genesis, block_digest, key, nonce); + return std::memcmp(expected.data, c, 32) == 0; +} +} diff --git a/src/crypto/wow_miner_proof.h b/src/crypto/wow_miner_proof.h new file mode 100644 index 000000000..edb05d663 --- /dev/null +++ b/src/crypto/wow_miner_proof.h @@ -0,0 +1,30 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#pragma once + +#include +#include +#include + +#include "crypto.h" +#include "span.h" + +namespace wownero::research +{ +// Experimental Schnorr representation proof for K = xG + yT. +using miner_proof = std::array; + +bool check_miner_public_key(const crypto::public_key &key); + +// block_digest must cover the complete canonical block statement, excluding the proof itself. +// network_genesis is the network's genesis hash. Both parameters are transcript commitments. +// Returns false and clears proof on invalid scalars, public key, or witness mismatch. +bool generate_miner_proof(const crypto::hash &network_genesis, + const crypto::hash &block_digest, const crypto::public_key &key, + const crypto::secret_key &x, const crypto::secret_key &y, miner_proof &proof); + +bool verify_miner_proof(const crypto::hash &network_genesis, + const crypto::hash &block_digest, const crypto::public_key &key, + epee::span proof); +} diff --git a/src/cryptonote_basic/CMakeLists.txt b/src/cryptonote_basic/CMakeLists.txt index 0b6bf9790..d067b6ea8 100644 --- a/src/cryptonote_basic/CMakeLists.txt +++ b/src/cryptonote_basic/CMakeLists.txt @@ -54,6 +54,10 @@ set(cryptonote_basic_sources merge_mining.cpp miner.cpp) +if(WOWNERO_FCMP_TESTNET) + list(APPEND cryptonote_basic_sources wow_miner.cpp) +endif() + set(cryptonote_basic_headers) monero_find_all_headers(cryptonote_basic_private_headers "${CMAKE_CURRENT_SOURCE_DIR}") @@ -81,3 +85,7 @@ target_link_libraries(cryptonote_basic ${Boost_THREAD_LIBRARY} PRIVATE ${EXTRA_LIBRARIES}) + +if(WOWNERO_FCMP_TESTNET) + target_link_libraries(cryptonote_basic PUBLIC carrot_core) +endif() diff --git a/src/cryptonote_basic/cryptonote_basic.h b/src/cryptonote_basic/cryptonote_basic.h index ee9cc1e78..d69ca3988 100644 --- a/src/cryptonote_basic/cryptonote_basic.h +++ b/src/cryptonote_basic/cryptonote_basic.h @@ -48,6 +48,9 @@ #include "cryptonote_config.h" #include "crypto/crypto.h" #include "crypto/hash.h" +#ifdef WOWNERO_FCMP_TESTNET +#include "crypto/wow_miner_proof.h" +#endif #include "ringct/rctTypes.h" #include "device/device.hpp" #include "cryptonote_basic/fwd.h" @@ -611,6 +614,10 @@ namespace cryptonote uint64_t timestamp; crypto::hash prev_id; uint32_t nonce; +#ifdef WOWNERO_FCMP_TESTNET + wownero::research::miner_proof wow_miner_signature{}; + uint16_t wow_vote = 0; +#endif BEGIN_SERIALIZE() VARINT_FIELD(major_version) @@ -618,6 +625,14 @@ namespace cryptonote VARINT_FIELD(timestamp) FIELD(prev_id) FIELD(nonce) +#ifdef WOWNERO_FCMP_TESTNET + if (major_version >= HF_VERSION_CARROT) + { + ar.tag("wow_miner_signature"); + ar.serialize_blob(wow_miner_signature.data(), wow_miner_signature.size()); + FIELD(wow_vote) + } +#endif END_SERIALIZE() }; diff --git a/src/cryptonote_basic/cryptonote_format_utils.cpp b/src/cryptonote_basic/cryptonote_format_utils.cpp index 84634a9e0..abf4768e1 100644 --- a/src/cryptonote_basic/cryptonote_format_utils.cpp +++ b/src/cryptonote_basic/cryptonote_format_utils.cpp @@ -270,12 +270,13 @@ namespace cryptonote return true; } //--------------------------------------------------------------- - bool parse_and_validate_tx_base_from_blob(const blobdata_ref& tx_blob, transaction& tx, const bool max_size_check) + bool parse_and_validate_tx_base_from_blob(const blobdata_ref& tx_blob, transaction& tx, const bool max_size_check, const bool require_eof) { CHECK_AND_ASSERT_MES(passes_max_size_check(max_size_check, tx_blob), false, "Tx blob too big"); binary_archive ba{epee::strspan(tx_blob)}; bool r = tx.serialize_base(ba); CHECK_AND_ASSERT_MES(r, false, "Failed to parse transaction from blob"); + CHECK_AND_ASSERT_MES(::serialization::check_stream_state(ba, !require_eof), false, "Unexpected trailing data after transaction base"); CHECK_AND_ASSERT_MES(expand_transaction_1(tx, true), false, "Failed to expand transaction data"); tx.invalidate_hashes(); return true; diff --git a/src/cryptonote_basic/cryptonote_format_utils.h b/src/cryptonote_basic/cryptonote_format_utils.h index a6d669dfa..7a3f10c85 100644 --- a/src/cryptonote_basic/cryptonote_format_utils.h +++ b/src/cryptonote_basic/cryptonote_format_utils.h @@ -61,7 +61,7 @@ namespace cryptonote bool parse_and_validate_tx_from_blob(const blobdata_ref& tx_blob, transaction& tx, crypto::hash& tx_hash, crypto::hash& tx_prefix_hash, const bool max_size_check = false); bool parse_and_validate_tx_from_blob(const blobdata_ref& tx_blob, transaction& tx, crypto::hash& tx_hash, const bool max_size_check = false); bool parse_and_validate_tx_from_blob(const blobdata_ref& tx_blob, transaction& tx, const bool max_size_check = false); - bool parse_and_validate_tx_base_from_blob(const blobdata_ref& tx_blob, transaction& tx, const bool max_size_check = false); + bool parse_and_validate_tx_base_from_blob(const blobdata_ref& tx_blob, transaction& tx, const bool max_size_check = false, const bool require_eof = false); /** * @brief extract transaction version from transaction blob * @param tx_blob transction blob diff --git a/src/cryptonote_basic/miner.cpp b/src/cryptonote_basic/miner.cpp index 28801aadb..cd42ec195 100644 --- a/src/cryptonote_basic/miner.cpp +++ b/src/cryptonote_basic/miner.cpp @@ -92,6 +92,10 @@ namespace cryptonote namespace { +#ifdef WOWNERO_FCMP_TESTNET + const command_line::arg_descriptor arg_wow_miner_key_fd = { + "wow-miner-key-fd", "Inherited pipe containing the Carrot mining account frame", -1}; +#endif const command_line::arg_descriptor arg_extra_messages = {"extra-messages-file", "Specify file for extra messages to include into coinbase transactions", "", true}; const command_line::arg_descriptor arg_start_mining = {"start-mining", "Specify wallet address to mining for", "", true}; const command_line::arg_descriptor arg_mining_threads = {"mining-threads", "Specify mining threads count", 0, true}; @@ -285,6 +289,9 @@ namespace cryptonote //----------------------------------------------------------------------------------------------------- void miner::init_options(boost::program_options::options_description& desc) { +#ifdef WOWNERO_FCMP_TESTNET + command_line::add_arg(desc, arg_wow_miner_key_fd); +#endif command_line::add_arg(desc, arg_extra_messages); command_line::add_arg(desc, arg_start_mining); command_line::add_arg(desc, arg_mining_threads); @@ -297,6 +304,16 @@ namespace cryptonote //----------------------------------------------------------------------------------------------------- bool miner::init(const boost::program_options::variables_map& vm, network_type nettype) { +#ifdef WOWNERO_FCMP_TESTNET + const int key_fd = command_line::get_arg(vm, arg_wow_miner_key_fd); + if (key_fd != -1) + { + wow_miner_account account; + CHECK_AND_ASSERT_MES(read_wow_miner_account(key_fd, account), false, + "Invalid mining account pipe: expected fd >= 3 and a complete canonical WOWMIN01 frame within five seconds"); + m_wow_miner_account = account; + } +#endif if(command_line::has_arg(vm, arg_extra_messages)) { std::string buff; @@ -370,6 +387,12 @@ namespace cryptonote //----------------------------------------------------------------------------------------------------- bool miner::start(const account_public_address& adr, size_t threads_count, bool do_background, bool ignore_battery) { +#ifdef WOWNERO_FCMP_TESTNET + account_public_address signer_address; + CHECK_AND_ASSERT_MES(m_wow_miner_account && m_wow_miner_account->address(signer_address), false, + "Mining requires an account supplied through --wow-miner-key-fd at startup"); + CHECK_AND_ASSERT_MES(adr == signer_address, false, "Mining address does not match the supplied account"); +#endif m_block_reward = 0; m_mine_address = adr; m_threads_total = static_cast(threads_count); @@ -393,7 +416,11 @@ namespace cryptonote return false; } +#ifdef WOWNERO_FCMP_TESTNET + CHECK_AND_ASSERT_MES(request_block_template(), false, "Unable to prepare Wownero mining template"); +#else request_block_template();//lets update block template +#endif m_stop = false; m_thread_index = 0; @@ -475,6 +502,10 @@ namespace cryptonote //----------------------------------------------------------------------------------------------------- bool miner::find_nonce_for_given_block(const get_block_hash_t &gbh, block& bl, const difficulty_type& diffic, uint64_t height, const crypto::hash *seed_hash) { +#ifdef WOWNERO_FCMP_TESTNET + CHECK_AND_ASSERT_MES(bl.major_version < HF_VERSION_CARROT, false, + "Synchronous mining cannot sign Carrot blocks; use the account-backed miner"); +#endif for(; bl.nonce != std::numeric_limits::max(); bl.nonce++) { crypto::hash h; @@ -533,6 +564,9 @@ namespace cryptonote difficulty_type local_diff = 0; uint32_t local_template_ver = 0; block b; +#ifdef WOWNERO_FCMP_TESTNET + crypto::secret_key miner_x, miner_y; +#endif slow_hash_allocate_state(); ++m_threads_active; while(!m_stop) @@ -565,6 +599,14 @@ namespace cryptonote CRITICAL_REGION_END(); local_template_ver = m_template_no; nonce = m_starter_nonce + th_local_index; +#ifdef WOWNERO_FCMP_TESTNET + if (!prepare_wow_miner_output_keys(b, *m_wow_miner_account, miner_x, miner_y)) + { + MERROR("Mining template does not pay the supplied Carrot account"); + m_stop = true; + break; + } +#endif } if(!local_template_ver)//no any set_block_template call @@ -575,6 +617,14 @@ namespace cryptonote } b.nonce = nonce; +#ifdef WOWNERO_FCMP_TESTNET + if (!sign_wow_miner_block(b, miner_x, miner_y)) + { + MERROR("Failed to sign the Wownero mining candidate"); + m_stop = true; + break; + } +#endif crypto::hash h; if ((b.major_version >= RX_BLOCK_VERSION) && !rx_set) diff --git a/src/cryptonote_basic/miner.h b/src/cryptonote_basic/miner.h index 47d1ce507..a5f4da7a9 100644 --- a/src/cryptonote_basic/miner.h +++ b/src/cryptonote_basic/miner.h @@ -36,6 +36,10 @@ #include "cryptonote_basic.h" #include "verification_context.h" #include "difficulty.h" +#ifdef WOWNERO_FCMP_TESTNET +#include +#include "wow_miner.h" +#endif #include "math_helper.h" #include "syncobj.h" #include "cryptonote_basic/blobdatatype.h" @@ -138,6 +142,9 @@ namespace cryptonote i_miner_handler* m_phandler; get_block_hash_t m_gbh; account_public_address m_mine_address; +#ifdef WOWNERO_FCMP_TESTNET + std::optional m_wow_miner_account; +#endif epee::math_helper::once_a_time_seconds<5> m_update_block_template_interval; epee::math_helper::once_a_time_seconds<2> m_update_merge_hr_interval; epee::math_helper::once_a_time_seconds<1> m_autodetect_interval; diff --git a/src/cryptonote_basic/wow_miner.cpp b/src/cryptonote_basic/wow_miner.cpp new file mode 100644 index 000000000..d66857b32 --- /dev/null +++ b/src/cryptonote_basic/wow_miner.cpp @@ -0,0 +1,184 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include "wow_miner.h" + +#include +#include +#include +#include +#include +#include + +#include "carrot_core/account_secrets.h" +#include "carrot_core/device_ram_borrowed.h" +#include "carrot_core/scan.h" +#include "cryptonote_format_utils.h" +#include "string_tools.h" +#include "wownero/testnet_genesis.h" + +namespace cryptonote +{ +namespace +{ +template unsigned char *bytes(T &value) +{ + return reinterpret_cast(value.data); +} +template const unsigned char *bytes(const T &value) +{ + return reinterpret_cast(value.data); +} + +const crypto::hash &network_genesis() +{ + static const crypto::hash value = [] { + crypto::hash result; + if (!epee::string_tools::hex_to_pod(wownero::testnet::genesis_hash, result)) + throw std::logic_error("Invalid compiled Wownero testnet genesis hash"); + return result; + }(); + return value; +} + +const txout_to_carrot_v1 *miner_output(const block &b) +{ + if (b.major_version < HF_VERSION_CARROT || b.wow_vote > 2 || + b.miner_tx.vin.size() != 1 || b.miner_tx.vout.size() != 1 || + !boost::strict_get(&b.miner_tx.vin.front())) + return nullptr; + return boost::strict_get(&b.miner_tx.vout.front().target); +} +} + +bool wow_miner_account::address(account_public_address &out) const +{ + if (sc_check(bytes(generate_image)) || sc_check(bytes(prove_spend)) || + sc_check(bytes(view_incoming)) || !sc_isnonzero(bytes(view_incoming))) + return false; + carrot::make_carrot_spend_pubkey(generate_image, prove_spend, out.m_spend_public_key); + return wownero::research::check_miner_public_key(out.m_spend_public_key) && + crypto::secret_key_to_public_key(view_incoming, out.m_view_public_key); +} + +bool read_wow_miner_account(int fd, wow_miner_account &out) +{ + out = {}; + if (fd < 3) + return false; + struct descriptor_guard { int fd; ~descriptor_guard() { close(fd); } } guard{fd}; + struct stat info{}; + if (fstat(fd, &info) != 0 || !S_ISFIFO(info.st_mode)) + return false; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + auto read_chunk = [&](void *destination, std::size_t size) { + auto *cursor = static_cast(destination); + while (size) + { + const auto remaining = std::chrono::duration_cast( + deadline - std::chrono::steady_clock::now()).count(); + if (remaining <= 0) + return false; + pollfd ready{fd, POLLIN, 0}; + const int status = poll(&ready, 1, static_cast(remaining)); + if (status < 0 && errno == EINTR) + continue; + if (status <= 0 || (ready.revents & (POLLERR | POLLNVAL))) + return false; + const ssize_t count = read(fd, cursor, size); + if (count < 0 && errno == EINTR) + continue; + if (count <= 0) + return false; + cursor += count; + size -= count; + } + return true; + }; + char magic[8]; + wow_miner_account candidate; + if (!read_chunk(magic, sizeof(magic)) || std::memcmp(magic, "WOWMIN01", 8) || + !read_chunk(candidate.generate_image.data, 32) || + !read_chunk(candidate.prove_spend.data, 32) || + !read_chunk(candidate.view_incoming.data, 32)) + return false; + // The fixed frame is sufficient. The input descriptor is closed immediately. + account_public_address address; + if (!candidate.address(address)) + return false; + out = candidate; + return true; +} + +crypto::hash get_wow_miner_statement_hash(const block &b) +{ + block statement = b; + statement.wow_miner_signature.fill(0); + statement.invalidate_hashes(); + statement.miner_tx.invalidate_hashes(); + const auto blob = get_block_hashing_blob(statement); + return crypto::cn_fast_hash(blob.data(), blob.size()); +} + +bool prepare_wow_miner_output_keys(const block &b, const wow_miner_account &account, + crypto::secret_key &x, crypto::secret_key &y) +{ + x = {}; y = {}; + const auto *output = miner_output(b); + account_public_address address; + if (!output || !account.address(address)) + return false; + std::vector fields; + if (!parse_tx_extra(b.miner_tx.extra, fields)) + return false; + const tx_extra_pub_key *ephemeral = nullptr; + for (const auto &field : fields) + { + if (const auto *key = boost::strict_get(&field)) + { + if (ephemeral) + return false; + ephemeral = key; + } + if (boost::strict_get(&field)) + return false; + } + if (!ephemeral) + return false; + carrot::CarrotCoinbaseEnoteV1 enote; + enote.block_index = boost::get(b.miner_tx.vin.front()).height; + enote.amount = b.miner_tx.vout.front().amount; + enote.onetime_address = output->key; + enote.view_tag = output->view_tag; + enote.anchor_enc = output->encrypted_janus_anchor; + static_assert(sizeof(enote.enote_ephemeral_pubkey) == sizeof(ephemeral->pub_key)); + std::memcpy(&enote.enote_ephemeral_pubkey, &ephemeral->pub_key, sizeof(ephemeral->pub_key)); + carrot::view_incoming_key_ram_borrowed_device device(account.view_incoming); + mx25519_pubkey shared; + crypto::secret_key extension_g, extension_t; + if (!carrot::try_make_carrot_shared_key_receiver(device, enote.enote_ephemeral_pubkey, shared) || + !carrot::try_scan_carrot_coinbase_enote_receiver(enote, shared, address.m_spend_public_key, + address.m_view_public_key, extension_g, extension_t)) + return false; + sc_add(bytes(x), bytes(account.generate_image), bytes(extension_g)); + sc_add(bytes(y), bytes(account.prove_spend), bytes(extension_t)); + return true; +} + +bool sign_wow_miner_block(block &b, const crypto::secret_key &x, const crypto::secret_key &y) +{ + b.wow_miner_signature.fill(0); + b.invalidate_hashes(); + const auto *output = miner_output(b); + return output && wownero::research::generate_miner_proof(network_genesis(), + get_wow_miner_statement_hash(b), output->key, x, y, b.wow_miner_signature); +} + +bool verify_wow_miner_block(const block &b) +{ + const auto *output = miner_output(b); + return output && wownero::research::verify_miner_proof(network_genesis(), + get_wow_miner_statement_hash(b), output->key, + {b.wow_miner_signature.data(), b.wow_miner_signature.size()}); +} +} diff --git a/src/cryptonote_basic/wow_miner.h b/src/cryptonote_basic/wow_miner.h new file mode 100644 index 000000000..d0bd69e7f --- /dev/null +++ b/src/cryptonote_basic/wow_miner.h @@ -0,0 +1,25 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#pragma once + +#include "cryptonote_basic.h" + +namespace cryptonote +{ +struct wow_miner_account +{ + crypto::secret_key generate_image{}, prove_spend{}, view_incoming{}; + bool address(account_public_address &out) const; +}; + +// Consumes a pipe descriptor and closes it on every outcome. The frame is +// "WOWMIN01" followed by three canonical scalars; no credential enters argv. +bool read_wow_miner_account(int fd, wow_miner_account &out); + +crypto::hash get_wow_miner_statement_hash(const block &b); +bool prepare_wow_miner_output_keys(const block &b, const wow_miner_account &account, + crypto::secret_key &x, crypto::secret_key &y); +bool sign_wow_miner_block(block &b, const crypto::secret_key &x, const crypto::secret_key &y); +bool verify_wow_miner_block(const block &b); +} diff --git a/src/cryptonote_config.h b/src/cryptonote_config.h index 03a79cb80..bc88b7f00 100644 --- a/src/cryptonote_config.h +++ b/src/cryptonote_config.h @@ -34,39 +34,70 @@ #include #include #include +#ifdef WOWNERO_FCMP_TESTNET +#include "wownero/testnet_genesis.h" +#endif #define CRYPTONOTE_DNS_TIMEOUT_MS 20000 #define CRYPTONOTE_MAX_BLOCK_NUMBER 500000000 #define CRYPTONOTE_MAX_TX_SIZE 1000000 #define CRYPTONOTE_MAX_TX_PER_BLOCK 0x10000000 +#ifdef WOWNERO_FCMP_TESTNET +#define CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW 288 +#else #define CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW 60 +#endif #define CURRENT_TRANSACTION_VERSION 2 #define CURRENT_BLOCK_MAJOR_VERSION 1 #define CURRENT_BLOCK_MINOR_VERSION 0 #define CRYPTONOTE_BLOCK_FUTURE_TIME_LIMIT 60*60*2 +#ifdef WOWNERO_FCMP_TESTNET +#define CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE 4 +#else #define CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE 10 +#endif #define BLOCKCHAIN_TIMESTAMP_CHECK_WINDOW 60 // MONEY_SUPPLY - total number coins to be generated #define MONEY_SUPPLY ((uint64_t)(-1)) +#ifdef WOWNERO_FCMP_TESTNET +#define EMISSION_SPEED_FACTOR_PER_MINUTE (24) +#else #define EMISSION_SPEED_FACTOR_PER_MINUTE (20) +#endif +#ifdef WOWNERO_FCMP_TESTNET +#define FINAL_SUBSIDY_PER_MINUTE ((uint64_t)0) +#else #define FINAL_SUBSIDY_PER_MINUTE ((uint64_t)300000000000) // 3 * pow(10, 11) +#endif #define CRYPTONOTE_REWARD_BLOCKS_WINDOW 100 #define CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V2 60000 //size of block (bytes) after which reward for block calculated using block size #define CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V1 20000 //size of block (bytes) after which reward for block calculated using block size - before first fork #define CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V5 300000 //size of block (bytes) after which reward for block calculated using block size - second change, from v5 +#ifdef WOWNERO_FCMP_TESTNET +#define CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V17 300000 +#else #define CRYPTONOTE_BLOCK_GRANTED_FULL_REWARD_ZONE_V17 10000000 //STRESSNET SPECIFIC size of block (bytes) after which reward for block calculated using block size - third change, from v17 +#endif #define CRYPTONOTE_LONG_TERM_BLOCK_WEIGHT_WINDOW_SIZE 100000 // size in blocks of the long term block weight median window #define CRYPTONOTE_STRESSNET_BLOCK_WEIGHT_WINDOW_SIZE 10080 // size in blocks of the long term block weight median window [2 weeks] [STRESSNET ONLY!!!!] #define CRYPTONOTE_SHORT_TERM_BLOCK_SURGE_FACTOR_V10 50 #define CRYPTONOTE_SHORT_TERM_BLOCK_SURGE_FACTOR_V17 8 #define CRYPTONOTE_COINBASE_BLOB_RESERVED_SIZE 600 +#ifdef WOWNERO_FCMP_TESTNET +#define CRYPTONOTE_DISPLAY_DECIMAL_POINT 11 +#else #define CRYPTONOTE_DISPLAY_DECIMAL_POINT 12 +#endif // COIN - number of smallest units in one coin +#ifdef WOWNERO_FCMP_TESTNET +#define COIN ((uint64_t)100000000000) +#else #define COIN ((uint64_t)1000000000000) // pow(10, 12) +#endif #define FEE_PER_KB_OLD ((uint64_t)10000000000) // pow(10, 10) #define FEE_PER_KB ((uint64_t)2000000000) // 2 * pow(10, 9) @@ -80,11 +111,31 @@ #define ORPHANED_BLOCKS_MAX_COUNT 100 +#ifdef WOWNERO_FCMP_TESTNET +#define DIFFICULTY_TARGET_V2 300 +#else #define DIFFICULTY_TARGET_V2 120 // seconds +#endif +#ifdef WOWNERO_FCMP_TESTNET +#define DIFFICULTY_TARGET_V1 300 +#else #define DIFFICULTY_TARGET_V1 60 // seconds - before first fork +#endif +#ifdef WOWNERO_FCMP_TESTNET +#define DIFFICULTY_WINDOW 144 +#else #define DIFFICULTY_WINDOW 720 // blocks +#endif +#ifdef WOWNERO_FCMP_TESTNET +#define DIFFICULTY_LAG 3 +#else #define DIFFICULTY_LAG 15 // !!! +#endif +#ifdef WOWNERO_FCMP_TESTNET +#define DIFFICULTY_CUT 12 +#else #define DIFFICULTY_CUT 60 // timestamps to cut after sorting +#endif #define DIFFICULTY_BLOCKS_COUNT DIFFICULTY_WINDOW + DIFFICULTY_LAG @@ -172,7 +223,11 @@ #define RPC_IP_FAILS_BEFORE_BLOCK 3 +#ifdef WOWNERO_FCMP_TESTNET +#define CRYPTONOTE_NAME "wownero-fcmp-testnet" +#else #define CRYPTONOTE_NAME "bitmonero" +#endif #define CRYPTONOTE_BLOCKCHAINDATA_FILENAME "data.mdb" #define CRYPTONOTE_BLOCKCHAINDATA_LOCK_FILENAME "lock.mdb" #define P2P_NET_DATA_FILENAME "p2pstate.bin" @@ -209,8 +264,16 @@ #define HF_VERSION_REJECT_UNMIXABLE_V1 17 #define HF_VERSION_REJECT_MANY_MINER_OUTPUTS 17 #define HF_VERSION_2026_SCALING 17 +#ifdef WOWNERO_FCMP_TESTNET +#define HF_VERSION_POW_COMMITMENT 255 +#else #define HF_VERSION_POW_COMMITMENT 17 +#endif +#ifdef WOWNERO_FCMP_TESTNET +#define HF_VERSION_RANDOMX_V2 255 +#else #define HF_VERSION_RANDOMX_V2 17 +#endif #define MAX_HF_VERSION 18 #define PER_KB_FEE_QUANTIZATION_DECIMALS 8 @@ -231,7 +294,11 @@ // https://libera.monerologs.net/no-wallet-left-behind/20250505#c523568-c523686 #define FCMP_PLUS_PLUS_MAX_INPUTS 128 #define FCMP_PLUS_PLUS_MAX_OUTPUTS 16 +#ifdef WOWNERO_FCMP_TESTNET +#define FCMP_PLUS_PLUS_MAX_MINER_OUTPUTS 1 +#else #define FCMP_PLUS_PLUS_MAX_MINER_OUTPUTS 10000 +#endif #define FCMP_PLUS_PLUS_MAX_TX_WEIGHT 178063 // Limiting n layers ensures proof size is capped to a reasonable level when de-serializing a proof @@ -300,6 +367,20 @@ namespace config // Multisig const uint32_t MULTISIG_MAX_SIGNERS{16}; +#ifdef WOWNERO_FCMP_TESTNET + namespace testnet + { + uint64_t const CRYPTONOTE_PUBLIC_ADDRESS_BASE58_PREFIX = 35346; + uint64_t const CRYPTONOTE_PUBLIC_INTEGRATED_ADDRESS_BASE58_PREFIX = 35347; + uint64_t const CRYPTONOTE_PUBLIC_SUBADDRESS_BASE58_PREFIX = 35348; + uint16_t const P2P_DEFAULT_PORT = 48880; + uint16_t const RPC_DEFAULT_PORT = 48881; + uint16_t const ZMQ_RPC_DEFAULT_PORT = 48882; + boost::uuids::uuid const NETWORK_ID = {{0x45, 0x73, 0x4e, 0x6a, 0x7a, 0x13, 0x5c, 0x1e, 0x8b, 0x11, 0xe4, 0x0c, 0xee, 0xdd, 0x04, 0x03}}; + std::string const GENESIS_TX = wownero::testnet::genesis_tx; + uint32_t const GENESIS_NONCE = wownero::testnet::genesis_nonce; + } +#else namespace testnet { uint64_t const CRYPTONOTE_PUBLIC_ADDRESS_BASE58_PREFIX = 53; @@ -314,6 +395,7 @@ namespace config std::string const GENESIS_TX = "013c01ff0001ffffffffffff03029b2e4c0281c0b02e7c53291a94d1d0cbff8883f8024f5142ee494ffbbd08807121017767aafcde9be00dcfd098715ebcf7f410daebc582fda69d24a28e9d0bc890d1"; uint32_t const GENESIS_NONCE = 10001; } +#endif namespace stagenet { @@ -388,6 +470,10 @@ namespace cryptonote ::config::stagenet::GENESIS_TX, ::config::stagenet::GENESIS_NONCE }; +#ifdef WOWNERO_FCMP_TESTNET + if (nettype != TESTNET) + throw std::runtime_error("Wownero FCMP research binaries require the isolated testnet network"); +#endif switch (nettype) { case MAINNET: return mainnet; diff --git a/src/cryptonote_core/blockchain.cpp b/src/cryptonote_core/blockchain.cpp index 4617439b8..1d8c05fc1 100644 --- a/src/cryptonote_core/blockchain.cpp +++ b/src/cryptonote_core/blockchain.cpp @@ -40,6 +40,9 @@ #include "cryptonote_basic/cryptonote_basic_impl.h" #include "tx_pool.h" #include "blockchain.h" +#ifdef WOWNERO_FCMP_TESTNET +#include "cryptonote_basic/wow_miner.h" +#endif #include "blockchain_db/blockchain_db.h" #include "cryptonote_basic/events.h" #include "cryptonote_config.h" @@ -132,7 +135,7 @@ Blockchain::Blockchain(tx_memory_pool& tx_pool) : m_long_term_block_weights_cache_rolling_median(CRYPTONOTE_LONG_TERM_BLOCK_WEIGHT_WINDOW_SIZE), m_difficulty_for_next_block_top_hash(crypto::null_hash), m_difficulty_for_next_block(1), - m_btc_valid(false), + m_btc_include_sensitive(true), m_btc_valid(false), m_batch_success(true), m_prepare_height(0) { @@ -169,7 +172,7 @@ bool Blockchain::have_tx_keyimg_as_spent(const crypto::key_image &key_im) const // and collects the public key for each from the transaction it was included in // via the visitor passed to it. template -bool Blockchain::scan_outputkeys_for_indexes(size_t tx_version, const txin_to_key& tx_in_to_key, visitor_t &vis, const crypto::hash &tx_prefix_hash, uint64_t* pmax_related_block_height) const +bool Blockchain::scan_outputkeys_for_indexes(const uint8_t hf_version, size_t tx_version, const txin_to_key& tx_in_to_key, visitor_t &vis, const crypto::hash &tx_prefix_hash, uint64_t* pmax_related_block_height) const { LOG_PRINT_L3("Blockchain::" << __func__); @@ -248,6 +251,7 @@ bool Blockchain::scan_outputkeys_for_indexes(size_t tx_version, const txin_to_ke } size_t count = 0; + uint64_t last_height = 0; for (const uint64_t& i : absolute_offsets) { try @@ -261,6 +265,15 @@ bool Blockchain::scan_outputkeys_for_indexes(size_t tx_version, const txin_to_ke else output_index = m_db->get_output_key(tx_in_to_key.amount, i).data; + if (hf_version >= HF_VERSION_FCMP_PLUS_PLUS) + { + // The legacy spend guard uses the final ring member height to exclude + // Carrot outputs. It must be the greatest height from FCMP activation. + // Preserve historical offset behavior before activation. + CHECK_AND_ASSERT_MES(output_index.height >= last_height, false, "Unexpected decreasing ring member height"); + last_height = output_index.height; + } + // call to the passed boost visitor to grab the public key for the output if (!vis.handle_output(output_index.unlock_time, output_index.pubkey, output_index.commitment)) { @@ -720,7 +733,7 @@ block Blockchain::pop_block_from_blockchain(bool keep_txs) curr_ring.reserve(pin->key_offsets.size()); outputs_visitor vis{curr_ring}; - if (!scan_outputkeys_for_indexes(tx.version, *pin, vis, tx_prefix_hash)) + if (!scan_outputkeys_for_indexes(version, tx.version, *pin, vis, tx_prefix_hash)) { dereferenced_mix_ring.clear(); break; @@ -1035,6 +1048,10 @@ difficulty_type Blockchain::get_difficulty_for_next_block() } size_t target = get_difficulty_target(); difficulty_type diff = next_difficulty(timestamps, difficulties, target); +#ifdef WOWNERO_FCMP_TESTNET + if (height > 0 && height <= 720) + diff = 100; +#endif CRITICAL_REGION_LOCAL1(m_difficulty_lock); m_difficulty_for_next_block_top_hash = top_hash; @@ -1395,6 +1412,10 @@ difficulty_type Blockchain::get_next_difficulty_for_alternative_chain(const std: size_t target = get_ideal_hard_fork_version(bei.height) < 2 ? DIFFICULTY_TARGET_V1 : DIFFICULTY_TARGET_V2; // calculate the difficulty target for the block and return it +#ifdef WOWNERO_FCMP_TESTNET + if (bei.height > 0 && bei.height <= 720) + return 100; +#endif return next_difficulty(timestamps, cumulative_difficulties, target); } //------------------------------------------------------------------ @@ -1434,6 +1455,13 @@ bool Blockchain::prevalidate_miner_transaction(const block& b, uint64_t height, CHECK_AND_ASSERT_MES(check_output_types(b.miner_tx, hf_version), false, "miner transaction has invalid output type(s) in block " << get_block_hash(b)); +#ifdef WOWNERO_FCMP_TESTNET + if (hf_version >= HF_VERSION_CARROT) + { + CHECK_AND_ASSERT_MES(verify_wow_miner_block(b), false, "Invalid Carrot miner ownership proof or vote"); + } +#endif + CHECK_AND_ASSERT_MES(check_transaction_output_pubkeys_order(b.miner_tx, hf_version), false, "FCMP++ miner transaction has unsorted outputs in block " << get_block_hash(b)); @@ -1658,7 +1686,7 @@ uint64_t Blockchain::get_current_cumulative_block_weight_median() const // in a lot of places. That flag is not referenced in any of the code // nor any of the makefiles, however. Need to look into whether or not it's // necessary at all. -bool Blockchain::create_block_template(block& b, const crypto::hash *from_block, const account_public_address& miner_address, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash) +bool Blockchain::create_block_template(block& b, const crypto::hash *from_block, const account_public_address& miner_address, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash, bool include_sensitive) { LOG_PRINT_L3("Blockchain::" << __func__); size_t median_weight; @@ -1676,7 +1704,8 @@ bool Blockchain::create_block_template(block& b, const crypto::hash *from_block, // this would be the case anyway if we'd lock, and the change happened // just after the block template was created if (!memcmp(&miner_address, &m_btc_address, sizeof(cryptonote::account_public_address)) && m_btc_nonce == ex_nonce - && m_btc_pool_cookie == m_tx_pool.cookie() && m_btc.prev_id == get_tail_id()) { + && m_btc_pool_cookie == m_tx_pool.cookie() && m_btc.prev_id == get_tail_id() + && m_btc_include_sensitive == include_sensitive) { MDEBUG("Using cached template"); const uint64_t now = time(NULL); if (m_btc.timestamp < now) // ensures it can't get below the median of the last few blocks @@ -1814,7 +1843,7 @@ bool Blockchain::create_block_template(block& b, const crypto::hash *from_block, size_t txs_weight; uint64_t fee; - if (!m_tx_pool.fill_block_template(b, median_weight, already_generated_coins, txs_weight, fee, expected_reward, b.major_version)) + if (!m_tx_pool.fill_block_template(b, median_weight, already_generated_coins, txs_weight, fee, expected_reward, b.major_version, include_sensitive)) { return false; } @@ -1933,16 +1962,16 @@ bool Blockchain::create_block_template(block& b, const crypto::hash *from_block, #endif if (!from_block) - cache_block_template(b, miner_address, ex_nonce, diffic, height, expected_reward, cumulative_weight, seed_height, seed_hash, pool_cookie); + cache_block_template(b, miner_address, ex_nonce, diffic, height, expected_reward, cumulative_weight, seed_height, seed_hash, pool_cookie, include_sensitive); return true; } LOG_ERROR("Failed to create_block_template with " << 10 << " tries"); return false; } //------------------------------------------------------------------ -bool Blockchain::create_block_template(block& b, const account_public_address& miner_address, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash) +bool Blockchain::create_block_template(block& b, const account_public_address& miner_address, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash, bool include_sensitive) { - return create_block_template(b, NULL, miner_address, diffic, height, expected_reward, cumulative_weight, ex_nonce, seed_height, seed_hash); + return create_block_template(b, NULL, miner_address, diffic, height, expected_reward, cumulative_weight, ex_nonce, seed_height, seed_hash, include_sensitive); } //------------------------------------------------------------------ bool Blockchain::get_miner_data(uint8_t& major_version, uint64_t& height, crypto::hash& prev_id, uint8_t& fcmp_pp_n_tree_layers, crypto::ec_point& fcmp_pp_tree_root, crypto::hash& seed_hash, difficulty_type& difficulty, uint64_t& median_weight, uint64_t& already_generated_coins, std::vector& tx_backlog) @@ -4328,7 +4357,7 @@ bool Blockchain::check_tx_input(size_t tx_version, const txin_to_key& txin, cons // collect output keys outputs_visitor vi(output_keys, *this, hf_version); - if (!scan_outputkeys_for_indexes(tx_version, txin, vi, tx_prefix_hash, pmax_related_block_height)) + if (!scan_outputkeys_for_indexes(hf_version, tx_version, txin, vi, tx_prefix_hash, pmax_related_block_height)) { MERROR_VER("Failed to get output keys for tx with amount = " << print_money(txin.amount) << " and count indexes " << txin.key_offsets.size()); return false; @@ -6261,7 +6290,7 @@ void Blockchain::invalidate_block_template_cache() m_btc_valid = false; } -void Blockchain::cache_block_template(const block &b, const cryptonote::account_public_address &address, const blobdata &nonce, const difficulty_type &diff, uint64_t height, uint64_t expected_reward, uint64_t cumulative_weight, uint64_t seed_height, const crypto::hash &seed_hash, uint64_t pool_cookie) +void Blockchain::cache_block_template(const block &b, const cryptonote::account_public_address &address, const blobdata &nonce, const difficulty_type &diff, uint64_t height, uint64_t expected_reward, uint64_t cumulative_weight, uint64_t seed_height, const crypto::hash &seed_hash, uint64_t pool_cookie, bool include_sensitive) { MDEBUG("Setting block template cache"); m_btc = b; @@ -6274,6 +6303,7 @@ void Blockchain::cache_block_template(const block &b, const cryptonote::account_ m_btc_seed_hash = seed_hash; m_btc_seed_height = seed_height; m_btc_pool_cookie = pool_cookie; + m_btc_include_sensitive = include_sensitive; m_btc_valid = true; } diff --git a/src/cryptonote_core/blockchain.h b/src/cryptonote_core/blockchain.h index 6b50d6a59..f896213cd 100644 --- a/src/cryptonote_core/blockchain.h +++ b/src/cryptonote_core/blockchain.h @@ -390,8 +390,8 @@ namespace cryptonote * * @return true if block template filled in successfully, else false */ - bool create_block_template(block& b, const account_public_address& miner_address, difficulty_type& di, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash); - bool create_block_template(block& b, const crypto::hash *from_block, const account_public_address& miner_address, difficulty_type& di, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash); + bool create_block_template(block& b, const account_public_address& miner_address, difficulty_type& di, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash, bool include_sensitive = true); + bool create_block_template(block& b, const crypto::hash *from_block, const account_public_address& miner_address, difficulty_type& di, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash, bool include_sensitive = true); /** * @brief gets data required to create a block template and start mining on it @@ -1303,6 +1303,7 @@ namespace cryptonote uint64_t m_btc_cumulative_weight; crypto::hash m_btc_seed_hash; uint64_t m_btc_seed_height; + bool m_btc_include_sensitive; bool m_btc_valid; @@ -1347,11 +1348,12 @@ namespace cryptonote * @param tx_prefix_hash the hash of the associated transaction_prefix * @param pmax_related_block_height return-by-pointer the height of the most recent block in the input set * @param tx_version version of the tx, if > 1 we also get commitments + * @param hf_version hard-fork version rules to use * * @return false if any keys are not found or any inputs are not unlocked, otherwise true */ template - inline bool scan_outputkeys_for_indexes(size_t tx_version, const txin_to_key& tx_in_to_key, visitor_t &vis, const crypto::hash &tx_prefix_hash, uint64_t* pmax_related_block_height = NULL) const; + inline bool scan_outputkeys_for_indexes(const uint8_t hf_version, size_t tx_version, const txin_to_key& tx_in_to_key, visitor_t &vis, const crypto::hash &tx_prefix_hash, uint64_t* pmax_related_block_height = NULL) const; /** * @brief collect output public keys of a transaction input set @@ -1702,7 +1704,7 @@ namespace cryptonote * * At some point, may be used to push an update to miners */ - void cache_block_template(const block &b, const cryptonote::account_public_address &address, const blobdata &nonce, const difficulty_type &diff, uint64_t height, uint64_t expected_reward, uint64_t cumulative_weight, uint64_t seed_height, const crypto::hash &seed_hash, uint64_t pool_cookie); + void cache_block_template(const block &b, const cryptonote::account_public_address &address, const blobdata &nonce, const difficulty_type &diff, uint64_t height, uint64_t expected_reward, uint64_t cumulative_weight, uint64_t seed_height, const crypto::hash &seed_hash, uint64_t pool_cookie, bool include_sensitive); /** * @brief sends new block notifications to ZMQ `miner_data` subscribers diff --git a/src/cryptonote_core/cryptonote_core.cpp b/src/cryptonote_core/cryptonote_core.cpp index 8d90009f4..6ec8840ba 100644 --- a/src/cryptonote_core/cryptonote_core.cpp +++ b/src/cryptonote_core/cryptonote_core.cpp @@ -889,6 +889,14 @@ namespace cryptonote return false; } + if (!check_tx_inputs_ring_members_overflow(tx, hf_version)) + { + MERROR_VER("tx ring members overflowed"); + tvc.m_verifivation_failed = true; + tvc.m_invalid_input = true; + return false; + } + if (!check_tx_inputs_keyimages_domain(tx)) { MERROR_VER("tx uses key image not in the valid domain"); @@ -1044,6 +1052,26 @@ namespace cryptonote return true; } //----------------------------------------------------------------------------------------------- + bool core::check_tx_inputs_ring_members_overflow(const transaction& tx, const uint8_t hf_version) + { + if (hf_version >= HF_VERSION_FCMP_PLUS_PLUS) + { + for(const auto& in: tx.vin) + { + CHECKED_GET_SPECIFIC_VARIANT(in, const txin_to_key, tokey_in, false); + const std::vector absolute_offsets = relative_output_offsets_to_absolute(tokey_in.key_offsets); + for (size_t i = 1; i < absolute_offsets.size(); ++i) + { + const uint64_t prev_offset = absolute_offsets[i-1]; + const uint64_t next_offset = absolute_offsets[i]; + if (prev_offset > next_offset) + return false; + } + } + } + return true; + } + //----------------------------------------------------------------------------------------------- bool core::check_tx_inputs_keyimages_domain(const transaction& tx) { std::unordered_set ki; @@ -1218,9 +1246,9 @@ namespace cryptonote return m_blockchain_storage.create_block_template(b, adr, diffic, height, expected_reward, cumulative_weight, ex_nonce, seed_height, seed_hash); } //----------------------------------------------------------------------------------------------- - bool core::get_block_template(block& b, const crypto::hash *prev_block, const account_public_address& adr, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash) + bool core::get_block_template(block& b, const crypto::hash *prev_block, const account_public_address& adr, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t& cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash, bool include_sensitive) { - return m_blockchain_storage.create_block_template(b, prev_block, adr, diffic, height, expected_reward, cumulative_weight, ex_nonce, seed_height, seed_hash); + return m_blockchain_storage.create_block_template(b, prev_block, adr, diffic, height, expected_reward, cumulative_weight, ex_nonce, seed_height, seed_hash, include_sensitive); } //----------------------------------------------------------------------------------------------- bool core::get_miner_data(uint8_t& major_version, uint64_t& height, crypto::hash& prev_id, uint8_t& fcmp_pp_n_tree_layers, crypto::ec_point& fcmp_pp_tree_root, crypto::hash& seed_hash, difficulty_type& difficulty, uint64_t& median_weight, uint64_t& already_generated_coins, std::vector& tx_backlog) @@ -1270,6 +1298,9 @@ namespace cryptonote //----------------------------------------------------------------------------------------------- block_complete_entry get_block_complete_entry(block& b, tx_memory_pool &pool) { + const std::unordered_set tx_hashes(b.tx_hashes.cbegin(), b.tx_hashes.cend()); + CHECK_AND_ASSERT_THROW_MES(tx_hashes.size() == b.tx_hashes.size(), "Duplicate transaction hashes in block"); + block_complete_entry bce; bce.block = cryptonote::block_to_blob(b); bce.block_weight = 0; // we can leave it to 0, those txes aren't pruned @@ -1623,6 +1654,9 @@ namespace cryptonote //----------------------------------------------------------------------------------------------- bool core::check_updates() { +#ifdef WOWNERO_FCMP_TESTNET + return true; +#endif static const char software[] = "monero"; #ifdef BUILD_TAG static const char buildtag[] = BOOST_PP_STRINGIZE(BUILD_TAG); diff --git a/src/cryptonote_core/cryptonote_core.h b/src/cryptonote_core/cryptonote_core.h index bd015628f..e6c88e19d 100644 --- a/src/cryptonote_core/cryptonote_core.h +++ b/src/cryptonote_core/cryptonote_core.h @@ -238,7 +238,7 @@ namespace cryptonote * @note see Blockchain::create_block_template */ bool get_block_template(block& b, const account_public_address& adr, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t &cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash) final; - bool get_block_template(block& b, const crypto::hash *prev_block, const account_public_address& adr, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t &cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash); + bool get_block_template(block& b, const crypto::hash *prev_block, const account_public_address& adr, difficulty_type& diffic, uint64_t& height, uint64_t& expected_reward, uint64_t &cumulative_weight, const blobdata& ex_nonce, uint64_t &seed_height, crypto::hash &seed_hash, bool include_sensitive = true); /** * @copydoc Blockchain::get_miner_data @@ -908,6 +908,16 @@ namespace cryptonote */ static bool check_tx_inputs_ring_members_diff(const transaction& tx, const uint8_t hf_version); + /** + * @brief verify that ring member absolute offsets don't overflow + * + * @param tx the transaction to check + * @param hf_version the hard fork version rules to use + * + * @return false if any absolute offsets overflowed, true otherwise + */ + static bool check_tx_inputs_ring_members_overflow(const transaction& tx, const uint8_t hf_version); + /** * @brief verify that each input key image in a transaction is in * the valid domain diff --git a/src/cryptonote_core/tx_pool.cpp b/src/cryptonote_core/tx_pool.cpp index e233a1b64..c86492201 100644 --- a/src/cryptonote_core/tx_pool.cpp +++ b/src/cryptonote_core/tx_pool.cpp @@ -1673,7 +1673,7 @@ namespace cryptonote } //--------------------------------------------------------------------------------- //TODO: investigate whether boolean return is appropriate - bool tx_memory_pool::fill_block_template(block &bl, size_t median_weight, uint64_t already_generated_coins, size_t &total_weight, uint64_t &fee, uint64_t &expected_reward, uint8_t version) + bool tx_memory_pool::fill_block_template(block &bl, size_t median_weight, uint64_t already_generated_coins, size_t &total_weight, uint64_t &fee, uint64_t &expected_reward, uint8_t version, bool include_sensitive) { CRITICAL_REGION_LOCAL(m_transactions_lock); CRITICAL_REGION_LOCAL1(m_blockchain); @@ -1713,7 +1713,8 @@ namespace cryptonote } LOG_PRINT_L2("Considering " << sorted_it->get_right() << ", weight " << meta.weight << ", current block weight " << total_weight << "/" << max_total_weight << ", current coinbase " << print_money(best_coinbase) << ", relay method " << (unsigned)meta.get_relay_method()); - if (!meta.matches(relay_category::legacy) && !(m_mine_stem_txes && meta.get_relay_method() == relay_method::stem)) + const relay_category category = include_sensitive ? relay_category::legacy : relay_category::broadcasted; + if (!meta.matches(category) && !(include_sensitive && m_mine_stem_txes && meta.get_relay_method() == relay_method::stem)) { LOG_PRINT_L2(" tx relay method is " << (unsigned)meta.get_relay_method()); continue; diff --git a/src/cryptonote_core/tx_pool.h b/src/cryptonote_core/tx_pool.h index 9d1a791fa..432d24f9c 100644 --- a/src/cryptonote_core/tx_pool.h +++ b/src/cryptonote_core/tx_pool.h @@ -257,7 +257,7 @@ namespace cryptonote * * @return true */ - bool fill_block_template(block &bl, size_t median_weight, uint64_t already_generated_coins, size_t &total_weight, uint64_t &fee, uint64_t &expected_reward, uint8_t version); + bool fill_block_template(block &bl, size_t median_weight, uint64_t already_generated_coins, size_t &total_weight, uint64_t &fee, uint64_t &expected_reward, uint8_t version, bool include_sensitive = true); /** * @brief get a list of all transactions in the pool diff --git a/src/cryptonote_protocol/cryptonote_protocol_handler.inl b/src/cryptonote_protocol/cryptonote_protocol_handler.inl index 3867243ee..5dedaa13f 100644 --- a/src/cryptonote_protocol/cryptonote_protocol_handler.inl +++ b/src/cryptonote_protocol/cryptonote_protocol_handler.inl @@ -114,7 +114,7 @@ namespace cryptonote const bool max_size_check = true; if (is_pruned) { - if ((parse_success = cryptonote::parse_and_validate_tx_base_from_blob(tx_entry.blob, tx, max_size_check))) + if ((parse_success = cryptonote::parse_and_validate_tx_base_from_blob(tx_entry.blob, tx, max_size_check, /*require_eof=*/true))) parse_success = cryptonote::get_pruned_transaction_hash(tx, tx_entry.prunable_hash, tx_hash); } else @@ -1650,6 +1650,7 @@ namespace cryptonote if (!m_core.prepare_handle_incoming_blocks(blocks, pblocks)) { LOG_ERROR_CCONTEXT("Failure in prepare_handle_incoming_blocks"); + m_block_queue.flush_spans(span_connection_id, true); drop_connections(span_origin); return 1; } @@ -3103,6 +3104,7 @@ skip: template void t_cryptonote_protocol_handler::drop_connection(const boost::uuids::uuid& id, bool add_fail) { + m_block_queue.flush_spans(id, true); m_p2p->for_connection(id, [this, add_fail](cryptonote_connection_context& context, nodetool::peerid_type peer_id, uint32_t f)->bool{ // This _could be_ outside of strand, so careful on actions drop_connection(context, add_fail, false); diff --git a/src/daemon/CMakeLists.txt b/src/daemon/CMakeLists.txt index a7eff1744..a82896703 100644 --- a/src/daemon/CMakeLists.txt +++ b/src/daemon/CMakeLists.txt @@ -72,3 +72,7 @@ set_property(TARGET daemon PROPERTY OUTPUT_NAME "monerod") install(TARGETS daemon DESTINATION bin) + +if(WOWNERO_FCMP_TESTNET AND TARGET daemon) + set_property(TARGET daemon PROPERTY OUTPUT_NAME "wownero-fcmp-testnetd") +endif() diff --git a/src/daemon/main.cpp b/src/daemon/main.cpp index 8ad5de09c..c03e42840 100644 --- a/src/daemon/main.cpp +++ b/src/daemon/main.cpp @@ -46,6 +46,16 @@ #include "daemon/command_line_args.h" #include "version.h" +namespace +{ +#ifdef WOWNERO_FCMP_TESTNET +constexpr const char *project_name = "Wownero FCMP testnet"; +#else +constexpr const char *project_name = "Monero"; +#endif +} + + #ifdef STACK_TRACE #include "common/stack_trace.h" #endif // STACK_TRACE @@ -192,7 +202,7 @@ int main(int argc, char const * argv[]) if (command_line::get_arg(vm, command_line::arg_help)) { - std::cout << "Monero '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")" << ENDL << ENDL; + std::cout << project_name << " '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")" << ENDL << ENDL; std::cout << "Usage: " + std::string{argv[0]} + " [options|settings] [daemon_command...]" << std::endl << std::endl; std::cout << visible_options << std::endl; return 0; @@ -201,7 +211,7 @@ int main(int argc, char const * argv[]) // Monero Version if (command_line::get_arg(vm, command_line::arg_version)) { - std::cout << "Monero '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")" << ENDL; + std::cout << project_name << " '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")" << ENDL; return 0; } @@ -247,6 +257,14 @@ int main(int argc, char const * argv[]) return 1; } +#ifdef WOWNERO_FCMP_TESTNET + if (cryptonote::core::get_network_type_from_args(vm) != cryptonote::TESTNET) + { + std::cerr << "Wownero FCMP research daemon requires --testnet; mainnet, stagenet and regtest are disabled." << std::endl; + return 1; + } +#endif + // data_dir // default: e.g. ~/.bitmonero/ or ~/.bitmonero/testnet // if data-dir argument given: @@ -295,7 +313,7 @@ int main(int argc, char const * argv[]) tools::set_max_concurrency(command_line::get_arg(vm, daemon_args::arg_max_concurrency)); // logging is now set up - MGINFO("Monero '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")"); + MGINFO(project_name << " '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")"); // If there are positional options, we're running a daemon command { diff --git a/src/hardforks/hardforks.cpp b/src/hardforks/hardforks.cpp index 4ca280730..f992c2044 100644 --- a/src/hardforks/hardforks.cpp +++ b/src/hardforks/hardforks.cpp @@ -81,6 +81,15 @@ const hardfork_t mainnet_hard_forks[] = { const size_t num_mainnet_hard_forks = sizeof(mainnet_hard_forks) / sizeof(mainnet_hard_forks[0]); const uint64_t mainnet_hard_fork_version_1_till = 1009826; +#ifdef WOWNERO_FCMP_TESTNET +const hardfork_t testnet_hard_forks[] = { + { 1, 0, 0, 1 }, + { 17, 1, 0, 2 }, + { 18, 2, 0, 3 }, +}; +const size_t num_testnet_hard_forks = sizeof(testnet_hard_forks) / sizeof(testnet_hard_forks[0]); +const uint64_t testnet_hard_fork_version_1_till = 0; +#else const hardfork_t testnet_hard_forks[] = { // version 1 from the start of the blockchain { 1, 1, 0, 1341378000 }, @@ -112,6 +121,8 @@ const hardfork_t testnet_hard_forks[] = { const size_t num_testnet_hard_forks = sizeof(testnet_hard_forks) / sizeof(testnet_hard_forks[0]); const uint64_t testnet_hard_fork_version_1_till = 624633; +#endif + const hardfork_t stagenet_hard_forks[] = { // version 1 from the start of the blockchain { 1, 1, 0, 1341378000 }, diff --git a/src/p2p/net_node.inl b/src/p2p/net_node.inl index 821c7923b..3657333a1 100644 --- a/src/p2p/net_node.inl +++ b/src/p2p/net_node.inl @@ -832,6 +832,9 @@ namespace nodetool template std::set node_server::get_ip_seed_nodes() const { +#ifdef WOWNERO_FCMP_TESTNET + return {}; +#endif std::set full_addrs; if (m_nettype == cryptonote::TESTNET) { @@ -867,6 +870,9 @@ namespace nodetool template std::set node_server::get_dns_seed_nodes() { +#ifdef WOWNERO_FCMP_TESTNET + return {}; +#endif if (!m_exclusive_peers.empty() || m_offline) { return {}; @@ -983,6 +989,9 @@ namespace nodetool template std::set node_server::get_seed_nodes(epee::net_utils::zone zone) { +#ifdef WOWNERO_FCMP_TESTNET + return {}; +#endif switch (zone) { case epee::net_utils::zone::public_: @@ -2202,6 +2211,9 @@ namespace nodetool template bool node_server::update_dns_blocklist() { +#ifdef WOWNERO_FCMP_TESTNET + return true; +#endif if (!m_enable_dns_blocklist) return true; if (m_nettype != cryptonote::MAINNET) diff --git a/src/rpc/core_rpc_server.cpp b/src/rpc/core_rpc_server.cpp index 364e9f70a..0ac241107 100644 --- a/src/rpc/core_rpc_server.cpp +++ b/src/rpc/core_rpc_server.cpp @@ -1467,6 +1467,7 @@ namespace cryptonote COMMAND_RPC_GET_PEER_LIST::request peer_list_req; COMMAND_RPC_GET_PEER_LIST::response peer_list_res; + peer_list_req.public_only = true; peer_list_req.include_blocked = req.include_blocked; const bool success = on_get_peer_list(peer_list_req, peer_list_res, ctx); res.status = peer_list_res.status; @@ -1665,10 +1666,10 @@ namespace cryptonote return 0; } //------------------------------------------------------------------------------------------------------------------------------ - bool core_rpc_server::get_block_template(const account_public_address &address, const crypto::hash *prev_block, const cryptonote::blobdata &extra_nonce, size_t &reserved_offset, cryptonote::difficulty_type &difficulty, uint64_t &height, uint64_t &expected_reward, uint64_t& cumulative_weight, block &b, uint64_t &seed_height, crypto::hash &seed_hash, crypto::hash &next_seed_hash, epee::json_rpc::error &error_resp) + bool core_rpc_server::get_block_template(const account_public_address &address, const crypto::hash *prev_block, const cryptonote::blobdata &extra_nonce, size_t &reserved_offset, cryptonote::difficulty_type &difficulty, uint64_t &height, uint64_t &expected_reward, uint64_t& cumulative_weight, block &b, uint64_t &seed_height, crypto::hash &seed_hash, crypto::hash &next_seed_hash, epee::json_rpc::error &error_resp, bool include_sensitive) { b = boost::value_initialized(); - if(!m_core.get_block_template(b, prev_block, address, difficulty, height, expected_reward, cumulative_weight, extra_nonce, seed_height, seed_hash)) + if(!m_core.get_block_template(b, prev_block, address, difficulty, height, expected_reward, cumulative_weight, extra_nonce, seed_height, seed_hash, include_sensitive)) { error_resp.code = CORE_RPC_ERROR_CODE_INTERNAL_ERROR; error_resp.message = "Internal error: failed to create block template"; @@ -1790,7 +1791,8 @@ namespace cryptonote } } crypto::hash seed_hash, next_seed_hash; - if (!get_block_template(info.address, req.prev_block.empty() ? NULL : &prev_block, blob_reserve, reserved_offset, wdiff, res.height, res.expected_reward, res.cumulative_weight, b, res.seed_height, seed_hash, next_seed_hash, error_resp)) + const bool include_sensitive = !m_restricted || !ctx; + if (!get_block_template(info.address, req.prev_block.empty() ? NULL : &prev_block, blob_reserve, reserved_offset, wdiff, res.height, res.expected_reward, res.cumulative_weight, b, res.seed_height, seed_hash, next_seed_hash, error_resp, include_sensitive)) return false; if (b.major_version >= RX_BLOCK_VERSION) { @@ -2095,6 +2097,25 @@ namespace cryptonote return false; } + if (m_restricted && ctx) + { + block parent; + if (!m_core.get_block_by_hash(b.prev_id, parent)) + { + error_resp.code = CORE_RPC_ERROR_CODE_BLOCK_NOT_ACCEPTED; + error_resp.message = "Unknown parent block"; + return false; + } + const uint64_t block_height = get_block_height(parent) + 1; + const uint64_t chain_height = m_core.get_current_blockchain_height(); + if (crypto::rx_seedheight(block_height) != crypto::rx_seedheight(chain_height)) + { + error_resp.code = CORE_RPC_ERROR_CODE_BLOCK_NOT_ACCEPTED; + error_resp.message = "Block is too old for restricted RPC"; + return false; + } + } + block_verification_context bvc; if(!m_core.handle_block_found(b, bvc)) { diff --git a/src/rpc/core_rpc_server.h b/src/rpc/core_rpc_server.h index d1ffe6b1f..0ff4d4484 100644 --- a/src/rpc/core_rpc_server.h +++ b/src/rpc/core_rpc_server.h @@ -254,7 +254,7 @@ private: //utils uint64_t get_block_reward(const block& blk); bool fill_block_header_response(const block& blk, bool orphan_status, uint64_t height, const crypto::hash& hash, block_header_response& response, bool fill_pow_hash); - bool get_block_template(const account_public_address &address, const crypto::hash *prev_block, const cryptonote::blobdata &extra_nonce, size_t &reserved_offset, cryptonote::difficulty_type &difficulty, uint64_t &height, uint64_t &expected_reward, uint64_t& cumulative_weight, block &b, uint64_t &seed_height, crypto::hash &seed_hash, crypto::hash &next_seed_hash, epee::json_rpc::error &error_resp); + bool get_block_template(const account_public_address &address, const crypto::hash *prev_block, const cryptonote::blobdata &extra_nonce, size_t &reserved_offset, cryptonote::difficulty_type &difficulty, uint64_t &height, uint64_t &expected_reward, uint64_t& cumulative_weight, block &b, uint64_t &seed_height, crypto::hash &seed_hash, crypto::hash &next_seed_hash, epee::json_rpc::error &error_resp, bool include_sensitive = true); bool handle_get_blocks(const COMMAND_RPC_GET_BLOCKS_FAST::request& req, COMMAND_RPC_GET_BLOCKS_FAST::response& res, const connection_context *ctx); core& m_core; diff --git a/src/rpc/rpc_handler.cpp b/src/rpc/rpc_handler.cpp index d2b77327c..8c23479c4 100644 --- a/src/rpc/rpc_handler.cpp +++ b/src/rpc/rpc_handler.cpp @@ -59,10 +59,10 @@ namespace rpc crypto::hash hash10 = get_hash(d.cached_to - 10); if (hash10 == d.cached_m10_hash) { + CHECK_AND_ASSERT_MES(d.cached_distribution.size() >= 10, boost::none, "Cached distribution size does not match cached bounds"); d.cached_to -= 10; d.cached_top_hash = hash10; d.cached_m10_hash = crypto::null_hash; - CHECK_AND_ASSERT_MES(d.cached_distribution.size() >= 10, boost::none, "Cached distribution size does not match cached bounds"); for (int p = 0; p < 10; ++p) d.cached_distribution.pop_back(); can_extend = true; diff --git a/src/simplewallet/CMakeLists.txt b/src/simplewallet/CMakeLists.txt index d4177e44d..4616c3544 100644 --- a/src/simplewallet/CMakeLists.txt +++ b/src/simplewallet/CMakeLists.txt @@ -63,3 +63,7 @@ set_property(TARGET simplewallet PROPERTY OUTPUT_NAME "monero-wallet-cli") install(TARGETS simplewallet DESTINATION bin) + +if(WOWNERO_FCMP_TESTNET AND TARGET simplewallet) + set_property(TARGET simplewallet PROPERTY OUTPUT_NAME "wownero-fcmp-testnet-wallet-cli") +endif() diff --git a/src/version.cpp.in b/src/version.cpp.in index fe2102838..4966a374a 100644 --- a/src/version.cpp.in +++ b/src/version.cpp.in @@ -1,6 +1,11 @@ #define DEF_MONERO_VERSION_TAG "@VERSIONTAG@" +#ifdef WOWNERO_FCMP_TESTNET +#define DEF_MONERO_VERSION "0.1.0-fcmp-testnet" +#define DEF_MONERO_RELEASE_NAME "Wownero FCMP Research" +#else #define DEF_MONERO_VERSION "0.19.0.0-beta.3.0" #define DEF_MONERO_RELEASE_NAME "Fluorine Fermi" +#endif #define DEF_MONERO_VERSION_FULL DEF_MONERO_VERSION "-" DEF_MONERO_VERSION_TAG #define DEF_MONERO_VERSION_IS_RELEASE @VERSION_IS_RELEASE@ diff --git a/src/wallet/CMakeLists.txt b/src/wallet/CMakeLists.txt index 52998b89f..17bf5fe21 100644 --- a/src/wallet/CMakeLists.txt +++ b/src/wallet/CMakeLists.txt @@ -111,3 +111,7 @@ endif() add_subdirectory(api) add_subdirectory(wallet2_basic) + +if(WOWNERO_FCMP_TESTNET AND TARGET wallet_rpc_server) + set_property(TARGET wallet_rpc_server PROPERTY OUTPUT_NAME "wownero-fcmp-testnet-wallet-rpc") +endif() diff --git a/src/wallet/wallet2.cpp b/src/wallet/wallet2.cpp index 0105711a0..39d9d0d5c 100644 --- a/src/wallet/wallet2.cpp +++ b/src/wallet/wallet2.cpp @@ -181,7 +181,11 @@ namespace boost::filesystem::path dir = tools::get_default_data_dir(); // remove .bitmonero, replace with .shared-ringdb dir = dir.remove_filename(); +#ifdef WOWNERO_FCMP_TESTNET + dir /= ".wownero-fcmp-testnet-ringdb"; +#else dir /= ".shared-ringdb"; +#endif return dir.string(); } diff --git a/src/wallet/wallet_args.cpp b/src/wallet/wallet_args.cpp index 089d40731..30660ec80 100644 --- a/src/wallet/wallet_args.cpp +++ b/src/wallet/wallet_args.cpp @@ -36,6 +36,16 @@ #include "string_tools.h" #include "version.h" +namespace +{ +#ifdef WOWNERO_FCMP_TESTNET +constexpr const char *project_name = "Wownero FCMP testnet"; +#else +constexpr const char *project_name = "Monero"; +#endif +} + + #if defined(WIN32) #include #endif @@ -142,7 +152,7 @@ namespace wallet_args if (command_line::get_arg(vm, command_line::arg_help)) { - Print(print) << "Monero '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")" << ENDL; + Print(print) << project_name << " '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")" << ENDL; Print(print) << wallet_args::tr("This is the command line monero wallet. It needs to connect to a monero\n" "daemon to work correctly.") << ENDL; Print(print) << wallet_args::tr("Usage:") << ENDL << " " << usage; @@ -152,7 +162,7 @@ namespace wallet_args } else if (command_line::get_arg(vm, command_line::arg_version)) { - Print(print) << "Monero '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")"; + Print(print) << project_name << " '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")"; should_terminate = true; return true; } @@ -182,6 +192,15 @@ namespace wallet_args if (should_terminate) return {std::move(vm), should_terminate}; +#ifdef WOWNERO_FCMP_TESTNET + if (!vm.count("testnet") || !vm["testnet"].as() || + (vm.count("stagenet") && vm["stagenet"].as())) + { + Print(print) << "Wownero FCMP research wallet requires --testnet; other networks are disabled."; + return {boost::none, true}; + } +#endif + std::string log_path; if (!command_line::is_arg_defaulted(vm, arg_log_file)) log_path = command_line::get_arg(vm, arg_log_file); @@ -203,7 +222,7 @@ namespace wallet_args if (!command_line::is_arg_defaulted(vm, arg_max_concurrency)) tools::set_max_concurrency(command_line::get_arg(vm, arg_max_concurrency)); - Print(print) << "Monero '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")"; + Print(print) << project_name << " '" << MONERO_RELEASE_NAME << "' (v" << MONERO_VERSION_FULL << ")"; if (!command_line::is_arg_defaulted(vm, arg_log_level)) MINFO("Setting log level = " << command_line::get_arg(vm, arg_log_level)); diff --git a/src/wownero/testnet_genesis.h b/src/wownero/testnet_genesis.h new file mode 100644 index 000000000..e19c6cad4 --- /dev/null +++ b/src/wownero/testnet_genesis.h @@ -0,0 +1,12 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause +// Do not edit. Regenerate with the wow_testnet_genesis executable. + +#pragma once + +namespace wownero::testnet +{ +inline constexpr char genesis_tx[] = "01a00201ff0001ffffffffffff0302d85a51f64dfbe2d5343e2f5a398582b26c882d60a91fc5fb1040540082c184d6400162182889579a8d2f59afdd01d79e92c0b2270d9f58947c6a4f742832afbe949f021d574f572046434d502b2b20746573746e657420323032362d31302d3034"; +inline constexpr char genesis_hash[] = "ec4899fa454479072232abe5924c1c5a2400c5a3f307f0ef6bd6f66bcbb9b685"; +inline constexpr unsigned genesis_nonce = 20261004; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a226678a5..8c631eebb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -137,3 +137,8 @@ set(enabled_tests add_custom_target(tests DEPENDS enabled_tests) set_property(TARGET tests PROPERTY FOLDER "${folder}") + +option(BUILD_WOW_MINER_PROOF_EXPERIMENT "Build the unaudited Carrot miner proof experiment." OFF) +if(BUILD_WOW_MINER_PROOF_EXPERIMENT) + add_subdirectory(wow_miner_proof) +endif() diff --git a/tests/wow_miner_proof/CMakeLists.txt b/tests/wow_miner_proof/CMakeLists.txt new file mode 100644 index 000000000..aff8a1186 --- /dev/null +++ b/tests/wow_miner_proof/CMakeLists.txt @@ -0,0 +1,38 @@ +# Copyright (c) 2026, The Wownero Project +# SPDX-License-Identifier: BSD-3-Clause + +if(WOWNERO_FCMP_TESTNET) + add_library(wow_miner_proof_experiment INTERFACE) + target_link_libraries(wow_miner_proof_experiment INTERFACE cncrypto) +else() + monero_add_library(wow_miner_proof_experiment + ../../src/crypto/wow_miner_proof.cpp) + target_link_libraries(wow_miner_proof_experiment PUBLIC cncrypto) +endif() + +monero_add_minimal_executable(wow_miner_proof_tests tests.cpp) +target_link_libraries(wow_miner_proof_tests PRIVATE + wow_miner_proof_experiment carrot_core ${GTEST_LIBRARIES}) +add_test(NAME wow_miner_proof COMMAND wow_miner_proof_tests) + +monero_add_minimal_executable(wow_miner_proof_benchmark benchmark.cpp) +target_link_libraries(wow_miner_proof_benchmark PRIVATE + wow_miner_proof_experiment carrot_core) + +monero_add_minimal_executable(wow_testnet_genesis genesis.cpp) +target_link_libraries(wow_testnet_genesis PRIVATE cryptonote_basic) + +if(WOWNERO_FCMP_TESTNET) + monero_add_minimal_executable(wow_testnet_tests testnet.cpp) + target_link_libraries(wow_testnet_tests PRIVATE cryptonote_basic ${GTEST_LIBRARIES}) + add_test(NAME wow_testnet COMMAND wow_testnet_tests) + monero_add_minimal_executable(wow_release_regressions release_regressions.cpp) + target_link_libraries(wow_release_regressions PRIVATE + cryptonote_core rpc_base ${GTEST_LIBRARIES}) + add_test(NAME wow_release_regressions COMMAND wow_release_regressions) + monero_add_minimal_executable(wow_pow_vectors pow_vectors.cpp) + target_include_directories(wow_pow_vectors PRIVATE ../../external/randomwow/src) + target_link_libraries(wow_pow_vectors PRIVATE randomx epee) + monero_add_minimal_executable(wow_local_network local_network.cpp) + target_link_libraries(wow_local_network PRIVATE wallet cryptonote_basic) +endif() diff --git a/tests/wow_miner_proof/README.md b/tests/wow_miner_proof/README.md new file mode 100644 index 000000000..ad9a6934d --- /dev/null +++ b/tests/wow_miner_proof/README.md @@ -0,0 +1,102 @@ +# Carrot miner proof experiment + +> Status: draft · Updated 2026-10-05 · Applies to: Wownero FCMP++ testnet research + +This directory contains a standalone Carrot miner proof experiment and an optional, isolated Wownero FCMP++ network profile. Both build selectors default to `OFF`. The network profile is a research implementation, not a Wownero mainnet migration or a deployable release. + +## Statement and encoding + +Let `K = xG + yT` be the coinbase output public key. `G` and `T` are the exact Ed25519 subgroup generators supplied by the pinned FCMP++ implementation. They are not Ristretto encodings. Knowledge of a representation authorizes this experiment; the proof does not establish who performed the proof of work or prevent remote signing services. + +The prover samples independent nonzero scalars `a, b`, computes `R = aG + bT`, then: + +```text +c = Hs(domain || network_genesis || block_digest || encode(K) || encode(R)) +s_g = a + c*x mod l +s_t = b + c*y mod l +proof = encode(c) || encode(s_g) || encode(s_t) +``` + +`domain` is the 25 ASCII bytes `WOW-CARROT-MINER-PROOF-v1`, without a terminating NUL. The four remaining transcript fields are 32 bytes each. `Hs` is upstream `crypto::hash_to_scalar`, using Keccak-256 followed by reduction modulo the Ed25519 subgroup order `l`. The proof contains three canonical little-endian scalars, totaling 96 bytes. Sampling repeats for identity `R` or zero `c`. + +The verifier reconstructs `R = s_g G + s_t T - cK` and recomputes `c`. It rejects incorrect lengths, noncanonical scalars, zero challenges, identity reconstructed nonces, and invalid public keys. Public keys must use canonical Edwards encodings, be nonidentity, and satisfy `[l]K = identity`. This excludes small-order and mixed-order points without depending on a system libsodium point-validation version. + +The signer rejects a witness that does not open the supplied output key. Nonces and witness scalars use upstream locked and scrubbed containers. This is not a completed side-channel audit of the scalar multiplication implementation or compiler output. + +## Coinbase compatibility + +Tests construct a Carrot coinbase with upstream `get_coinbase_enote_v1`, scan it through the receiver API, and add the recovered sender extensions to the account's two spending scalars. Both new Carrot addresses and legacy single-generator addresses are covered. A Carrot payment to a legacy address still acquires a `T` component through its sender extension. + +## Isolated network profile + +`WOWNERO_FCMP_TESTNET=ON` enables a separate testnet identity, genesis, address prefixes, data directory and binary names. Its daemon and wallets require `--testnet`; mainnet, stagenet and regtest are refused. Built-in upstream seeds, DNS blocklists, checkpoints and binary update queries are disabled. Explicit peers remain available. + +The fresh chain uses feature version 1 at genesis, FCMP++/Carrot version 17 at height 1, and version 18 at height 2. These numbers follow the reference implementation and do not specify a migration from Wownero mainnet. The profile uses 11 decimal places, a five-minute target, Wownero emission parameters without tail subsidy, 288-block coinbase maturity, four-block ordinary output age and one coinbase output. RandomWOW is pinned as a separate submodule. Its difficulty window, lag and cut are 144, 3 and 12; bootstrap difficulty is 100 through height 720. + +The genesis generator hashes independent public domains to curve points without a known spending witness. Its single reward is not assigned to an operator. Regenerate `src/wownero/testnet_genesis.h` with `wow_testnet_genesis`; do not edit the generated bytes. + +## Block authorization + +From feature version 17, the header contains a 96-byte miner proof followed by a two-byte vote. Votes are restricted to 0, 1 or 2. The statement hashes the canonical block hashing blob with the proof zeroed, including the nonce, timestamp, previous block, complete coinbase, transaction set, FCMP root and layer count. Height is bound through the coinbase input. Cached transaction and block hashes are invalidated when constructing the statement. The actual proof enters the normal proof-of-work input. + +The built-in miner scans each template's Carrot coinbase and signs every nonce candidate. Consensus checks the proof during coinbase prevalidation. The synchronous mining helper refuses Carrot blocks because it lacks a signing account. The proof adds 32 bytes to Wownero's existing 64-byte signature and requires no trusted setup beyond the inherited curve generators. It remains unaudited. + +Mining credentials enter through `--wow-miner-key-fd`, which accepts an inherited pipe descriptor of at least 3. The bounded input frame is the eight ASCII bytes `WOWMIN01`, followed by the canonical generate-image, prove-spend and incoming-view scalars, 32 bytes each. The reader closes the descriptor and rejects incomplete frames, invalid scalars and invalid account keys. A legacy account uses a zero prove-spend scalar. The payout address must match the account. Credential values must never enter arguments, logs or source files. + +## Remaining qualification + +Native tests cover block binding, serialization, proof-of-work binding, network isolation and credential input. Daemon regressions cover fork-gated legacy ring offset bounds, exact pruned-transaction boundaries, nested database admission, distribution-cache refusal and console sanitization. The local integration target exercises mining, synchronization, wallet scanning, FCMP payments and restricted-template privacy. It also rejects an unauthorized alternate block, replaces a two-block fork with a greater-work chain, restarts both nodes and confirms an FCMP spend from a wallet that scanned the losing fork. + +Remaining qualification includes independent cryptographic review, complete release-fix reconciliation, deeper reorganizations and proof-of-work key transitions, sustained mining measurements and a reviewed deployment plan. The explicit PoW target compares four public inputs under the RandomWOW interpreter and secure JIT. Matching outputs across platforms establish agreement for those vectors; they do not exhaustively validate every generated program. + +## Reproduction + +Configure the pinned source and initialized submodules with the repository's normal platform dependencies: + +```sh +cmake -S . -B "$WOW_BUILD_DIR" -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_TESTS=ON -DBUILD_WOW_MINER_PROOF_EXPERIMENT=ON +cmake --build "$WOW_BUILD_DIR" \ + --target wow_miner_proof_tests wow_miner_proof_benchmark +ctest --test-dir "$WOW_BUILD_DIR" -R '^wow_miner_proof$' --output-on-failure +"$WOW_BUILD_DIR/tests/wow_miner_proof/wow_miner_proof_benchmark" +``` + +Set `WOW_BUILD_DIR` to a disposable build directory outside the source checkout. `BUILD_WOW_MINER_PROOF_EXPERIMENT` defaults to `OFF` and requires `BUILD_TESTS=ON`. + +The benchmark reports 1,000 wall-clock samples after ten warmups, with a fixed synthetic statement and fresh nonces. Every resulting proof and legacy signature is checked. It includes public-key validation and witness matching in the experimental signing cost. Coinbase scanning, statement serialization, proof of work, network propagation and complete block validation are excluded. Recorded measurements belong with the exact source commit and host/compiler evidence. + +Reference: [Carrot specification](https://github.com/jeffro256/carrot/blob/master/carrot.md). + +## Local network reproduction + +Use a separate build directory for the network profile: + +```sh +cmake -S . -B "$WOW_BUILD_DIR" -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_TESTS=ON -DBUILD_WOW_MINER_PROOF_EXPERIMENT=ON \ + -DWOWNERO_FCMP_TESTNET=ON +cmake --build "$WOW_BUILD_DIR" \ + --target daemon simplewallet wallet_rpc_server wow_testnet_tests \ + wow_miner_proof_tests wow_release_regressions wow_pow_vectors wow_local_network +ctest --test-dir "$WOW_BUILD_DIR" -R '^wow_(miner_proof|testnet|release_regressions)$' --output-on-failure +"$WOW_BUILD_DIR/tests/wow_miner_proof/wow_pow_vectors" +"$WOW_BUILD_DIR/tests/wow_miner_proof/wow_local_network" \ + "$WOW_BUILD_DIR/bin/wownero-fcmp-testnetd" "$WOW_NEW_TEST_DATA_DIR" +``` + +The integration target first runs a loopback fixture that drops the initial `get_info` response, delays the next response for six seconds and requires recovery on a fresh connection. Run this cheap check independently with `wow_local_network --rpc-read-check`. The mining test then requires a new disposable data directory. It binds only loopback ports 49880–49882 and 49890–49892 for P2P, unrestricted RPC and restricted RPC. It disables TLS on its loopback RPC endpoints and rejects an unexpected RPC private-key file. Mining startup retries only an explicit busy response, with a bounded deadline and a specific refusal cause. The read-only `get_info` query uses a 30-second per-attempt timeout, reconnects once after a transport or decoding failure and requires a valid response; state-changing calls are not retried after transport failure. It generates in-memory wallets, passes mining credentials through a pipe and stops its child daemons on exit. It retains public chain data and logs, but no recoverable wallet. The target is explicit rather than part of ordinary CTest because real proof-of-work mining is resource-intensive. + +## Verification checklist + +- [x] Carrot coinbase creation, receiver scanning and representation proof round trip. +- [x] Legacy-address compatibility and rejection of missing witness components. +- [x] Network, digest, key and proof mutation rejection; proof length checks. +- [x] Noncanonical scalar and point rejection; identity, torsion and mixed-order rejection. +- [ ] Independent cryptographic review and cross-implementation vectors. +- [x] Block serialization, statement mutation rejection and miner integration. +- [x] Valid-PoW unauthorized block rejection and authorized control acceptance. +- [x] Local FCMP payment, observed maturity and observer restart. +- [x] Local alternate-chain rejection, two-block wallet recovery and subsequent FCMP spend. +- [ ] Broader fork recovery and proof-of-work key-transition qualification. +- [ ] Sustained mining measurements and deployment-platform qualification. diff --git a/tests/wow_miner_proof/benchmark.cpp b/tests/wow_miner_proof/benchmark.cpp new file mode 100644 index 000000000..872964375 --- /dev/null +++ b/tests/wow_miner_proof/benchmark.cpp @@ -0,0 +1,81 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include "fixture.h" + +#include +#include +#include +#include +#include + +namespace wr = wownero::research; + +template double measure(F &&operation) +{ + const auto start = std::chrono::steady_clock::now(); + if (!operation()) + throw std::runtime_error("Benchmark operation failed validation"); + const auto end = std::chrono::steady_clock::now(); + return std::chrono::duration(end - start).count(); +} + +void report(const char *name, std::vector &samples) +{ + std::sort(samples.begin(), samples.end()); + std::cout << " \"" << name << "\": {\"median_us\": " << samples[samples.size() / 2] + << ", \"p95_us\": " << samples[(samples.size() * 95 + 99) / 100 - 1] << "}"; +} + +int main() +{ + try + { + constexpr std::size_t count = 1000; + const wr::test::coinbase_fixture fixture; + crypto::secret_key legacy_secret; + crypto::public_key legacy_public; + crypto::generate_keys(legacy_public, legacy_secret); + std::vector prove, verify, legacy_prove, legacy_verify; + for (std::size_t i = 0; i < count + 10; ++i) + { + wr::miner_proof proof; + const auto p = measure([&] { return wr::generate_miner_proof(fixture.network, + fixture.message, fixture.enote.onetime_address, fixture.x, fixture.y, proof); }); + const auto v = measure([&] { return fixture.verify(proof); }); + crypto::signature signature; + const auto lp = measure([&] { + crypto::generate_signature(fixture.message, legacy_public, legacy_secret, signature); + return true; + }); + const auto lv = measure([&] { + return crypto::check_signature(fixture.message, legacy_public, signature); + }); + if (i >= 10) + { + prove.push_back(p); + verify.push_back(v); + legacy_prove.push_back(lp); + legacy_verify.push_back(lv); + } + } + std::cout << std::fixed << std::setprecision(3) + << "{\n \"samples\": " << count << ",\n \"warmups\": 10,\n" + << " \"proof_bytes\": " << wr::miner_proof{}.size() << ",\n" + << " \"scope\": \"native primitive; fixed statement; fresh nonces; no block validation or PoW\",\n"; + report("prove", prove); + std::cout << ",\n"; + report("verify", verify); + std::cout << ",\n"; + report("legacy_prove", legacy_prove); + std::cout << ",\n"; + report("legacy_verify", legacy_verify); + std::cout << "\n}\n"; + return 0; + } + catch (const std::exception &error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/wow_miner_proof/fixture.h b/tests/wow_miner_proof/fixture.h new file mode 100644 index 000000000..26ef44607 --- /dev/null +++ b/tests/wow_miner_proof/fixture.h @@ -0,0 +1,77 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#pragma once + +#include "carrot_core/account_secrets.h" +#include "carrot_core/device_ram_borrowed.h" +#include "carrot_core/payment_proposal.h" +#include "carrot_core/scan.h" +#include "crypto/generators.h" +#include "crypto/wow_miner_proof.h" + +#include + +namespace wownero::research::test +{ +template unsigned char *bytes(T &value) +{ + return reinterpret_cast(value.data); +} + +struct coinbase_fixture +{ + crypto::secret_key account_g, account_t, x, y; + carrot::CarrotCoinbaseEnoteV1 enote; + crypto::hash network = crypto::cn_fast_hash("research-network", 16); + crypto::hash message = crypto::cn_fast_hash("research-block", 14); + + explicit coinbase_fixture(bool legacy_address = false) + { + crypto::secret_key master, view_balance, image_preimage, view; + crypto::random32_unbiased(bytes(master)); + crypto::public_key partial_spend, account_spend, view_public; + carrot::make_carrot_provespend_key(master, account_t); + carrot::make_carrot_partial_spend_pubkey(account_t, partial_spend); + carrot::make_carrot_viewbalance_secret(master, view_balance); + carrot::make_carrot_generateimage_preimage(view_balance, image_preimage); + carrot::make_carrot_generateimage_key(image_preimage, partial_spend, account_g); + carrot::make_carrot_viewincoming_key(view_balance, view); + if (legacy_address) + account_t = crypto::secret_key{}; + carrot::make_carrot_spend_pubkey(account_g, account_t, account_spend); + if (!crypto::secret_key_to_public_key(view, view_public)) + throw std::runtime_error("Unable to derive fixture view public key"); + + carrot::CarrotPaymentProposalV1 proposal{}; + carrot::make_carrot_main_address_v1(account_spend, view_public, proposal.destination); + proposal.amount = 100000000000; + proposal.randomness = carrot::gen_janus_anchor(); + carrot::get_coinbase_enote_v1(proposal, 101, enote); + carrot::view_incoming_key_ram_borrowed_device device(view); + mx25519_pubkey shared; + crypto::secret_key extension_g, extension_t; + if (!carrot::try_make_carrot_shared_key_receiver(device, enote.enote_ephemeral_pubkey, shared) || + !carrot::try_scan_carrot_coinbase_enote_receiver(enote, shared, account_spend, + view_public, extension_g, extension_t)) + throw std::runtime_error("Unable to scan fixture coinbase"); + + sc_add(bytes(x), bytes(account_g), bytes(extension_g)); + sc_add(bytes(y), bytes(account_t), bytes(extension_t)); + } + + miner_proof sign() const + { + miner_proof proof; + if (!generate_miner_proof(network, message, enote.onetime_address, x, y, proof)) + throw std::runtime_error("Unable to sign fixture coinbase"); + return proof; + } + + bool verify(const miner_proof &proof) const + { + return verify_miner_proof(network, message, enote.onetime_address, + {proof.data(), proof.size()}); + } +}; +} diff --git a/tests/wow_miner_proof/genesis.cpp b/tests/wow_miner_proof/genesis.cpp new file mode 100644 index 000000000..be7759f58 --- /dev/null +++ b/tests/wow_miner_proof/genesis.cpp @@ -0,0 +1,48 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include +#include + +#include "crypto/crypto.h" +#include "cryptonote_basic/cryptonote_basic.h" +#include "cryptonote_basic/cryptonote_format_utils.h" +#include "string_tools.h" + +namespace +{ +crypto::public_key public_point(std::string_view domain) +{ + crypto::public_key key; + crypto::unbiased_hash_to_ec(reinterpret_cast(domain.data()), domain.size(), key); + return key; +} +} + +int main() +{ + cryptonote::block genesis{}; + genesis.major_version = 1; + genesis.minor_version = 0; + genesis.timestamp = 0; + genesis.nonce = 20261004; + auto &tx = genesis.miner_tx; + tx.version = 1; + tx.unlock_time = 288; + tx.vin.emplace_back(cryptonote::txin_gen{0}); + // Hash-to-point creates a genesis destination without a known spending witness. + tx.vout.push_back({UINT64_MAX >> 20, + cryptonote::txout_to_key{public_point("Wownero FCMP testnet genesis output 2026-10-04")}}); + cryptonote::add_tx_pub_key_to_extra(tx, + public_point("Wownero FCMP testnet genesis transaction key 2026-10-04")); + cryptonote::add_extra_nonce_to_tx_extra(tx.extra, "WOW FCMP++ testnet 2026-10-04"); + std::cout << "// Copyright (c) 2026, The Wownero Project\n" + << "// SPDX-License-Identifier: BSD-3-Clause\n" + << "// Do not edit. Regenerate with the wow_testnet_genesis executable.\n\n" + << "#pragma once\n\nnamespace wownero::testnet\n{\n" + << "inline constexpr char genesis_tx[] = \"" + << epee::string_tools::buff_to_hex_nodelimer(cryptonote::tx_to_blob(tx)) << "\";\n" + << "inline constexpr char genesis_hash[] = \"" + << epee::string_tools::pod_to_hex(cryptonote::get_block_hash(genesis)) << "\";\n" + << "inline constexpr unsigned genesis_nonce = 20261004;\n}\n"; +} diff --git a/tests/wow_miner_proof/local_network.cpp b/tests/wow_miner_proof/local_network.cpp new file mode 100644 index 000000000..f80fdca23 --- /dev/null +++ b/tests/wow_miner_proof/local_network.cpp @@ -0,0 +1,605 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cryptonote_basic/account.h" +#include "cryptonote_basic/wow_miner.h" +#include "cryptonote_basic/difficulty.h" +#include "cryptonote_core/cryptonote_tx_utils.h" +#include "rpc/core_rpc_server_error_codes.h" +#include "net/http_client.h" +#include "rpc/core_rpc_server_commands_defs.h" +#include "wallet/wallet2.h" + +namespace +{ +using namespace cryptonote; +using namespace std::chrono_literals; + +void require(bool condition, const char *reason) +{ + if (!condition) + throw std::runtime_error(reason); +} + +struct local_node +{ + pid_t pid = -1; + epee::net_utils::http::http_simple_client rpc; + + ~local_node() { stop(); } + void stop() + { + if (pid < 0) + return; + kill(pid, SIGTERM); + for (unsigned i = 0; i < 300; ++i) + { + if (waitpid(pid, nullptr, WNOHANG) == pid) + { + pid = -1; + return; + } + std::this_thread::sleep_for(100ms); + } + kill(pid, SIGKILL); + waitpid(pid, nullptr, 0); + pid = -1; + } + + void start(const std::string &binary, const std::filesystem::path &data, unsigned port, + unsigned peer, const wow_miner_account *account = nullptr, bool offline = false) + { + std::filesystem::create_directories(data); + int keys[2] = {-1, -1}; + if (account) + { + require(pipe(keys) == 0, "Unable to create the mining credential pipe"); + require(write(keys[1], "WOWMIN01", 8) == 8, "Unable to write mining frame header"); + for (const auto *key : {&account->generate_image, &account->prove_spend, &account->view_incoming}) + require(write(keys[1], key->data, 32) == 32, "Unable to write mining frame"); + close(keys[1]); + } + const int log = open((data / "console.log").c_str(), O_CREAT | O_WRONLY | O_APPEND, 0600); + require(log >= 0, "Unable to open the local daemon log"); + std::vector arguments{binary, "--testnet", "--non-interactive", "--no-zmq", + "--data-dir", data.string(), "--log-file", (data / "daemon.log").string(), + "--p2p-bind-ip", "127.0.0.1", "--p2p-bind-port", std::to_string(port), + "--rpc-bind-ip", "127.0.0.1", "--rpc-bind-port", std::to_string(port + 1), + "--rpc-restricted-bind-port", std::to_string(port + 2), + "--rpc-ssl", "disabled", + "--allow-local-ip", "--hide-my-port", "--no-igd", "--max-concurrency", "2", + "--check-updates", "disabled", "--disable-dns-checkpoints", "--log-level", "0"}; + if (offline) + arguments.emplace_back("--offline"); + if (peer) + { + arguments.emplace_back("--add-exclusive-node"); + arguments.push_back("127.0.0.1:" + std::to_string(peer)); + } + if (account) + { + arguments.emplace_back("--wow-miner-key-fd"); + arguments.push_back(std::to_string(keys[0])); + } + std::vector argv; + for (auto &argument : arguments) + argv.push_back(argument.data()); + argv.push_back(nullptr); + pid = fork(); + require(pid >= 0, "Unable to fork the local daemon"); + if (pid == 0) + { + dup2(log, STDOUT_FILENO); + dup2(log, STDERR_FILENO); + close(log); + execv(binary.c_str(), argv.data()); + _exit(127); + } + close(log); + if (keys[0] >= 0) + close(keys[0]); + require(rpc.set_server("127.0.0.1:" + std::to_string(port + 1), boost::none), "Invalid local RPC endpoint"); + const auto deadline = std::chrono::steady_clock::now() + 90s; + while (std::chrono::steady_clock::now() < deadline) + { + int status; + if (waitpid(pid, &status, WNOHANG) == pid) + { + pid = -1; + throw std::runtime_error("Local daemon exited before RPC startup; inspect its local log"); + } + COMMAND_RPC_GET_INFO::response info{}; + if (get_info(info)) + { + require(!std::filesystem::exists(data / "testnet" / "rpc_ssl.key"), + "Loopback daemon created an RPC private-key file"); + return; + } + std::this_thread::sleep_for(500ms); + } + throw std::runtime_error("Local daemon RPC startup timed out"); + } + + bool get_info(COMMAND_RPC_GET_INFO::response &info) + { + for (unsigned attempt = 0; attempt < 2; ++attempt) + { + info = {}; + if (epee::net_utils::invoke_http_json("/get_info", COMMAND_RPC_GET_INFO::request{}, info, rpc, 30s)) + { + if (info.status == CORE_RPC_STATUS_OK) + return true; + std::cerr << "get_info refused: " << info.status << std::endl; + return false; + } + if (attempt == 0) + { + std::cerr << "get_info transport or decoding failed; reconnecting once" << std::endl; + rpc.disconnect(); + } + } + std::cerr << "get_info transport or decoding failed after reconnect" << std::endl; + return false; + } + void mining(const std::string &address, bool enable) + { + if (enable) + { + COMMAND_RPC_START_MINING::request req{}; + COMMAND_RPC_START_MINING::response res{}; + req.miner_address = address; + req.threads_count = 2; + req.ignore_battery = true; + const auto deadline = std::chrono::steady_clock::now() + 90s; + do + { + res = {}; + require(epee::net_utils::invoke_http_json("/start_mining", req, res, rpc, 30s), + "Local mining start RPC transport failed"); + if (res.status == CORE_RPC_STATUS_OK) + return; + if (res.status != CORE_RPC_STATUS_BUSY) + throw std::runtime_error("Local mining start refused: " + res.status); + std::this_thread::sleep_for(250ms); + } while (std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("Local mining start remained busy after the readiness deadline"); + } + else + { + COMMAND_RPC_STOP_MINING::response res{}; + require(epee::net_utils::invoke_http_json("/stop_mining", COMMAND_RPC_STOP_MINING::request{}, res, rpc, 30s) + && res.status == CORE_RPC_STATUS_OK, "Local mining stop failed"); + } + } +}; + + +void verify_read_reconnect() +{ + using tcp = boost::asio::ip::tcp; + boost::asio::io_context io; + tcp::acceptor listener(io, tcp::endpoint(boost::asio::ip::address_v4::loopback(), 0)); + std::atomic first_request_dropped{false}; + std::shared_ptr responding_socket; + COMMAND_RPC_GET_INFO::response expected{}; + expected.status = CORE_RPC_STATUS_OK; + expected.height = 42; + const std::string body = epee::serialization::store_t_to_json(expected); + const auto reply = std::make_shared( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + std::to_string(body.size()) + "\r\nConnection: close\r\n\r\n" + body); + std::function accept; + accept = [&] { + const auto socket = std::make_shared(io); + listener.async_accept(*socket, [&, socket](const boost::system::error_code &error) { + if (error) + return; + const auto request = std::make_shared(); + boost::asio::async_read_until(*socket, *request, "\r\n\r\n", + [&, socket, request](const boost::system::error_code &read_error, size_t) { + if (read_error) + return; + if (!first_request_dropped.exchange(true)) + { + boost::system::error_code ignored; + socket->close(ignored); + accept(); + return; + } + responding_socket = socket; + // Transaction validation can hold the daemon lock beyond a short read deadline. + const auto delay = std::make_shared(io, 6s); + delay->async_wait([socket, reply, delay](const boost::system::error_code &wait_error) { + if (wait_error) + return; + boost::asio::async_write(*socket, boost::asio::buffer(*reply), + [socket, reply](const boost::system::error_code &, size_t) { + boost::system::error_code ignored; + socket->shutdown(tcp::socket::shutdown_send, ignored); + }); + }); + }); + }); + }; + local_node client; + require(client.rpc.set_server("127.0.0.1:" + std::to_string(listener.local_endpoint().port()), + boost::none, epee::net_utils::ssl_support_t::e_ssl_support_disabled), + "Unable to configure the read-reconnection fixture"); + accept(); + std::thread server([&] { io.run(); }); + COMMAND_RPC_GET_INFO::response observed{}; + bool received = false; + try + { + received = client.get_info(observed); + } + catch (...) + { + io.stop(); + server.join(); + throw; + } + io.stop(); + server.join(); + require(first_request_dropped && received && observed.height == expected.height, + "Read-only RPC did not recover the response after a dropped connection"); + std::cout << "read_rpc_reconnect_verified=true" << std::endl; +} + +struct prepared_block +{ + block candidate; + crypto::hash seed; + crypto::secret_key x, y; + uint64_t difficulty; + + prepared_block(local_node &node, const std::string &address, const wow_miner_account &account) + { + COMMAND_RPC_GETBLOCKTEMPLATE::request req{}; + COMMAND_RPC_GETBLOCKTEMPLATE::response res{}; + req.wallet_address = address; + require(epee::net_utils::invoke_http_json_rpc("/json_rpc", "get_block_template", req, res, node.rpc) + && res.status == CORE_RPC_STATUS_OK, "Unable to obtain a test block template"); + require(res.difficulty_top64 == 0, "Unexpected local test difficulty width"); + std::string blob; + require(epee::string_tools::parse_hexstr_to_binbuff(res.blocktemplate_blob, blob) + && parse_and_validate_block_from_blob(blob, candidate) + && epee::string_tools::hex_to_pod(res.seed_hash, seed), "Invalid local block template"); + require(prepare_wow_miner_output_keys(candidate, account, x, y), "Unable to recover template witnesses"); + difficulty = res.difficulty; + } + + void solve(bool authorize) + { + const auto deadline = std::chrono::steady_clock::now() + 90s; + do + { + candidate.invalidate_hashes(); + if (authorize) + require(sign_wow_miner_block(candidate, x, y), "Unable to authorize the test block"); + else + candidate.wow_miner_signature.fill(0); + crypto::hash pow; + get_altblock_longhash(candidate, pow, seed); + if (check_hash(pow, difficulty)) + return; + ++candidate.nonce; + } while (std::chrono::steady_clock::now() < deadline); + throw std::runtime_error("Test block proof-of-work search timed out"); + } +}; + +void submit_block(local_node &node, const block &candidate, bool expect_acceptance) +{ + COMMAND_RPC_SUBMITBLOCK::request req{epee::string_tools::buff_to_hex_nodelimer(block_to_blob(candidate))}; + COMMAND_RPC_SUBMITBLOCK::response res{}; + epee::json_rpc::error error{}; + const bool accepted = epee::net_utils::invoke_http_json_rpc("/json_rpc", "submit_block", + req, res, error, node.rpc, 30s); + if (expect_acceptance) + require(accepted && res.status == CORE_RPC_STATUS_OK, "Authorized test block was not accepted"); + else + require(!accepted && error.code == CORE_RPC_ERROR_CODE_BLOCK_NOT_ACCEPTED, + "Invalid test block was not explicitly rejected"); +} + +void verify_miner_proof_enforcement(local_node &node, const std::string &address, + const wow_miner_account &account) +{ + COMMAND_RPC_GET_INFO::response before{}, after{}; + require(node.get_info(before), "Unable to read the chain before authorization tests"); + prepared_block test(node, address, account); + test.solve(false); + submit_block(node, test.candidate, false); + require(node.get_info(after) && after.top_block_hash == before.top_block_hash, + "Unauthorized block changed the chain tip"); + test.solve(true); + submit_block(node, test.candidate, true); + std::cout << "unauthorized_pow_block_rejected=true authorized_control_accepted=true" << std::endl; +} + +void verify_restricted_templates(local_node &node, const std::string &address, + const std::vector &transfers) +{ + COMMAND_RPC_FLUSH_TRANSACTION_POOL::request flush{}; + for (const auto &transfer : transfers) + { + COMMAND_RPC_SEND_RAW_TX::request req{}; + COMMAND_RPC_SEND_RAW_TX::response res{}; + req.tx_as_hex = epee::string_tools::buff_to_hex_nodelimer(tx_to_blob(transfer.tx)); + req.do_not_relay = true; + req.do_sanity_checks = true; + require(epee::net_utils::invoke_http_json("/send_raw_transaction", req, res, node.rpc, 30s) + && res.status == CORE_RPC_STATUS_OK && res.not_relayed, "Unable to admit a private test transaction"); + flush.txids.push_back(epee::string_tools::pod_to_hex(get_transaction_hash(transfer.tx))); + } + epee::net_utils::http::http_simple_client restricted; + require(restricted.set_server("127.0.0.1:49882", boost::none), "Invalid restricted test endpoint"); + // Alternate callers to exercise both directions of the shared template cache. + for (const bool public_rpc : {false, true, true, false, true}) + { + COMMAND_RPC_GETBLOCKTEMPLATE::request req{}; + COMMAND_RPC_GETBLOCKTEMPLATE::response res{}; + req.wallet_address = address; + auto &client = public_rpc ? restricted : node.rpc; + require(epee::net_utils::invoke_http_json_rpc("/json_rpc", "get_block_template", req, res, client) + && res.status == CORE_RPC_STATUS_OK, "Template policy test RPC failed"); + std::string blob; + block b; + require(epee::string_tools::parse_hexstr_to_binbuff(res.blocktemplate_blob, blob) + && parse_and_validate_block_from_blob(blob, b), "Template policy test returned an invalid block"); + for (const auto &transfer : transfers) + { + const bool included = std::find(b.tx_hashes.begin(), b.tx_hashes.end(), + get_transaction_hash(transfer.tx)) != b.tx_hashes.end(); + require(included == !public_rpc, "Template disclosed a private transaction or omitted the private control"); + } + } + COMMAND_RPC_FLUSH_TRANSACTION_POOL::response flushed{}; + require(epee::net_utils::invoke_http_json_rpc("/json_rpc", "flush_txpool", flush, flushed, node.rpc) + && flushed.status == CORE_RPC_STATUS_OK, "Unable to clear the private test transactions"); + std::cout << "restricted_template_privacy_verified=true" << std::endl; +} + +void verify_reorganization(local_node &miner, local_node &observer, const std::string &binary, + const std::filesystem::path &data, const std::string &address, const wow_miner_account &account, + tools::wallet2 &recovering_wallet) +{ + COMMAND_RPC_GET_INFO::response common{}, competing{}, tip{}; + require(miner.get_info(common) && observer.get_info(tip) && common.top_block_hash == tip.top_block_hash, + "Reorganization test requires a common starting tip"); + miner.stop(); + observer.stop(); + miner.start(binary, data / "miner", 49880, 0, &account, true); + observer.start(binary, data / "observer", 49890, 0, &account, true); + for (unsigned i = 0; i < 2; ++i) + { + prepared_block b(miner, address, account); + b.solve(true); + submit_block(miner, b.candidate, true); + } + require(miner.get_info(competing) && competing.height == common.height + 2, + "Short fork did not advance by two blocks"); + require(recovering_wallet.init("http://127.0.0.1:49881"), "Unable to select the losing fork for wallet scanning"); + recovering_wallet.refresh(true); + require(recovering_wallet.get_blockchain_current_height() == competing.height, + "The recovering wallet did not scan the losing fork"); + + prepared_block alternative(observer, address, account); + alternative.solve(false); + submit_block(miner, alternative.candidate, false); + require(miner.get_info(tip) && tip.top_block_hash == competing.top_block_hash, + "Unauthorized alternate block changed the chain tip"); + + std::vector fork; + alternative.solve(true); + submit_block(observer, alternative.candidate, true); + fork.push_back(alternative.candidate); + for (unsigned i = 0; i < 3; ++i) + { + prepared_block b(observer, address, account); + b.solve(true); + submit_block(observer, b.candidate, true); + fork.push_back(b.candidate); + } + submit_block(miner, fork.front(), true); + require(miner.get_info(tip) && tip.top_block_hash == competing.top_block_hash, + "A shorter valid alternate chain displaced the main chain"); + for (size_t i = 1; i < fork.size(); ++i) + submit_block(miner, fork[i], true); + require(observer.get_info(competing) && miner.get_info(tip) + && tip.top_block_hash == competing.top_block_hash && tip.height == common.height + fork.size(), + "The node did not adopt the greater-work authorized fork"); + miner.stop(); + observer.stop(); + miner.start(binary, data / "miner", 49880, 49890, &account); + observer.start(binary, data / "observer", 49890, 49880); + require(miner.get_info(tip) && tip.top_block_hash == competing.top_block_hash + && observer.get_info(tip) && tip.top_block_hash == competing.top_block_hash, + "Restart did not preserve the reorganized chain"); + require(recovering_wallet.init("http://127.0.0.1:49891"), "Unable to restore the observer wallet endpoint"); + recovering_wallet.refresh(true); + require(recovering_wallet.get_blockchain_current_height() == competing.height, + "The recovering wallet did not adopt the winning fork"); + std::cout << "alternate_unauthorized_pow_rejected=true reorganization_verified=true wallet_reorganization_verified=true detached_blocks=2" << std::endl; +} + +void wait_height(local_node &node, uint64_t wanted) +{ + const auto deadline = std::chrono::steady_clock::now() + 15min; + auto next_report = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() < deadline) + { + COMMAND_RPC_GET_INFO::response info{}; + require(node.get_info(info), "Local get_info did not return a valid response"); + if (info.height >= wanted) + return; + if (std::chrono::steady_clock::now() >= next_report) + { + std::cout << "height=" << info.height << " target=" << wanted << std::endl; + next_report = std::chrono::steady_clock::now() + 20s; + } + std::this_thread::sleep_for(250ms); + } + throw std::runtime_error("Local mining or synchronization timed out"); +} +} + +int main(int argc, char **argv) +{ + try + { + if (argc == 2 && std::string(argv[1]) == "--rpc-read-check") + { + verify_read_reconnect(); + return 0; + } + require(argc == 3, "Usage: wow_local_network DAEMON NEW_DATA_DIRECTORY | --rpc-read-check"); + verify_read_reconnect(); + const std::filesystem::path data = std::filesystem::absolute(argv[2]); + require(!std::filesystem::exists(data), "The local test requires a new data directory"); + account_base source, destination; + source.generate(); + destination.generate(); + const auto &keys = source.get_keys(); + wow_miner_account miner_account; + miner_account.generate_image = keys.m_spend_secret_key; + miner_account.view_incoming = keys.m_view_secret_key; + const auto address = source.get_public_address_str(TESTNET); + local_node miner, observer; + miner.start(argv[1], data / "miner", 49880, 49890, &miner_account); + observer.start(argv[1], data / "observer", 49890, 49880); + miner.mining(address, true); + wait_height(miner, CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW + 12); + miner.mining(address, false); + COMMAND_RPC_GET_INFO::response mined{}; + require(miner.get_info(mined), "Miner RPC failed after stopping"); + wait_height(observer, mined.height); + COMMAND_RPC_GET_INFO::response observed{}; + require(observer.get_info(observed) && observed.top_block_hash == mined.top_block_hash, + "The local nodes disagree on the mined chain"); + std::cout << "synchronized_height=" << mined.height << std::endl; + + tools::wallet2 sender(TESTNET, 1, true), receiver(TESTNET, 1, true); + sender.ask_password(tools::wallet2::AskPasswordNever); + receiver.ask_password(tools::wallet2::AskPasswordNever); + sender.generate("", "", keys.m_account_address, keys.m_spend_secret_key, keys.m_view_secret_key); + const auto &recipient_keys = destination.get_keys(); + receiver.generate("", "", recipient_keys.m_account_address, + recipient_keys.m_spend_secret_key, recipient_keys.m_view_secret_key); + require(sender.init("http://127.0.0.1:49881") && receiver.init("http://127.0.0.1:49891"), + "Unable to initialize the in-memory test wallets"); + sender.refresh(true); + require(sender.unlocked_balance(0, true) > COIN, "Mature coinbase funds were not scanned"); + std::vector destinations{{COIN, recipient_keys.m_account_address, false}}; + auto transfers = sender.create_transactions_2(destinations, 0, tools::fee_priority::Unimportant, {}, 0, {}); + require(!transfers.empty(), "Wallet produced no FCMP transaction"); + for (const auto &transfer : transfers) + { + require(rct::is_rct_fcmp(transfer.tx.rct_signatures.type), "Wallet did not construct an FCMP transaction"); + std::cout << "fcmp_transaction_bytes=" << tx_to_blob(transfer.tx).size() + << " fee_atomic=" << transfer.fee << std::endl; + } + verify_restricted_templates(miner, address, transfers); + sender.commit_tx(transfers); + std::cout << "fcmp_transaction_relayed=true" << std::endl; + COMMAND_RPC_GET_TRANSACTIONS::request lookup{}; + lookup.prune = true; + for (const auto &transfer : transfers) + lookup.txs_hashes.push_back(epee::string_tools::pod_to_hex(get_transaction_hash(transfer.tx))); + miner.mining(address, true); + const auto payment_deadline = std::chrono::steady_clock::now() + 180s; + auto next_report = std::chrono::steady_clock::now(); + for (;;) + { + require(std::chrono::steady_clock::now() < payment_deadline, "Payment inclusion or maturity timed out"); + COMMAND_RPC_GET_TRANSACTIONS::response transactions{}; + require(epee::net_utils::invoke_http_json("/get_transactions", lookup, transactions, miner.rpc, 10s) + && transactions.status == CORE_RPC_STATUS_OK, "Unable to observe the relayed payment"); + require(miner.get_info(mined), "Unable to read the payment confirmation height"); + const bool mature = std::all_of(lookup.txs_hashes.begin(), lookup.txs_hashes.end(), [&](const std::string &hash) { + return std::any_of(transactions.txs.begin(), transactions.txs.end(), [&](const auto &tx) { + return tx.tx_hash == hash && !tx.in_pool && + mined.height > tx.block_height + CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE; + }); + }); + if (mature) + break; + if (std::chrono::steady_clock::now() >= next_report) + { + std::cout << "payment_confirmation_pending height=" << mined.height << std::endl; + next_report = std::chrono::steady_clock::now() + 20s; + } + std::this_thread::sleep_for(500ms); + } + miner.mining(address, false); + require(miner.get_info(mined), "Miner RPC failed after payment confirmation"); + wait_height(observer, mined.height); + receiver.refresh(true); + require(receiver.balance(0, true) == COIN, "Recipient did not receive the exact payment"); + require(receiver.unlocked_balance(0, true) == COIN, "Recipient unlocked payment amount differs"); + verify_miner_proof_enforcement(miner, address, miner_account); + require(miner.get_info(mined), "Unable to read height after the authorized control block"); + wait_height(observer, mined.height); + observer.stop(); + observer.start(argv[1], data / "observer", 49890, 49880); + require(observer.get_info(observed) && observed.top_block_hash == mined.top_block_hash, + "Observer restart did not recover the accepted chain"); + std::cout << "fcmp_payment_verified=true restart_verified=true height=" << mined.height << std::endl; + verify_reorganization(miner, observer, argv[1], data, address, miner_account, receiver); + receiver.refresh(true); + require(receiver.unlocked_balance(0, true) == COIN, "Reorganization changed the settled recipient balance"); + std::vector return_destinations{{COIN / 2, keys.m_account_address, false}}; + auto returned = receiver.create_transactions_2(return_destinations, 0, tools::fee_priority::Unimportant, {}, 0, {}); + require(!returned.empty(), "No FCMP payment was constructed after reorganization"); + for (const auto &transfer : returned) + require(rct::is_rct_fcmp(transfer.tx.rct_signatures.type), "Post-reorganization spend did not use FCMP"); + receiver.commit_tx(returned); + require(miner.get_info(mined), "Unable to read post-reorganization height"); + miner.mining(address, true); + const auto recovery_deadline = std::chrono::steady_clock::now() + 180s; + for (;;) + { + require(std::chrono::steady_clock::now() < recovery_deadline, "Post-reorganization payment confirmation timed out"); + COMMAND_RPC_GET_TRANSACTIONS::request req{}; + COMMAND_RPC_GET_TRANSACTIONS::response res{}; + req.prune = true; + for (const auto &transfer : returned) + req.txs_hashes.push_back(epee::string_tools::pod_to_hex(get_transaction_hash(transfer.tx))); + require(epee::net_utils::invoke_http_json("/get_transactions", req, res, miner.rpc, 10s) + && res.status == CORE_RPC_STATUS_OK, "Unable to observe the post-reorganization payment"); + const bool confirmed = std::all_of(req.txs_hashes.begin(), req.txs_hashes.end(), [&](const auto &hash) { + return std::any_of(res.txs.begin(), res.txs.end(), [&](const auto &tx) { + return tx.tx_hash == hash && !tx.in_pool; + }); + }); + if (confirmed) + break; + std::this_thread::sleep_for(500ms); + } + miner.mining(address, false); + require(miner.get_info(mined), "Unable to read final chain height"); + wait_height(observer, mined.height); + std::cout << "post_reorganization_fcmp_spend_confirmed=true height=" << mined.height << std::endl; + return 0; + } + catch (const std::exception &error) + { + std::cerr << "Local test failed: " << error.what() << std::endl; + return 1; + } +} diff --git a/tests/wow_miner_proof/pow_vectors.cpp b/tests/wow_miner_proof/pow_vectors.cpp new file mode 100644 index 000000000..00e9b26cb --- /dev/null +++ b/tests/wow_miner_proof/pow_vectors.cpp @@ -0,0 +1,58 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include +#include +#include +#include +#include +#include + +#include "randomx.h" +#include "string_tools.h" + +int main() +{ + try + { + const std::string seed = "Wownero FCMP testnet PoW qualification v1"; + const std::vector inputs{ + "Wownero FCMP testnet genesis domain", + "Wownero FCMP testnet fork A", + "Wownero FCMP testnet fork B", + std::string(256, '\0')}; + std::vector> expected; + for (const auto flags : {RANDOMX_FLAG_DEFAULT, RANDOMX_FLAG_JIT}) + { + std::unique_ptr cache( + randomx_alloc_cache(flags), randomx_release_cache); + if (!cache) + throw std::runtime_error("Unable to allocate the RandomWOW qualification cache"); + randomx_init_cache(cache.get(), seed.data(), seed.size()); + std::unique_ptr vm( + randomx_create_vm(flags | RANDOMX_FLAG_SECURE, cache.get(), nullptr), randomx_destroy_vm); + if (!vm) + throw std::runtime_error("Unable to create the requested RandomWOW VM"); + for (size_t i = 0; i < inputs.size(); ++i) + { + std::array hash{}; + randomx_calculate_hash(vm.get(), inputs[i].data(), inputs[i].size(), hash.data()); + if (flags == RANDOMX_FLAG_DEFAULT) + expected.push_back(hash); + else if (hash != expected.at(i)) + throw std::runtime_error("RandomWOW interpreter and JIT disagree"); + } + } + for (size_t i = 0; i < expected.size(); ++i) + std::cout << "vector=" << i << " hash=" + << epee::string_tools::buff_to_hex_nodelimer(std::string(expected[i].data(), expected[i].size())) + << '\n'; + std::cout << "interpreter_jit_agreement=true\n"; + return 0; + } + catch (const std::exception &error) + { + std::cerr << "PoW qualification failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/tests/wow_miner_proof/release_regressions.cpp b/tests/wow_miner_proof/release_regressions.cpp new file mode 100644 index 000000000..71aa636d0 --- /dev/null +++ b/tests/wow_miner_proof/release_regressions.cpp @@ -0,0 +1,167 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include +#include +#include +#include +#include + +#include "blockchain_db/lmdb/db_lmdb.h" +#include "cryptonote_basic/cryptonote_format_utils.h" +#include "cryptonote_core/cryptonote_core.h" +#include "easylogging++.h" +#include "rpc/rpc_handler.h" +#include "string_tools.h" +#include "wownero/testnet_genesis.h" + +namespace +{ +using namespace cryptonote; +using namespace std::chrono_literals; + +TEST(WowReleaseRegression, LegacyRingOffsetsRespectForkBoundary) +{ + transaction tx; + tx.version = 2; + txin_to_key input{}; + input.key_offsets = {std::numeric_limits::max(), 1}; + tx.vin.push_back(input); + EXPECT_TRUE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS - 1)); + EXPECT_FALSE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS)); + EXPECT_FALSE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS + 1)); + + auto &ring = boost::get(tx.vin.front()).key_offsets; + ring = {std::numeric_limits::max() - 1, 1}; + EXPECT_TRUE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS)); + ring = {1, std::numeric_limits::max()}; + EXPECT_FALSE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS)); +} + +TEST(WowReleaseRegression, LegacyRingOffsetsAreCheckedPerInput) +{ + transaction tx; + tx.version = 2; + txin_to_key first{}, second{}; + first.key_offsets = {std::numeric_limits::max()}; + second.key_offsets = {1, 2}; + tx.vin = {first, second}; + EXPECT_TRUE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS)); + boost::get(tx.vin.back()).key_offsets = {std::numeric_limits::max(), 2}; + EXPECT_FALSE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS)); + tx.vin.back() = txin_gen{}; + EXPECT_FALSE(core::check_tx_inputs_ring_members_overflow(tx, HF_VERSION_FCMP_PLUS_PLUS)); +} + +TEST(WowReleaseRegression, PrunedBaseRequiresExactBoundary) +{ + std::string blob; + ASSERT_TRUE(epee::string_tools::parse_hexstr_to_binbuff(wownero::testnet::genesis_tx, blob)); + transaction tx; + ASSERT_TRUE(parse_and_validate_tx_base_from_blob(blob, tx, true, true)); + blob.push_back('\0'); + EXPECT_FALSE(parse_and_validate_tx_base_from_blob(blob, tx, true, true)); + EXPECT_TRUE(parse_and_validate_tx_base_from_blob(blob, tx, true, false)); + blob.pop_back(); + blob.pop_back(); + EXPECT_FALSE(parse_and_validate_tx_base_from_blob(blob, tx, true, true)); +} + +TEST(WowReleaseRegression, NestedDatabaseAdmissionSurvivesClosedGate) +{ + const auto baseline = mdb_txn_safe::num_active_txns.load(); + std::promise outer_ready, start_nested, nested_done, release_outer; + auto ready = outer_ready.get_future(); + auto start = start_nested.get_future(); + auto done = nested_done.get_future(); + auto release = release_outer.get_future(); + std::thread worker([&] { + mdb_txn_safe outer; + outer_ready.set_value(); + start.wait(); + { + mdb_txn_safe nested; + nested_done.set_value(); + release.wait(); + } + }); + ready.wait(); + mdb_txn_safe::prevent_new_txns(); + start_nested.set_value(); + const bool nested_admitted = done.wait_for(2s) == std::future_status::ready; + // Reopen even on failure so the regression cannot strand its worker. + mdb_txn_safe::allow_new_txns(); + release_outer.set_value(); + worker.join(); + EXPECT_TRUE(nested_admitted); + EXPECT_EQ(mdb_txn_safe::num_active_txns.load(), baseline); +} + +TEST(WowReleaseRegression, FreshDatabaseAdmissionWaitsForOpenGate) +{ + const auto baseline = mdb_txn_safe::num_active_txns.load(); + std::promise started, admitted; + auto running = started.get_future(); + auto ready = admitted.get_future(); + mdb_txn_safe::prevent_new_txns(); + std::thread worker([&] { + started.set_value(); + mdb_txn_safe txn; + admitted.set_value(); + }); + running.wait(); + const bool remained_closed = ready.wait_for(100ms) == std::future_status::timeout; + mdb_txn_safe::allow_new_txns(); + worker.join(); + EXPECT_TRUE(remained_closed); + EXPECT_EQ(mdb_txn_safe::num_active_txns.load(), baseline); +} + +TEST(WowReleaseRegression, DistributionRefusalPreservesCachedBounds) +{ + bool reorganized = false; + auto hash = [&](uint64_t height) { + crypto::hash value{}; + value.data[0] = static_cast(height); + value.data[1] = reorganized && height > 10; + return value; + }; + unsigned calls = 0; + auto distribution = [&](uint64_t, uint64_t, uint64_t, + uint64_t &start, std::vector &values, uint64_t &base) { + ++calls; + start = 16; + base = 0; + values = {1, 2, 3, 4, 5}; + return true; + }; + ASSERT_TRUE(rpc::RpcHandler::get_output_distribution(distribution, 0, 0, 20, hash, true, 40)); + reorganized = true; + EXPECT_FALSE(rpc::RpcHandler::get_output_distribution(distribution, 0, 0, 21, hash, true, 40)); + EXPECT_FALSE(rpc::RpcHandler::get_output_distribution(distribution, 0, 0, 21, hash, true, 40)); + EXPECT_EQ(calls, 1u); + reorganized = false; + const auto original = rpc::RpcHandler::get_output_distribution(distribution, 0, 0, 20, hash, true, 40); + ASSERT_TRUE(original); + EXPECT_EQ(original->distribution, (std::vector{1, 2, 3, 4, 5})); +} + +TEST(WowReleaseRegression, ConsoleSanitizationRemovesEscapeControls) +{ + std::string message = "payment \x1b[2J accepted"; + el::base::sanitize(message); + EXPECT_EQ(message.find('\x1b'), std::string::npos); + EXPECT_NE(message.find("payment"), std::string::npos); + EXPECT_NE(message.find("accepted"), std::string::npos); + std::string plain = "valid UTF-8: \xc3\xa9\n"; + const auto expected = plain; + el::base::sanitize(plain); + EXPECT_EQ(plain, expected); +} +} + +int main(int argc, char **argv) +{ + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/wow_miner_proof/testnet.cpp b/tests/wow_miner_proof/testnet.cpp new file mode 100644 index 000000000..eac0bb8a6 --- /dev/null +++ b/tests/wow_miner_proof/testnet.cpp @@ -0,0 +1,185 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include +#include +#include +#include + +#include "carrot_core/payment_proposal.h" +#include "crypto/generators.h" +#include "cryptonote_basic/cryptonote_format_utils.h" +#include "cryptonote_basic/wow_miner.h" +#include "string_tools.h" +#include "wownero/testnet_genesis.h" + +namespace +{ +using namespace cryptonote; + +struct block_fixture +{ + wow_miner_account account; + crypto::secret_key x, y; + block b{}; + + explicit block_fixture(bool legacy = false) + { + crypto::random32_unbiased(reinterpret_cast(account.generate_image.data)); + if (!legacy) + crypto::random32_unbiased(reinterpret_cast(account.prove_spend.data)); + crypto::random32_unbiased(reinterpret_cast(account.view_incoming.data)); + account_public_address address; + if (!account.address(address)) + throw std::runtime_error("Invalid generated test account"); + carrot::CarrotPaymentProposalV1 proposal{}; + carrot::make_carrot_main_address_v1(address.m_spend_public_key, address.m_view_public_key, proposal.destination); + proposal.amount = COIN; + proposal.randomness = carrot::gen_janus_anchor(); + carrot::CarrotCoinbaseEnoteV1 enote; + carrot::get_coinbase_enote_v1(proposal, 101, enote); + b.major_version = HF_VERSION_CARROT; + b.minor_version = HF_VERSION_CARROT; + b.timestamp = 1000; + b.nonce = 7; + b.fcmp_pp_n_tree_layers = 1; + b.fcmp_pp_tree_root = {}; + auto &tx = b.miner_tx; + tx.version = 2; + tx.unlock_time = 101 + CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW; + tx.vin.emplace_back(txin_gen{101}); + txout_to_carrot_v1 output; + output.key = enote.onetime_address; + output.view_tag = enote.view_tag; + output.encrypted_janus_anchor = enote.anchor_enc; + tx.vout.push_back({proposal.amount, output}); + crypto::public_key ephemeral; + std::memcpy(&ephemeral, &enote.enote_ephemeral_pubkey, sizeof(ephemeral)); + add_tx_pub_key_to_extra(tx, ephemeral); + if (!prepare_wow_miner_output_keys(b, account, x, y) || !sign_wow_miner_block(b, x, y)) + throw std::runtime_error("Unable to prepare signed Carrot test block"); + } +}; + +TEST(WowTestnet, NewAndLegacyCarrotAccounts) +{ + for (bool legacy : {false, true}) + { + block_fixture fixture(legacy); + EXPECT_TRUE(verify_wow_miner_block(fixture.b)); + auto wrong = fixture.account; + crypto::random32_unbiased(reinterpret_cast(wrong.view_incoming.data)); + EXPECT_FALSE(prepare_wow_miner_output_keys(fixture.b, wrong, fixture.x, fixture.y)); + } +} + +TEST(WowTestnet, CompleteBlockBinding) +{ + block_fixture fixture; + const std::function mutations[] = { + [](block &b) { ++b.nonce; }, + [](block &b) { ++b.timestamp; }, + [](block &b) { ++b.minor_version; }, + [](block &b) { b.prev_id.data[0] ^= 1; }, + [](block &b) { b.wow_vote = 1; }, + [](block &b) { ++b.miner_tx.unlock_time; }, + [](block &b) { ++b.miner_tx.vout.front().amount; }, + [](block &b) { b.miner_tx.extra.push_back(0); }, + [](block &b) { ++boost::get(b.miner_tx.vin.front()).height; }, + [](block &b) { b.tx_hashes.push_back(crypto::cn_fast_hash("tx", 2)); }, + [](block &b) { ++b.fcmp_pp_n_tree_layers; }, + [](block &b) { b.fcmp_pp_tree_root.data[0] ^= 1; }, + }; + // Populate caches first; verification must not trust stale cached hashes. + get_block_hash(fixture.b); + for (const auto &mutate : mutations) + { + block altered = fixture.b; + mutate(altered); + EXPECT_FALSE(verify_wow_miner_block(altered)); + } +} + +TEST(WowTestnet, SignatureSerializationAndPowBinding) +{ + block_fixture fixture; + block decoded; + ASSERT_TRUE(parse_and_validate_block_from_blob(block_to_blob(fixture.b), decoded)); + EXPECT_TRUE(verify_wow_miner_block(decoded)); + const auto statement = get_wow_miner_statement_hash(fixture.b); + const auto pow_blob = get_block_hashing_blob(fixture.b); + fixture.b.wow_miner_signature[0] ^= 1; + EXPECT_EQ(statement, get_wow_miner_statement_hash(fixture.b)); + EXPECT_NE(pow_blob, get_block_hashing_blob(fixture.b)); + EXPECT_FALSE(verify_wow_miner_block(fixture.b)); +} + +TEST(WowTestnet, RejectsInvalidMinerShapes) +{ + block_fixture fixture; + for (auto vote : {0, 1, 2}) + { + fixture.b.wow_vote = vote; + ASSERT_TRUE(sign_wow_miner_block(fixture.b, fixture.x, fixture.y)); + EXPECT_TRUE(verify_wow_miner_block(fixture.b)); + } + fixture.b.wow_vote = 3; + EXPECT_FALSE(sign_wow_miner_block(fixture.b, fixture.x, fixture.y)); + fixture.b.wow_vote = 0; + fixture.b.miner_tx.vout.push_back(fixture.b.miner_tx.vout.front()); + EXPECT_FALSE(sign_wow_miner_block(fixture.b, fixture.x, fixture.y)); + fixture.b.miner_tx.vout.resize(1); + fixture.b.miner_tx.vout.front().target = txout_to_key{}; + EXPECT_FALSE(verify_wow_miner_block(fixture.b)); +} + +TEST(WowTestnet, IsolatedNetworkAndGenesis) +{ + EXPECT_THROW(get_config(MAINNET), std::runtime_error); + EXPECT_THROW(get_config(STAGENET), std::runtime_error); + EXPECT_THROW(get_config(FAKECHAIN), std::runtime_error); + EXPECT_NO_THROW(get_config(TESTNET)); + block genesis{}; + genesis.major_version = 1; + genesis.nonce = wownero::testnet::genesis_nonce; + std::string blob; + ASSERT_TRUE(epee::string_tools::parse_hexstr_to_binbuff(wownero::testnet::genesis_tx, blob)); + ASSERT_TRUE(parse_and_validate_tx_from_blob(blob, genesis.miner_tx)); + EXPECT_EQ(epee::string_tools::pod_to_hex(get_block_hash(genesis)), wownero::testnet::genesis_hash); + EXPECT_EQ(genesis.miner_tx.unlock_time, CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW); + EXPECT_TRUE(wownero::research::check_miner_public_key( + boost::get(genesis.miner_tx.vout.front().target).key)); +} + +TEST(WowTestnet, MiningCredentialsUsePipeAndCloseDescriptor) +{ + block_fixture fixture; + int descriptors[2]; + ASSERT_EQ(pipe(descriptors), 0); + ASSERT_EQ(write(descriptors[1], "WOWMIN01", 8), 8); + for (const auto *key : {&fixture.account.generate_image, &fixture.account.prove_spend, &fixture.account.view_incoming}) + ASSERT_EQ(write(descriptors[1], key->data, 32), 32); + close(descriptors[1]); + wow_miner_account decoded; + ASSERT_TRUE(read_wow_miner_account(descriptors[0], decoded)); + EXPECT_EQ(fcntl(descriptors[0], F_GETFD), -1); + account_public_address expected, actual; + ASSERT_TRUE(fixture.account.address(expected)); + ASSERT_TRUE(decoded.address(actual)); + EXPECT_TRUE(expected == actual); + + ASSERT_EQ(pipe(descriptors), 0); + ASSERT_EQ(write(descriptors[1], "WOWMIN01", 8), 8); + close(descriptors[1]); + EXPECT_FALSE(read_wow_miner_account(descriptors[0], decoded)); + EXPECT_FALSE(decoded.address(actual)); + EXPECT_EQ(fcntl(descriptors[0], F_GETFD), -1); + EXPECT_FALSE(read_wow_miner_account(STDIN_FILENO, decoded)); +} +} + +int main(int argc, char **argv) +{ + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/wow_miner_proof/tests.cpp b/tests/wow_miner_proof/tests.cpp new file mode 100644 index 000000000..01822d68c --- /dev/null +++ b/tests/wow_miner_proof/tests.cpp @@ -0,0 +1,185 @@ +// Copyright (c) 2026, The Wownero Project +// SPDX-License-Identifier: BSD-3-Clause + +#include "fixture.h" + +#include +#include +#include + +namespace wr = wownero::research; +using wr::test::coinbase_fixture; +using wr::test::bytes; + +TEST(WowMinerProof, CarrotCoinbaseReceiverCanSign) +{ + const coinbase_fixture f; + EXPECT_TRUE(f.verify(f.sign())); +} + +TEST(WowMinerProof, CarrotCoinbaseToLegacyAddressCanSign) +{ + const coinbase_fixture f(true); + EXPECT_TRUE(f.verify(f.sign())); + EXPECT_TRUE(sc_isnonzero(reinterpret_cast(f.y.data))); +} + +TEST(WowMinerProof, FreshNoncesForRepeatedStatement) +{ + const coinbase_fixture f; + const auto first = f.sign(); + const auto second = f.sign(); + EXPECT_NE(first, second); + EXPECT_TRUE(f.verify(first)); + EXPECT_TRUE(f.verify(second)); +} + +TEST(WowMinerProof, BindsEveryByteOfBlockAndNetworkDigest) +{ + coinbase_fixture f; + const auto proof = f.sign(); + for (auto *digest : {&f.message, &f.network}) + for (auto &byte : digest->data) + { + byte ^= 1; + EXPECT_FALSE(f.verify(proof)); + byte ^= 1; + } +} + +TEST(WowMinerProof, CannotTransferProofToAnotherCoinbase) +{ + const coinbase_fixture first, second; + EXPECT_FALSE(second.verify(first.sign())); +} + +TEST(WowMinerProof, RejectsMutationOfEveryProofByte) +{ + const coinbase_fixture f; + auto proof = f.sign(); + for (auto &byte : proof) + { + byte ^= 1; + EXPECT_FALSE(f.verify(proof)); + byte ^= 1; + } +} + +TEST(WowMinerProof, RejectsTruncatedAndExtendedProof) +{ + const coinbase_fixture f; + const auto proof = f.sign(); + for (std::size_t length = 0; length < proof.size(); ++length) + EXPECT_FALSE(wr::verify_miner_proof(f.network, f.message, f.enote.onetime_address, + {proof.data(), length})); + std::array extended{}; + std::copy(proof.begin(), proof.end(), extended.begin()); + EXPECT_FALSE(wr::verify_miner_proof(f.network, f.message, f.enote.onetime_address, + {extended.data(), extended.size()})); +} + +TEST(WowMinerProof, RejectsNoncanonicalScalarsAndZeroChallenge) +{ + const coinbase_fixture f; + const auto original = f.sign(); + for (std::size_t offset : {0, 32, 64}) + { + auto proof = original; + std::fill(proof.begin() + offset, proof.begin() + offset + 32, 0xff); + EXPECT_FALSE(f.verify(proof)); + } + auto proof = original; + std::fill(proof.begin(), proof.begin() + 32, 0); + EXPECT_FALSE(f.verify(proof)); +} + +TEST(WowMinerProof, MissingEitherWitnessRefusesAndClearsOutput) +{ + const coinbase_fixture f; + crypto::secret_key absent{}; + for (const bool missing_g : {false, true}) + { + auto proof = f.sign(); + EXPECT_FALSE(wr::generate_miner_proof(f.network, f.message, f.enote.onetime_address, + missing_g ? absent : f.x, missing_g ? f.y : absent, proof)); + EXPECT_TRUE(std::all_of(proof.begin(), proof.end(), [](auto byte) { return byte == 0; })); + } +} + +TEST(WowMinerProof, AccountKeysCannotSignWithoutCoinbaseExtensions) +{ + const coinbase_fixture f; + wr::miner_proof proof; + EXPECT_FALSE(wr::generate_miner_proof(f.network, f.message, f.enote.onetime_address, + f.account_g, f.account_t, proof)); +} + +TEST(WowMinerProof, RejectsNoncanonicalSecretScalar) +{ + const coinbase_fixture f; + crypto::secret_key invalid; + std::memset(invalid.data, 0xff, sizeof(invalid.data)); + wr::miner_proof proof; + EXPECT_FALSE(wr::generate_miner_proof(f.network, f.message, f.enote.onetime_address, + invalid, f.y, proof)); + EXPECT_FALSE(wr::generate_miner_proof(f.network, f.message, f.enote.onetime_address, + f.x, invalid, proof)); +} + +TEST(WowMinerProof, AllowsZeroComponentForValidNonidentityRepresentation) +{ + const coinbase_fixture f; + const crypto::secret_key zero{}; + for (const bool zero_g : {false, true}) + { + const auto &x = zero_g ? zero : f.x; + const auto &y = zero_g ? f.y : zero; + crypto::public_key key; + carrot::make_carrot_spend_pubkey(x, y, key); + wr::miner_proof proof; + ASSERT_TRUE(wr::generate_miner_proof(f.network, f.message, key, x, y, proof)); + EXPECT_TRUE(wr::verify_miner_proof(f.network, f.message, key, {proof.data(), proof.size()})); + } +} + +TEST(WowMinerProof, RejectsIdentityTorsionMixedOrderAndNoncanonicalKeys) +{ + EXPECT_TRUE(wr::check_miner_public_key(crypto::get_G())); + EXPECT_TRUE(wr::check_miner_public_key(crypto::get_T())); + crypto::public_key identity{}; + identity.data[0] = 1; + EXPECT_FALSE(wr::check_miner_public_key(identity)); + crypto::public_key order_two; + std::memset(order_two.data, 0xff, 32); + order_two.data[0] = static_cast(0xec); + order_two.data[31] = 0x7f; + EXPECT_FALSE(wr::check_miner_public_key(order_two)); + + ge_p3 torsion; + ASSERT_EQ(0, ge_frombytes_vartime(&torsion, bytes(order_two))); + ge_cached cached; + ge_p3_to_cached(&cached, &torsion); + ge_p1p1 mixed_sum; + ge_add(&mixed_sum, &crypto::get_G_p3(), &cached); + ge_p3 mixed_point; + ge_p1p1_to_p3(&mixed_point, &mixed_sum); + crypto::public_key mixed; + ge_p3_tobytes(bytes(mixed), &mixed_point); + EXPECT_FALSE(wr::check_miner_public_key(mixed)); + + crypto::public_key noncanonical; + std::memset(noncanonical.data, 0xff, 32); + noncanonical.data[0] = static_cast(0xee); + noncanonical.data[31] = 0x7f; + EXPECT_FALSE(wr::check_miner_public_key(noncanonical)); + identity.data[31] = static_cast(0x80); + EXPECT_FALSE(wr::check_miner_public_key(identity)); + + coinbase_fixture f; + const auto proof = f.sign(); + for (const auto &key : {identity, order_two, mixed, noncanonical}) + { + f.enote.onetime_address = key; + EXPECT_FALSE(f.verify(proof)); + } +}