From c4fd3ab1e85b03e285d67b65033308fb05161b62 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Sat, 23 May 2026 14:25:01 +0700 Subject: [PATCH 1/4] feat: remove bip30 field from coinstat index --- src/index/coinstatsindex.cpp | 7 ------- src/index/coinstatsindex.h | 1 + src/kernel/coinstats.h | 2 +- src/rpc/blockchain.cpp | 2 -- test/functional/feature_coinstatsindex.py | 4 ---- 5 files changed, 2 insertions(+), 14 deletions(-) diff --git a/src/index/coinstatsindex.cpp b/src/index/coinstatsindex.cpp index 49c81ba1efbc..479198ed8629 100644 --- a/src/index/coinstatsindex.cpp +++ b/src/index/coinstatsindex.cpp @@ -144,13 +144,6 @@ bool CoinStatsIndex::WriteBlock(const CBlock& block, const CBlockIndex* pindex) for (size_t i = 0; i < block.vtx.size(); ++i) { const auto& tx{block.vtx.at(i)}; - // Skip duplicate txid coinbase transactions (BIP30). - if (IsBIP30Unspendable(*pindex) && tx->IsCoinBase()) { - m_total_unspendable_amount += block_subsidy; - m_total_unspendables_bip30 += block_subsidy; - continue; - } - for (uint32_t j = 0; j < tx->vout.size(); ++j) { const CTxOut& out{tx->vout[j]}; Coin coin{out, pindex->nHeight, tx->IsCoinBase()}; diff --git a/src/index/coinstatsindex.h b/src/index/coinstatsindex.h index b7ad12b76082..dbe2e3d87f0d 100644 --- a/src/index/coinstatsindex.h +++ b/src/index/coinstatsindex.h @@ -32,6 +32,7 @@ class CoinStatsIndex final : public BaseIndex CAmount m_total_new_outputs_ex_coinbase_amount{0}; CAmount m_total_coinbase_amount{0}; CAmount m_total_unspendables_genesis_block{0}; + //! There's no unspendable coinbase outputs in dash core. TODO: remove it with a version bump CAmount m_total_unspendables_bip30{0}; CAmount m_total_unspendables_scripts{0}; CAmount m_total_unspendables_unclaimed_rewards{0}; diff --git a/src/kernel/coinstats.h b/src/kernel/coinstats.h index c103966568f2..f1d7c118f59c 100644 --- a/src/kernel/coinstats.h +++ b/src/kernel/coinstats.h @@ -58,7 +58,7 @@ struct CCoinsStats { CAmount total_coinbase_amount{0}; //! The unspendable coinbase amount from the genesis block CAmount total_unspendables_genesis_block{0}; - //! The two unspendable coinbase outputs total amount caused by BIP30 + //! There's no unspendable coinbase outputs in dash core. TODO: remove it with a version bump CAmount total_unspendables_bip30{0}; //! Total cumulative amount of outputs sent to unspendable scripts (OP_RETURN for example) up to and including this block CAmount total_unspendables_scripts{0}; diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 1803294f7b3c..abe9b63c42f3 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1171,7 +1171,6 @@ static RPCHelpMan gettxoutsetinfo() {RPCResult::Type::OBJ, "unspendables", "Detailed view of the unspendable categories", { {RPCResult::Type::STR_AMOUNT, "genesis_block", "The unspendable amount of the Genesis block subsidy"}, - {RPCResult::Type::STR_AMOUNT, "bip30", "Transactions overridden by duplicates (no longer possible with BIP30)"}, {RPCResult::Type::STR_AMOUNT, "scripts", "Amounts sent to scripts that are unspendable (for example OP_RETURN outputs)"}, {RPCResult::Type::STR_AMOUNT, "unclaimed_rewards", "Fee rewards that miners did not claim in their coinbase transaction"}, }} @@ -1275,7 +1274,6 @@ static RPCHelpMan gettxoutsetinfo() UniValue unspendables(UniValue::VOBJ); unspendables.pushKV("genesis_block", ValueFromAmount(stats.total_unspendables_genesis_block - prev_stats.total_unspendables_genesis_block)); - unspendables.pushKV("bip30", ValueFromAmount(stats.total_unspendables_bip30 - prev_stats.total_unspendables_bip30)); unspendables.pushKV("scripts", ValueFromAmount(stats.total_unspendables_scripts - prev_stats.total_unspendables_scripts)); unspendables.pushKV("unclaimed_rewards", ValueFromAmount(stats.total_unspendables_unclaimed_rewards - prev_stats.total_unspendables_unclaimed_rewards)); block_info.pushKV("unspendables", unspendables); diff --git a/test/functional/feature_coinstatsindex.py b/test/functional/feature_coinstatsindex.py index cad3bc886a2a..b27f1b0b4759 100755 --- a/test/functional/feature_coinstatsindex.py +++ b/test/functional/feature_coinstatsindex.py @@ -135,7 +135,6 @@ def _test_coin_stats_index(self): 'coinbase': 0, 'unspendables': { 'genesis_block': 50, - 'bip30': 0, 'scripts': 0, 'unclaimed_rewards': 0 } @@ -152,7 +151,6 @@ def _test_coin_stats_index(self): 'coinbase': Decimal('500.00025500'), 'unspendables': { 'genesis_block': 0, - 'bip30': 0, 'scripts': 0, 'unclaimed_rewards': 0, } @@ -189,7 +187,6 @@ def _test_coin_stats_index(self): 'coinbase': Decimal('500.00101000'), 'unspendables': { 'genesis_block': 0, - 'bip30': 0, 'scripts': Decimal('20.99900000'), 'unclaimed_rewards': 0, } @@ -220,7 +217,6 @@ def _test_coin_stats_index(self): 'coinbase': 40, 'unspendables': { 'genesis_block': 0, - 'bip30': 0, 'scripts': 0, 'unclaimed_rewards': 460 } From 43a40fed2093e52fbe58bd6a76e7ce2117023878 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Mon, 25 May 2026 13:43:26 +0700 Subject: [PATCH 2/4] test: script to scan pre-bip34 coinbases to find potential issues for futher blocks --- contrib/devtools/scan-pre-bip34-coinbases.py | 199 +++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100755 contrib/devtools/scan-pre-bip34-coinbases.py diff --git a/contrib/devtools/scan-pre-bip34-coinbases.py b/contrib/devtools/scan-pre-bip34-coinbases.py new file mode 100755 index 000000000000..10473c5687bd --- /dev/null +++ b/contrib/devtools/scan-pre-bip34-coinbases.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Scan pre-BIP34 coinbases for future BIP30-collision targets. + +Background +---------- +BIP34 ("height in coinbase") does not, by itself, fully imply BIP30 +("no duplicate txid"). A pre-BIP34 coinbase had a free-form scriptSig. +If the leading bytes of that scriptSig happen to form a minimal +CScriptNum encoding of some height H > BIP34Height, then at height H a +miner can produce a BIP34-conformant coinbase whose entire transaction +is byte-identical to the historical pre-BIP34 one -- a duplicate txid. + +Bitcoin Core handles this with BIP34_IMPLIES_BIP30_LIMIT = 1,983,702, +derived from an exhaustive scan of its own pre-BIP34 coinbases. Dash +carries no such constant. Dash mainnet's pre-BIP34 window is heights +1..950; testnet's is 1..75. + +This script asks dashd over RPC for each coinbase in the pre-BIP34 +range, decodes the leading scriptSig push as a minimal CScriptNum, +and reports every "indicated height" that is greater than the chain's +BIP34Height. Each such height is a candidate future collision target. + +Authentication uses the dashd cookie at //.cookie. +Pass --rpcuser / --rpcpassword as an explicit override. + +Empty output for mainnet implies the BIP30 enforcement machinery in +validation.cpp is dead code on Dash and can be removed. +""" + +import argparse +import base64 +import json +import os +import sys +import urllib.request + + +# Per-network defaults: (BIP34Height, default RPC port, on-disk chain folder name). +NETWORK_BIP34 = { + "main": {"bip34": 951, "port": 9998, "chain": ""}, + "test": {"bip34": 76, "port": 19998, "chain": "testnet3"}, +} + + +def read_auth_cookie(datadir, chain_folder): + """Read //.cookie -> (user, password).""" + cookie_path = os.path.join(datadir, chain_folder, ".cookie") + try: + with open(cookie_path, "r", encoding="ascii") as f: + user, password = f.read().split(":", 1) + except OSError as e: + raise SystemExit(f"Cannot read cookie at {cookie_path}: {e}. Pass --rpcuser/--rpcpassword or --datadir.") + return user, password + + +def rpc(url, user, password, method, params): + req = urllib.request.Request( + url, + data=json.dumps({"jsonrpc": "1.0", "id": "scan", "method": method, "params": params}).encode(), + headers={"Content-Type": "application/json"}, + ) + token = base64.b64encode(f"{user}:{password}".encode()).decode() + req.add_header("Authorization", f"Basic {token}") + try: + with urllib.request.urlopen(req) as resp: + payload = resp.read() + except urllib.error.HTTPError as e: + # dashd returns HTTP 500 for JSON-RPC application errors; body still has the error JSON. + payload = e.read() or b"" + try: + body = json.loads(payload) + except ValueError: + raise RuntimeError(f"{method} {params}: HTTP {e.code} {e.reason} (body: {payload!r})") + raise RuntimeError(f"{method} {params}: {body.get('error', body)}") + body = json.loads(payload) + if body.get("error") is not None: + raise RuntimeError(f"{method} {params}: {body['error']}") + return body["result"] + + +def decode_leading_bip34_height(scriptsig): + """Interpret the leading bytes of scriptsig as a *minimal* CScriptNum push. + + Returns the indicated height, or None if the prefix is not a valid minimal + encoding (in which case no future BIP34 block could byte-match this + coinbase, and there is no collision risk). + """ + if not scriptsig: + return None + b = scriptsig[0] + # OP_0 -> 0 + if b == 0x00: + return 0 + # OP_1NEGATE -> -1 + if b == 0x4f: + return -1 + # OP_1..OP_16 -> 1..16 + if 0x51 <= b <= 0x60: + return b - 0x50 + # Direct push of 1..75 bytes + if 1 <= b <= 75: + n = b + if len(scriptsig) < 1 + n: + return None + payload = scriptsig[1 : 1 + n] + # Minimality: a 1-byte payload that would fit an OP_N is non-minimal. + if n == 1 and (payload[0] == 0 or 1 <= payload[0] <= 16 or payload[0] == 0x81): + return None + # Minimality: the top byte must carry information (sign bit or magnitude). + if (payload[-1] & 0x7f) == 0 and (n < 2 or (payload[-2] & 0x80) == 0): + return None + # Decode sign-magnitude little-endian + val = 0 + for i, byte in enumerate(payload): + val |= byte << (8 * i) + sign_bit = 0x80 << (8 * (n - 1)) + if val & sign_bit: + val = -(val & ~sign_bit) + return val + # OP_PUSHDATA1/2/4 are not minimal forms for small heights, skip. + return None + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--network", default="main", choices=NETWORK_BIP34.keys()) + p.add_argument("--datadir", default=os.path.expanduser("~/.dashcore"), help="dashd datadir") + p.add_argument("--rpchost", default="127.0.0.1") + p.add_argument("--rpcport", type=int, default=None, help="default: per-network (9998/19998/...)") + p.add_argument("--rpcuser", default=None, help="override; otherwise read from .cookie") + p.add_argument("--rpcpassword", default=None) + p.add_argument("--start", type=int, default=1) + p.add_argument("--end", type=int, default=None, help="default: BIP34Height - 1 for the chosen network") + p.add_argument("--verbose", action="store_true", help="print every block's decoded height") + args = p.parse_args() + + net = NETWORK_BIP34[args.network] + bip34_height = net["bip34"] + end = args.end if args.end is not None else bip34_height - 1 + if end < args.start: + print(f"Nothing to scan: BIP34Height={bip34_height}, range [{args.start}, {end}] is empty.") + return 0 + + if args.rpcuser and args.rpcpassword: + user, password = args.rpcuser, args.rpcpassword + else: + user, password = read_auth_cookie(args.datadir, net["chain"]) + port = args.rpcport if args.rpcport is not None else net["port"] + url = f"http://{args.rpchost}:{port}/" + print(f"Scanning {args.network} heights {args.start}..{end} (BIP34Height={bip34_height}) @ {url}", file=sys.stderr) + + candidates = [] # (height, indicated_H, coinbase_hex) -- indicated_H > BIP34Height + unparseable = [] # (height, coinbase_hex) -- decoder rejected as non-minimal + for h in range(args.start, end + 1): + block_hash = rpc(url, user, password, "getblockhash", [h]) + block = rpc(url, user, password, "getblock", [block_hash, 2]) + cb_hex = block["tx"][0]["vin"][0]["coinbase"] + indicated = decode_leading_bip34_height(bytes.fromhex(cb_hex)) + if indicated is None: + unparseable.append((h, cb_hex)) + elif indicated > bip34_height: + candidates.append((h, indicated, cb_hex)) + if args.verbose: + label = "non-minimal" if indicated is None else f"indicated={indicated}" + print(f" h={h:5d} {label:18} scriptSig={cb_hex}") + + print() + print(f"Scanned {end - args.start + 1} blocks (heights {args.start}..{end}, BIP34Height={bip34_height}).") + print(f" {len(candidates)} candidate future-collision targets (indicated height > BIP34Height).") + print(f" {len(unparseable)} scriptSigs not minimally encoding any integer.") + print(f" These are safe ONLY IF the decoder above is exhaustive -- please eyeball:") + for h, cb_hex in unparseable: + print(f" h={h:6d} scriptSig={cb_hex}") + + if candidates: + print() + print(f" {'pre-BIP34 h':>12} {'indicated H':>12} coinbase scriptSig (full hex)") + for h, indicated, cb_hex in candidates: + print(f" {h:>12} {indicated:>12} {cb_hex}") + print() + print("RESULT: Each row above is a candidate future-collision target. At indicated") + print(" height H, a miner could in principle re-mine the pre-BIP34 coinbase") + print(" verbatim (subject to subsidy/fee feasibility) and produce a duplicate") + print(" txid. Either keep BIP30 enforcement, audit each H individually, or") + print(" define a Dash BIP34_IMPLIES_BIP30_LIMIT above the largest indicated H.") + return 1 + + print() + print("RESULT: No pre-BIP34 coinbase has a leading minimal CScriptNum > BIP34Height.") + print(" Assuming the decoder is correct (verify against the unparseable list),") + print(" BIP30 enforcement is unreachable on this chain.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From e2a05a85f756198234f74deb94fdda2b185b27f0 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Sat, 23 May 2026 15:35:15 +0700 Subject: [PATCH 3/4] refactor: remove Bitcoin's specific workarounds around BIP30/BIP34 --- src/chain.h | 2 +- src/rpc/blockchain.cpp | 4 ++-- src/validation.cpp | 41 +++++------------------------------------ src/validation.h | 6 ------ 4 files changed, 8 insertions(+), 45 deletions(-) diff --git a/src/chain.h b/src/chain.h index 4236e5a7adff..01efbef4ff08 100644 --- a/src/chain.h +++ b/src/chain.h @@ -96,7 +96,7 @@ enum BlockStatus : uint32_t { */ BLOCK_VALID_TRANSACTIONS = 3, - //! Outputs do not overspend inputs, no double spends, coinbase output ok, no immature coinbase spends, BIP30. + //! Outputs do not overspend inputs, no double spends, coinbase output ok, no immature coinbase spends. //! Implies all parents are also at least CHAIN. BLOCK_VALID_CHAIN = 4, diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index abe9b63c42f3..4abf27d57e5c 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -2130,9 +2130,9 @@ static RPCHelpMan getblockstats() size_t out_size = GetSerializeSize(out, PROTOCOL_VERSION) + PER_UTXO_OVERHEAD; utxo_size_inc += out_size; - // The Genesis block and the repeated BIP30 block coinbases don't change the UTXO + // The Genesis block coinbase don't change the UTXO // set counts, so they have to be excluded from the statistics - if (pindex.nHeight == 0 || (IsBIP30Repeat(pindex) && tx->IsCoinBase())) continue; + if (pindex.nHeight == 0) continue; // Skip unspendable outputs since they are not included in the UTXO set if (out.scriptPubKey.IsUnspendable()) continue; diff --git a/src/validation.cpp b/src/validation.cpp index 89dfdaa75166..01e258225758 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -2038,21 +2038,11 @@ DisconnectResult CChainState::DisconnectBlock(const CBlock& block, const CBlockI return DISCONNECT_FAILED; } - // Ignore blocks that contain transactions which are 'overwritten' by later transactions, - // unless those are already completely spent. - // See https://github.com/bitcoin/bitcoin/issues/22596 for additional information. - // Note: the blocks specified here are different than the ones used in ConnectBlock because DisconnectBlock - // unwinds the blocks in reverse. As a result, the inconsistency is not discovered until the earlier - // blocks with the duplicate coinbase transactions are disconnected. - bool fEnforceBIP30 = !((pindex->nHeight==91722 && pindex->GetBlockHash() == uint256S("0x00000000000271a2dc26e7667f8419f2e15416dc6955e5a6c6cdf3f2574dd08e")) || - (pindex->nHeight==91812 && pindex->GetBlockHash() == uint256S("0x00000000000af0aed4792b1acee3d966af36cf5def14935db8de83d6f9306f2f"))); - // undo transactions in reverse order for (int i = block.vtx.size() - 1; i >= 0; i--) { const CTransaction &tx = *(block.vtx[i]); uint256 hash = tx.GetHash(); bool is_coinbase = tx.IsCoinBase(); - bool is_bip30_exception = (is_coinbase && !fEnforceBIP30); if (fAddressIndex) { for (unsigned int k = tx.vout.size(); k-- > 0;) { @@ -2081,9 +2071,7 @@ DisconnectResult CChainState::DisconnectBlock(const CBlock& block, const CBlockI Coin coin; bool is_spent = view.SpendCoin(out, &coin); if (!is_spent || tx.vout[o] != coin.out || pindex->nHeight != coin.nHeight || is_coinbase != coin.fCoinBase) { - if (!is_bip30_exception) { - fClean = false; // transaction output mismatch - } + fClean = false; // transaction output mismatch } } } @@ -2382,24 +2370,17 @@ bool CChainState::ConnectBlock(const CBlock& block, BlockValidationState& state, // can be duplicated to remove the ability to spend the first instance -- even after // being sent to another address. // See BIP30, CVE-2012-1909, and http://r6.ca/blog/20120206T005236Z.html for more information. - // This rule was originally applied to all blocks with a timestamp after March 15, 2012, 0:00 UTC. - // Now that the whole chain is irreversibly beyond that time it is applied to all blocks except the - // two in the chain that violate it. This prevents exploiting the issue against nodes during their + // This prevents exploiting the issue against nodes during their // initial block download. - bool fEnforceBIP30 = !IsBIP30Repeat(*pindex); // Once BIP34 activated it was not possible to create new duplicate coinbases and thus other than starting - // with the 2 existing duplicate coinbase pairs, not possible to create overwriting txs. But by the - // time BIP34 activated, in each of the existing pairs the duplicate coinbase had overwritten the first - // before the first had been spent. Since those coinbases are sufficiently buried it's no longer possible to create further - // duplicate transactions descending from the known pairs either. + // with the 2 existing duplicate coinbase pairs, not possible to create overwriting txs. // If we're on the known chain at height greater than where BIP34 activated, we can save the db accesses needed for the BIP30 check. assert(pindex->pprev); CBlockIndex* pindexBIP34height = pindex->pprev->GetAncestor(m_params.GetConsensus().BIP34Height); //Only continue to enforce if we're below BIP34 activation height or the block hash at that height doesn't correspond. - fEnforceBIP30 = fEnforceBIP30 && (!pindexBIP34height || !(pindexBIP34height->GetBlockHash() == m_params.GetConsensus().BIP34Hash)); - - if (fEnforceBIP30) { + bool fEnforceBIP30 = !pindexBIP34height || !(pindexBIP34height->GetBlockHash() == m_params.GetConsensus().BIP34Hash); + if (fEnforceBIP30 && pindex->nHeight <= m_params.GetConsensus().BIP34Height) { for (const auto& tx : block.vtx) { for (size_t o = 0; o < tx->vout.size(); o++) { if (view.HaveCoin(COutPoint(tx->GetHash(), o))) { @@ -6157,15 +6138,3 @@ ChainstateManager::~ChainstateManager() i.clear(); } } - -bool IsBIP30Repeat(const CBlockIndex& block_index) -{ - return (block_index.nHeight==91842 && block_index.GetBlockHash() == uint256S("0x00000000000a4d0a398161ffc163c503763b1f4360639393e0e4c8e300e0caec")) || - (block_index.nHeight==91880 && block_index.GetBlockHash() == uint256S("0x00000000000743f190a18c5577a3c2d2a1f610ae9601ac046a38084ccb7cd721")); -} - -bool IsBIP30Unspendable(const CBlockIndex& block_index) -{ - return (block_index.nHeight==91722 && block_index.GetBlockHash() == uint256S("0x00000000000271a2dc26e7667f8419f2e15416dc6955e5a6c6cdf3f2574dd08e")) || - (block_index.nHeight==91812 && block_index.GetBlockHash() == uint256S("0x00000000000af0aed4792b1acee3d966af36cf5def14935db8de83d6f9306f2f")); -} diff --git a/src/validation.h b/src/validation.h index 666773994265..ff36f0aeaf47 100644 --- a/src/validation.h +++ b/src/validation.h @@ -1126,10 +1126,4 @@ bool LoadMempool(CTxMemPool& pool, CChainState& active_chainstate, FopenFn mocka */ const AssumeutxoData* ExpectedAssumeutxo(const int height, const CChainParams& params); -/** Identifies blocks that overwrote an existing coinbase output in the UTXO set (see BIP30) */ -bool IsBIP30Repeat(const CBlockIndex& block_index); - -/** Identifies blocks which coinbase output was subsequently overwritten in the UTXO set (see BIP30) */ -bool IsBIP30Unspendable(const CBlockIndex& block_index); - #endif // BITCOIN_VALIDATION_H From ba09ca3d5ddcc7dbe6eaabf289918015f0ecbf42 Mon Sep 17 00:00:00 2001 From: Konstantin Akimov Date: Mon, 25 May 2026 13:44:16 +0700 Subject: [PATCH 4/4] test: remove unused scan-pre-bip34-coinbases.py once fix is merged --- contrib/devtools/scan-pre-bip34-coinbases.py | 199 ------------------- 1 file changed, 199 deletions(-) delete mode 100755 contrib/devtools/scan-pre-bip34-coinbases.py diff --git a/contrib/devtools/scan-pre-bip34-coinbases.py b/contrib/devtools/scan-pre-bip34-coinbases.py deleted file mode 100755 index 10473c5687bd..000000000000 --- a/contrib/devtools/scan-pre-bip34-coinbases.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 The Dash Core developers -# Distributed under the MIT software license, see the accompanying -# file COPYING or http://www.opensource.org/licenses/mit-license.php. -"""Scan pre-BIP34 coinbases for future BIP30-collision targets. - -Background ----------- -BIP34 ("height in coinbase") does not, by itself, fully imply BIP30 -("no duplicate txid"). A pre-BIP34 coinbase had a free-form scriptSig. -If the leading bytes of that scriptSig happen to form a minimal -CScriptNum encoding of some height H > BIP34Height, then at height H a -miner can produce a BIP34-conformant coinbase whose entire transaction -is byte-identical to the historical pre-BIP34 one -- a duplicate txid. - -Bitcoin Core handles this with BIP34_IMPLIES_BIP30_LIMIT = 1,983,702, -derived from an exhaustive scan of its own pre-BIP34 coinbases. Dash -carries no such constant. Dash mainnet's pre-BIP34 window is heights -1..950; testnet's is 1..75. - -This script asks dashd over RPC for each coinbase in the pre-BIP34 -range, decodes the leading scriptSig push as a minimal CScriptNum, -and reports every "indicated height" that is greater than the chain's -BIP34Height. Each such height is a candidate future collision target. - -Authentication uses the dashd cookie at //.cookie. -Pass --rpcuser / --rpcpassword as an explicit override. - -Empty output for mainnet implies the BIP30 enforcement machinery in -validation.cpp is dead code on Dash and can be removed. -""" - -import argparse -import base64 -import json -import os -import sys -import urllib.request - - -# Per-network defaults: (BIP34Height, default RPC port, on-disk chain folder name). -NETWORK_BIP34 = { - "main": {"bip34": 951, "port": 9998, "chain": ""}, - "test": {"bip34": 76, "port": 19998, "chain": "testnet3"}, -} - - -def read_auth_cookie(datadir, chain_folder): - """Read //.cookie -> (user, password).""" - cookie_path = os.path.join(datadir, chain_folder, ".cookie") - try: - with open(cookie_path, "r", encoding="ascii") as f: - user, password = f.read().split(":", 1) - except OSError as e: - raise SystemExit(f"Cannot read cookie at {cookie_path}: {e}. Pass --rpcuser/--rpcpassword or --datadir.") - return user, password - - -def rpc(url, user, password, method, params): - req = urllib.request.Request( - url, - data=json.dumps({"jsonrpc": "1.0", "id": "scan", "method": method, "params": params}).encode(), - headers={"Content-Type": "application/json"}, - ) - token = base64.b64encode(f"{user}:{password}".encode()).decode() - req.add_header("Authorization", f"Basic {token}") - try: - with urllib.request.urlopen(req) as resp: - payload = resp.read() - except urllib.error.HTTPError as e: - # dashd returns HTTP 500 for JSON-RPC application errors; body still has the error JSON. - payload = e.read() or b"" - try: - body = json.loads(payload) - except ValueError: - raise RuntimeError(f"{method} {params}: HTTP {e.code} {e.reason} (body: {payload!r})") - raise RuntimeError(f"{method} {params}: {body.get('error', body)}") - body = json.loads(payload) - if body.get("error") is not None: - raise RuntimeError(f"{method} {params}: {body['error']}") - return body["result"] - - -def decode_leading_bip34_height(scriptsig): - """Interpret the leading bytes of scriptsig as a *minimal* CScriptNum push. - - Returns the indicated height, or None if the prefix is not a valid minimal - encoding (in which case no future BIP34 block could byte-match this - coinbase, and there is no collision risk). - """ - if not scriptsig: - return None - b = scriptsig[0] - # OP_0 -> 0 - if b == 0x00: - return 0 - # OP_1NEGATE -> -1 - if b == 0x4f: - return -1 - # OP_1..OP_16 -> 1..16 - if 0x51 <= b <= 0x60: - return b - 0x50 - # Direct push of 1..75 bytes - if 1 <= b <= 75: - n = b - if len(scriptsig) < 1 + n: - return None - payload = scriptsig[1 : 1 + n] - # Minimality: a 1-byte payload that would fit an OP_N is non-minimal. - if n == 1 and (payload[0] == 0 or 1 <= payload[0] <= 16 or payload[0] == 0x81): - return None - # Minimality: the top byte must carry information (sign bit or magnitude). - if (payload[-1] & 0x7f) == 0 and (n < 2 or (payload[-2] & 0x80) == 0): - return None - # Decode sign-magnitude little-endian - val = 0 - for i, byte in enumerate(payload): - val |= byte << (8 * i) - sign_bit = 0x80 << (8 * (n - 1)) - if val & sign_bit: - val = -(val & ~sign_bit) - return val - # OP_PUSHDATA1/2/4 are not minimal forms for small heights, skip. - return None - - -def main(): - p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - p.add_argument("--network", default="main", choices=NETWORK_BIP34.keys()) - p.add_argument("--datadir", default=os.path.expanduser("~/.dashcore"), help="dashd datadir") - p.add_argument("--rpchost", default="127.0.0.1") - p.add_argument("--rpcport", type=int, default=None, help="default: per-network (9998/19998/...)") - p.add_argument("--rpcuser", default=None, help="override; otherwise read from .cookie") - p.add_argument("--rpcpassword", default=None) - p.add_argument("--start", type=int, default=1) - p.add_argument("--end", type=int, default=None, help="default: BIP34Height - 1 for the chosen network") - p.add_argument("--verbose", action="store_true", help="print every block's decoded height") - args = p.parse_args() - - net = NETWORK_BIP34[args.network] - bip34_height = net["bip34"] - end = args.end if args.end is not None else bip34_height - 1 - if end < args.start: - print(f"Nothing to scan: BIP34Height={bip34_height}, range [{args.start}, {end}] is empty.") - return 0 - - if args.rpcuser and args.rpcpassword: - user, password = args.rpcuser, args.rpcpassword - else: - user, password = read_auth_cookie(args.datadir, net["chain"]) - port = args.rpcport if args.rpcport is not None else net["port"] - url = f"http://{args.rpchost}:{port}/" - print(f"Scanning {args.network} heights {args.start}..{end} (BIP34Height={bip34_height}) @ {url}", file=sys.stderr) - - candidates = [] # (height, indicated_H, coinbase_hex) -- indicated_H > BIP34Height - unparseable = [] # (height, coinbase_hex) -- decoder rejected as non-minimal - for h in range(args.start, end + 1): - block_hash = rpc(url, user, password, "getblockhash", [h]) - block = rpc(url, user, password, "getblock", [block_hash, 2]) - cb_hex = block["tx"][0]["vin"][0]["coinbase"] - indicated = decode_leading_bip34_height(bytes.fromhex(cb_hex)) - if indicated is None: - unparseable.append((h, cb_hex)) - elif indicated > bip34_height: - candidates.append((h, indicated, cb_hex)) - if args.verbose: - label = "non-minimal" if indicated is None else f"indicated={indicated}" - print(f" h={h:5d} {label:18} scriptSig={cb_hex}") - - print() - print(f"Scanned {end - args.start + 1} blocks (heights {args.start}..{end}, BIP34Height={bip34_height}).") - print(f" {len(candidates)} candidate future-collision targets (indicated height > BIP34Height).") - print(f" {len(unparseable)} scriptSigs not minimally encoding any integer.") - print(f" These are safe ONLY IF the decoder above is exhaustive -- please eyeball:") - for h, cb_hex in unparseable: - print(f" h={h:6d} scriptSig={cb_hex}") - - if candidates: - print() - print(f" {'pre-BIP34 h':>12} {'indicated H':>12} coinbase scriptSig (full hex)") - for h, indicated, cb_hex in candidates: - print(f" {h:>12} {indicated:>12} {cb_hex}") - print() - print("RESULT: Each row above is a candidate future-collision target. At indicated") - print(" height H, a miner could in principle re-mine the pre-BIP34 coinbase") - print(" verbatim (subject to subsidy/fee feasibility) and produce a duplicate") - print(" txid. Either keep BIP30 enforcement, audit each H individually, or") - print(" define a Dash BIP34_IMPLIES_BIP30_LIMIT above the largest indicated H.") - return 1 - - print() - print("RESULT: No pre-BIP34 coinbase has a leading minimal CScriptNum > BIP34Height.") - print(" Assuming the decoder is correct (verify against the unparseable list),") - print(" BIP30 enforcement is unreachable on this chain.") - return 0 - - -if __name__ == "__main__": - sys.exit(main())