diff --git a/src/impl/bch/coin/abla_block_feed.hpp b/src/impl/bch/coin/abla_block_feed.hpp new file mode 100644 index 000000000..9d10bc52e --- /dev/null +++ b/src/impl/bch/coin/abla_block_feed.hpp @@ -0,0 +1,136 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin::AblaBlockFeed -- M5: drives the ABLA size feed from the REAL +// block-connect path. This is the wiring that was missing after M5 slice C: +// AblaTracker.record_block_size() existed but nothing called it; the running +// State was only ever exercised by the out-of-tree roundtrip harness. This +// feed closes that gap by subscribing to the embedded daemon's full_block +// event and folding each best-chain block's actual serialized size into the +// tracker. +// +// WHY HERE (full-block / daemon layer, not the header chain): +// ABLA advances per block by that block's *actual serialized size*, which +// the headers-only SPV header_chain structurally does not carry (M4 s3 pin / +// M5 PINNED acceptance item). The size therefore must be fed as full blocks +// arrive at the embedded-daemon layer -- exactly where interfaces::Node's +// full_block event fires (p2p_node block + cmpctblock handlers). +// +// CONTIGUOUS-TIP-ONLY by construction. We resolve each block's height from the +// best-chain HeaderChain index (header sync precedes full-block download in the +// BIP130 flow). A block whose hash is not on the indexed best chain (unsolicited +// block, side branch, or one whose header has not been indexed yet) is skipped: +// we cannot assign it a trustworthy height, so we hand the tracker nothing and +// let its own gap-detect do the rest. The tracker already enforces ordering: +// height == cursor+1 -> fold (hot path) +// height <= cursor -> idempotent ignore (duplicate / replayed old block) +// height > cursor+1 -> GAP -> stale -> builder falls back to the 32 MB floor +// until reanchor() supplies a fresh known-good State. +// So a reorg or a skipped block can only ever DROP the budget to the safe +// floor, never raise it on bad data. Reanchoring (BCHN pin / explicit) is a +// separate path; until it runs the floor holds -- the never-undercut invariant. +// +// p2pool-merged-v36 SURFACE: NONE. Identical to abla.hpp / abla_tracker.hpp, +// this governs only the LOCAL build-time block-size budget. It never touches +// PoW hash, share format, coinbase commitment, or PPLNS math -- zero interop +// risk. +// +// PER-COIN ISOLATION: everything here is src/impl/bch/coin/ only. The block +// accessor (full_block event), the height source (HeaderChain), and the sink +// (AblaTracker) are all bch-owned. No bitcoin_family/ or src/core block +// primitive is reached for -- size comes from this module's own block.hpp +// Serialize via pack(), height from this module's own header index. +// +// Build-INERT / source-only: header-only, no impl_bch CMake registration +// (bch stays skip-green; don't race ci-steward). +// --------------------------------------------------------------------------- + +#include "abla_tracker.hpp" +#include "block.hpp" +#include "header_chain.hpp" // bch::coin::block_hash(), HeaderChain +#include "node_interface.hpp" // bch::interfaces::Node (full_block event) + +#include // Event, EventDisposable +#include // pack() -> serialized span +#include +#include + +#include +#include + +namespace bch { +namespace coin { + +/// Subscribes to interfaces::Node::full_block and folds each best-chain block's +/// real serialized size into an AblaTracker. Single-threaded use from the +/// daemon's block-processing context is assumed (mirrors how the header chain +/// advances and how AblaTracker documents its own threading contract). +class AblaBlockFeed { +public: + /// @param tracker the ABLA size tracker to feed (daemon-owned; must outlive this). + /// @param chain best-chain header index, the height source for received blocks. + AblaBlockFeed(AblaTracker& tracker, const HeaderChain& chain) + : m_tracker(tracker), m_chain(chain) {} + + AblaBlockFeed(const AblaBlockFeed&) = delete; + AblaBlockFeed& operator=(const AblaBlockFeed&) = delete; + + /// Wire this feed to a node's full_block event. The returned subscription + /// is retained internally and torn down on destruction (or detach()). + /// Call once, after the node and header chain exist. + void attach(bch::interfaces::Node& node) { + m_sub = node.full_block.subscribe( + [this](const BlockType& block) { on_full_block(block); }); + } + + /// Drop the subscription early (idempotent). Destruction does this anyway. + void detach() { + if (m_sub) { + m_sub->dispose(); + m_sub.reset(); + } + } + + /// Fold one received full block into the ABLA tracker. Public so the + /// out-of-tree harness can drive it without a live Event/socket; in + /// production it is invoked only by the full_block subscription. + void on_full_block(const BlockType& block) { + // Identity: SHA256d of the 80-byte header (the hash used for inv/getdata + // and the key the header index stores). + const uint256 hash = block_hash(static_cast(block)); + + // Height source: the best-chain header index. Absent => not on our best + // chain (yet) => skip and let the tracker's gap-detect handle the + // resulting discontinuity (=> floor) rather than guess a height. + const auto entry = m_chain.get_header(hash); + if (!entry) { + LOG_DEBUG_COIND << "[EMB-BCH] ABLA feed: block " << hash.GetHex().substr(0, 16) + << "... not on indexed best chain -- skipped (gap-detect will floor)"; + return; + } + + // Size: the block's ACTUAL serialized byte length via this module's own + // legacy (no-witness) block Serialize. This is the consensus serialized + // size ABLA folds through NextBlockState. + const uint64_t serialized_size = + static_cast(pack(block).size()); + + m_tracker.record_block_size(entry->height, serialized_size); + + LOG_DEBUG_COIND << "[EMB-BCH] ABLA feed: height=" << entry->height + << " size=" << serialized_size + << " current=" << (m_tracker.is_current(entry->height) ? "yes" : "no") + << " budget=" << m_tracker.budget_for_tip(m_tracker.cursor_height()); + } + + bool is_attached() const { return static_cast(m_sub); } + + ~AblaBlockFeed() { detach(); } + +private: + AblaTracker& m_tracker; + const HeaderChain& m_chain; + std::shared_ptr m_sub; +}; + +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/abla_runtime.hpp b/src/impl/bch/coin/abla_runtime.hpp new file mode 100644 index 000000000..22db07218 --- /dev/null +++ b/src/impl/bch/coin/abla_runtime.hpp @@ -0,0 +1,100 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin::AblaRuntime -- M5: the CLOSING assembly of the embedded-daemon +// ABLA size loop. The four pieces existed in isolation after slices C/sG: +// AblaTracker -- folds each best-chain block size, holds running State +// AblaBlockFeed -- subscribes to interfaces::Node::full_block, sinks size +// EmbeddedCoinNode -- consumes the tracker for the dynamic build budget +// full_block event -- fires from all 3 p2p delivery paths (direct / cmpct / +// blocktxn) AFTER the merkle-root accept gate (sG) +// ...but nothing OWNED a tracker, owned a feed, and tied them to a live node + +// EmbeddedCoinNode. This runtime is that owner: one object the embedded-daemon +// front-end (coin::Node) holds, constructed once the HeaderChain exists and +// wired once the node + EmbeddedCoinNode exist. After wire(): +// full_block --> AblaBlockFeed --> AblaTracker --> EmbeddedCoinNode::getwork +// --> TemplateBuilder dynamic ABLA budget (else 32 MB floor). +// +// COLD START = floor anchor. Absent a BCHN-pinned {height,State}, we anchor at +// the activation/floor State (limit == the 32 MB floor); folding live sizes +// forward can only RAISE the budget, never undercut the floor -- the +// never-undercut invariant holds from the first block. A later BCHN pin is a +// reanchor() passthrough, no reconstruction. +// +// LIFETIME: m_tracker is declared before m_feed so the feed`s AblaTracker& +// binds to a fully-constructed member, and the tracker outlives both the feed +// subscription and the raw pointer handed to EmbeddedCoinNode. The runtime must +// outlive the node it wired (the daemon owns both for its whole run). +// +// p2pool-merged-v36 SURFACE: NONE. Pure local build-time block-size budget -- +// no PoW hash, share format, coinbase commitment, or PPLNS math is touched. +// PER-COIN ISOLATION: src/impl/bch/coin/ only; every type reached is bch-owned. +// Build-INERT / source-only: header-only, no impl_bch CMake registration +// (bch stays skip-green; don`t race ci-steward). +// --------------------------------------------------------------------------- + +#include "abla.hpp" // abla::State (reanchor / pinned-anchor ctor) +#include "abla_tracker.hpp" // AblaTracker +#include "abla_block_feed.hpp" // AblaBlockFeed +#include "header_chain.hpp" // HeaderChain (height source for the feed) +#include "node_interface.hpp" // bch::interfaces::Node (full_block source) +#include "template_builder.hpp" // EmbeddedCoinNode (set_abla_tracker sink) + +#include + +#include + +namespace bch { +namespace coin { + +/// Owns the ABLA tracker + full_block feed for one embedded daemon instance and +/// wires them to a live node + EmbeddedCoinNode. Single-threaded use from the +/// daemon block-processing context (same contract as AblaTracker / feed). +class AblaRuntime { +public: + /// Cold-start: anchor at the activation/floor State for `anchor_height`. + /// Safe default when no BCHN-pinned anchor is available yet. + AblaRuntime(bool is_testnet, uint32_t anchor_height, const HeaderChain& chain) + : m_tracker(AblaTracker::floor_anchored(is_testnet, anchor_height)), + m_feed(m_tracker, chain) {} + + /// BCHN-pinned anchor: start from a known-good {height, State} captured at + /// daemon start. Lets the live budget track the real consensus limit from + /// block one instead of climbing from the floor. + AblaRuntime(bool is_testnet, uint32_t anchor_height, abla::State anchor_state, + const HeaderChain& chain) + : m_tracker(is_testnet, anchor_height, anchor_state), + m_feed(m_tracker, chain) {} + + AblaRuntime(const AblaRuntime&) = delete; + AblaRuntime& operator=(const AblaRuntime&) = delete; + + /// Close the loop: attach the feed to the node`s full_block event and hand + /// the tracker to the EmbeddedCoinNode. Call once, after both exist. + void wire(bch::interfaces::Node& node, EmbeddedCoinNode& embedded) { + m_feed.attach(node); + embedded.set_abla_tracker(&m_tracker); + LOG_INFO << "[EMB-BCH] ABLA runtime wired: full_block -> feed -> tracker" + << " -> template builder (anchor_height=" << m_tracker.cursor_height() + << ", budget=" << m_tracker.budget_for_tip(m_tracker.cursor_height()) + << ", " << (m_tracker.is_stale() ? "stale" : "current") << ")"; + } + + /// Re-establish a known-good anchor after a gap/reorg or on a fresh BCHN + /// pin. Thin passthrough to the tracker; the feed keeps its subscription. + void reanchor(uint32_t height, abla::State state) { + m_tracker.reanchor(height, state); + LOG_INFO << "[EMB-BCH] ABLA runtime reanchored at height=" << height + << " budget=" << m_tracker.budget_for_tip(height); + } + + AblaTracker& tracker() { return m_tracker; } + const AblaTracker& tracker() const { return m_tracker; } + bool is_wired() const { return m_feed.is_attached(); } + +private: + AblaTracker m_tracker; // declared first: outlives m_feed + the EmbeddedCoinNode pointer + AblaBlockFeed m_feed; +}; + +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/abla_tracker.hpp b/src/impl/bch/coin/abla_tracker.hpp new file mode 100644 index 000000000..f550d1d5c --- /dev/null +++ b/src/impl/bch/coin/abla_tracker.hpp @@ -0,0 +1,141 @@ +#pragma once +// BCH ABLA size-feed tracker (CHIP-2023-01) -- M5 slice C. +// +// This is the FULL-BLOCK / EMBEDDED-DAEMON-LAYER home of the live block-size +// feed that drives ABLA. It exists because of a structural constraint pinned +// in M4 s3: ABLA is advanced by each block's *actual serialized size*, which +// the headers-only SPV `header_chain` does not carry (it stores headers, not +// block bodies / sizes). So the running ABLA State cannot be derived from the +// header chain; it must be fed real per-block sizes as full blocks arrive at +// the daemon layer. That feed re-homes HERE, not in the share layer and not in +// the header chain (M5 PINNED acceptance item). +// +// Relationship to the rest of the size path: +// abla.hpp -- the consensus algorithm (State/Config/replay), +// a 1:1 BCHN v29 port. CONSENSUS-CRITICAL, frozen. +// AblaTracker (this file) -- a thin LOCAL bookkeeping wrapper: it keeps the +// running State at the chain tip by folding live +// block sizes through abla::State::NextBlockState, +// and hands a tip State* to the template builder. +// template_builder.hpp -- consumes the tip State* as a dynamic build-time +// byte budget (build_template's `tip_state` arg). +// +// p2pool-merged-v36 SURFACE: NONE. Like abla.hpp, this governs only the LOCAL +// block-size build budget (how large a template we may assemble). It never +// touches PoW hash, share format, coinbase commitment, or PPLNS math, so there +// is zero interop risk. The hard floor below guarantees the budget can only +// ever EQUAL-OR-EXCEED the 32 MB activation/floor limit -- ABLA only raises it, +// and when the feed is absent or has a gap we fall back to the floor outright. +// +// Build-INERT / source-only: header-only, no impl_bch CMake registration +// (bch stays skip-green; don't race ci-steward). + +#include "abla.hpp" + +#include + +namespace bch { +namespace coin { + +/// Keeps the running ABLA State at the chain tip by folding live full-block +/// sizes (oldest-first, contiguous) through the consensus control function. +/// +/// Lifecycle / ownership: lives at the embedded-daemon / full-block layer +/// (alongside the block-download path). On each fully-received best-chain block +/// the daemon calls record_block_size(height, serialized_size); the template +/// builder asks state_for_tip(tip_height) when assembling work. Single-threaded +/// use from the daemon's block-processing context is assumed (mirrors how the +/// header chain advances); add external synchronisation if that changes. +class AblaTracker { +public: + /// Construct from a known-good anchor: the ABLA State of `anchor_height` + /// and the matching per-network Config. The natural BCH anchor is a + /// BCHN-pinned {height, State} captured at daemon start; absent that, use + /// floor_anchored() below, which anchors at the activation/floor State. + AblaTracker(bool is_testnet, uint32_t anchor_height, abla::State anchor_state) + : m_config(is_testnet ? abla::testnet_config() : abla::mainnet_config()), + m_is_testnet(is_testnet), + m_cursor_height(anchor_height), + m_cursor(anchor_state), + m_valid(true) {} + + /// Anchor at the activation/floor State for `anchor_height`. This is the + /// safe cold-start: the floor State's limit is exactly the 32 MB floor, and + /// folding live sizes forward can only raise it (ABLA never drops below the + /// floor). Use when no BCHN-pinned anchor is available yet. + static AblaTracker floor_anchored(bool is_testnet, uint32_t anchor_height) { + const abla::Config cfg = + is_testnet ? abla::testnet_config() : abla::mainnet_config(); + return AblaTracker(is_testnet, anchor_height, abla::State(cfg, 0)); + } + + /// Live feed: record the serialized size of the best-chain block at + /// `height`. Sizes MUST arrive contiguously (each height one greater than + /// the last); ABLA cannot skip blocks. Behaviour: + /// height == cursor+1 : fold it in, advance the tip State (the hot path). + /// height <= cursor : already incorporated -> ignored (idempotent). + /// height > cursor+1 : a GAP -> the running State is no longer trustable; + /// mark stale so the builder falls back to the floor + /// until reanchor() supplies a fresh known-good State. + /// While stale, further records are ignored until reanchor(). + void record_block_size(uint32_t height, uint64_t serialized_size) { + if (!m_valid) + return; // awaiting reanchor() after a gap/reorg + if (height <= m_cursor_height) + return; // duplicate / out-of-order replay of an old block + if (height != m_cursor_height + 1) { + m_valid = false; // gap: cannot fold non-contiguous sizes + return; + } + m_cursor = m_cursor.NextBlockState(m_config, serialized_size); + m_cursor_height = height; + } + + /// Re-establish a known-good anchor (after a gap, reorg, or initial BCHN + /// pin). Clears the stale flag and resumes folding from `height`. + void reanchor(uint32_t height, abla::State state) { + m_cursor_height = height; + m_cursor = state; + m_valid = true; + } + + /// State governing the block we are about to build on top of `tip_height`. + /// That is the running State AFTER the tip block's size has been folded in, + /// i.e. cursor must sit exactly at the tip. Returns nullptr -- so the + /// template builder uses the floor fallback -- when the feed is stale (a + /// gap occurred) or has not yet caught up to (or has overrun) the tip. + /// The returned pointer is valid until the next record/reanchor call. + const abla::State* state_for_tip(uint32_t tip_height) const { + if (!m_valid || m_cursor_height != tip_height) + return nullptr; + return &m_cursor; + } + + /// The dynamic build budget the builder would use right now for `tip_height` + /// -- the live ABLA limit when the feed is current, else the floor. This is + /// the SAME value build_template computes from state_for_tip(); exposed for + /// logging/monitoring. By the ABLA invariant (control >= epsilon0, buffer >= + /// beta0) the live limit is always >= the floor, so this never undercuts the + /// 32 MB floor regardless of feed state. + uint64_t budget_for_tip(uint32_t tip_height) const { + const abla::State* s = state_for_tip(tip_height); + const uint64_t live = s ? s->GetBlockSizeLimit() + : abla::floor_block_size_limit(m_is_testnet); + const uint64_t floor = abla::floor_block_size_limit(m_is_testnet); + return live < floor ? floor : live; // hard floor guard (belt + braces) + } + + bool is_current(uint32_t tip_height) const { return m_valid && m_cursor_height == tip_height; } + bool is_stale() const { return !m_valid; } + uint32_t cursor_height() const { return m_cursor_height; } + +private: + abla::Config m_config; + bool m_is_testnet; + uint32_t m_cursor_height; + abla::State m_cursor; + bool m_valid; +}; + +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/bchn_anchor_record.hpp b/src/impl/bch/coin/bchn_anchor_record.hpp new file mode 100644 index 000000000..5c9ab37ed --- /dev/null +++ b/src/impl/bch/coin/bchn_anchor_record.hpp @@ -0,0 +1,71 @@ +#pragma once + +// BCHN cold-start anchor record (VM300 bchn-bch, operator-approved read-only +// capture @ height 955700; see the/docs/c2pool-bch-embedded-divergence-set.md +// section 4, recorded under frstrtr/the @0d55c4d2). This is a STATIC record of +// the values read once from VM300 -- the daemon reads THIS record, never the +// live VM (VM300 stays read-only, no qm op). apply_bchn_anchor() pins the +// {height, abla::State} from here; everything else (hash/chainwork/time) just +// documents provenance + lets a future SPV cold-start trust a header origin +// instead of climbing from genesis. +// +// Embedded-internal only: ABLA / cold-start anchoring is NOT part of the +// p2pool-merged-v36 share/sharechain surface (BCH is a SHA256d standalone +// parent). Zero p2pool-v36 / bitcoin_family surface. + +#include +#include + +#include "abla.hpp" // abla::Config / abla::State + +namespace bch { +namespace coin { + +/// The VM300 BCHN cold-start anchor, captured read-only @ height 955700. +/// Values are verbatim from the recorded divergence-set section-4 block. +struct BchnAnchorRecord { + static constexpr uint32_t height = 955700u; + static constexpr std::string_view hash = + "000000000000000002065e870dae738e8d5a5ee26fe6e2969f0581e4076d8493"; + static constexpr std::string_view chainwork = + "000000000000000000000000000000000000000002f722c9abbcbe29d5eacea0"; + static constexpr std::string_view merkleroot = + "b1d174425bd0478fed3b07d16f0c262fdc32ca95f36ec565874ceaf10115edf6"; + static constexpr uint32_t time = 1781698703u; + static constexpr uint32_t mediantime = 1781697829u; + static constexpr uint32_t bits = 18025987u; // nBits (0x???) + static constexpr uint32_t version = 0x200ce000u; + + // Recorded ABLA control state @ 955700. blocksize is the captured block's + // serialized size; control/elastic are the running ABLA state fields. + static constexpr uint64_t abla_blocksize = 1069u; + static constexpr uint64_t abla_control_size = 16000000u; // epsilon + static constexpr uint64_t abla_elastic_buffer = 16000000u; // beta + static constexpr uint64_t abla_blocksizelimit = 32000000u; + static constexpr uint64_t abla_nextblocksizelimit = 32000000u; + + /// Rebuild the recorded ABLA State for the daemon's network. The capture + /// shows the control state still at the 32 MB floor (control+elastic == + /// 32000000, i.e. epsilon == beta == 16000000), so the floor State and the + /// recorded State are byte-identical -- which is exactly why the M4 + /// safe-floor budget (slice 3, 6336679a) is correct against live mainnet. + /// We build via abla::State(Config, blkSize) so any future non-floor + /// capture would surface as a control/elastic mismatch in is_floor(). + static abla::State state(bool is_testnet) { + // Mirror AblaTracker::floor_anchored() per-network Config selection. + const abla::Config cfg = + is_testnet ? abla::testnet_config() : abla::mainnet_config(); + return abla::State(cfg, abla_blocksize); + } + + /// True iff the recorded control state is still at the 32 MB floor. When + /// this holds, pinning the anchor changes nothing vs the cold-start floor + /// (it only sharpens chainwork/height provenance); when it ever goes false + /// the dry-run logs a real, non-floor budget to pin. + static bool is_floor() { + return abla_control_size + abla_elastic_buffer == abla::DEFAULT_CONSENSUS_BLOCK_SIZE; + } +}; + +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/cashaddr.hpp b/src/impl/bch/coin/cashaddr.hpp new file mode 100644 index 000000000..abf46c2f7 --- /dev/null +++ b/src/impl/bch/coin/cashaddr.hpp @@ -0,0 +1,274 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin::cashaddr -- BCH CashAddr address codec (M4 TODO from config_coin.hpp). +// +// Faithful header-only port of Bitcoin Cash Node's src/cashaddr.cpp + +// src/cashaddrenc.cpp (Pieter Wuille / The Bitcoin developers, MIT). BCH +// addresses diverge from BTC base58/bech32: a base32 charset with a BCH-code +// 40-bit PolyMod checksum and a "bitcoincash:" / "bchtest:" / "bchreg:" prefix. +// +// Self-contained vs the BCHN original: the BCHN code threads CTxDestination / +// CChainParams / pubkey / script types through Encode/DecodeCashAddr. None of +// that graph is needed at the codec layer -- the codec maps {type, hash-bytes} +// <-> string. This header exposes exactly that seam (CashAddrContent) so it is +// build-inert and unit-testable out of tree with the BCHN KAT vectors, with no +// boost / chainparams dependency. Callers (config/payout-address layer) map a +// CashAddrType + 20/32-byte hash to/from CTxDestination themselves. +// +// >>> BCH DIVERGENCES carried here (M1 4.x) <<< +// - CashTokens token-aware address types (CHIP-2022-02, May 2023): the 'z'/'r' +// token-aware variants (TOKEN_PUBKEY_TYPE / TOKEN_SCRIPT_TYPE) are BCH +// consensus and are encoded faithfully -- transparent to the template layer, +// they only widen the operator-facing address surface. +// - P2SH32 (CHIP, May 2023): 32-byte hashes are accepted via the version +// size bits (hash_size doubling), alongside the legacy 20-byte P2PKH/P2SH. +// +// p2pool-merged-v36 SURFACE: NONE. CashAddr is the operator-facing config / +// payout-address encoding; the share, sharechain, coinbase-commitment and PPLNS +// layers serialize SCRIPTS, not address strings (those paths are already pinned +// conformant). This adds an input/display codec only -- no share-format change. +// PER-COIN ISOLATION: src/impl/bch/coin/ only; every symbol is bch-owned. +// Build-INERT / source-only: header-only, no impl_bch CMake registration +// (bch stays skip-green; don't race ci-steward). +// --------------------------------------------------------------------------- + +#include +#include +#include +#include + +namespace bch { +namespace coin { +namespace cashaddr { + +// Address types -- 1:1 with BCHN cashaddrenc.h CashAddrType. +enum CashAddrType : uint8_t { + PUBKEY_TYPE = 0, + SCRIPT_TYPE = 1, + TOKEN_PUBKEY_TYPE = 2, //< Token-Aware P2PKH (CashTokens) + TOKEN_SCRIPT_TYPE = 3, //< Token-Aware P2SH (CashTokens) +}; + +struct CashAddrContent { + CashAddrType type{}; + std::vector hash; + + bool IsNull() const { return hash.empty(); } + bool IsTokenAwareType() const { + return type == TOKEN_PUBKEY_TYPE || type == TOKEN_SCRIPT_TYPE; + } +}; + +// Network prefixes (BCHN chainparams.cpp cashaddrPrefix). +inline constexpr const char* MAINNET_PREFIX = "bitcoincash"; +inline constexpr const char* TESTNET_PREFIX = "bchtest"; +inline constexpr const char* REGTEST_PREFIX = "bchreg"; +inline std::string prefix_for(bool testnet) { + return testnet ? TESTNET_PREFIX : MAINNET_PREFIX; +} + +namespace detail { + +using data = std::vector; + +// The cashaddr base32 charset for encoding. +inline constexpr char CHARSET[] = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +inline constexpr uint8_t PACKED_VAL_LIMIT = 32u; + +// Reverse charset for decoding (index by ASCII; -1 = invalid). +inline constexpr int8_t CHARSET_REV[128] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 15, -1, 10, 17, 21, 20, 26, 30, 7, + 5, -1, -1, -1, -1, -1, -1, -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, + 31, 27, 19, -1, 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, + -1, -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, 1, 0, + 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1}; + +inline data Cat(data a, const data& b) { + a.insert(a.end(), b.begin(), b.end()); + return a; +} + +// BCH 40-bit BCH-code PolyMod (cashaddr.cpp). Returns the value to XOR into the +// 8 trailing 5-bit groups to make the checksum 1. +inline uint64_t PolyMod(const data& v) { + uint64_t c = 1; + for (uint8_t d : v) { + uint8_t c0 = c >> 35; + c = ((c & 0x07ffffffff) << 5) ^ d; + if (c0 & 0x01) c ^= 0x98f2bc8e61; + if (c0 & 0x02) c ^= 0x79b76d99e2; + if (c0 & 0x04) c ^= 0xf33e5fb3c4; + if (c0 & 0x08) c ^= 0xae2eabe2a8; + if (c0 & 0x10) c ^= 0x1e4f43e470; + } + return c ^ 1; +} + +inline uint8_t LowerCase(uint8_t c) { return c | 0x20; } + +inline data ExpandPrefix(const std::string& prefix) { + data ret; + ret.resize(prefix.size() + 1); + for (size_t i = 0; i < prefix.size(); ++i) ret[i] = uint8_t(prefix[i]) & 0x1f; + ret[prefix.size()] = 0; + return ret; +} + +inline bool VerifyChecksum(const std::string& prefix, const data& payload) { + return PolyMod(Cat(ExpandPrefix(prefix), payload)) == 0; +} + +inline data CreateChecksum(const std::string& prefix, const data& payload) { + data enc = Cat(ExpandPrefix(prefix), payload); + enc.resize(enc.size() + 8); + uint64_t mod = PolyMod(enc); + data ret(8); + for (size_t i = 0; i < 8; ++i) ret[i] = (mod >> (5 * (7 - i))) & 0x1f; + return ret; +} + +// Standard Bitcoin ConvertBits (util/strencodings.h). frombits->tobits with +// optional padding; returns false on invalid padding when pad=false. +template +inline bool ConvertBits(const O& outfn, data::const_iterator it, + data::const_iterator end) { + size_t acc = 0; + int bits = 0; + constexpr size_t maxv = (1 << tobits) - 1; + constexpr size_t max_acc = (1 << (frombits + tobits - 1)) - 1; + while (it != end) { + acc = ((acc << frombits) | *it) & max_acc; + bits += frombits; + while (bits >= tobits) { + bits -= tobits; + outfn((acc >> bits) & maxv); + } + ++it; + } + if (pad) { + if (bits) outfn((acc << (tobits - bits)) & maxv); + } else if (bits >= frombits || ((acc << (tobits - bits)) & maxv)) { + return false; + } + return true; +} + +// Pack {type, hash} into 5-bit payload with version byte (cashaddrenc.cpp). +inline data PackAddrData(const data& id, uint8_t type) { + uint8_t version_byte(type << 3); + size_t size = id.size(); + uint8_t encoded_size = 0; + switch (size * 8) { + case 160: encoded_size = 0; break; + case 192: encoded_size = 1; break; + case 224: encoded_size = 2; break; + case 256: encoded_size = 3; break; + case 320: encoded_size = 4; break; + case 384: encoded_size = 5; break; + case 448: encoded_size = 6; break; + case 512: encoded_size = 7; break; + default: + // Invalid hash length -> empty payload (caller treats as failure). + return {}; + } + version_byte |= encoded_size; + data buf = {version_byte}; + buf.insert(buf.end(), id.begin(), id.end()); + + data converted; + converted.reserve(((size + 1) * 8 + 4) / 5); + ConvertBits<8, 5, true>([&](uint8_t c) { converted.push_back(c); }, + buf.begin(), buf.end()); + return converted; +} + +} // namespace detail + +// Encode a cashaddr string from a 5-bit payload (cashaddr.cpp Encode). +inline std::string Encode(const std::string& prefix, const detail::data& payload) { + detail::data checksum = detail::CreateChecksum(prefix, payload); + detail::data combined = detail::Cat(payload, checksum); + std::string ret = prefix + ':'; + ret.reserve(ret.size() + combined.size()); + for (const uint8_t c : combined) ret += detail::CHARSET[c % detail::PACKED_VAL_LIMIT]; + return ret; +} + +// Decode a cashaddr string to {prefix, 5-bit payload} (cashaddr.cpp Decode). +// Returns {"", {}} on any structural / checksum failure. +inline std::pair Decode(const std::string& str, + const std::string& default_prefix) { + bool lower = false, upper = false, hasNumber = false; + size_t prefixSize = 0; + for (size_t i = 0; i < str.size(); ++i) { + uint8_t c = str[i]; + if (c >= 'a' && c <= 'z') { lower = true; continue; } + if (c >= 'A' && c <= 'Z') { upper = true; continue; } + if (c >= '0' && c <= '9') { hasNumber = true; continue; } + if (c == ':') { + if (hasNumber || i == 0 || prefixSize != 0) return {}; + prefixSize = i; + continue; + } + return {}; + } + if (upper && lower) return {}; + + std::string prefix; + if (prefixSize == 0) { + prefix = default_prefix; + } else { + prefix.reserve(prefixSize); + for (size_t i = 0; i < prefixSize; ++i) prefix += char(detail::LowerCase(str[i])); + prefixSize++; + } + + const size_t valuesSize = str.size() - prefixSize; + detail::data values(valuesSize); + for (size_t i = 0; i < valuesSize; ++i) { + uint8_t c = str[i + prefixSize]; + if (c > 127 || detail::CHARSET_REV[c] == -1) return {}; + values[i] = detail::CHARSET_REV[c]; + } + if (!detail::VerifyChecksum(prefix, values)) return {}; + return {std::move(prefix), detail::data(values.begin(), values.end() - 8)}; +} + +// {type, hash} -> address string (cashaddrenc.cpp EncodeCashAddr). +inline std::string EncodeCashAddr(const std::string& prefix, const CashAddrContent& content) { + detail::data payload = detail::PackAddrData(content.hash, content.type); + if (payload.empty()) return {}; + return Encode(prefix, payload); +} + +// address string -> {type, hash} (cashaddrenc.cpp DecodeCashAddrContent). +// Returns a null CashAddrContent on prefix mismatch / bad version / bad size. +inline CashAddrContent DecodeCashAddrContent(const std::string& addr, + const std::string& expectedPrefix) { + auto [prefix, payload] = Decode(addr, expectedPrefix); + if (prefix != expectedPrefix) return {}; + if (payload.empty()) return {}; + + detail::data out; + out.reserve(payload.size() * 5 / 8); + if (!detail::ConvertBits<5, 8, false>([&](uint8_t c) { out.push_back(c); }, + payload.begin(), payload.end())) { + return {}; + } + + uint8_t version = out[0]; + if (version & 0x80) return {}; // reserved bit + auto type = CashAddrType((version >> 3) & 0x1f); + uint32_t hash_size = 20 + 4 * (version & 0x03); + if (version & 0x04) hash_size *= 2; + if (out.size() != hash_size + 1) return {}; + + out.erase(out.begin()); // pop version + return {type, std::move(out)}; +} + +} // namespace cashaddr +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/embedded_daemon.hpp b/src/impl/bch/coin/embedded_daemon.hpp new file mode 100644 index 000000000..6afb209fb --- /dev/null +++ b/src/impl/bch/coin/embedded_daemon.hpp @@ -0,0 +1,172 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin::EmbeddedDaemon -- M5: the embedded-daemon ENTRYPOINT +// assembly. After AblaRuntime (9f5050f2) closed the size loop, four front-ends +// existed but nothing OWNED them as one running daemon instance: +// HeaderChain -- SPV header store + tip (M3); height source +// Mempool -- tx acceptance pool (M4); template input +// EmbeddedCoinNode -- in-process getwork() work source (template builder) +// coin::Node -- P2P + external-RPC front-end; the full_block source +// (derives bch::interfaces::Node) AND the retained +// external BCHN-RPC fallback path (init_rpc / m_rpc) +// AblaRuntime -- owns AblaTracker + AblaBlockFeed; wires full_block +// --> feed --> tracker --> EmbeddedCoinNode budget +// +// This object is that owner: one daemon = one EmbeddedDaemon. It constructs the +// members in the lifetime order the wiring demands, runs the node, and closes +// the ABLA loop via AblaRuntime::wire(). It is the site coin::Node +// instantiates concretely (config binds here, per node.hpp's deferral note). +// +// EMBEDDED-PRIMARY, EXTERNAL-FALLBACK (v36-master-plan, REQUIRED for every coin) +// The embedded work source (EmbeddedCoinNode::getwork) is the DEFAULT. The +// external BCHN-RPC path stays alongside it -- coin::Node::init_rpc() is NOT +// removed; m_node keeps its NodeRPC. The CoinNode seam (node_iface.hpp) is +// built embedded-primary with the RPC as the live fallback. Removing the +// external path would violate the per-coin external_fallback invariant. +// +// COLD START = FLOOR ANCHOR (VM300 pin is the NEXT, operator-gated step). +// Absent a BCHN-pinned {height,State} captured from VM300 bchn-bch, the ABLA +// runtime anchors at the activation/floor State -- folding live block sizes +// forward can only RAISE the budget, never undercut the 32 MB floor. The +// BCHN pin is a later reanchor() passthrough (AblaRuntime::reanchor), NOT a +// reconstruction; capturing it touches VM300 read-only and is surfaced to the +// operator as a [decision-needed] before any read. This assembly is complete +// and correct WITHOUT the pin; the pin only sharpens the cold-start budget. +// +// LIFETIME (members declared in this exact order so refs bind to live objects): +// m_chain before m_embedded (EmbeddedCoinNode HeaderChain&) and m_abla +// (AblaBlockFeed HeaderChain&) -- both bind to a constructed chain. +// m_pool before m_embedded (EmbeddedCoinNode Mempool&). +// m_embedded, m_node before wire() hands their addresses to m_abla; m_abla's +// AblaTracker outlives the EmbeddedCoinNode raw pointer it sinks into +// (the daemon owns all of them for its whole run). +// +// p2pool-merged-v36 SURFACE: NONE. Pure local daemon assembly -- no PoW hash, +// share format, coinbase commitment, AuxPoW, or PPLNS math is touched; getwork +// emits the same coin-agnostic WorkData the sweep already pinned conformant. +// PER-COIN ISOLATION: src/impl/bch/coin/ only; every type is bch-owned. +// Build-INERT / source-only: header-only, no impl_bch CMake registration +// (bch stays skip-green; don't race ci-steward). +// --------------------------------------------------------------------------- + +#include +#include + +#include "header_chain.hpp" // HeaderChain +#include "mempool.hpp" // Mempool +#include "template_builder.hpp" // EmbeddedCoinNode +#include "node.hpp" // coin::Node (interfaces::Node source) +#include "coin_node.hpp" // CoinNode (core::coin::ICoinNode seam) +#include "abla_runtime.hpp" // AblaRuntime (owns tracker + feed) +#include "bchn_anchor_record.hpp" // BchnAnchorRecord (cold-start anchor) + +#include + +namespace bch { +namespace coin { + +/// Owns and wires one embedded BCH daemon instance. Single-threaded +/// construction from the binary entrypoint; run() then drives the node. +template +class EmbeddedDaemon { +public: + using config_t = ConfigType; + + /// Cold-start ctor: floor-anchored ABLA at `anchor_height` (safe default + /// when no VM300 BCHN pin is available yet). `context`/`config` outlive the + /// daemon (owned by the binary entrypoint, same contract as coin::Node). + EmbeddedDaemon(auto* context, config_t* config, uint32_t anchor_height) + : m_config(config), + m_chain(), + m_pool(), + m_embedded(m_chain, m_pool, config->m_testnet), + m_node(context, config), + m_abla(config->m_testnet, anchor_height, m_chain) {} + + EmbeddedDaemon(const EmbeddedDaemon&) = delete; + EmbeddedDaemon& operator=(const EmbeddedDaemon&) = delete; + + /// Bring the daemon up: start the node front-end (external RPC fallback + + /// P2P relay) and close the ABLA size loop. After this, full_block events + /// flow node --> feed --> tracker --> EmbeddedCoinNode dynamic budget, and + /// EmbeddedCoinNode::getwork() is the live in-process work source. + void run() { + m_node.run(); // init_rpc(): external BCHN-RPC fallback retained + m_abla.wire(m_node, m_embedded); + // Build the CoinNode seam NOW (not in the ctor): m_node.rpc() is only + // live after run()/init_rpc(). Embedded work source = primary, the + // external BCHN-RPC = retained fallback (v36 external_fallback law). + m_coin_node = std::make_unique(&m_embedded, m_node.rpc()); + LOG_INFO << "[EMB-BCH] embedded daemon up: embedded-primary work source," + << " external BCHN-RPC fallback retained, ABLA loop closed" + << " (cold-start floor anchor; VM300 pin pending operator)."; + } + + /// Apply a BCHN-pinned {height, State} captured from VM300 bchn-bch. This + /// is the operator-gated reanchor step -- call ONLY after the read is + /// approved; until then the floor anchor is correct and never-undercut. + void apply_bchn_anchor(uint32_t height, abla::State state) { + m_abla.reanchor(height, state); + } + + /// DRY RUN of the cold-start reanchor: read the STATIC VM300 anchor record + /// (BchnAnchorRecord -- captured once, read-only; the live VM is never + /// touched here) and LOG exactly what apply_bchn_anchor() WOULD pin, with + /// no mutation of the running ABLA state. This is the cold-start path + /// wiring: origin is the recorded {height,hash,chainwork,time} anchor, NOT + /// genesis; the AblaRuntime replay stays pinned to the 32 MB safe floor + /// whenever the recorded control state is still at floor (the 955700 + /// capture is). The real reanchor stays operator-gated -- this only proves + /// the wiring and surfaces a non-floor budget the moment a capture shows one. + void dry_run_bchn_anchor() const { + using Rec = BchnAnchorRecord; + const abla::State rec_state = Rec::state(m_config->m_testnet); + const uint64_t rec_limit = rec_state.GetBlockSizeLimit(); + LOG_INFO << "[EMB-BCH] cold-start anchor DRY RUN (record-only, VM300 untouched):" + << " height=" << Rec::height + << " hash=" << Rec::hash + << " chainwork=" << Rec::chainwork + << " time=" << Rec::time + << " -> ABLA limit=" << rec_limit + << " (control=" << rec_state.GetControlBlockSize() + << " elastic=" << rec_state.GetElasticBufferSize() << ")."; + if (Rec::is_floor()) { + LOG_INFO << "[EMB-BCH] recorded ABLA control state == 32 MB floor;" + << " pinning is a no-op vs cold-start floor (provenance only)." + << " apply_bchn_anchor() remains operator-gated."; + } else { + LOG_WARNING << "[EMB-BCH] recorded ABLA control state is ABOVE floor" + << " (limit=" << rec_limit << "); apply_bchn_anchor(" + << Rec::height << ", state) would RAISE the cold-start" + << " budget. Operator gate required before pinning."; + } + } + + // Accessors for the CoinNode seam cluster (embedded-primary + RPC fallback) + // and for tests; the daemon retains ownership. + EmbeddedCoinNode& embedded() { return m_embedded; } + Node& node() { return m_node; } + /// The CoinNode seam handed to the pool/web_server (core::coin::ICoinNode). + /// Valid only after run() has built it. Embedded-primary + external-RPC + /// fallback; the daemon owns it for its whole run. + CoinNode& coin_node() { return *m_coin_node; } + bool seam_ready() const { return m_coin_node != nullptr; } + AblaRuntime& abla() { return m_abla; } + HeaderChain& chain() { return m_chain; } + Mempool& mempool() { return m_pool; } + bool is_wired() const { return m_abla.is_wired(); } + +private: + config_t* m_config; // not owned (binary entrypoint owns it) + HeaderChain m_chain; // before m_embedded + m_abla: their refs bind here + Mempool m_pool; // before m_embedded + EmbeddedCoinNode m_embedded; // in-process work source + Node m_node; // P2P + external-RPC fallback; full_block source + AblaRuntime m_abla; // owns tracker + feed; wired in run() + // Built in run() once m_node.rpc() is live; binds raw ptrs to m_embedded + // (primary) + m_node's NodeRPC (fallback), both outlive it (daemon-owned). + std::unique_ptr m_coin_node; +}; + +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/merkle.hpp b/src/impl/bch/coin/merkle.hpp new file mode 100644 index 000000000..0bf757874 --- /dev/null +++ b/src/impl/bch/coin/merkle.hpp @@ -0,0 +1,55 @@ +#pragma once +// --------------------------------------------------------------------------- +// bch::coin merkle helpers -- single source of truth for the SHA256d +// transaction merkle root. BCH did NOT change the merkle rule from Bitcoin: +// pairwise SHA256d, odd row duplicates its last element. CTOR (Nov 2018) +// changes only the *order* transactions appear in the block (coinbase first, +// then ascending txid); the merkle is still computed over that block order +// as-is, so nothing CTOR-specific lives here. +// +// Extracted from template_builder.hpp so both the producer side (GBT template +// builder, M4) and the consumer side (p2p_node full-block accept, M5) compute +// the root through one implementation rather than two divergent copies. +// +// p2pool-merged-v36 SURFACE: NONE. This is the consensus merkle of the parent +// BCH block -- it never touches share hashing, the share merkle_link, the +// coinbase commitment, or PPLNS. Source-only / build-inert. +// --------------------------------------------------------------------------- + +#include +#include + +#include +#include +#include + +namespace bch +{ +namespace coin +{ + +/// SHA256d of two concatenated 32-byte hashes (an internal merkle node). +inline uint256 merkle_hash_pair(const uint256& left, const uint256& right) { + auto sl = std::span(left.data(), 32); + auto sr = std::span(right.data(), 32); + return Hash(sl, sr); +} + +/// Merkle root over txids (SHA256d pairwise, last element duplicated for odd +/// counts). Identical algorithm to BTC -- BCH did not change the merkle rule. +inline uint256 compute_merkle_root(std::vector hashes) { + if (hashes.empty()) return uint256::ZERO; + while (hashes.size() > 1) { + if (hashes.size() & 1u) + hashes.push_back(hashes.back()); + std::vector next; + next.reserve(hashes.size() / 2); + for (size_t i = 0; i < hashes.size(); i += 2) + next.push_back(merkle_hash_pair(hashes[i], hashes[i + 1])); + hashes = std::move(next); + } + return hashes[0]; +} + +} // namespace coin +} // namespace bch diff --git a/src/impl/bch/coin/node.hpp b/src/impl/bch/coin/node.hpp index 835b81e9f..d7b2b3a2c 100644 --- a/src/impl/bch/coin/node.hpp +++ b/src/impl/bch/coin/node.hpp @@ -104,6 +104,12 @@ class Node : public bch::interfaces::Node bool has_p2p() const { return m_p2p != nullptr; } + /// External BCHN-RPC client, or nullptr until run()/init_rpc() has run. + /// Handed to CoinNode as the live external FALLBACK sink behind the + /// embedded work source (v36 external_fallback invariant). Not owned by + /// the caller -- the Node retains ownership for its whole lifetime. + NodeRPC* rpc() { return m_rpc.get(); } + /// Send getheaders to drive header sync. /// Locator should be hashes from chain tip back to genesis (sparsely); /// for an empty chain pass {genesis_hash}. Stop = uint256::ZERO means diff --git a/src/impl/bch/coin/p2p_node.hpp b/src/impl/bch/coin/p2p_node.hpp index 259761301..73a18a723 100644 --- a/src/impl/bch/coin/p2p_node.hpp +++ b/src/impl/bch/coin/p2p_node.hpp @@ -20,6 +20,23 @@ // headers/blocks are the standard SHA256d serialization. // * doublespendproof (DSPROOF, 0x94a0) inv is recognized but not requested. // +// >>> BLOCK-ARRIVAL EMIT CONFORMANCE (vs frstrtr/p2poolBCH @6603b79) <<< +// The Python baseline delivers block arrivals through exactly two events in +// p2pool/bitcoin/p2p.py: +// - handle_inv -> factory.new_block.happened(inv['hash']) (announcement) +// - handle_block -> get_block.got_response(hash, block) (full block, +// matched to an explicit get_block ReplyMatcher request; single path, +// no broadcast event, no compact-block handling at all). +// We mirror both: new_block.happened(hash) on the block inv, and the direct +// `block` handler calls m_peer->get_block(hash, block) (the same ReplyMatcher +// surface) before firing full_block. The two compact-block delivery paths +// (cmpctblock-complete and cmpctblock+blocktxn) have NO Python analog -- they +// are strictly additive embedded-daemon coverage that funnels reconstructed +// blocks into the same full_block sink. full_block itself is internal ABLA / +// mempool plumbing with zero p2pool-merged-v36 surface (Python reads block +// size from the daemon, not from a broadcast event). Emit-completeness across +// all three delivery paths therefore conforms to the baseline and is additive. +// // This class is templated on ConfigType and touches the per-coin config only // through `m_config->coin()->m_p2p.prefix`. It carries NO concrete dependency // on bch's config.hpp -- the config binding is deferred to the @@ -31,6 +48,7 @@ #include "node_interface.hpp" #include "compact_blocks.hpp" #include "mempool.hpp" +#include "merkle.hpp" #include @@ -607,6 +625,39 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: m_coin->new_tx.happened(Transaction(msg->m_tx)); } + // Validate a fully-assembled/received block against its header's merkle + // commitment, then publish it on full_block. All three delivery paths -- + // the direct `block` message, compact-block-complete, and the compact + + // getblocktxn round-trip -- funnel through here so a block can only reach + // the ABLA size feed (and any future block-connect consumer) once its tx + // set provably matches the header it was announced under. + // + // The check is the parent-chain consensus merkle (SHA256d over txids, BCH + // == BTC rule; CTOR fixes only tx *order*, which the block already carries). + // A mismatch means the peer handed us a header/txs pair that does not + // cohere -- we DROP it rather than fold a bogus serialized size into ABLA, + // where the resulting discontinuity would (safely) sink the template budget + // to the 32 MB floor. Mirrors BCHN CheckMerkleRoot (validation.cpp) at + // accept time. p2pool-merged-v36 surface: NONE -- local accept gate only. + void emit_full_block(const BlockType& block, const uint256& blockhash) + { + std::vector txids; + txids.reserve(block.m_txs.size()); + for (const auto& tx : block.m_txs) + txids.push_back(compute_txid(tx)); + const uint256 computed = compute_merkle_root(txids); + + if (computed != block.m_merkle_root) { + LOG_WARNING << "[" << m_chain_label << "] Block " + << blockhash.GetHex().substr(0, 16) << "... merkle mismatch (header " + << block.m_merkle_root.GetHex().substr(0, 16) << "... vs computed " + << computed.GetHex().substr(0, 16) << "...) over " + << block.m_txs.size() << " txs -- dropping, full_block NOT emitted"; + return; + } + m_coin->full_block.happened(block); + } + ADD_P2P_HANDLER(block) { // BCH blocks are the standard SHA256d serialization — no AuxPoW @@ -625,7 +676,7 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: LOG_INFO << "[" << m_chain_label << "] Full block received: " << blockhash.GetHex().substr(0, 16) << "..." << " txs=" << block.m_txs.size(); - m_coin->full_block.happened(block); + emit_full_block(block, blockhash); } ADD_P2P_HANDLER(headers) @@ -781,7 +832,7 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: m_peer->get_block(blockhash, result.block); auto header = static_cast(result.block); m_peer->get_header(blockhash, header); - m_coin->full_block.happened(result.block); + emit_full_block(result.block, blockhash); } else { LOG_INFO << "[" << m_chain_label << "] Compact block incomplete, " << result.missing_indexes.size() << " txs missing — requesting via getblocktxn"; @@ -909,6 +960,15 @@ class NodeP2P : public core::ICommunicator, public core::INetwork, public core:: LOG_INFO << "[" << m_chain_label << "] Compact block completed via blocktxn: " << blockhash.GetHex(); + // Emit the reconstructed block on full_block, exactly as the direct + // `block` handler and the complete-path `cmpctblock` handler do. Without + // this, a block delivered via the compact + getblocktxn round-trip (the + // common case when the mempool is missing some txns) never reaches the + // ABLA size feed: AblaBlockFeed would see a height discontinuity and the + // template budget would drop to the 32 MB safe floor. Producer-side + // completion of the embedded-daemon block-connect -> full_block path. + emit_full_block(block, blockhash); + m_pending_cmpct.reset(); m_pending_missing_indexes.clear(); } diff --git a/src/impl/bch/coin/template_builder.hpp b/src/impl/bch/coin/template_builder.hpp index 4c56b2f19..174673e52 100644 --- a/src/impl/bch/coin/template_builder.hpp +++ b/src/impl/bch/coin/template_builder.hpp @@ -30,9 +30,11 @@ #include "mempool.hpp" #include "transaction.hpp" #include "block.hpp" +#include "merkle.hpp" // shared SHA256d tx-merkle (also used by p2p_node full-block accept) #include "rpc_data.hpp" #include "../coinbase_commitment.hpp" // s19 seam (commitment built downstream) #include "abla.hpp" // s2: ABLA block-size limit (CHIP-2023-01) +#include "abla_tracker.hpp" // sC: live full-block size feed -> ABLA state #include #include @@ -101,29 +103,7 @@ inline uint64_t get_block_subsidy(uint32_t height) { return INITIAL_SUBSIDY >> halvings; } -// ─── Merkle Tree (SHA256d, BCH == BTC) ─────────────────────────────────────── - -inline uint256 merkle_hash_pair(const uint256& left, const uint256& right) { - auto sl = std::span(left.data(), 32); - auto sr = std::span(right.data(), 32); - return Hash(sl, sr); -} - -/// Merkle root over txids (SHA256d pairwise, last element duplicated for odd -/// counts). Identical algorithm to BTC -- BCH did not change the merkle rule. -inline uint256 compute_merkle_root(std::vector hashes) { - if (hashes.empty()) return uint256::ZERO; - while (hashes.size() > 1) { - if (hashes.size() & 1u) - hashes.push_back(hashes.back()); - std::vector next; - next.reserve(hashes.size() / 2); - for (size_t i = 0; i < hashes.size(); i += 2) - next.push_back(merkle_hash_pair(hashes[i], hashes[i + 1])); - hashes = std::move(next); - } - return hashes[0]; -} +// ─── Merkle Tree → moved to merkle.hpp (shared producer/consumer) ────────── // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -291,7 +271,19 @@ class EmbeddedCoinNode : public CoinNodeInterface { << m_chain.height() << ")"; throw std::runtime_error("EmbeddedCoinNode::getwork: chain not synced — waiting for header sync"); } - auto result = TemplateBuilder::build_template(m_chain, m_pool, m_testnet); + // Dynamic ABLA budget: when an AblaTracker is wired (the full-block / + // daemon layer feeding live per-block sizes) and its running state sits + // exactly at our tip, hand build_template that per-tip State so the + // budget tracks the live consensus limit. Otherwise pass nullptr and + // build_template falls back to the 32 MB activation/floor -- the hard, + // never-undercut fallback for an absent or stale (gapped) feed. + const abla::State* tip_state = nullptr; + if (m_abla) { + auto tip = m_chain.tip(); + if (tip) + tip_state = m_abla->state_for_tip(tip->height); + } + auto result = TemplateBuilder::build_template(m_chain, m_pool, m_testnet, tip_state); if (!result) { LOG_WARNING << "[EMB-BCH] getwork() FAILED: no tip (chain empty)"; throw std::runtime_error("EmbeddedCoinNode::getwork: chain has no tip (not yet synced to genesis)"); @@ -323,6 +315,11 @@ class EmbeddedCoinNode : public CoinNodeInterface { /// UTXO-readiness gate (coinbase maturity = 100 blocks on BCH). void set_utxo_ready_fn(std::function fn) { m_utxo_ready = std::move(fn); } + /// Wire the live ABLA size feed (full-block/daemon layer). Optional: when + /// unset the template builder uses the 32 MB floor budget. The tracker is + /// owned by the daemon layer and must outlive this node. + void set_abla_tracker(AblaTracker* abla) { m_abla = abla; } + bool is_synced() const override { if (!m_chain.is_synced()) return false; if (m_utxo_ready && !m_utxo_ready()) return false; @@ -334,6 +331,7 @@ class EmbeddedCoinNode : public CoinNodeInterface { Mempool& m_pool; std::function m_utxo_ready; bool m_testnet; + AblaTracker* m_abla = nullptr; // sC: live size feed (daemon-owned, optional) }; } // namespace coin diff --git a/src/impl/bch/config_coin.hpp b/src/impl/bch/config_coin.hpp index 0c0446420..b76cd4ae0 100644 --- a/src/impl/bch/config_coin.hpp +++ b/src/impl/bch/config_coin.hpp @@ -9,10 +9,12 @@ // mainchain. It is explicitly OUT OF SCOPE for this coin module. Do not add // HogEx commitment handling here. See feedback: hogex-not-bch. // -// CashAddr scaffolding: BCH address encoding diverges from BTC base58/bech32. -// TODO(M4): port CashAddr (prefix "bitcoincash:") encode/decode at the -// template/address layer -- the wire/config shape below is coin-agnostic and -// matches the btc reference 1:1 (P2P prefix + NetService + RPC userpass). +// CashAddr: BCH address encoding diverges from BTC base58/bech32. The codec +// (prefix "bitcoincash:"/"bchtest:"/"bchreg:", base32 + BCH-code PolyMod +// checksum, CashTokens z/r token-aware types, P2SH32) lives in +// coin/cashaddr.hpp -- {type,hash} <-> string, operator/payout-address layer. +// The wire/config shape below stays coin-agnostic and matches the btc +// reference 1:1 (P2P prefix + NetService + RPC userpass). #include #include diff --git a/src/impl/bch/messages.hpp b/src/impl/bch/messages.hpp new file mode 100644 index 000000000..b37a00f03 --- /dev/null +++ b/src/impl/bch/messages.hpp @@ -0,0 +1,202 @@ +#pragma once + +// BCH share/pool p2p protocol messages. +// +// Mirrors btc::messages — the on-wire p2pool protocol is coin-independent and +// must stay byte-compatible with frstrtr/p2pool-merged-v36 (p2pool/p2p.py). +// +// BCH divergence from btc: BCH has NO SegWit / witness. coin::Transaction and +// coin::MutableTransaction serialize without a marker/flag byte or witness +// stack (see coin/transaction.hpp), so message_remember_tx writes its txs +// plainly — there is no TX_WITH_WITNESS wrapper on this chain. + +#include "coin/block.hpp" +#include "coin/transaction.hpp" + +#include + +#include +#include +#include + +namespace bch +{ + +// message_version +BEGIN_MESSAGE(version) + MESSAGE_FIELDS + ( + (uint32_t, m_version), + (uint64_t, m_services), + (addr_t , m_addr_to), + (addr_t, m_addr_from), + (uint64_t, m_nonce), + (std::string, m_subversion), + (uint32_t, m_mode), //# always 1 for legacy compatibility + (uint256, m_best_share) + ) + { + READWRITE(obj.m_version, obj.m_services, obj.m_addr_to, obj.m_addr_from, obj.m_nonce, obj.m_subversion, obj.m_mode, obj.m_best_share); + } +END_MESSAGE() + +// message_ping +BEGIN_MESSAGE(ping) + WITHOUT_MESSAGE_FIELDS() { } +END_MESSAGE() + +// message_addrme +BEGIN_MESSAGE(addrme) + MESSAGE_FIELDS + ( + (uint16_t, m_port) + ) + { + READWRITE(obj.m_port); + } +END_MESSAGE() + +// message_getaddrs +BEGIN_MESSAGE(getaddrs) + MESSAGE_FIELDS + ( + (uint32_t, m_count) + ) + { + READWRITE(obj.m_count); + } +END_MESSAGE() + +// message_addrs +BEGIN_MESSAGE(addrs) + MESSAGE_FIELDS + ( + (std::vector, m_addrs) + ) + { + READWRITE(obj.m_addrs); + } +END_MESSAGE() + +// message_shares +BEGIN_MESSAGE(shares) + MESSAGE_FIELDS + ( + (std::vector, m_shares) + ) + { + READWRITE(obj.m_shares); + } +END_MESSAGE() + +// message_sharereq +BEGIN_MESSAGE(sharereq) + MESSAGE_FIELDS + ( + (uint256, m_id), + (std::vector, m_hashes), + (uint64_t, m_parents), + (std::vector, m_stops) + ) + { + READWRITE(obj.m_id, obj.m_hashes, VarInt(obj.m_parents), obj.m_stops); + } +END_MESSAGE() + +enum ShareReplyResult +{ + good = 0, + too_long = 1, + unk2 = 2, + unk3 = 3, + unk4 = 4, + unk5 = 5, + unk6 = 6 +}; + +// message_sharereply +BEGIN_MESSAGE(sharereply) + MESSAGE_FIELDS + ( + (uint256, m_id), + (ShareReplyResult, m_result), + (std::vector, m_shares) + ) + { + READWRITE(obj.m_id, Using>(obj.m_result), obj.m_shares); + } +END_MESSAGE() + +// message_bestblock +BEGIN_MESSAGE(bestblock) + MESSAGE_FIELDS + ( + (coin::BlockHeaderType, m_header) + ) + { + READWRITE(obj.m_header); + } +END_MESSAGE() + +// message_have_tx +BEGIN_MESSAGE(have_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes) + ) + { + READWRITE(obj.m_tx_hashes); + } +END_MESSAGE() + +// message_losing_tx +BEGIN_MESSAGE(losing_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes) + ) + { + READWRITE(obj.m_tx_hashes); + } +END_MESSAGE() + +// message_forget_tx +BEGIN_MESSAGE(forget_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes) + ) + { + READWRITE(obj.m_tx_hashes); + } +END_MESSAGE() + +// message_remember_tx +// BCH: no witness — serialize MutableTransaction plainly (no TX_WITH_WITNESS). +BEGIN_MESSAGE(remember_tx) + MESSAGE_FIELDS + ( + (std::vector, m_tx_hashes), + (std::vector, m_txs) + ) + { + READWRITE(obj.m_tx_hashes, obj.m_txs); + } +END_MESSAGE() + +using Handler = MessageHandler< + message_ping, + message_addrme, + message_getaddrs, + message_addrs, + message_shares, + message_sharereq, + message_sharereply, + message_bestblock, + message_have_tx, + message_losing_tx, + message_forget_tx, + message_remember_tx +>; + +} // namespace bch diff --git a/src/impl/bch/node.hpp b/src/impl/bch/node.hpp new file mode 100644 index 000000000..52d9d626b --- /dev/null +++ b/src/impl/bch/node.hpp @@ -0,0 +1,664 @@ +#pragma once + +// BCH share/pool p2p node (NodeImpl / pool::BaseNode declaration). +// +// Mirror of btc::NodeImpl with the namespace swapped to bch. The c2pool +// pool-layer node logic is coin-independent: it drives the p2pool p2p +// protocol (version handshake, share download/relay, tx remember/forget, +// bestblock relay) over the share chain and tracker. Only the chain-specific +// touches differ on BCH: +// +// * NO SegWit / witness — coin::Transaction and coin::MutableTransaction +// serialize plainly (see coin/transaction.hpp + messages.hpp slice A). +// The header references plain coin::Transaction; there is no witness/ +// marker/flag handling here to drop. +// * NO merged-mining / AuxPoW — BCH is a SHA256d standalone-parent chain. +// The header carries no aux paths; nothing BTC-specific to omit. +// * verify_pool runs SHA256d share PoW verification (NOT scrypt). +// * LevelDB storage net name: "bitcoincash" / "bitcoincash_testnet". +// +// This is a header-only slice (declarations only); node.cpp body lands later. + +#include "config.hpp" +#include "share.hpp" +#include "share_tracker.hpp" +#include "peer.hpp" +#include "messages.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace bch +{ +struct HandleSharesData; +struct ShareReplyData +{ + std::vector m_items; + std::vector m_raw_items; +}; + +class NodeImpl : public pool::BaseNode +{ + // Async share downloader: + // ID = uint256 (matches sharereq id to sharereply id) + // RESPONSE = parsed shares plus their original raw payloads + // REQUEST args: req_id, peer, hashes, parents, stops + using share_getter_t = ReplyMatcher::ID + ::RESPONSE + ::REQUEST, uint64_t, std::vector>; + +protected: + bch::Handler m_handler; + share_getter_t m_share_getter; + ShareTracker m_tracker; + std::unique_ptr m_storage; + + // Global pool of known transactions, populated by remember_tx and coin daemon. + // Protocol handlers look up tx hashes here when processing shares. + std::map m_known_txs; + + // Thread pool for parallel share_init_verify (SHA256d CPU work). + // Keeps expensive crypto off the io_context thread. + boost::asio::thread_pool m_verify_pool{4}; + + // ── Async compute pipeline ────────────────────────────────────────── + // think() runs on m_think_pool (1 thread) holding m_tracker_mutex exclusively. + // The IO thread NEVER calls lock() — only try_to_lock(). If the mutex is + // held by the compute thread, the IO thread defers the operation and continues + // processing network I/O, keepalive timers, and stratum. This eliminates the + // event loop freeze that previously caused all peers to timeout simultaneously. + // + // Synchronization contract: + // Compute thread: unique_lock(m_tracker_mutex) — exclusive, blocking + // IO thread reads: shared_lock(try_to_lock) — non-blocking, skip if busy + // IO thread writes: unique_lock(try_to_lock) — non-blocking, queue if busy + boost::asio::thread_pool m_think_pool{1}; + std::atomic m_think_running{false}; + std::atomic m_clean_running{false}; + mutable std::shared_mutex m_tracker_mutex; + + // ── Lock-free stats snapshot ───────────────────────────────────────── + // Published by think() on the compute thread under m_tracker_mutex. + // Read by ALL consumers (sync_status, loading page, global_stats, + // graph data, etc.) WITHOUT needing the tracker lock. + // + // This is the c2pool equivalent of p2pool's reactor-thread stats + // variables: updated atomically each think() cycle, always available. + // Eliminates the systemic "0/empty" data problem where 20+ callbacks + // returned stale data whenever think() held the exclusive lock. + struct TrackerSnapshot { + int chain_count{0}; + int verified_count{0}; + int head_count{0}; + int orphan_shares{0}; + int dead_shares{0}; + int fork_count{0}; + double pool_hashrate{0}; + }; + void publish_snapshot() { + TrackerSnapshot s; + s.chain_count = static_cast(m_tracker.chain.size()); + s.verified_count = static_cast(m_tracker.verified.size()); + s.head_count = static_cast(m_tracker.chain.get_heads().size()); + s.fork_count = s.head_count; + std::lock_guard lock(m_snapshot_mutex); + m_snapshot = s; + } + mutable std::mutex m_snapshot_mutex; + TrackerSnapshot m_snapshot; + + // Identity of the compute thread (m_think_pool's single thread). + // Used by TrackerReadGuard to skip shared_lock when the caller is + // already on the compute thread (which holds exclusive lock). + std::atomic m_compute_thread_id{}; + + // Pending share batches queued while think() holds the mutex. + // Drained on the IO thread after think() releases the lock. + // Uses unique_ptr because HandleSharesData is forward-declared here. + struct PendingShareBatch { + std::unique_ptr data; + NetService addr; + }; + std::vector m_pending_adds; + + // ── think() watchdog + backpressure (V36 livelock defense-in-depth) ── + // If a think() cycle exceeds THINK_WATCHDOG_SECONDS the watchdog logs the + // compute-thread state (timing + backtrace dump), flags the cycle, and + // resets m_think_running so the pipeline recovers instead of wedging. + // Implemented as an atomic deadline checked by an IO-thread steady_timer: + // the watchdog NEVER touches m_tracker_mutex (would itself block on the + // stuck compute thread). MAX_PENDING_ADDS caps the deferred queue so a + // stuck/slow think() cannot grow memory without bound — over the cap new + // batches are dropped with a LOG_WARNING backpressure message. + static constexpr int THINK_WATCHDOG_SECONDS = 30; + static constexpr size_t MAX_PENDING_ADDS = 256; + // Steady-clock time-point (ns since epoch) by which the in-flight think() + // cycle must complete. 0 == no cycle armed. Set on dispatch, cleared on + // completion; read by the watchdog timer on the IO thread. + std::atomic m_think_deadline_ns{0}; + // Generation counter: bumped each dispatch so a fired watchdog only acts + // on the cycle it was armed for (guards against late/duplicate fires). + std::atomic m_think_generation{0}; + // IO-thread timer that polls the deadline. Armed on run_think() dispatch. + std::unique_ptr m_watchdog_timer; + // Arm/disarm helpers (defined in node.cpp). + void arm_think_watchdog(); + void disarm_think_watchdog(); + + // Top-5 scored heads from last think() — used by clean_tracker() + // to protect the best chains from head pruning (p2pool node.py:363). + std::vector m_last_top5_heads; + + // Buffer of newly verified share hashes, flushed to LevelDB periodically + std::vector m_verified_flush_buf; + + // Buffer of pruned share hashes, batch-deleted from LevelDB after clean_tracker() + std::vector m_removal_flush_buf; + +public: + NodeImpl() + : m_share_getter(nullptr, + [](uint256, peer_ptr, std::vector, uint64_t, std::vector){}) {} + + NodeImpl(boost::asio::io_context* ctx, config_t* config) + : base_t(ctx, config), + m_share_getter(ctx, + [](uint256 req_id, peer_ptr to_peer, + std::vector hashes, uint64_t parents, + std::vector stops) + { + auto rmsg = bch::message_sharereq::make_raw(req_id, hashes, parents, stops); + to_peer->write(std::move(rmsg)); + }, + 15) // p2pool p2p.py:80: timeout=15 for share requests + { + // Seed addr store with hardcoded bootstrap peers + m_addrs.load(config->pool()->m_bootstrap_addrs); + // Randomise our nonce so we detect self-connections + std::mt19937_64 rng(std::random_device{}()); + m_nonce = rng(); + // Route m_chain (used by BaseNode) to the tracker's main chain + m_chain = &m_tracker.chain; + + // Open LevelDB storage and load any persisted shares + std::string net_name = config->m_testnet ? "bitcoincash_testnet" : "bitcoincash"; + m_storage = std::make_unique(net_name); + load_persisted_shares(); + + // Wire up verified-hash persistence callback (p2pool known_verified pattern) + m_tracker.m_on_share_verified = [this](const uint256& hash) { + m_verified_flush_buf.push_back(hash); + if (m_verified_flush_buf.size() >= 50) + flush_verified_to_leveldb(); + }; + + // Wire up share removal → LevelDB cleanup (p2pool main.py:269-270) + // Buffer removals; clean_tracker() flushes after drop-tails. + // Safe on crash: unflushed shares get pruned at next startup by load_persisted_shares(). + m_tracker.chain.on_removed([this](const uint256& hash) { + m_removal_flush_buf.push_back(hash); + }); + } + + // INetwork: Pool node does not initiate disconnect — peer connections + // manage their own lifecycle via close_connection()/error() below. + void disconnect() override { } + void connected(std::shared_ptr socket) override; + + // ICommunicator (override BaseNode to track outbound lifecycle): + void error(const message_error_type& err, const NetService& service, const std::source_location where = std::source_location::current()) override; + void close_connection(const NetService& service) override; + + // BaseNode: + void send_ping(peer_ptr peer) override; + std::optional handle_version(std::unique_ptr rmsg, peer_ptr peer) override; + + // ltc + void send_version(peer_ptr peer); + void processing_shares(HandleSharesData& data, NetService addr); + void processing_shares_phase2(HandleSharesData& data, NetService addr); + /// Direct tracker access — compute-thread-only (already holds exclusive lock) + /// or startup code (before compute thread exists). + /// IO-thread code MUST use read_tracker() instead. + ShareTracker& tracker() { return m_tracker; } + + /// RAII guard for IO-thread tracker reads. + /// - IO thread: acquires shared_lock(try_to_lock). Returns falsy if busy. + /// - Compute thread: skips locking (exclusive already held). Always truthy. + /// Guard lifetime = lock lifetime. No way to hold a dangling reference. + class TrackerReadGuard { + std::shared_lock lock_; + ShareTracker& tracker_; + bool ok_; + public: + TrackerReadGuard(std::shared_mutex& mtx, ShareTracker& t, bool on_compute) + : lock_(mtx, std::defer_lock), tracker_(t) + { + if (on_compute) { + ok_ = true; // exclusive lock already held by caller + } else { + ok_ = lock_.try_lock(); + } + } + TrackerReadGuard(TrackerReadGuard&&) = default; + TrackerReadGuard(const TrackerReadGuard&) = delete; + TrackerReadGuard& operator=(const TrackerReadGuard&) = delete; + TrackerReadGuard& operator=(TrackerReadGuard&&) = default; + + explicit operator bool() const { return ok_; } + ShareTracker& operator*() { return tracker_; } + ShareTracker* operator->() { return &tracker_; } + }; + + /// True if the calling thread is the compute thread (m_think_pool). + /// Compute thread already holds exclusive lock — shared_lock must be skipped. + bool is_compute_thread() const { + return std::this_thread::get_id() == m_compute_thread_id.load(std::memory_order_relaxed); + } + + /// Preferred tracker accessor for IO-thread callbacks. + /// Returns a guard that: + /// - On IO thread: acquires shared_lock(try_to_lock). Check `if (!guard)` before use. + /// - On compute thread: skips locking (exclusive lock already held). Always valid. + TrackerReadGuard read_tracker() { + return TrackerReadGuard(m_tracker_mutex, m_tracker, is_compute_thread()); + } + + /// Acquire shared (reader) lock on the tracker mutex — BLOCKING. + /// Only for consensus-critical paths (share creation) where skipping is not acceptable. + /// IO-thread callers: prefer read_tracker() (non-blocking) unless you MUST have the lock. + std::shared_lock tracker_shared_lock() { + return std::shared_lock(m_tracker_mutex); + } + + // Async share download — response delivered to callback when sharereply arrives + void request_shares(uint256 id, peer_ptr peer, + std::vector hashes, uint64_t parents, + std::vector stops, + std::function callback) + { + m_share_getter.request(id, callback, id, peer, hashes, parents, stops); + } + + // Called from HANDLER(sharereply) to complete a pending async request + void got_share_reply(uint256 id, bch::ShareReplyData shares) + { + try { m_share_getter.got_response(id, shares); } + catch (const std::invalid_argument&) { /* request already timed out */ } + } + + std::vector handle_get_share(std::vector hashes, uint64_t parents, std::vector stops, NetService peer_addr); + + /// Broadcast a new best-block notification to all connected P2P peers. + void broadcast_bestblock(const coin::BlockHeaderType& header) { + for (auto& [nonce, peer] : m_peers) + peer->write(message_bestblock::make_raw(header)); + } + + /// Return a JSON array of connected peer info for the /peer_list endpoint. + nlohmann::json get_peer_info_json() const { + nlohmann::json arr = nlohmann::json::array(); + for (const auto& [nonce, peer] : m_peers) { + auto addr = peer->addr(); + bool incoming = (m_outbound_addrs.find(addr) == m_outbound_addrs.end()); + auto uptime_sec = std::chrono::duration_cast( + std::chrono::steady_clock::now() - peer->m_connected_at).count(); + arr.push_back({ + {"address", addr.to_string()}, + {"version", peer->m_other_subversion}, + {"incoming", incoming}, + {"uptime", uptime_sec}, + {"downtime", 0}, + {"web_port", 0} + }); + } + return arr; + } + + /// Register a callback invoked whenever a bestblock message is received + /// from any peer (after relaying). Use this to trigger work refresh. + void set_on_bestblock(std::function fn) { m_on_bestblock = std::move(fn); } + + /// Set the software version string announced to peers in P2P version messages. + void set_software_version(std::string ver) { m_software_version = std::move(ver); } + + /// Send a set of shares (with any needed txs) to a single peer. + /// Skips shares that originated from that peer. + void send_shares(peer_ptr peer, const std::vector& share_hashes); + + /// Broadcast a locally-generated (or newly-received) share to all peers. + void broadcast_share(const uint256& share_hash); + + /// Re-announce our current advertised head (advertised_best_share()) to all + /// connected peers. Invoked when think()/clean updates the best share so a + /// peer that completed the version handshake before we had any shares (when + /// our advert was ZERO) still learns our head and pulls via download_shares. + void readvertise_best(); + + /// Start downloading shares from a peer, beginning at `target_hash`. + /// Recursively fetches parents until the chain is connected or CHAIN_LENGTH reached. + void download_shares(peer_ptr peer, const uint256& target_hash); + + /// Return the best VERIFIED share head for work/template selection, or + /// uint256::ZERO when no verified chain exists yet and peers are connected + /// (so work never builds on a MAX_TARGET head). NOT for the wire advert — + /// use advertised_best_share() there. + uint256 best_share_hash(); + + /// Head advertised to peers (version handshake + re-advertisement ONLY). + /// Returns our tallest RAW chain head so a connecting peer always learns we + /// have a chain and pulls it; unlike best_share_hash() it does not collapse + /// to ZERO once peers exist. Root-2 genesis-null-advert fix. + uint256 advertised_best_share(); + + /// Load persisted shares from LevelDB storage into the tracker. + void load_persisted_shares(); + void flush_verified_to_leveldb(); + + /// Graceful shutdown: flush pending verified/removal buffers to LevelDB. + void shutdown(); + + /// Start dialing outbound peers from AddrStore / bootstrap list. + /// Maintains target outbound peer count active outbound connections. + void start_outbound_connections(); + + /// Set desired number of outbound peers maintained by connection loop. + /// A value of 0 disables outbound dialing. + void set_target_outbound_peers(size_t count) { m_target_outbound_peers = count; } + + /// Set max total P2P peers (inbound + outbound). + void set_max_peers(size_t count) { m_max_peers = count; } + + /// Set P2P ban duration (seconds). + void set_ban_duration(int seconds) { m_ban_duration = std::chrono::seconds(seconds); } + + /// Set cache size limits for memory control. + void set_cache_limits(size_t max_shared, size_t max_known_txs, size_t max_raw) { + m_max_shared_hashes = max_shared; + m_max_known_txs = max_known_txs; + m_max_raw_shares = max_raw; + } + + /// Set RSS memory limit in MB (abort if exceeded). Static because checked in process_shares. + static void set_rss_limit_mb(long mb); + + /// Expose tracker mutex for IO-thread callbacks that access the tracker. + /// Callers MUST use shared_lock(try_to_lock) — NEVER blocking lock(). + std::shared_mutex& tracker_mutex() { return m_tracker_mutex; } + + /// Unified share retention: single-pass prune of chain + verified + LevelDB. + /// Replaces multi-pass trim with work-based dead head detection and + /// deferred destruction for verified cascade safety. + /// Called from run_think() on the ioc thread. + void prune_shares(const uint256& best_share); + + /// Run the share tracker think() cycle: verifies chains, scores heads, + /// identifies bad peers, and requests needed shares. + /// Should be called periodically (e.g. after processing_shares or on a timer). + void run_think(); + + /// Fast-path: update best share after creating a local share. + /// Bypasses run_think() scoring — just sets the new tip and triggers + /// work refresh so miners immediately build on the new share. + /// p2pool equivalent: set_best_share() → work_event.happened(). + void notify_local_share(const uint256& share_hash); + + /// Periodic maintenance: eat stale heads, drop tails, then run_think(). + /// Matches p2pool's clean_tracker() (node.py:355-402). + void clean_tracker(); + + /// Drain share batches queued while think() held the tracker mutex. + /// Called on the IO thread after think() releases the lock. + void drain_pending_adds(); + + /// p2pool-style heartbeat: chain height, local/pool hashrate, orphan/DOA stats. + /// Runs on a separate 30s timer — diagnostic only, not consensus-critical. + void heartbeat_log(); + + /// Set the block_rel_height function used by run_think() for chain scoring. + /// fn(block_hash) should return confirmations from the coin daemon: + /// >= 0 : block is in main chain (0 = tip, higher = deeper) + /// < 0 : block is NOT in main chain (orphaned/stale) + using block_rel_height_fn_t = std::function; + void set_block_rel_height_fn(block_rel_height_fn_t fn) { m_block_rel_height_fn = std::move(fn); } + + /// Lock-free stats snapshot — published by think(), never fails, never needs tracker lock. + /// Inline definition must precede callers in the same header. + TrackerSnapshot get_tracker_snapshot() const; + int get_chain_count() const; + int get_verified_count() const; + + /// Called when best_share changes (p2pool: new_work_event) + /// Triggers immediate work update for all stratum miners. + void set_on_best_share_changed(std::function fn) { m_on_best_share_changed = std::move(fn); } + + /// Callback to get local hashrate from stratum server (H/s) + void set_local_hashrate_fn(std::function fn) { m_local_hashrate_fn = std::move(fn); } + + /// Local mining stats from RateMonitor (for p2pool-style status lines) + struct LocalRateStats { + double hashrate = 0; // H/s (total local) + double effective_dt = 0; // seconds of data in window + int total_datums = 0; // pseudoshares in window + int dead_datums = 0; // dead (DOA) pseudoshares in window + }; + void set_local_rate_stats_fn(std::function fn) { m_local_rate_stats_fn = std::move(fn); } + + /// Current PPLNS outputs {script_hex, satoshis} for payout display + void set_current_pplns_fn(std::function>()> fn) { + m_current_pplns_fn = std::move(fn); + } + + /// Node operator's payout script hex (for matching in PPLNS outputs) + void set_node_payout_script_hex(const std::string& hex) { m_node_payout_script_hex = hex; } + const std::string& get_node_payout_script_hex() const { return m_node_payout_script_hex; } + + /// Local miner scripts (from stratum sessions' pubkey_hashes → all script forms) + void set_local_miner_scripts_fn(std::function()> fn) { + m_local_miner_scripts_fn = std::move(fn); + } + + /// Check whether a peer address is currently banned. + bool is_banned(const NetService& addr) const; + + /// ── Runtime admin API (pool peer bans + whitelist) ───────────────── + /// All methods assumed to run on the io_context thread — callers + /// (web_server HTTP handlers) dispatch via thread_safe_wrap(). + /// + /// Returned JSON uses the shape: + /// {"ok": true|false, "error"?: "...", "bans": [...], "whitelist": [...]} + nlohmann::json admin_list_bans() const; + nlohmann::json admin_ban_ip(const std::string& ip, int duration_sec); + nlohmann::json admin_unban_ip(const std::string& ip); + nlohmann::json admin_list_whitelist() const; + nlohmann::json admin_whitelist_add(const std::string& host, uint16_t port); + nlohmann::json admin_whitelist_remove(const std::string& host, uint16_t port); + nlohmann::json admin_list_peers() const; + nlohmann::json admin_drop_peer(const std::string& ip); + nlohmann::json admin_dial_peer(const std::string& host, uint16_t port); + + /// Path to persisted whitelist file (~/.c2pool/pool_whitelist.json). + /// Set by c2pool_refactored.cpp before start(); empty = no persistence. + void set_whitelist_path(const std::string& path); + + /// True if addr's IP matches a whitelist entry (IP or host:port). + bool is_whitelisted(const NetService& addr) const; + +protected: + std::string m_software_version = "/c2pool:0.1/"; // overridden by set_software_version() + std::function m_on_bestblock; + std::function m_on_best_share_changed; + std::function m_local_hashrate_fn; + std::function m_local_rate_stats_fn; + std::function>()> m_current_pplns_fn; + std::function()> m_local_miner_scripts_fn; + std::string m_node_payout_script_hex; + std::set m_shared_share_hashes; // de-dup set for broadcast_share + std::set m_rejected_share_hashes; // shares rejected by peers — never re-broadcast + std::set m_downloading_shares; // hashes currently being fetched + + // Track share hashes that peers couldn't provide (empty reply). + // After MAX_EMPTY_RETRIES failures, stop requesting — the share is + // pruned from the network. p2pool avoids this via reactive desired_var + // + sleep(1) backoff. We use explicit failure counting. + static constexpr int MAX_EMPTY_RETRIES = 3; + std::unordered_map m_download_fail_count; + + // Track req_id → peer addr for selective cancellation on disconnect. + // p2pool has per-peer get_shares (GenericDeferrer), so connectionLost calls + // respond_all() on just that peer's deferrer. c2pool has a shared m_share_getter, + // so we track which req_ids belong to which peer for cancel-on-disconnect. + std::map m_pending_share_reqs; // req_id → peer addr + + // Track recently-broadcast share hashes + timestamp so we can detect + // rapid disconnections (peer rejected our share → PoW invalid loop). + struct BroadcastRecord { + std::vector hashes; + std::chrono::steady_clock::time_point when; + }; + std::map m_last_broadcast_to; // per-peer + + // Connection maintenance + static constexpr size_t DEFAULT_TARGET_OUTBOUND_PEERS = 8; + size_t m_max_peers = 30; + size_t m_target_outbound_peers = DEFAULT_TARGET_OUTBOUND_PEERS; + std::unique_ptr m_connect_timer; + std::unique_ptr m_readvert_timer; // one-shot ROOT-2 re-advert + std::set m_pending_outbound; // addresses currently being dialed + std::set m_outbound_addrs; // successfully connected outbound peers + + // Peer banning: maps address → ban expiry time + std::map m_ban_list; + std::chrono::seconds m_ban_duration{300}; // 5 minutes (configurable) + + // IP-only manual bans (admin endpoint). Keyed by IP string so the + // operator can ban/unban without knowing the peer's source port. + std::map m_ip_ban_list; + + // Whitelist: IPs that bypass is_banned() and host:port entries kept as + // permanent dial targets. Persists across restart via m_whitelist_path. + std::set m_whitelist_ips; + std::set m_whitelist_hosts; + std::string m_whitelist_path; + + void load_whitelist_from_disk(); + void save_whitelist_to_disk() const; + + // Rate-limiting no longer needed: think() runs on a dedicated compute + // thread (m_think_pool), serialized by m_think_running atomic flag. + // The IO thread never blocks. p2pool's reactor.callLater equivalent. + + // Cache limits (configurable) + size_t m_max_shared_hashes = 50000; + size_t m_max_known_txs = 10000; + size_t m_max_raw_shares = 50000; + + // Block depth function for chain scoring (set via set_block_rel_height_fn) + block_rel_height_fn_t m_block_rel_height_fn; + + // Cached best share hash from the most recent think() cycle + uint256 m_best_share_hash; + + // ROOT-2: fire exactly one delayed re-advert when the verified chain + // first transitions from empty to non-empty (peers that handshook + // during the empty window otherwise never see a best-change event). + bool m_verified_was_empty = true; + + // Cache of original raw serialized bytes keyed by share hash. + // Used for relay so we send the exact bytes we received, avoiding + // any round-trip serialization differences. + std::unordered_map m_raw_share_cache; +}; + +struct HandleSharesData +{ + std::vector m_items; + std::vector m_raw_items; // original raw bytes, parallel with m_items + std::map> m_txs; + + void add(const ShareType& share, std::vector txs) + { + m_items.push_back(share); + m_raw_items.emplace_back(); // no cached raw bytes + m_txs[share.hash()] = std::move(txs); + } + + void add(const ShareType& share, std::vector txs, + const chain::RawShare& raw) + { + m_items.push_back(share); + m_raw_items.push_back(raw); + m_txs[share.hash()] = std::move(txs); + } +}; + + +/* + void handle_message_addrs(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_addrme(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_ping(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_getaddrs(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_shares(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_sharereq(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_sharereply(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_bestblock(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_have_tx(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_losing_tx(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_remember_tx(std::shared_ptr msg, PoolProtocol* protocol); + void handle_message_forget_tx(std::shared_ptr msg, PoolProtocol* protocol); +*/ + +class Legacy : public pool::Protocol +{ +public: + void handle_message(std::unique_ptr rmsg, NodeImpl::peer_ptr peer) override; + + ADD_HANDLER(addrs, bch::message_addrs); + ADD_HANDLER(addrme, bch::message_addrme); + ADD_HANDLER(ping, bch::message_ping); + ADD_HANDLER(getaddrs, bch::message_getaddrs); + ADD_HANDLER(shares, bch::message_shares); + ADD_HANDLER(sharereq, bch::message_sharereq); + ADD_HANDLER(sharereply, bch::message_sharereply); + ADD_HANDLER(bestblock, bch::message_bestblock); + ADD_HANDLER(have_tx, bch::message_have_tx); + ADD_HANDLER(losing_tx, bch::message_losing_tx); + ADD_HANDLER(remember_tx, bch::message_remember_tx); + ADD_HANDLER(forget_tx, bch::message_forget_tx); +}; + +class Actual : public pool::Protocol +{ +public: + void handle_message(std::unique_ptr rmsg, NodeImpl::peer_ptr peer) override; + + ADD_HANDLER(addrs, bch::message_addrs); + ADD_HANDLER(addrme, bch::message_addrme); + ADD_HANDLER(ping, bch::message_ping); + ADD_HANDLER(getaddrs, bch::message_getaddrs); + ADD_HANDLER(shares, bch::message_shares); + ADD_HANDLER(sharereq, bch::message_sharereq); + ADD_HANDLER(sharereply, bch::message_sharereply); + ADD_HANDLER(bestblock, bch::message_bestblock); + ADD_HANDLER(have_tx, bch::message_have_tx); + ADD_HANDLER(losing_tx, bch::message_losing_tx); + ADD_HANDLER(remember_tx, bch::message_remember_tx); + ADD_HANDLER(forget_tx, bch::message_forget_tx); +}; + +using Node = pool::NodeBridge; + +} // namespace bch diff --git a/src/impl/bch/peer.hpp b/src/impl/bch/peer.hpp new file mode 100644 index 000000000..9cc4a4ebd --- /dev/null +++ b/src/impl/bch/peer.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "coin/transaction.hpp" + +#include +#include +#include +#include +#include +#include + +namespace bch +{ + +// Per-connection peer state for the BCH share/pool p2p layer. +// Mirrors btc::Peer; BCH carries no witness data, so m_remembered_txs holds +// plain (non-witness) coin::Transaction — see coin/transaction.hpp. +struct Peer +{ + std::optional m_other_version; + std::string m_other_subversion; + uint64_t m_other_services; + uint64_t m_nonce; + std::chrono::steady_clock::time_point m_connected_at{std::chrono::steady_clock::now()}; + + std::set m_remote_txs; // hashes advertised by the peer + + std::map m_remembered_txs; +}; + +}; // namespace bch diff --git a/src/impl/bch/pool_monitor.hpp b/src/impl/bch/pool_monitor.hpp new file mode 100644 index 000000000..259d8ee97 --- /dev/null +++ b/src/impl/bch/pool_monitor.hpp @@ -0,0 +1,369 @@ +#pragma once + +// Phase 3L: Log-based pool monitoring for attack detection. +// +// Emits structured [MONITOR] log lines every status cycle (~30s). +// All output is grep-friendly — no HTTP endpoints, no web changes. +// +// Log line prefixes: +// [MONITOR-HASHRATE] — pool hashrate vs moving average +// [MONITOR-CONC] — per-address work concentration +// [MONITOR-EMERGENCY] — emergency decay triggers / share gaps +// [MONITOR-DIFF] — difficulty anomaly detection +// [MONITOR-SUMMARY] — one-line health summary +// +// Port of p2pool-v36 p2pool/monitor.py (commit d831a045). +// +// BCH note: BCH is a SHA256d standalone-parent chain (no merged-mining, +// no AuxPoW, no SegWit/witness). This monitor is coin-independent — it only +// reads share-chain metadata — so the port is a 1:1 mirror of btc::PoolMonitor +// with the namespace swapped to bch. The only chain-specific touch is the +// address formatter (see check_concentration): BCH uses base58 legacy / +// CashAddr addressing, NOT litecoin, so script_to_address is called with +// is_litecoin=false. + +#include "config_pool.hpp" +#include "share_tracker.hpp" +#include "share_check.hpp" +#include "core/address_utils.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace bch +{ + +// SI-suffix formatter for hashrate values +inline std::string fmt_hashrate(double n) +{ + static const char* suffixes[] = {"", "k", "M", "G", "T", "P"}; + for (const char* s : suffixes) + { + if (std::abs(n) < 1000.0) + { + std::ostringstream os; + os.precision(1); + os << std::fixed << n << s; + return os.str(); + } + n /= 1000.0; + } + std::ostringstream os; + os.precision(1); + os << std::fixed << n << "E"; + return os.str(); +} + +class PoolMonitor +{ +public: + // Configurable thresholds + static constexpr double CONCENTRATION_WARN_PCT = 25.0; + static constexpr double CONCENTRATION_ALERT_PCT = 40.0; + static constexpr double HASHRATE_SPIKE_FACTOR = 1.5; + static constexpr double HASHRATE_DROP_FACTOR = 0.5; + static constexpr double DIFF_ANOMALY_FACTOR = 2.0; + + PoolMonitor() = default; + + // Run one monitoring cycle. Returns number of alerts emitted. + int run_cycle(ShareTracker& tracker, const uint256& best_share_hash) + { + ++cycle_; + int alerts = 0; + + if (best_share_hash.IsNull()) + return 0; + + auto [height, last] = tracker.chain.get_height_and_last(best_share_hash); + if (height < 10) + return 0; + + alerts += check_hashrate(tracker, best_share_hash, height); + alerts += check_concentration(tracker, best_share_hash, height); + alerts += check_share_gap(tracker, best_share_hash); + alerts += check_difficulty(tracker, best_share_hash, height); + + // Summary line + const char* status = (alerts == 0) ? "OK" : "ALERT"; + uint32_t gap = 0; + { + uint32_t ts = 0; + tracker.chain.get_share(best_share_hash).invoke([&](auto* obj) { + ts = obj->m_timestamp; + }); + auto now = static_cast(std::time(nullptr)); + gap = (now > ts) ? (now - ts) : 0; + } + LOG_INFO << "[MONITOR-SUMMARY] cycle=" << cycle_ + << " height=" << height + << " gap=" << gap << "s" + << " status=" << status + << " alerts=" << alerts; + + return alerts; + } + +private: + uint64_t cycle_ = 0; + uint64_t emergency_count_ = 0; + + // 1-hour rolling hashrate history + struct HrSample { int64_t ts; double hr; }; + std::vector hr_history_; + + // ── Hashrate spike/drop detection ────────────────────────────── + + int check_hashrate(ShareTracker& tracker, const uint256& best, int32_t height) + { + int alerts = 0; + int32_t lookbehind = std::min(height - 1, + static_cast(3600 / PoolConfig::share_period())); + if (lookbehind < 2) + return 0; + + auto aps = tracker.get_pool_attempts_per_second(best, lookbehind); + double real_att_s = static_cast(aps.GetLow64()); + + auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + hr_history_.push_back({now, real_att_s}); + + // Keep 1 hour + auto cutoff = now - 3600; + hr_history_.erase( + std::remove_if(hr_history_.begin(), hr_history_.end(), + [cutoff](const HrSample& s) { return s.ts < cutoff; }), + hr_history_.end()); + + if (hr_history_.size() < 3) + return 0; + + double sum = 0; + for (auto& s : hr_history_) sum += s.hr; + double avg_hr = sum / static_cast(hr_history_.size()); + + if (avg_hr > 0) + { + double ratio = real_att_s / avg_hr; + if (ratio > HASHRATE_SPIKE_FACTOR) + { + LOG_WARNING << "[MONITOR-HASHRATE] ALERT spike=" << ratio + << "x current=" << fmt_hashrate(real_att_s) << "H/s" + << " avg_1h=" << fmt_hashrate(avg_hr) << "H/s"; + ++alerts; + } + else if (ratio < HASHRATE_DROP_FACTOR) + { + LOG_WARNING << "[MONITOR-HASHRATE] ALERT drop=" << ratio + << "x current=" << fmt_hashrate(real_att_s) << "H/s" + << " avg_1h=" << fmt_hashrate(avg_hr) << "H/s"; + ++alerts; + } + else if (cycle_ % 10 == 0) + { + LOG_INFO << "[MONITOR-HASHRATE] ok ratio=" << ratio + << " current=" << fmt_hashrate(real_att_s) << "H/s" + << " avg_1h=" << fmt_hashrate(avg_hr) << "H/s" + << " samples=" << hr_history_.size(); + } + } + return alerts; + } + + // ── Per-address work concentration ───────────────────────────── + + int check_concentration(ShareTracker& tracker, const uint256& best, int32_t height) + { + int alerts = 0; + + struct Window { const char* label; int32_t depth; }; + std::vector windows; + windows.push_back({"short", std::min(height, 100)}); + windows.push_back({"medium", std::min(height, 720)}); + if (height >= static_cast(PoolConfig::real_chain_length())) + windows.push_back({"full", std::min(height, + static_cast(PoolConfig::real_chain_length()))}); + + for (auto& [label, depth] : windows) + { + std::map, double> addr_work; + double total_work = 0; + + auto chain_view = tracker.chain.get_chain(best, depth); + for (auto [hash, data] : chain_view) + { + double w = 0; + std::vector addr_bytes; + data.share.invoke([&](auto* obj) { + auto target = chain::bits_to_target(obj->m_bits); + auto att = chain::target_to_average_attempts(target); + w = static_cast(att.GetLow64()); + addr_bytes = get_share_script(obj); + }); + addr_work[addr_bytes] += w; + total_work += w; + } + + if (total_work <= 0) + continue; + + for (auto& [addr, work] : addr_work) + { + double pct = 100.0 * work / total_work; + // Convert scriptPubKey to human-readable address. + // BCH: base58 legacy / CashAddr — not litecoin — so is_litecoin=false. + std::string addr_str = core::script_to_address( + addr, false /*is_litecoin*/, PoolConfig::is_testnet); + if (addr_str.empty()) { + // Fallback: show truncated script hex for non-standard scripts + for (size_t i = 0; i < std::min(addr.size(), size_t(15)); ++i) { + char buf[3]; + snprintf(buf, sizeof(buf), "%02x", addr[i]); + addr_str += buf; + } + addr_str = "script:" + addr_str; + } + if (pct >= CONCENTRATION_ALERT_PCT) + { + LOG_WARNING << "[MONITOR-CONC] ALERT addr=" << addr_str + << " pct=" << pct << "%" + << " window=" << label << "(" << depth << ")"; + ++alerts; + } + else if (pct >= CONCENTRATION_WARN_PCT) + { + LOG_WARNING << "[MONITOR-CONC] WARN addr=" << addr_str + << " pct=" << pct << "%" + << " window=" << label << "(" << depth << ")"; + } + } + + // Every 10th cycle, log top-3 miners + if (cycle_ % 10 == 0) + { + std::vector, double>> sorted_addrs( + addr_work.begin(), addr_work.end()); + std::sort(sorted_addrs.begin(), sorted_addrs.end(), + [](auto& a, auto& b) { return a.second > b.second; }); + + std::ostringstream top3; + int count = 0; + for (auto& [a, w] : sorted_addrs) + { + if (count >= 3) break; + if (count > 0) top3 << " "; + std::string display = core::script_to_address( + a, false, PoolConfig::is_testnet); + if (display.empty()) { + for (size_t i = 0; i < std::min(a.size(), size_t(8)); ++i) { + char buf[3]; snprintf(buf, sizeof(buf), "%02x", a[i]); + display += buf; + } + } + top3 << display << ":" << (100.0 * w / total_work) << "%"; + ++count; + } + LOG_INFO << "[MONITOR-CONC] top3 window=" << label << "(" << depth << ") " << top3.str(); + } + } + return alerts; + } + + // ── Share gap / emergency decay detection ────────────────────── + + int check_share_gap(ShareTracker& tracker, const uint256& best) + { + int alerts = 0; + uint32_t ts = 0; + tracker.chain.get_share(best).invoke([&](auto* obj) { + ts = obj->m_timestamp; + }); + + auto now = static_cast(std::time(nullptr)); + uint32_t gap = (now > ts) ? (now - ts) : 0; + auto emergency_threshold = PoolConfig::share_period() * 20; + + if (gap > emergency_threshold) + { + ++emergency_count_; + LOG_WARNING << "[MONITOR-EMERGENCY] ALERT gap=" << gap + << "s threshold=" << emergency_threshold + << "s emergency_count=" << emergency_count_; + ++alerts; + } + else if (gap > PoolConfig::share_period() * 10) + { + LOG_WARNING << "[MONITOR-EMERGENCY] WARN gap=" << gap + << "s (approaching threshold=" << emergency_threshold << "s)"; + } + return alerts; + } + + // ── Difficulty anomaly detection ─────────────────────────────── + + int check_difficulty(ShareTracker& tracker, const uint256& best, int32_t height) + { + int alerts = 0; + int32_t lookbehind = std::min(height - 1, + static_cast(PoolConfig::TARGET_LOOKBEHIND)); + if (lookbehind < 2) + return 0; + + auto aps = tracker.get_pool_attempts_per_second( + best, lookbehind, /*min_work=*/true); + if (aps.IsNull()) + return 0; + + // expected_target = 2^256 / (SHARE_PERIOD * aps) - 1 + uint288 two_256; + two_256.SetHex("10000000000000000000000000000000000000000000000000000000000000000"); + uint288 divisor = aps * static_cast(PoolConfig::share_period()); + if (divisor.IsNull()) + return 0; + uint288 expected_288 = two_256 / divisor; + + // actual_target = max_target from best share + uint256 actual_target; + tracker.chain.get_share(best).invoke([&](auto* obj) { + actual_target = chain::bits_to_target(obj->m_max_bits); + }); + + double expected_d = static_cast(expected_288.GetLow64()); + double actual_d = static_cast(actual_target.GetLow64()); + if (expected_d <= 0) + return 0; + + double ratio = actual_d / expected_d; + + if (ratio > DIFF_ANOMALY_FACTOR) + { + LOG_WARNING << "[MONITOR-DIFF] ALERT target_ratio=" << ratio + << " (actual easier than expected by " << ((ratio - 1) * 100) << "%)"; + ++alerts; + } + else if (ratio < 1.0 / DIFF_ANOMALY_FACTOR) + { + LOG_WARNING << "[MONITOR-DIFF] ALERT target_ratio=" << ratio + << " (actual harder than expected by " << ((1.0 / ratio - 1) * 100) << "%)"; + ++alerts; + } + else if (cycle_ % 10 == 0) + { + LOG_INFO << "[MONITOR-DIFF] ok target_ratio=" << ratio + << " pool=" << fmt_hashrate(static_cast(aps.GetLow64())) << "H/s"; + } + return alerts; + } +}; + +} // namespace bch diff --git a/src/impl/bch/share_check.hpp b/src/impl/bch/share_check.hpp index 870452df9..9f7574931 100644 --- a/src/impl/bch/share_check.hpp +++ b/src/impl/bch/share_check.hpp @@ -485,7 +485,7 @@ uint256 share_init_verify(const ShareT& share, bool check_pow = true) constexpr int64_t ver = ShareT::version; - if constexpr (ver >= bch::SEGWIT_ACTIVATION_VERSION) + if constexpr (bch::is_segwit_activated(ver)) { if constexpr (requires { share.m_segwit_data; }) { @@ -563,7 +563,7 @@ uint256 share_init_verify(const ShareT& share, bool check_pow = true) // segwit_data (optional) if constexpr (requires { share.m_segwit_data; }) { - if constexpr (ver >= bch::SEGWIT_ACTIVATION_VERSION) + if constexpr (bch::is_segwit_activated(ver)) { // PossiblyNoneType: ALWAYS serialize (p2pool writes default when None) if (share.m_segwit_data.has_value()) { @@ -671,7 +671,7 @@ uint256 share_init_verify(const ShareT& share, bool check_pow = true) // --- Merkle root --- // For segwit-activated shares, use segwit_data.txid_merkle_link; otherwise merkle_link uint256 merkle_root; - if constexpr (ver >= bch::SEGWIT_ACTIVATION_VERSION) + if constexpr (bch::is_segwit_activated(ver)) { if constexpr (requires { share.m_segwit_data; }) { @@ -1163,7 +1163,7 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool size_t n_outs = payout_outputs.size() + 1 /* donation */ + 1 /* OP_RETURN commitment */; // Segwit commitment output (if applicable) bool has_segwit = false; - if constexpr (ver >= bch::SEGWIT_ACTIVATION_VERSION) + if constexpr (bch::is_segwit_activated(ver)) { if constexpr (requires { share.m_segwit_data; }) { @@ -1282,7 +1282,7 @@ uint256 generate_share_transaction(const ShareT& share, TrackerT& tracker, bool if constexpr (requires { share.m_segwit_data; }) { - if constexpr (ver >= bch::SEGWIT_ACTIVATION_VERSION) + if constexpr (bch::is_segwit_activated(ver)) { // PossiblyNoneType: ALWAYS serialize (p2pool writes default when None). // Must match share_init_verify — both paths must produce identical ref_hash. @@ -1973,7 +1973,7 @@ uint256 verify_share(const ShareT& share, TrackerT& tracker) if constexpr (requires { share.m_segwit_data; }) { - if constexpr (ver >= bch::SEGWIT_ACTIVATION_VERSION) + if constexpr (bch::is_segwit_activated(ver)) { // PossiblyNoneType: ALWAYS serialize (p2pool writes default when None) if (share.m_segwit_data.has_value()) { diff --git a/src/impl/bch/share_tracker.hpp b/src/impl/bch/share_tracker.hpp index cb117005a..29874817a 100644 --- a/src/impl/bch/share_tracker.hpp +++ b/src/impl/bch/share_tracker.hpp @@ -278,6 +278,22 @@ class ShareTracker public: ShareChain chain; + // Verified best-chain mirror (p2pool known_verified pattern): shares that + // have passed full PoW + context verification. Work/template selection and + // block scanning run against this sub-chain, never the raw "chain" buffer. + ShareChain verified; + + // -- Node-layer callback hooks (wired by the pool node in node.hpp) -- + // BCH is a SHA256d standalone-parent chain: NO merged-mining / AuxPoW, so + // there is no m_on_merged_block_check (present in btc for the DOGE aux leg). + // Fired when a share passes verification (LevelDB known_verified persistence). + std::function m_on_share_verified; + // Fired when a verified share meets the BCH block target (found block). + std::function m_on_block_found; + // Fired for every verified share with its difficulty + miner script + // (best-share difficulty tracking for the dashboard). + std::function m_on_share_difficulty; + // -- PPLNS cumulative weights computation (O(log n) via skip list) -- CumulativeWeights get_cumulative_weights(const uint256& start, int32_t max_shares, const uint288& desired_weight) { @@ -580,6 +596,61 @@ class ShareTracker int32_t m_decayed_cache_shares{0}; uint288 m_decayed_cache_desired; CumulativeWeights m_decayed_cache_result; + +public: + // -- Pool hashrate estimation -- + /// Pool hashrate estimation -- matches p2pool get_pool_attempts_per_second exactly. + /// Uses skip list O(log n) for ancestor lookup + TrackerView delta cache O(1) for work sum. + /// BCH-faithful: SHA256d work/target math, identical to the shared p2pool formula + /// (no scrypt, no merged-mining divergence -- coin-independent). + /// + /// p2pool (data.py:2489-2499): + /// near = tracker.items[previous_share_hash] + /// far = tracker.items[tracker.get_nth_parent_hash(previous_share_hash, dist - 1)] + /// attempts = tracker.get_delta(near.hash, far.hash).min_work (if min_work) + /// = tracker.get_delta(near.hash, far.hash).work (otherwise) + /// time = near.timestamp - far.timestamp + /// return attempts // time (integer=True in generate_transaction) + uint288 get_pool_attempts_per_second(const uint256& share_hash, int32_t dist, bool use_min_work = false) + { + if (dist < 2 || !chain.contains(share_hash)) + return uint288(0); + + // p2pool: far = tracker.items[tracker.get_nth_parent_hash(share_hash, dist - 1)] + auto far_hash = chain.get_nth_parent_via_skip(share_hash, dist - 1); + if (far_hash.IsNull() || !chain.contains(far_hash)) + return uint288(0); + + // Verify skip list vs naive walk (periodic -- detect stale pointers) + { + static int skip_verify = 0; + if (skip_verify++ % 100 == 0) { + try { + auto naive_far = chain.get_nth_parent_key(share_hash, dist - 1); + if (naive_far != far_hash) { + LOG_ERROR << "[SKIP-MISMATCH] dist=" << dist + << " skip=" << far_hash.GetHex().substr(0,16) + << " naive=" << naive_far.GetHex().substr(0,16) + << " near=" << share_hash.GetHex().substr(0,16); + } + } catch (...) {} + } + } + + // p2pool: tracker.get_delta(near.hash, far.hash) -- O(1) via TrackerView + auto delta = chain.get_delta(share_hash, far_hash); + uint288 attempts = use_min_work ? delta.min_work : delta.work; + + // p2pool: time = near.timestamp - far.timestamp; if time <= 0: time = 1 + uint32_t near_ts = 0, far_ts = 0; + chain.get_share(share_hash).invoke([&](auto* obj) { near_ts = obj->m_timestamp; }); + chain.get_share(far_hash).invoke([&](auto* obj) { far_ts = obj->m_timestamp; }); + + int32_t time_span = static_cast(near_ts) - static_cast(far_ts); + if (time_span <= 0) time_span = 1; + + return attempts / uint288(time_span); + } }; } // namespace bch diff --git a/src/impl/bch/share_types.hpp b/src/impl/bch/share_types.hpp index c86926382..32a49054e 100644 --- a/src/impl/bch/share_types.hpp +++ b/src/impl/bch/share_types.hpp @@ -7,11 +7,20 @@ namespace bch { -const uint64_t SEGWIT_ACTIVATION_VERSION = 17; +// BCH has NO SegWit (rejected at the 2017 fork). The p2poolBCH oracle +// (p2pool/data.py:63-66) derives is_segwit_activated from +// getattr(net, \"SEGWIT_ACTIVATION_VERSION\", 0); the bitcoincash[_testnet].py +// network configs define NO SEGWIT_ACTIVATION_VERSION, so the default 0 makes it +// return False for EVERY share version -- segwit_data is never present in a BCH +// share. A disabled (== 0) sentinel here reproduces that exactly, keeping the +// share wire-format + ref_hash byte-conformant with p2poolBCH (was 17, a stray +// BTC/LTC port that forced segwit_data on for all v17..v35 -> sharechain fork). +const uint64_t SEGWIT_ACTIVATION_VERSION = 0; constexpr bool is_segwit_activated(uint64_t version) { - return version >= SEGWIT_ACTIVATION_VERSION; + // Mirror the oracle guard: disabled when the sentinel is 0. + return SEGWIT_ACTIVATION_VERSION != 0 && version >= SEGWIT_ACTIVATION_VERSION; } enum StaleInfo diff --git a/src/impl/bch/test/abla_block_feed_test.cpp b/src/impl/bch/test/abla_block_feed_test.cpp new file mode 100644 index 000000000..3a9d450fb --- /dev/null +++ b/src/impl/bch/test/abla_block_feed_test.cpp @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// bch::coin AblaBlockFeed test (M5 -- the full-block -> ABLA wiring layer). +// +// AblaBlockFeed is the piece that closes the gap M5 slice C left open: +// AblaTracker::record_block_size() existed, but nothing fed it from the real +// block-connect path. The feed subscribes to interfaces::Node::full_block, +// resolves each received block's height from the best-chain HeaderChain index, +// and folds the block's ACTUAL serialized size into the tracker. Its safety +// claims are NOT exercised by abla_floor_invariant_test, which drives the +// tracker directly and bypasses the feed's two load-bearing decisions: +// +// (a) HEIGHT RESOLUTION via the header index -- the feed never *guesses* a +// height; it reads it from HeaderChain::get_header(block_hash). +// (b) OFF-CHAIN SKIP -- a block whose hash is not on the indexed best chain +// (unsolicited / side branch / header not yet synced) is dropped with NO +// record_block_size() call, so the tracker's cursor is never advanced on +// a block we cannot trust a height for. +// +// This test pins (a) and (b) directly against a real (in-memory) HeaderChain +// seeded so that one crafted block resolves to a known height, asserting: +// 1. on_full_block(on-chain block) -> folds at the resolved height; cursor +// advances, budget stays >= 32 MB floor (ABLA only raises). +// 2. on_full_block(off-chain block) -> NO state change: budget and currency +// identical before/after; the cursor is NOT moved on an unindexed block. +// 3. on_full_block(duplicate of the on-chain block) -> tracker's height<=cursor +// idempotent-ignore holds end-to-end through the feed (no spurious gap). +// +// Build-INERT / source-only posture (matches abla_floor_invariant_test): impl_bch +// stays unregistered in CMake (bch = skip-green; don't race ci-steward). Verified +// with -fsyntax-only and standalone compile+run. p2pool-merged-v36 surface: NONE +// (pure local build-time block-size budget; no PoW/share/coinbase/PPLNS math). +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/abla_block_feed.hpp" +#include "../coin/abla_tracker.hpp" +#include "../coin/block.hpp" +#include "../coin/header_chain.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// Craft a minimal full block with a non-null prev (so it is NOT treated as the +// genesis-special case) and a distinct nonce. Empty tx vector is fine -- the +// feed records pack(block).size(), and ABLA only ever RAISES vs the floor, so a +// small block keeps the budget pinned at the floor (still >= floor: the invariant). +bch::coin::BlockType make_block(uint32_t nonce) { + bch::coin::BlockType b; + b.m_version = 0x20000000; + b.m_previous_block.SetHex( + "00000000000000000001a2b3c4d5e6f700000000000000000000000000000000"); + b.m_merkle_root.SetHex( + "1111111111111111111111111111111111111111111111111111111111111111"); + b.m_timestamp = 1700000000; + b.m_bits = 0x1d00ffff; + b.m_nonce = nonce; + return b; +} + +} // namespace + +int main() { + using bch::coin::AblaTracker; + using bch::coin::AblaBlockFeed; + using bch::coin::BCHChainParams; + using bch::coin::HeaderChain; + + const uint32_t H = 800001; // the height our on-chain block resolves to + + // The on-chain block, and its SHA256d identity hash. + bch::coin::BlockType on_chain = make_block(/*nonce=*/1); + const uint256 on_hash = + bch::coin::block_hash(static_cast(on_chain)); + + // Seed an in-memory HeaderChain whose fast-start checkpoint IS our block's + // hash at height H. get_header(on_hash) now returns {height = H}; any other + // hash is unindexed -> the feed's off-chain branch. + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = BCHChainParams::Checkpoint{H, on_hash}; + HeaderChain chain(params); + CHECK(chain.init()); + { + auto e = chain.get_header(on_hash); + CHECK(e.has_value()); + CHECK(e && e->height == H); + } + + const uint64_t floor = bch::coin::abla::floor_block_size_limit(/*is_testnet=*/false); + + // Anchor the tracker at H-1 so the on-chain block at H is a CONTIGUOUS fold. + AblaTracker tracker = AblaTracker::floor_anchored(/*is_testnet=*/false, H - 1); + AblaBlockFeed feed(tracker, chain); + + // 1) On-chain block -> feed resolves height H from the index and folds. + CHECK(tracker.cursor_height() == H - 1); + feed.on_full_block(on_chain); + CHECK(tracker.is_current(H)); // resolved + folded at H + CHECK(tracker.cursor_height() == H); // cursor advanced exactly one + CHECK(tracker.budget_for_tip(H) >= floor); // ABLA only raises, never sub-floor + + // 2) Off-chain block -> hash not indexed -> NO state change at all. + const uint64_t budget_before = tracker.budget_for_tip(H); + bch::coin::BlockType off_chain = make_block(/*nonce=*/99); // different hash + const uint256 off_hash = + bch::coin::block_hash(static_cast(off_chain)); + CHECK(!chain.get_header(off_hash).has_value()); // truly unindexed + feed.on_full_block(off_chain); + CHECK(tracker.is_current(H)); // cursor untouched... + CHECK(tracker.cursor_height() == H); // ...not advanced on a guess + CHECK(tracker.budget_for_tip(H) == budget_before); // zero state change + + // 3) Duplicate of the on-chain block -> height H <= cursor -> idempotent + // ignore propagates cleanly through the feed (no spurious gap -> floor). + feed.on_full_block(on_chain); + CHECK(tracker.is_current(H)); + CHECK(tracker.cursor_height() == H); + CHECK(tracker.budget_for_tip(H) == budget_before); + + if (failures == 0) { + std::cout << "abla_block_feed_test: ALL PASS\n"; + return 0; + } + std::cerr << "abla_block_feed_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/abla_floor_invariant_test.cpp b/src/impl/bch/test/abla_floor_invariant_test.cpp new file mode 100644 index 000000000..bacbf039f --- /dev/null +++ b/src/impl/bch/test/abla_floor_invariant_test.cpp @@ -0,0 +1,99 @@ +// --------------------------------------------------------------------------- +// bch::abla AblaTracker floor-invariant test (M5 cold-start safety). +// +// EmbeddedDaemon cold-starts the ABLA loop at the activation/floor State and +// folds live full-block sizes forward (node -> feed -> tracker). Its safety +// claim -- the one the whole embedded budget rests on -- is: the dynamic build +// budget can only EVER equal-or-exceed the 32 MB floor; folding live sizes can +// only RAISE it, and any feed fault (gap / stale / cold) falls back to the +// floor outright, never undercutting it. This test pins that claim directly +// against AblaTracker so a regression in the budget path cannot silently emit +// a sub-floor template. +// +// Invariants asserted: +// 1. cold-start floor_anchored -> budget_for_tip == floor exactly +// 2. contiguous record advances cursor & budget stays >= floor (grows) +// 3. GAP (height > cursor+1) -> stale: state_for_tip==nullptr, +// budget falls back to floor, and +// further records are IGNORED +// 4. reanchor() restores a current State and re-enables folding +// +// Build-INERT / source-only: impl_bch stays unregistered in CMake (bch = +// skip-green; don`t race ci-steward). Verified with -fsyntax-only; runs under +// the embedded test target once impl_bch is registered. Header-only against +// coin/abla*.hpp -- no node, RPC, or boost graph. p2pool-merged-v36 surface: +// NONE (pure local build-time block-size budget; no PoW/share/coinbase math). +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/abla.hpp" +#include "../coin/abla_tracker.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +} // namespace + +int main() { + using bch::coin::AblaTracker; + + const uint32_t H0 = 800000; // arbitrary anchor height + + // ---- mainnet (growing) ------------------------------------------------ + { + const uint64_t floor = bch::coin::abla::floor_block_size_limit(/*is_testnet=*/false); + AblaTracker t = AblaTracker::floor_anchored(/*is_testnet=*/false, H0); + + // 1) Cold start: budget is exactly the floor, state is current. + CHECK(t.budget_for_tip(H0) == floor); + CHECK(t.is_current(H0)); + CHECK(t.state_for_tip(H0) != nullptr); + + // 2) Fold a contiguous full-ish block in -> cursor advances, never < floor. + t.record_block_size(H0 + 1, /*serialized_size=*/30u * 1000000u); + CHECK(t.is_current(H0 + 1)); + CHECK(t.budget_for_tip(H0 + 1) >= floor); // ABLA only raises + + // 3) GAP: skip a height -> stale; sub-floor never returned, records ignored. + t.record_block_size(H0 + 5, /*serialized_size=*/1u * 1000000u); + CHECK(!t.is_current(H0 + 5)); // gap invalidated the cursor + CHECK(t.state_for_tip(H0 + 5) == nullptr); // builder takes floor fallback + CHECK(t.budget_for_tip(H0 + 5) == floor); // hard floor, never undercut + // a further record while stale must NOT silently resume folding + t.record_block_size(H0 + 6, 31u * 1000000u); + CHECK(t.budget_for_tip(H0 + 6) == floor); + + // 4) reanchor with a known-good floor State -> current again, folding on. + AblaTracker fresh = AblaTracker::floor_anchored(/*is_testnet=*/false, H0 + 6); + const bch::coin::abla::State* good = fresh.state_for_tip(H0 + 6); + CHECK(good != nullptr); + t.reanchor(H0 + 6, *good); + CHECK(t.is_current(H0 + 6)); + CHECK(t.budget_for_tip(H0 + 6) == floor); + t.record_block_size(H0 + 7, 30u * 1000000u); + CHECK(t.is_current(H0 + 7)); + CHECK(t.budget_for_tip(H0 + 7) >= floor); + } + + // ---- testnet (fixed 32 MB) ------------------------------------------- + { + const uint64_t floor = bch::coin::abla::floor_block_size_limit(/*is_testnet=*/true); + AblaTracker t = AblaTracker::floor_anchored(/*is_testnet=*/true, H0); + CHECK(t.budget_for_tip(H0) == floor); + // testnet ABLA is a no-op (fixedSize) -> budget stays pinned at floor. + t.record_block_size(H0 + 1, 32u * 1000000u); + CHECK(t.budget_for_tip(H0 + 1) == floor); + } + + if (failures == 0) { + std::cout << "abla_floor_invariant_test: ALL PASS\n"; + return 0; + } + std::cerr << "abla_floor_invariant_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/bchn_anchor_record_test.cpp b/src/impl/bch/test/bchn_anchor_record_test.cpp new file mode 100644 index 000000000..30cbec255 --- /dev/null +++ b/src/impl/bch/test/bchn_anchor_record_test.cpp @@ -0,0 +1,121 @@ +// --------------------------------------------------------------------------- +// bch::coin::BchnAnchorRecord cold-start anchor test (M5, embedded-daemon body). +// +// EmbeddedDaemon::dry_run_bchn_anchor() / apply_bchn_anchor() (wired @48a45344) +// pin the ABLA runtime from a STATIC, read-only VM300 capture (BchnAnchorRecord, +// height 955700; divergence-set section 4, frstrtr/the @0d55c4d2). The live VM +// is never touched. This test pins the two claims that wiring rests on: +// A. PROVENANCE+FLOOR: the recorded capture is still at the 32 MB floor, so +// pinning it changes nothing vs the cold-start floor anchor -- which is +// exactly why the M4 safe-floor budget (slice 3, 6336679a) is correct +// against live mainnet. is_floor() must agree with the rebuilt State. +// B. NON-FLOOR PIN HONOURED + NEVER UNDERCUTS: when a future capture is ABOVE +// floor, reanchor()ing the tracker from it must RAISE the build budget to +// that live limit (the pin sharpens the cold-start), and the budget can +// never drop below the floor regardless of the pinned State. +// +// This complements abla_floor_invariant_test.cpp (which pins the *folding* path +// off floor_anchored); here we pin the *anchor-record* path the operator-gated +// reanchor uses. Both feed the same budget_for_tip invariant. +// +// Build-INERT / source-only: impl_bch stays unregistered in CMake (bch = +// skip-green; don`t race ci-steward). Verified with -fsyntax-only; runs under +// the embedded test target once impl_bch is registered. Header-only against +// coin/abla*.hpp + bchn_anchor_record.hpp -- no HeaderChain, Node, RPC, or boost +// graph (the anchor record is static; reanchor is a tracker passthrough). +// p2pool-merged-v36 surface: NONE (embedded-internal ABLA cold-start only; BCH +// is a SHA256d standalone parent, ABLA is not on the share/sharechain surface). +// --------------------------------------------------------------------------- + +#include +#include + +#include "../coin/abla.hpp" +#include "../coin/abla_tracker.hpp" +#include "../coin/bchn_anchor_record.hpp" + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +} // namespace + +int main() { + using bch::coin::AblaTracker; + using bch::coin::BchnAnchorRecord; + namespace abla = bch::coin::abla; + + using Rec = BchnAnchorRecord; + + // ---- A. provenance + the recorded capture is at floor ------------------ + { + // Provenance constants are pinned verbatim from the VM300 capture. + CHECK(Rec::height == 955700u); + CHECK(Rec::hash.size() == 64); // 32-byte block hash, hex + CHECK(Rec::merkleroot.size() == 64); + CHECK(Rec::abla_blocksizelimit == 32000000u); + + // is_floor() must agree with the rebuilt ABLA State on BOTH networks: + // control+elastic == 32 MB <=> GetBlockSizeLimit() == the floor. + CHECK(Rec::is_floor()); // the 955700 capture is at floor + + for (bool testnet : {false, true}) { + const uint64_t floor = abla::floor_block_size_limit(testnet); + const abla::State rec = Rec::state(testnet); + // Recorded State limit == the floor: pinning is a no-op vs cold-start. + CHECK(rec.GetBlockSizeLimit() == floor); + // ...and the recorded limit field matches the rebuilt State on mainnet + // (testnet ABLA is fixedSize, so the recorded mainnet field is the ref). + if (!testnet) + CHECK(rec.GetBlockSizeLimit() == Rec::abla_blocksizelimit); + } + } + + // ---- B1. reanchor FROM the (floor) record is a no-op vs cold start ----- + { + const uint64_t floor = abla::floor_block_size_limit(/*is_testnet=*/false); + AblaTracker t = AblaTracker::floor_anchored(/*is_testnet=*/false, 800000u); + const uint64_t cold = t.budget_for_tip(800000u); + CHECK(cold == floor); + + // apply_bchn_anchor() passthrough: reanchor at the recorded height+State. + t.reanchor(Rec::height, Rec::state(/*is_testnet=*/false)); + CHECK(t.is_current(Rec::height)); + // Floor record pinned -> budget identical to the cold-start floor. + CHECK(t.budget_for_tip(Rec::height) == floor); + CHECK(t.budget_for_tip(Rec::height) >= floor); // hard floor, never under + } + + // ---- B2. a future ABOVE-floor capture RAISES the budget, never under --- + { + const uint64_t floor = abla::floor_block_size_limit(/*is_testnet=*/false); + const abla::Config cfg = abla::mainnet_config(); + + // Synthesise a plausible non-floor capture: replay the floor anchor over + // a run of maximally-full (32 MB) blocks. ABLA raises the control limit + // when blocks press the limit, so the resulting State sits ABOVE floor. + const size_t N = 64; + uint64_t sizes[N]; + for (size_t i = 0; i < N; ++i) sizes[i] = 32u * 1000000u; + const abla::State above = abla::replay(abla::State(cfg, 0), cfg, sizes, N); + CHECK(above.GetBlockSizeLimit() > floor); // pin would sharpen + + // Pinning THAT State must raise the live budget to its limit. + AblaTracker t = AblaTracker::floor_anchored(/*is_testnet=*/false, 800000u); + CHECK(t.budget_for_tip(800000u) == floor); + t.reanchor(900000u, above); + CHECK(t.is_current(900000u)); + CHECK(t.budget_for_tip(900000u) == above.GetBlockSizeLimit()); + CHECK(t.budget_for_tip(900000u) > floor); // raised, as captured + CHECK(t.budget_for_tip(900000u) >= floor); // and never undercuts + } + + if (failures == 0) { + std::cout << "bchn_anchor_record_test: ALL PASS\n"; + return 0; + } + std::cerr << "bchn_anchor_record_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/cashaddr_test.cpp b/src/impl/bch/test/cashaddr_test.cpp new file mode 100644 index 000000000..62cc915b7 --- /dev/null +++ b/src/impl/bch/test/cashaddr_test.cpp @@ -0,0 +1,119 @@ +// --------------------------------------------------------------------------- +// bch::coin::cashaddr KAT + roundtrip test (M4 CashAddr port validation). +// +// Vectors lifted verbatim from Bitcoin Cash Node src/test/cashaddrenc_tests.cpp +// and src/test/cashaddr_tests.cpp -- the conformance oracle. Asserts: +// 1. {type,hash} -> address matches BCHN's anchored strings (P2PKH/P2SH + +// token-aware z/r variants), proving version-byte + PolyMod parity. +// 2. P2SH32 (32-byte) encode/decode roundtrip (CHIP P2SH32 size bits). +// 3. decode is the exact inverse of encode (type + hash recovered). +// 4. checksum/structure rejection: corrupted char, wrong prefix, bad sep. +// 5. checksum-only vectors (cashaddr_tests.cpp valid_strings) round-trip. +// +// Build-INERT / source-only: header-only, no boost, no chainparams; impl_bch +// stays unregistered (bch = skip-green). Verify with -fsyntax-only; runs under +// the embedded test target once impl_bch is registered, or standalone (no deps). +// p2pool-merged-v36 surface: NONE. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include + +#include "../coin/cashaddr.hpp" + +using namespace bch::coin::cashaddr; +using bytes = std::vector; + +namespace { + +bytes parse_hex(const std::string& h) { + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + bytes out; + for (size_t i = 0; i + 1 < h.size(); i += 2) + out.push_back(uint8_t(nib(h[i]) << 4 | nib(h[i + 1]))); + return out; +} + +int checks = 0; +void check(bool cond, const std::string& what) { + ++checks; + if (!cond) { std::cerr << "FAIL: " << what << "\n"; std::exit(1); } +} + +} // namespace + +int main() { + // --- (1) Anchored encode vectors (BCHN test_encode_address) --------------- + // hash160 #1 = {118,160,64,...,115} + const bytes h1 = {118, 160, 64, 83, 189, 160, 168, 139, 218, 81, + 119, 184, 106, 21, 195, 178, 159, 85, 152, 115}; + check(EncodeCashAddr(MAINNET_PREFIX, {PUBKEY_TYPE, h1}) == + "bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a", "enc p2pkh h1"); + check(EncodeCashAddr(MAINNET_PREFIX, {SCRIPT_TYPE, h1}) == + "bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq", "enc p2sh h1"); + check(EncodeCashAddr(MAINNET_PREFIX, {TOKEN_PUBKEY_TYPE, h1}) == + "bitcoincash:zpm2qsznhks23z7629mms6s4cwef74vcwvrqekrq9w", "enc token-pubkey h1"); + check(EncodeCashAddr(MAINNET_PREFIX, {TOKEN_SCRIPT_TYPE, h1}) == + "bitcoincash:rpm2qsznhks23z7629mms6s4cwef74vcwv59yeyr7n", "enc token-script h1"); + + // BCHN cashaddrenc_tests test_vectors: F5BF...DAC9 PUBKEY + token variant. + const bytes hF = parse_hex("F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9"); + check(EncodeCashAddr(MAINNET_PREFIX, {PUBKEY_TYPE, hF}) == + "bitcoincash:qr6m7j9njldwwzlg9v7v53unlr4jkmx6eylep8ekg2", "enc p2pkh hF"); + check(EncodeCashAddr(MAINNET_PREFIX, {TOKEN_PUBKEY_TYPE, hF}) == + "bitcoincash:zr6m7j9njldwwzlg9v7v53unlr4jkmx6eycnjehshe", "enc token-pubkey hF"); + + // --- (2)+(3) Decode is the exact inverse; covers 20B and 32B (P2SH32) ----- + auto roundtrip = [&](CashAddrType ty, const bytes& hash, const char* px) { + std::string addr = EncodeCashAddr(px, {ty, hash}); + check(!addr.empty(), "rt encode nonempty"); + CashAddrContent got = DecodeCashAddrContent(addr, px); + check(!got.IsNull(), "rt decode nonnull"); + check(got.type == ty, "rt type"); + check(got.hash == hash, "rt hash"); + }; + roundtrip(PUBKEY_TYPE, h1, MAINNET_PREFIX); + roundtrip(SCRIPT_TYPE, h1, MAINNET_PREFIX); + roundtrip(TOKEN_PUBKEY_TYPE, hF, MAINNET_PREFIX); + // P2SH32: 32-byte script hash (CHIP P2SH32), token-aware on testnet prefix. + bytes h32(32); + for (size_t i = 0; i < 32; ++i) h32[i] = uint8_t(0x11 * (i + 1)); + roundtrip(SCRIPT_TYPE, h32, TESTNET_PREFIX); + roundtrip(TOKEN_SCRIPT_TYPE, h32, TESTNET_PREFIX); + + // --- (4) Rejection paths -------------------------------------------------- + // Corrupted checksum char. + check(DecodeCashAddrContent("bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6b", + MAINNET_PREFIX).IsNull(), "reject corrupt checksum"); + // Right address, wrong expected prefix. + check(DecodeCashAddrContent("bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a", + TESTNET_PREFIX).IsNull(), "reject wrong prefix"); + // Mixed case is invalid per spec. + check(DecodeCashAddrContent("bitcoincash:qpm2qsznhks23z7629mms6s4cWef74vcwvy22gdx6a", + MAINNET_PREFIX).IsNull(), "reject mixed case"); + // Invalid hash length -> empty encode. + check(EncodeCashAddr(MAINNET_PREFIX, {PUBKEY_TYPE, bytes(19)}).empty(), "reject 19B hash"); + + // --- (5) checksum-only valid strings (cashaddr_tests valid_strings) ------- + // These have a non-standard payload but a VALID BCH checksum; Decode must + // accept them and recover the lowercased prefix. + for (const char* s : {"prefix:x64nx6hz", + "bitcoincash:qpzry9x8gf2tvdw0s3jn54khce6mua7lcw20ayyn", + "bchtest:testnetaddress4d6njnut", + "bchreg:555555555555555555555555555555555555555555555udxmlmrz"}) { + auto [pfx, payload] = Decode(s, ""); + (void)payload; // checksum-only vectors: data payload is legitimately empty + check(!pfx.empty(), std::string("decode valid_string ") + s); + } + + std::cout << "cashaddr KAT: ALL " << checks << " checks PASS\n"; + return 0; +} diff --git a/src/impl/bch/test/coin_node_seam_test.cpp b/src/impl/bch/test/coin_node_seam_test.cpp new file mode 100644 index 000000000..cf5ec5d4f --- /dev/null +++ b/src/impl/bch/test/coin_node_seam_test.cpp @@ -0,0 +1,99 @@ +// --------------------------------------------------------------------------- +// bch::coin::CoinNode seam-contract test (M5 seam-wire validation). +// +// Validates the embedded-primary / external-RPC-fallback contract of the +// CoinNode seam (core::coin::ICoinNode) that EmbeddedDaemon::run() builds and +// hands to web_server. Asserts the three invariants web_server depends on: +// 1. no work source at all -> empty WorkView (no throw) +// 2. embedded present -> WorkView sourced from embedded; +// is_embedded()=true, has_rpc()=false +// 3. submit_block_hex w/ no RPC sink -> false (the no-RPC guard), NOT a throw +// +// The full per-coin WorkData (incl. m_txs) is retained coin-side via work.set() +// and only the agnostic slice (m_data/m_hashes/m_latency) crosses the seam -- +// this test confirms exactly those three fields survive and nothing else is +// required to satisfy ICoinNode. +// +// Build-INERT / source-only: impl_bch stays unregistered in CMake (bch = +// skip-green; don`t race ci-steward), so this is verified with -fsyntax-only +// and runs under the embedded test target once impl_bch is registered. Out of +// tree it links against coin_node.cpp + btclibs uint256; the external NodeRPC +// fallback leg is never invoked here (rpc=nullptr in both cases), so it needs +// no live RPC/boost graph. p2pool-merged-v36 surface: NONE (pure local seam). +// --------------------------------------------------------------------------- + +#include +#include +#include + +#include "../coin/coin_node.hpp" + +namespace { + +// Minimal embedded work source: returns a recognisable WorkData so the test can +// prove the seam moved THIS object`s agnostic slice into the WorkView. +class FakeEmbedded : public bch::coin::CoinNodeInterface { +public: + int calls = 0; + + bch::coin::rpc::WorkData getwork() override { + ++calls; + bch::coin::rpc::WorkData wd; + wd.m_data = nlohmann::json{{"marker", "embedded-bch"}}; + wd.m_hashes.push_back(uint256(static_cast(0xab))); + wd.m_latency = 42; + // m_txs intentionally left empty -- it must NOT cross the seam anyway. + wd.m_txs.clear(); + return wd; + } + + void submit_block(bch::coin::BlockType& /*block*/) override {} + nlohmann::json getblockchaininfo() override { return nlohmann::json::object(); } + bool is_synced() const override { return true; } +}; + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +} // namespace + +int main() { + using bch::coin::CoinNode; + + // 1) No work source configured -> empty view, no throw (1:1 btc/ltc ref). + { + CoinNode n(nullptr, nullptr); + CHECK(!n.is_embedded()); + CHECK(!n.has_rpc()); + core::coin::WorkView v = n.get_work_view(); + CHECK(v.m_data.is_null() || v.m_data.empty()); + CHECK(v.m_hashes.empty()); + CHECK(v.m_latency == 0); + } + + // 2) Embedded preferred, no RPC fallback wired -> view comes from embedded. + { + FakeEmbedded emb; + CoinNode n(&emb, /*rpc=*/nullptr); + CHECK(n.is_embedded()); + CHECK(!n.has_rpc()); + core::coin::WorkView v = n.get_work_view(); + CHECK(emb.calls == 1); // embedded WAS the source + CHECK(v.m_data.contains("marker")); + CHECK(v.m_data["marker"] == "embedded-bch"); + CHECK(v.m_hashes.size() == 1); + CHECK(v.m_latency == 42); + + // 3) No RPC sink -> submit_block_hex returns false (guard, not throw). + bool ok = n.submit_block_hex("00", /*ignore_failure=*/true); + CHECK(ok == false); + } + + if (failures == 0) { + std::cout << "coin_node_seam_test: ALL PASS\n"; + return 0; + } + std::cerr << "coin_node_seam_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/embedded_getwork_test.cpp b/src/impl/bch/test/embedded_getwork_test.cpp new file mode 100644 index 000000000..ea5167c84 --- /dev/null +++ b/src/impl/bch/test/embedded_getwork_test.cpp @@ -0,0 +1,175 @@ +// --------------------------------------------------------------------------- +// bch::coin::EmbeddedCoinNode::getwork() contract test (M5 -- embedded body). +// +// build_template() is exercised by abla_floor_invariant_test/abla_block_feed_test, +// and the CoinNode seam by coin_node_seam_test -- but the LATTER drives the seam +// through a *fake* CoinNodeInterface, and nothing covers EmbeddedCoinNode::getwork() +// itself: its sync gate, its no-tip gate, or the fact that the REAL embedded node +// satisfies the core::coin::ICoinNode seam end-to-end. This test closes exactly +// those gaps against an in-memory HeaderChain + empty Mempool: +// +// 1. NOT-SYNCED gate -- a checkpoint-only chain (synthetic tip, ts=0, age huge) +// makes getwork() THROW (chain not synced), never a half-built template. +// 2. SYNCED happy path -- after adding one recent-timestamp header on top of the +// fast-start seed, getwork() returns a GBT-shaped WorkData with the expected +// height (tip+1), previousblockhash (the tip hash), and the 32 MB ABLA floor +// sizelimit; the empty mempool yields zero template txs. +// 3. ABLA tracker wired at tip vs. STALE -- a tracker whose cursor == tip feeds a +// per-tip State; a stale (cursor != tip) tracker resolves to nullptr and +// build_template falls back to the floor. Both keep the budget >= 32 MB floor +// (ABLA only ever raises): getwork() succeeds and never undercuts in either. +// 4. REAL-NODE seam composition -- CoinNode(&EmbeddedCoinNode, rpc=nullptr) moves +// getwork()'s agnostic slice (m_data/m_hashes/m_latency) across the WorkView +// seam: is_embedded()=true, has_rpc()=false, the WorkView carries the height. +// (coin_node_seam_test proved the contract via a fake; this proves the REAL +// EmbeddedCoinNode satisfies it.) +// +// The header below the ASERT anchor (height 100k << mainnet anchor 661647) keeps +// build_template on the bits-fallback branch -- no ASERT math, no PoW to mine: +// set_peer_tip_height() puts the new header in the structural-only sync window so +// add_header() trusts its PoW, and validate_difficulty() trusts one block built +// directly on the null-prev fast-start seed. +// +// Build-INERT / source-only (matches the sibling tests): impl_bch stays unregistered +// in CMake (bch = skip-green; don't race ci-steward). Verified with -fsyntax-only and +// standalone compile+run. p2pool-merged-v36 surface: NONE -- getwork emits the same +// coin-agnostic WorkData the sweep already pinned conformant; no PoW/share/coinbase/ +// PPLNS math is touched. +// --------------------------------------------------------------------------- + +#include +#include +#include +#include +#include + +#include "../coin/abla.hpp" // floor_block_size_limit +#include "../coin/abla_tracker.hpp" // AblaTracker +#include "../coin/block.hpp" // BlockHeaderType, block_hash +#include "../coin/coin_node.hpp" // CoinNode (ICoinNode seam) +#include "../coin/header_chain.hpp" // HeaderChain, BCHChainParams +#include "../coin/mempool.hpp" // Mempool +#include "../coin/template_builder.hpp" // EmbeddedCoinNode + +namespace { + +int failures = 0; +#define CHECK(cond) do { if (!(cond)) { \ + std::cerr << "FAIL: " #cond " @ line " << __LINE__ << "\n"; ++failures; } } while (0) + +// A block header building on `prev` with a controllable timestamp. Below the +// ASERT anchor + inside the structural-only sync window, so neither PoW nor the +// ASERT retarget is enforced when this is add_header()-ed onto the seed. +bch::coin::BlockHeaderType make_header(const uint256& prev, uint32_t ts) { + bch::coin::BlockHeaderType h; + h.m_version = 0x20000000; + h.m_previous_block = prev; + h.m_merkle_root.SetHex( + "2222222222222222222222222222222222222222222222222222222222222222"); + h.m_timestamp = ts; + h.m_bits = 0x1d00ffff; + h.m_nonce = 7; + return h; +} + +} // namespace + +int main() { + using bch::coin::AblaTracker; + using bch::coin::BCHChainParams; + using bch::coin::CoinNode; + using bch::coin::EmbeddedCoinNode; + using bch::coin::HeaderChain; + using bch::coin::Mempool; + + const uint32_t H = 100000; // fast-start checkpoint height, << ASERT anchor + const uint64_t floor = bch::coin::abla::floor_block_size_limit(/*is_testnet=*/false); + const auto now = static_cast(std::time(nullptr)); + + uint256 cp_hash; + cp_hash.SetHex("00000000000000000002c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ffee00"); + + // -- 1) NOT-SYNCED gate ------------------------------------------------ + { + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = BCHChainParams::Checkpoint{H, cp_hash}; + HeaderChain chain(params); + CHECK(chain.init()); + CHECK(!chain.is_synced()); // synthetic seed: ts=0, age huge + + Mempool pool; + EmbeddedCoinNode emb(chain, pool, /*testnet=*/false); + bool threw = false; + try { (void)emb.getwork(); } catch (const std::exception&) { threw = true; } + CHECK(threw); // getwork refuses an unsynced chain + } + + // -- 2/3/4) SYNCED happy path + ABLA selection + seam ------------------ + BCHChainParams params = BCHChainParams::mainnet(); + params.fast_start_checkpoint = BCHChainParams::Checkpoint{H, cp_hash}; + HeaderChain chain(params); + CHECK(chain.init()); + chain.set_peer_tip_height(H + 5000); // structural-only window: skip PoW + // One recent header on top of the seed -> tip advances, chain reports synced. + bch::coin::BlockHeaderType h1 = make_header(cp_hash, now - 60); + CHECK(chain.add_header(h1)); + const uint256 h1_hash = bch::coin::block_hash(h1); + CHECK(chain.height() == H + 1); + CHECK(chain.is_synced()); + + Mempool pool; // empty: zero template txs + EmbeddedCoinNode emb(chain, pool, /*testnet=*/false); + + // 2) getwork() on a synced chain returns a GBT-shaped template. + { + bch::coin::rpc::WorkData wd = emb.getwork(); + CHECK(wd.m_data["height"].get() == static_cast(H + 2)); + CHECK(wd.m_data["previousblockhash"].get() == h1_hash.GetHex()); + CHECK(wd.m_data["sizelimit"].get() == static_cast(floor)); + CHECK(wd.m_data["transactions"].empty()); // empty mempool + CHECK(wd.m_data["coinbasevalue"].get() > 0); + CHECK(wd.m_txs.empty()); + CHECK(wd.m_hashes.empty()); + } + + // 3a) ABLA tracker wired AT the tip -> per-tip State feeds build_template; + // floor-anchored with no large folds, so the budget stays at the floor. + { + AblaTracker at_tip = AblaTracker::floor_anchored(/*is_testnet=*/false, H + 1); + emb.set_abla_tracker(&at_tip); + bch::coin::rpc::WorkData wd = emb.getwork(); + CHECK(wd.m_data["sizelimit"].get() >= static_cast(floor)); + CHECK(wd.m_data["height"].get() == static_cast(H + 2)); + } + + // 3b) STALE tracker (cursor != tip) -> state_for_tip()=nullptr -> floor + // fallback; getwork() still succeeds and never undercuts the floor. + { + AblaTracker stale = AblaTracker::floor_anchored(/*is_testnet=*/false, H); // cursor H != tip H+1 + emb.set_abla_tracker(&stale); + bch::coin::rpc::WorkData wd = emb.getwork(); + CHECK(wd.m_data["sizelimit"].get() == static_cast(floor)); + } + emb.set_abla_tracker(nullptr); // detach test-local tracker + + // 4) REAL EmbeddedCoinNode across the CoinNode (ICoinNode) seam. + { + CoinNode n(&emb, /*rpc=*/nullptr); + CHECK(n.is_embedded()); + CHECK(!n.has_rpc()); + core::coin::WorkView v = n.get_work_view(); + CHECK(v.m_data["height"].get() == static_cast(H + 2)); + CHECK(v.m_data.contains("sizelimit")); + CHECK(v.m_hashes.empty()); // no txs crossed the seam + // submit with no RPC sink -> false (guard, not throw): embedded-primary, + // external-RPC fallback absent here. + CHECK(n.submit_block_hex("00", /*ignore_failure=*/true) == false); + } + + if (failures == 0) { + std::cout << "embedded_getwork_test: ALL PASS\n"; + return 0; + } + std::cerr << "embedded_getwork_test: " << failures << " FAILURE(S)\n"; + return 1; +} diff --git a/src/impl/bch/test/stratum_conformance.md b/src/impl/bch/test/stratum_conformance.md new file mode 100644 index 000000000..a17e1f0fc --- /dev/null +++ b/src/impl/bch/test/stratum_conformance.md @@ -0,0 +1,35 @@ +# BCH stratum conformance vs p2poolBCH @6603b79 (M5 sweep — final leg) + +Verdict: CONFORMANT. Zero code change required; zero p2pool-v36 surface. + +## Architecture +The stratum server is coin-AGNOSTIC and lives in `src/core/stratum_server.{hpp,cpp}` ++ `src/core/stratum_work_source.hpp`. Per-coin modules contribute ONLY +`rpc::WorkData` (`src/impl/bch/coin/rpc_data.hpp`) via `getwork()` +(`src/impl/bch/coin/template_builder.hpp`). The notify wire-shape is therefore +fixed by core, identical across btc/bch/ltc/dgb/dash. + +## Field-mapping parity (BCH getwork m_data -> p2poolBCH data.py / stratum.py) + version -> work['version'] (BCH: floor 4, no BIP9 vbits) + previousblockhash -> int(work['previousblockhash'],16) -> notify prevhash via _swap4 + bits -> FloatingInteger(work['bits'][::-1]) (8-char lowercase hex, bchn GBT) + curtime -> work['curtime'] (==time) -> notify ntime BE-hex + coinbasevalue -> work['coinbasevalue'] (subsidy) + transactions -> unpacked_transactions / txhashes (CTOR-sorted, CashTokens transparent) + coinbaseflags -> work['coinbaseflags'] (empty; coinbase commitment built M3 s19) + +## Notify params (core stratum, == p2poolBCH stratum.py _send_work): + [jobid, prevhash(swap4), coinb1, coinb2, merkle_branch[], version_hex, + nbits_hex, ntime_hex, clean_jobs] -- BE-hex default (LE is operator env toggle). + +## BCH-specific divergences confirmed NON-impacting on stratum wire: + - No SegWit -> no witness commitment in coinb2 (share fmt: SegWit disabled, slice H). + - ASERT DAA -> only affects `bits` VALUE, not encoding (8-char hex unchanged). + - ABLA -> only affects `sizelimit`/budget, not any notify field. + - ASICBoost (version-rolling mask 0x1fffe000), vardiff, FR-1.15 firmware + env-toggles (FIXED_INITIAL_DIFF, NO_SUBMIT_RATCHET, IDLE_DOWNSHIFT) are all + coin-agnostic operator runtime tuning in core -- not BCH module surface. + +## M5 conformance sweep status: COMPLETE + share-emit (sF) + merkle-accept (sG) + share-serialization/SegWit-off (sH) + + PPLNS (verified no-diff) + stratum (this) all CONFORMANT vs p2poolBCH @6603b79.