diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,4 +31,6 @@ add_subdirectory(src) add_subdirectory(test) + +add_subdirectory(contrib/devtools) add_subdirectory(doc) diff --git a/Makefile.am b/Makefile.am --- a/Makefile.am +++ b/Makefile.am @@ -10,7 +10,7 @@ export PYTHONPATH -.PHONY: deploy FORCE +.PHONY: deploy check-devtools FORCE GZIP_ENV="-9n" @@ -273,3 +273,7 @@ clean-local: rm -rf coverage_percent.txt test_bitcoin.coverage/ total.coverage/ test/tmp/ cache/ $(OSX_APP) rm -rf test/functional/__pycache__ + +check-devtools: + @echo "Running devtools tests..." + @cd $(top_srcdir)/contrib/devtools/chainparams && ./test_make_chainparams.py diff --git a/contrib/devtools/CMakeLists.txt b/contrib/devtools/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/contrib/devtools/CMakeLists.txt @@ -0,0 +1,3 @@ +# Copyright (c) 2019 The Bitcoin developers + +add_subdirectory(chainparams) diff --git a/contrib/devtools/chainparams/CMakeLists.txt b/contrib/devtools/chainparams/CMakeLists.txt new file mode 100644 --- /dev/null +++ b/contrib/devtools/chainparams/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (c) 2019 The Bitcoin developers + +add_custom_target(check-devtools + WORKING_DIRECTORY + ${CMAKE_CURRENT_SOURCE_DIR} + COMMAND + ./test_make_chainparams.py +) + +add_dependencies(check-all check-devtools) diff --git a/contrib/devtools/chainparams/make_chainparams.py b/contrib/devtools/chainparams/make_chainparams.py new file mode 100755 --- /dev/null +++ b/contrib/devtools/chainparams/make_chainparams.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +# Copyright (c) 2019 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from enum import Enum +import argparse +import os.path +import re +import sys + +sys.path.append('../../../test/functional/test_framework') +from authproxy import AuthServiceProxy + + +class Chain(Enum): + MainNet = "MAINNET" + TestNet = "TESTNET" + + +def get_chainparams(rpc_caller, block): + # Fetch initial chain info + chaininfo = rpc_caller.getblockchaininfo() + if chaininfo['chain'] == 'main': + chain = Chain.MainNet + else: + chain = Chain.TestNet + + # Use highest valid chainwork. This doesn't need to match the block hash + # used by assume valid. + chainwork = chaininfo['chainwork'] + if not re.match('^[0-9a-z]{64}$', chainwork): + raise Exception("Chain work is not a valid uint256 hex value.") + + # Default to N blocks from the chain tip, depending on which chain we're on + if not block: + block = chaininfo['blocks'] + if chain == Chain.MainNet: + block -= 10 + else: + block -= 2000 + + block = str(block) + if not re.match('^[0-9a-z]{64}$', block): + if re.match('^[0-9]*$', block): + # Fetch block hash using block height + block = rpc_caller.getblockhash(int(block)) + else: + raise Exception("Block hash is not a valid block hash or height.") + + # Make sure the block hash is part of the chain. This call with raise an + # exception if not. + rpc_caller.getblockheader(block) + + return (chainwork, block) + + +def main(args): + (chainwork, blockhash) = get_chainparams(args['rpc'], args['block']) + output = "{}\n{}\n".format(blockhash, chainwork) + return output + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=( + "Make chainparams file." + "Prerequisites: RPC access to a bitcoind node.\n\n"), + formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument('--address', '-a', default="127.0.0.1:8332", + help="Node address for making RPC calls.\n" + "The chain (MainNet or TestNet) will be automatically detected.\n" + "Default: '127.0.0.1:8332'") + parser.add_argument('--block', '-b', + help="The block hash or height to use for fetching chainparams.\n" + "MainNet default: 10 blocks from the chain tip." + "TestNet default: 2000 blocks from the chain tip.") + parser.add_argument('--config', '-c', default="~/.bitcoin/bitcoin.conf", + help="Path to bitcoin.conf for RPC authentication arguments (rpcuser & rpcpassword).\n" + "Default: ~/.bitcoin/bitcoin.conf") + args = parser.parse_args() + args.config = os.path.expanduser(args.config) + + # Get user and password from config + user = None + password = None + if os.path.isfile(args.config): + with open(args.config, 'r', encoding='utf8') as f: + for line in f: + if line.startswith("rpcuser="): + # Ensure that there is only one rpcuser line + assert user is None + user = line.split("=")[1].strip("\n") + if line.startswith("rpcpassword="): + # Ensure that there is only one rpcpassword line + assert password is None + password = line.split("=")[1].strip("\n") + else: + raise FileNotFoundError("Missing bitcoin.conf") + if user is None: + raise ValueError("Config is missing rpcuser") + if password is None: + raise ValueError("Config is missing rpcpassword") + + args.rpc = AuthServiceProxy( + 'http://{}:{}@{}'.format(user, password, args.address)) + output = main(vars(args)) + if output: + print(output) diff --git a/contrib/devtools/chainparams/test_make_chainparams.py b/contrib/devtools/chainparams/test_make_chainparams.py new file mode 100755 --- /dev/null +++ b/contrib/devtools/chainparams/test_make_chainparams.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +# Copyright (c) 2019 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import unittest + +from make_chainparams import main as GenerateChainParams + + +class MockRPC: + def __init__(self, test, chain, numBlocks, expectedBlock, blockHash, chainWork): + self.test = test + self.chain = chain + self.numBlocks = numBlocks + self.expectedBlock = expectedBlock + self.blockHash = blockHash + self.chainWork = chainWork + + def getblockchaininfo(self): + return { + "chain": self.chain, + "blocks": self.numBlocks, + "headers": self.numBlocks, + "bestblockhash": "0000000000000000039c96605d7fca74a5e185ea5634198346e9e07fd235b666", + "difficulty": 274412074285.6605, + "mediantime": 1562168718, + "verificationprogress": 0.9999958005632363, + "initialblockdownload": False, + "chainwork": self.chainWork, + "size_on_disk": 952031444, + "pruned": True, + "pruneheight": 582974, + "automatic_pruning": True, + "prune_target_size": 1048576000, + } + + def getblockhash(self, block): + # Tests should always request the right block height. Even though a + # real node will rarely raise an exception for this call, we are + # more strict during testing. + self.test.assertEqual(block, self.expectedBlock, "Called 'getblockhash {}' when expected was 'getblockhash {}'".format( + block, self.expectedBlock)) + return self.blockHash + + def getblockheader(self, blockHash): + # Make sure to raise an exception in the same way a real node would + # when calling 'getblockheader' on a block hash that is not part of + # the chain. + self.test.assertEqual(blockHash, self.blockHash, "Called 'getblockheader {}' when expected was 'getblockheader {}'".format( + blockHash, self.blockHash)) + return { + "hash": blockHash, + "confirmations": 1, + "height": 591463, + "version": 536870912, + "versionHex": "20000000", + "merkleroot": "51c898f034b6c5a5513a7c35912e86d009188311e550bb3096e04afb11f40aba", + "time": 1563212034, + "mediantime": 1563208994, + "nonce": 3501699724, + "bits": "18040cd6", + "difficulty": 271470800310.0635, + "chainwork": "000000000000000000000000000000000000000000f4c5e639fa012518a48a57", + "previousblockhash": "00000000000000000307b45e4a6cf8d49e70b9012ea1d72a5ce334a4213f66bd", + } + + +class MockFailRPC(MockRPC): + # Provides a fail counter to fail after the Nth RPC command + + def __init__(self, test, chain, numBlocks, expectedBlock, blockHash, chainWork, failCounter): + super().__init__(test, chain, numBlocks, expectedBlock, blockHash, chainWork) + self.failCounter = failCounter + + def checkFailCounter(self): + self.failCounter -= 1 + if self.failCounter < 0: + raise Exception("""error code: -99 + error message: + mock error""") + + def getblockchaininfo(self): + self.checkFailCounter() + return super().getblockchaininfo() + + def getblockhash(self, block): + self.checkFailCounter() + return super().getblockhash(block) + + def getblockheader(self, blockHash): + self.checkFailCounter() + return super().getblockheader(blockHash) + + +def CheckMockFailure(test, args, errorMessage='error code: -99'): + with test.assertRaises(Exception) as context: + GenerateChainParams(args) + test.assertIn(errorMessage, str(context.exception)) + + +class GenerateChainParamsTests(unittest.TestCase): + maxDiff = None + + def setUp(self): + self.blockHash1 = '0000000000000000003ef673ae12bc6017481830d37b9c52ce1e79c080e812b8' + self.chainWork1 = '000000000000000000000000000000000000000000f2537ccf2e07bbe15e70e1' + self.blockHash2 = '0000000000000298a9fa227f0ec32f2b7585f3e64c8b3369e7f8b4fd8ea3d836' + self.chainWork2 = '00000000000000000000000000000000000000000000004fdb4795a837f19671' + + def test_happy_path_mainnet(self): + mockRPC = MockRPC(test=self, chain='main', numBlocks=123000, + expectedBlock=122990, blockHash=self.blockHash1, chainWork=self.chainWork1) + args = { + 'rpc': mockRPC, + 'block': None, + } + self.assertEqual(GenerateChainParams(args), "{}\n{}\n".format( + "0000000000000000003ef673ae12bc6017481830d37b9c52ce1e79c080e812b8", + "000000000000000000000000000000000000000000f2537ccf2e07bbe15e70e1")) + + def test_happy_path_testnet(self): + mockRPC = MockRPC(test=self, chain='test', numBlocks=234000, + expectedBlock=232000, blockHash=self.blockHash1, chainWork=self.chainWork1) + args = { + 'rpc': mockRPC, + 'block': None, + } + self.assertEqual(GenerateChainParams(args), "{}\n{}\n".format( + "0000000000000000003ef673ae12bc6017481830d37b9c52ce1e79c080e812b8", + "000000000000000000000000000000000000000000f2537ccf2e07bbe15e70e1")) + + def test_specific_block(self): + mockRPC = MockRPC(test=self, chain='main', numBlocks=123000, + expectedBlock=122990, blockHash=self.blockHash1, chainWork=self.chainWork1) + args = { + 'rpc': mockRPC, + 'block': self.blockHash1, + } + self.assertEqual(GenerateChainParams(args), "{}\n{}\n".format( + "0000000000000000003ef673ae12bc6017481830d37b9c52ce1e79c080e812b8", + "000000000000000000000000000000000000000000f2537ccf2e07bbe15e70e1")) + + def test_wrong_chain(self): + mockRPC = MockRPC(test=self, chain='main', numBlocks=123000, + expectedBlock=122990, blockHash=self.blockHash1, chainWork=self.chainWork1) + args = { + 'rpc': mockRPC, + 'block': self.blockHash2, + } + CheckMockFailure( + self, args, "expected was 'getblockheader 0000000000000000003ef673ae12bc6017481830d37b9c52ce1e79c080e812b8'") + + def test_bitcoin_cli_failures_testnet(self): + for chain in ['main', 'test']: + expectedBlock = 133990 + if chain == 'test': + expectedBlock = 132000 + + for failCounter in range(3): + mockFailRPC = MockFailRPC(test=self, chain=chain, numBlocks=134000, expectedBlock=expectedBlock, + blockHash=self.blockHash1, chainWork=self.chainWork1, failCounter=failCounter) + argsFail = { + 'rpc': mockFailRPC, + 'block': None, + } + CheckMockFailure(self, argsFail) + + +unittest.main()