Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/core/address_utils.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include <cstring>
#include <sstream>
#include <iomanip>
#include <functional>
#include <mutex>

#include <btclibs/crypto/sha256.h>
#include <btclibs/crypto/ripemd160.h>
Expand All @@ -12,6 +14,18 @@

namespace core {

namespace {
std::mutex& address_decoder_mutex() { static std::mutex m; return m; }
std::vector<AddressDecoderFn>& address_decoders() {
static std::vector<AddressDecoderFn> d; return d;
}
} // namespace

void register_address_decoder(AddressDecoderFn fn) {
std::lock_guard<std::mutex> lk(address_decoder_mutex());
address_decoders().push_back(std::move(fn));
}

std::string base58check_to_hash160(const std::string& address)
{
static constexpr const char* B58 =
Expand Down Expand Up @@ -271,6 +285,16 @@ std::vector<unsigned char> address_to_script(const std::string& address)
return hash160_to_merged_script(h160, addr_type);
}

// Coin-registered fallback decoders (e.g. BCH CashAddr). Core stays
// format-agnostic: first decoder to return a non-empty script wins.
{
std::lock_guard<std::mutex> lk(address_decoder_mutex());
for (auto& fn : address_decoders()) {
auto script = fn(address);
if (!script.empty()) return script;
}
}

return {};
}

Expand Down
9 changes: 9 additions & 0 deletions src/core/address_utils.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <string>
#include <vector>
#include <cstdint>
#include <functional>

namespace core {

Expand Down Expand Up @@ -52,6 +53,14 @@ bool is_address_for_chain(const std::string& address,
/// Returns empty vector on failure.
std::vector<unsigned char> address_to_script(const std::string& address);

// -- Generic coin-registered fallback decoders --------------------------------
// A coin module may register an opaque address decoder that address_to_script
// consults AFTER its built-in bech32/base58 formats fail. Core holds NO
// coin-specific address knowledge -- the decoder is a plain functor mapping an
// address string to a scriptPubKey ({} == "not my format, try the next one").
using AddressDecoderFn = std::function<std::vector<unsigned char>(const std::string&)>;
void register_address_decoder(AddressDecoderFn fn);

/// Convert a raw scriptPubKey to a human-readable address string.
/// Supports P2PKH, P2SH, P2WPKH, P2WSH. Returns "" on unrecognised script.
/// bech32_hrp: "tltc", "ltc", "bc", "tb" etc.
Expand Down
25 changes: 20 additions & 5 deletions src/core/stratum_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -625,11 +625,26 @@ nlohmann::json StratumSession::handle_authorize(const nlohmann::json& params, co
}
}

// If merged addresses were parsed (or auto-generated), resend work notification so the
// coinbase ref_hash includes them. The initial job sent right after
// subscribe had empty merged_addresses because authorize hadn't run yet.
if (!merged_addresses_.empty() && mining_interface_) {
send_notify_work(true); // force clean_jobs so miner drops old job without merged_addrs
// Resend work after a successful authorize. The job sent right after
// subscribe was built before authorize ran, so it carried neither the
// miner's primary payout address (username_ was empty -> empty
// payout_script -> degenerate value-0 OP_RETURN coinbase) nor any merged
// addresses. Two distinct primary classes need this resend, and NEITHER
// populates merged_addresses_:
// (1) Standalone parent coins (BCH/BTC, no merged chains) never
// populate merged_addresses_ at all.
// (2) A P2WSH/P2TR primary (Cases 7-9) carries a 32-byte witness
// program that cannot reduce to a 20-byte hash160, so the
// auto-derive above is skipped and merged_addresses_ stays empty
// even for a merged coin (LTC/DOGE) whose username_ IS set.
// Gating the resend on merged_addresses_ alone left BOTH classes mining
// the payout-less coinbase -> BCHN bad-txns/BIP30 on a won block. Resend
// whenever the now-known username_ or merged addrs can change the
// coinbase: address_to_script(username_) yields the real witness/script
// payout for (2) exactly as it yields the P2PKH payout for (1).
// force_clean so the miner drops the stale job.
if (mining_interface_ && (!username_.empty() || !merged_addresses_.empty())) {
send_notify_work(true);
}

// Start periodic work push (every SHARE_PERIOD) to keep miner on fresh work
Expand Down
71 changes: 71 additions & 0 deletions src/impl/bch/coin/cashaddr.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@
// ---------------------------------------------------------------------------

#include <cstdint>
#include <mutex>
#include <string>
#include <utility>
#include <vector>

#include <core/address_utils.hpp> // register_address_decoder (payout hook)

namespace bch {
namespace coin {
namespace cashaddr {
Expand Down Expand Up @@ -269,6 +272,74 @@ inline CashAddrContent DecodeCashAddrContent(const std::string& addr,
return {type, std::move(out)};
}

// -- {type,hash} -> BCH scriptPubKey (locking script) -------------------------
// P2PKH (20B): OP_DUP OP_HASH160 <20> OP_EQUALVERIFY OP_CHECKSIG
// P2SH (20B): OP_HASH160 <20> OP_EQUAL
// P2SH32(32B): OP_HASH256 <32> OP_EQUAL (CHIP-2022-02, May 2023)
// Token-aware variants lock identically -- token-awareness is a wallet display
// capability, not a script-level distinction. Returns {} on unsupported size.
inline std::vector<unsigned char> content_to_script(const CashAddrContent& c) {
const auto& h = c.hash;
std::vector<unsigned char> s;
switch (c.type) {
case PUBKEY_TYPE:
case TOKEN_PUBKEY_TYPE:
if (h.size() != 20) return {};
s = {0x76, 0xa9, 0x14};
s.insert(s.end(), h.begin(), h.end());
s.push_back(0x88); s.push_back(0xac);
return s;
case SCRIPT_TYPE:
case TOKEN_SCRIPT_TYPE:
if (h.size() == 20) { // P2SH20
s = {0xa9, 0x14};
s.insert(s.end(), h.begin(), h.end());
s.push_back(0x87);
return s;
}
if (h.size() == 32) { // P2SH32 (OP_HASH256)
s = {0xaa, 0x20};
s.insert(s.end(), h.begin(), h.end());
s.push_back(0x87);
return s;
}
return {};
}
return {};
}

// Decode a CashAddr string to its scriptPubKey under a specific network prefix.
// Returns {} if the string is not a valid CashAddr for that prefix.
inline std::vector<unsigned char> cashaddr_to_script(const std::string& addr,
const std::string& prefix) {
auto content = DecodeCashAddrContent(addr, prefix);
if (content.IsNull()) return {};
return content_to_script(content);
}

// Convenience: resolve under the active network prefix (mainnet/testnet/regtest).
inline std::vector<unsigned char> cashaddr_to_script_for_net(const std::string& addr,
bool testnet, bool regtest) {
const std::string prefix = regtest ? std::string(REGTEST_PREFIX)
: prefix_for(testnet);
return cashaddr_to_script(addr, prefix);
}

// Register the BCH CashAddr decoder into the generic core address hook so
// core::address_to_script (the stratum payout path) resolves a miner CashAddr
// username to a real payout scriptPubKey. Without this the BCH payout script is
// empty -> degenerate value-0 OP_RETURN coinbase -> BCHN bad-txns/BIP30 reject.
// Idempotent (once per process). Core stays CashAddr-agnostic.
inline void register_cashaddr_decoder(bool testnet, bool regtest) {
static std::once_flag once;
std::call_once(once, [testnet, regtest]() {
core::register_address_decoder(
[testnet, regtest](const std::string& a) -> std::vector<unsigned char> {
return cashaddr_to_script_for_net(a, testnet, regtest);
});
});
}

} // namespace cashaddr
} // namespace coin
} // namespace bch
144 changes: 144 additions & 0 deletions test/test_address_resolution.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,147 @@ TEST(AddressResolution, ConvertibleCheckP2TR)
bool convertible = (atype == "p2pkh" || atype == "p2wpkh" || atype == "p2sh");
EXPECT_FALSE(convertible);
}

// ============================================================================
// Suite 8: post-authorize work-resend single-fire gate (PR #585 seam b)
//
// Deterministic mirror of the decision in
// core::StratumSession::handle_authorize (src/core/stratum_server.cpp): the
// auto-derive predicate that populates merged_addresses_, the NEW (widened)
// resend gate that fires the single send_notify_work(true), and the OLD
// merged-only gate kept as a regression witness.
//
// Pins the *actually-changed path* across the full username matrix INCLUDING
// P2WSH/P2TR LTC primaries. Such primaries carry a 32-byte witness program
// that cannot reduce to a 20-byte hash160, so merged_addresses_ is never
// auto-derived even though username_ is set. Under the OLD merged-only gate
// they got no resend and kept mining the degenerate value-0 coinbase; the
// widened gate now resends once so the coinbase carries the real payout.
// ============================================================================

namespace {
// VERBATIM mirror of the auto-derive predicate (stratum_server.cpp): merged
// addresses are derived from the primary ONLY for convertible 20-byte types.
bool auto_derives_merged(const std::string& atype, size_t h160_len)
{
return h160_len == 40u &&
(atype == "p2pkh" || atype == "p2wpkh" || atype == "p2sh");
}

// NEW (PR #585) resend gate: fires when the now-known username_ OR any merged
// addr can change the coinbase.
bool resend_fires_new(const std::string& username, bool merged_nonempty)
{
return !username.empty() || merged_nonempty;
}

// OLD (pre-#585) gate, retained as the regression witness: merged-only.
bool resend_fires_old(bool merged_nonempty)
{
return merged_nonempty;
}

// Models has_merged_chain(): true for a merged-mining pool (LTC w/ DOGE aux),
// false for a standalone parent (BCH/BTC).
struct GateOutcome {
bool merged_nonempty;
int new_fires; // # of send_notify_work(true) the new gate would issue
bool old_would_fire;
std::vector<unsigned char> payout_script;
};

GateOutcome simulate_authorize(const std::string& username, bool has_merged_chain)
{
std::string atype;
auto h160 = address_to_hash160(username, atype);
bool merged_nonempty = has_merged_chain && auto_derives_merged(atype, h160.size());
GateOutcome o;
o.merged_nonempty = merged_nonempty;
// single guard around a single send_notify_work(true) -> count is 0 or 1
o.new_fires = resend_fires_new(username, merged_nonempty) ? 1 : 0;
o.old_would_fire = resend_fires_old(merged_nonempty);
o.payout_script = address_to_script(username);
return o;
}
} // namespace

// --- Unchanged classes (P2PKH / P2WPKH / P2SH merged primary): derive + fire,
// identical to the OLD gate. ---

TEST(ResendGate, P2PKH_MergedPrimary_FiresOnce_Unchanged)
{
auto o = simulate_authorize(LTC_P2PKH, /*has_merged_chain=*/true);
EXPECT_TRUE(o.merged_nonempty);
EXPECT_EQ(o.new_fires, 1); // single-fire
EXPECT_TRUE(o.old_would_fire); // old gate also fired -> behavior unchanged
EXPECT_FALSE(o.payout_script.empty());
}

TEST(ResendGate, P2WPKH_MergedPrimary_FiresOnce_Unchanged)
{
auto o = simulate_authorize(LTC_BECH32, true);
EXPECT_TRUE(o.merged_nonempty);
EXPECT_EQ(o.new_fires, 1);
EXPECT_TRUE(o.old_would_fire);
EXPECT_FALSE(o.payout_script.empty());
}

TEST(ResendGate, P2SH_MergedPrimary_FiresOnce_Unchanged)
{
auto o = simulate_authorize(LTC_P2SH, true);
EXPECT_TRUE(o.merged_nonempty);
EXPECT_EQ(o.new_fires, 1);
EXPECT_TRUE(o.old_would_fire);
EXPECT_FALSE(o.payout_script.empty());
}

// --- New-firing class 1: standalone parent (no merged chains). ---

TEST(ResendGate, StandaloneParent_FiresOnce_WasSilentBefore)
{
// Any valid primary on a pool with NO merged chains (BCH/BTC).
auto o = simulate_authorize(LTC_P2PKH, /*has_merged_chain=*/false);
EXPECT_FALSE(o.merged_nonempty);
EXPECT_EQ(o.new_fires, 1); // widened gate fires
EXPECT_FALSE(o.old_would_fire); // OLD merged-only gate stayed silent (the bug)
EXPECT_FALSE(o.payout_script.empty());
}

// --- New-firing class 2: P2WSH/P2TR LTC primary on a MERGED coin. The
// actually-changed path the integrator flagged: merged stays empty
// (non-convertible 32-byte program) yet the resend must now fire once with
// a real payout script. ---

TEST(ResendGate, P2WSH_LtcPrimary_FiresOnce_MergedEmpty_RealPayout)
{
auto o = simulate_authorize(LTC_P2WSH, /*has_merged_chain=*/true);
EXPECT_FALSE(o.merged_nonempty); // 32-byte program -> no auto-derive
EXPECT_EQ(o.new_fires, 1); // widened gate fires (latent-bug fix)
EXPECT_FALSE(o.old_would_fire); // OLD gate would have stayed silent
// carries the REAL payout: P2WSH = OP_0 <32-byte program>
ASSERT_EQ(o.payout_script.size(), 34u);
EXPECT_EQ(o.payout_script[0], 0x00);
EXPECT_EQ(o.payout_script[1], 0x20);
}

TEST(ResendGate, P2TR_LtcPrimary_FiresOnce_MergedEmpty_RealPayout)
{
auto o = simulate_authorize(LTC_P2TR, /*has_merged_chain=*/true);
EXPECT_FALSE(o.merged_nonempty); // 32-byte program -> no auto-derive
EXPECT_EQ(o.new_fires, 1); // widened gate fires (latent-bug fix)
EXPECT_FALSE(o.old_would_fire); // OLD gate would have stayed silent
// carries the REAL payout: P2TR = OP_1 <32-byte program>
ASSERT_EQ(o.payout_script.size(), 34u);
EXPECT_EQ(o.payout_script[0], 0x51);
EXPECT_EQ(o.payout_script[1], 0x20);
}

// Empty username (pre-authorize job) must NOT fire under either gate — the
// degenerate-coinbase precondition that motivated the resend in the first place.
TEST(ResendGate, EmptyUsername_NeverFires)
{
auto o = simulate_authorize("", /*has_merged_chain=*/false);
EXPECT_FALSE(o.merged_nonempty);
EXPECT_EQ(o.new_fires, 0);
EXPECT_FALSE(o.old_would_fire);
}
Loading