436 lines
20 KiB
Python
436 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (c) 2019 The PIVX developers
|
|
# Distributed under the MIT software license, see the accompanying
|
|
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
|
# -*- coding: utf-8 -*-
|
|
|
|
from io import BytesIO
|
|
from struct import pack
|
|
from random import randint, choice
|
|
import time
|
|
|
|
from test_framework.authproxy import JSONRPCException
|
|
from test_framework.blocktools import create_coinbase, create_block
|
|
from test_framework.key import CECKey
|
|
from test_framework.messages import CTransaction, CTxOut, CTxIn, COIN, msg_block
|
|
from test_framework.mininode import network_thread_start
|
|
from test_framework.test_framework import BitcoinTestFramework
|
|
from test_framework.script import CScript, OP_CHECKSIG
|
|
from test_framework.util import hash256, bytes_to_hex_str, hex_str_to_bytes, connect_nodes_bi, p2p_port
|
|
|
|
from .util import TestNode, create_transaction, utxo_to_stakingPrevOuts, dir_size
|
|
''' -------------------------------------------------------------------------
|
|
Agrarian_FakeStakeTest CLASS ----------------------------------------------------
|
|
|
|
General Test Class to be extended by individual tests for each attack test
|
|
'''
|
|
class Agrarian_FakeStakeTest(BitcoinTestFramework):
|
|
|
|
def set_test_params(self):
|
|
''' Setup test environment
|
|
:param:
|
|
:return:
|
|
'''
|
|
self.setup_clean_chain = True
|
|
self.num_nodes = 1
|
|
self.extra_args = [['-staking=1', '-debug=net']]*self.num_nodes
|
|
|
|
|
|
def setup_network(self):
|
|
''' Can't rely on syncing all the nodes when staking=1
|
|
:param:
|
|
:return:
|
|
'''
|
|
self.setup_nodes()
|
|
for i in range(self.num_nodes - 1):
|
|
for j in range(i+1, self.num_nodes):
|
|
connect_nodes_bi(self.nodes, i, j)
|
|
|
|
def init_test(self):
|
|
''' Initializes test parameters
|
|
:param:
|
|
:return:
|
|
'''
|
|
title = "*** Starting %s ***" % self.__class__.__name__
|
|
underline = "-" * len(title)
|
|
self.log.info("\n\n%s\n%s\n%s\n", title, underline, self.description)
|
|
# Global Test parameters (override in run_test)
|
|
self.DEFAULT_FEE = 0.1
|
|
# Spam blocks to send in current test
|
|
self.NUM_BLOCKS = 30
|
|
|
|
# Setup the p2p connections and start up the network thread.
|
|
self.test_nodes = []
|
|
for i in range(self.num_nodes):
|
|
self.test_nodes.append(TestNode())
|
|
self.test_nodes[i].peer_connect('127.0.0.1', p2p_port(i))
|
|
|
|
network_thread_start() # Start up network handling in another thread
|
|
self.node = self.nodes[0]
|
|
|
|
# Let the test nodes get in sync
|
|
for i in range(self.num_nodes):
|
|
self.test_nodes[i].wait_for_verack()
|
|
|
|
|
|
def run_test(self):
|
|
''' Performs the attack of this test - run init_test first.
|
|
:param:
|
|
:return:
|
|
'''
|
|
self.description = ""
|
|
self.init_test()
|
|
return
|
|
|
|
|
|
|
|
def create_spam_block(self, hashPrevBlock, stakingPrevOuts, height, fStakeDoubleSpent=False, fZPoS=False, spendingPrevOuts={}):
|
|
''' creates a block to spam the network with
|
|
:param hashPrevBlock: (hex string) hash of previous block
|
|
stakingPrevOuts: ({COutPoint --> (int, int, int, str)} dictionary)
|
|
map outpoints (to be used as staking inputs) to amount, block_time, nStakeModifier, hashStake
|
|
height: (int) block height
|
|
fStakeDoubleSpent: (bool) spend the coinstake input inside the block
|
|
fZPoS: (bool) stake the block with zerocoin
|
|
spendingPrevOuts: ({COutPoint --> (int, int, int, str)} dictionary)
|
|
map outpoints (to be used as tx inputs) to amount, block_time, nStakeModifier, hashStake
|
|
:return block: (CBlock) generated block
|
|
'''
|
|
|
|
# If not given inputs to create spam txes, use a copy of the staking inputs
|
|
if len(spendingPrevOuts) == 0:
|
|
spendingPrevOuts = dict(stakingPrevOuts)
|
|
|
|
# Get current time
|
|
current_time = int(time.time())
|
|
nTime = current_time & 0xfffffff0
|
|
|
|
# Create coinbase TX
|
|
# Even if PoS blocks have empty coinbase vout, the height is required for the vin script
|
|
coinbase = create_coinbase(height)
|
|
coinbase.vout[0].nValue = 0
|
|
coinbase.vout[0].scriptPubKey = b""
|
|
coinbase.nTime = nTime
|
|
coinbase.rehash()
|
|
|
|
# Create Block with coinbase
|
|
block = create_block(int(hashPrevBlock, 16), coinbase, nTime)
|
|
|
|
# Find valid kernel hash - Create a new private key used for block signing.
|
|
if not block.solve_stake(stakingPrevOuts):
|
|
raise Exception("Not able to solve for any prev_outpoint")
|
|
|
|
# Sign coinstake TX and add it to the block
|
|
signed_stake_tx = self.sign_stake_tx(block, stakingPrevOuts[block.prevoutStake][0], fZPoS)
|
|
block.vtx.append(signed_stake_tx)
|
|
|
|
# Remove coinstake input prevout unless we want to try double spending in the same block.
|
|
# Skip for zPoS as the spendingPrevouts are just regular UTXOs
|
|
if not fZPoS and not fStakeDoubleSpent:
|
|
del spendingPrevOuts[block.prevoutStake]
|
|
|
|
# remove a random prevout from the list
|
|
# (to randomize block creation if the same height is picked two times)
|
|
if len(spendingPrevOuts) > 0:
|
|
del spendingPrevOuts[choice(list(spendingPrevOuts))]
|
|
|
|
# Create spam for the block. Sign the spendingPrevouts
|
|
for outPoint in spendingPrevOuts:
|
|
value_out = int(spendingPrevOuts[outPoint][0] - self.DEFAULT_FEE * COIN)
|
|
tx = create_transaction(outPoint, b"", value_out, nTime, scriptPubKey=CScript([self.block_sig_key.get_pubkey(), OP_CHECKSIG]))
|
|
# sign txes
|
|
signed_tx_hex = self.node.signrawtransaction(bytes_to_hex_str(tx.serialize()))['hex']
|
|
signed_tx = CTransaction()
|
|
signed_tx.deserialize(BytesIO(hex_str_to_bytes(signed_tx_hex)))
|
|
block.vtx.append(signed_tx)
|
|
|
|
# Get correct MerkleRoot and rehash block
|
|
block.hashMerkleRoot = block.calc_merkle_root()
|
|
block.rehash()
|
|
|
|
# Sign block with coinstake key and return it
|
|
block.sign_block(self.block_sig_key)
|
|
return block
|
|
|
|
|
|
def spend_utxo(self, utxo, address_list):
|
|
''' spend amount from previously unspent output to a provided address
|
|
:param utxo: (JSON) returned from listunspent used as input
|
|
addresslist: (string) destination address
|
|
:return: txhash: (string) tx hash if successful, empty string otherwise
|
|
'''
|
|
try:
|
|
inputs = [{"txid":utxo["txid"], "vout":utxo["vout"]}]
|
|
out_amount = (float(utxo["amount"]) - self.DEFAULT_FEE)/len(address_list)
|
|
outputs = {}
|
|
for address in address_list:
|
|
outputs[address] = out_amount
|
|
spendingTx = self.node.createrawtransaction(inputs, outputs)
|
|
spendingTx_signed = self.node.signrawtransaction(spendingTx)
|
|
if spendingTx_signed["complete"]:
|
|
txhash = self.node.sendrawtransaction(spendingTx_signed["hex"])
|
|
return txhash
|
|
else:
|
|
self.log.warning("Error: %s" % str(spendingTx_signed["errors"]))
|
|
return ""
|
|
except JSONRPCException as e:
|
|
self.log.error("JSONRPCException: %s" % str(e))
|
|
return ""
|
|
|
|
|
|
def spend_utxos(self, utxo_list, address_list = []):
|
|
''' spend utxos to provided list of addresses or 10 new generate ones.
|
|
:param utxo_list: (JSON list) returned from listunspent used as input
|
|
address_list: (string list) [optional] recipient Agrarian addresses. if not set,
|
|
10 new addresses will be generated from the wallet for each tx.
|
|
:return: txHashes (string list) tx hashes
|
|
'''
|
|
txHashes = []
|
|
|
|
# If not given, get 10 new addresses from self.node wallet
|
|
if address_list == []:
|
|
for i in range(10):
|
|
address_list.append(self.node.getnewaddress())
|
|
|
|
for utxo in utxo_list:
|
|
try:
|
|
# spend current utxo to provided addresses
|
|
txHash = self.spend_utxo(utxo, address_list)
|
|
if txHash != "":
|
|
txHashes.append(txHash)
|
|
except JSONRPCException as e:
|
|
self.log.error("JSONRPCException: %s" % str(e))
|
|
continue
|
|
return txHashes
|
|
|
|
|
|
def stake_amplification_step(self, utxo_list, address_list = []):
|
|
''' spends a list of utxos providing the list of new outputs
|
|
:param utxo_list: (JSON list) returned from listunspent used as input
|
|
address_list: (string list) [optional] recipient Agrarian addresses.
|
|
:return: new_utxos: (JSON list) list of new (valid) inputs after the spends
|
|
'''
|
|
self.log.info("--> Stake Amplification step started with %d UTXOs", len(utxo_list))
|
|
txHashes = self.spend_utxos(utxo_list, address_list)
|
|
num_of_txes = len(txHashes)
|
|
new_utxos = []
|
|
if num_of_txes> 0:
|
|
self.log.info("Created %d transactions...Mining 2 blocks to include them..." % num_of_txes)
|
|
self.node.generate(2)
|
|
time.sleep(2)
|
|
new_utxos = self.node.listunspent()
|
|
|
|
self.log.info("Amplification step produced %d new \"Fake Stake\" inputs:" % len(new_utxos))
|
|
return new_utxos
|
|
|
|
|
|
|
|
def stake_amplification(self, utxo_list, iterations, address_list = []):
|
|
''' performs the "stake amplification" which gives higher chances at finding fake stakes
|
|
:param utxo_list: (JSON list) returned from listunspent used as input
|
|
iterations: (int) amount of stake amplification steps to perform
|
|
address_list: (string list) [optional] recipient Agrarian addresses.
|
|
:return: all_inputs: (JSON list) list of all spent inputs
|
|
'''
|
|
self.log.info("** Stake Amplification started with %d UTXOs", len(utxo_list))
|
|
valid_inputs = utxo_list
|
|
all_inputs = []
|
|
for i in range(iterations):
|
|
all_inputs = all_inputs + valid_inputs
|
|
old_inputs = valid_inputs
|
|
valid_inputs = self.stake_amplification_step(old_inputs, address_list)
|
|
self.log.info("** Stake Amplification ended with %d \"fake\" UTXOs", len(all_inputs))
|
|
return all_inputs
|
|
|
|
|
|
|
|
def sign_stake_tx(self, block, stake_in_value, fZPoS=False):
|
|
''' signs a coinstake transaction
|
|
:param block: (CBlock) block with stake to sign
|
|
stake_in_value: (int) staked amount
|
|
fZPoS: (bool) zerocoin stake
|
|
:return: stake_tx_signed: (CTransaction) signed tx
|
|
'''
|
|
self.block_sig_key = CECKey()
|
|
|
|
if fZPoS:
|
|
self.log.info("Signing zPoS stake...")
|
|
# Create raw zerocoin stake TX (signed)
|
|
raw_stake = self.node.createrawzerocoinstake(block.prevoutStake)
|
|
stake_tx_signed_raw_hex = raw_stake["hex"]
|
|
# Get stake TX private key to sign the block with
|
|
stake_pkey = raw_stake["private-key"]
|
|
self.block_sig_key.set_compressed(True)
|
|
self.block_sig_key.set_secretbytes(bytes.fromhex(stake_pkey))
|
|
|
|
else:
|
|
# Create a new private key and get the corresponding public key
|
|
self.block_sig_key.set_secretbytes(hash256(pack('<I', 0xffff)))
|
|
pubkey = self.block_sig_key.get_pubkey()
|
|
# Create the raw stake TX (unsigned)
|
|
scriptPubKey = CScript([pubkey, OP_CHECKSIG])
|
|
outNValue = int(stake_in_value + 2*COIN)
|
|
stake_tx_unsigned = CTransaction()
|
|
stake_tx_unsigned.nTime = block.nTime
|
|
stake_tx_unsigned.vin.append(CTxIn(block.prevoutStake))
|
|
stake_tx_unsigned.vin[0].nSequence = 0xffffffff
|
|
stake_tx_unsigned.vout.append(CTxOut())
|
|
stake_tx_unsigned.vout.append(CTxOut(outNValue, scriptPubKey))
|
|
# Sign the stake TX
|
|
stake_tx_signed_raw_hex = self.node.signrawtransaction(bytes_to_hex_str(stake_tx_unsigned.serialize()))['hex']
|
|
|
|
# Deserialize the signed raw tx into a CTransaction object and return it
|
|
stake_tx_signed = CTransaction()
|
|
stake_tx_signed.deserialize(BytesIO(hex_str_to_bytes(stake_tx_signed_raw_hex)))
|
|
return stake_tx_signed
|
|
|
|
|
|
def get_prevouts(self, utxo_list, blockHeight, zpos=False):
|
|
''' get prevouts (map) for each utxo in a list
|
|
:param utxo_list: <if zpos=False> (JSON list) utxos returned from listunspent used as input
|
|
<if zpos=True> (JSON list) mints returned from listmintedzerocoins used as input
|
|
blockHeight: (int) height of the previous block
|
|
zpos: (bool) type of utxo_list
|
|
:return: stakingPrevOuts: ({COutPoint --> (int, int, int, str)} dictionary)
|
|
map outpoints to amount, block_time, nStakeModifier, hashStake
|
|
'''
|
|
zerocoinDenomList = [1, 5, 10, 50, 100, 500, 1000, 5000]
|
|
stakingPrevOuts = {}
|
|
|
|
for utxo in utxo_list:
|
|
if zpos:
|
|
# get mint checkpoint
|
|
checkpointHeight = blockHeight - 200
|
|
checkpointBlock = self.node.getblock(self.node.getblockhash(checkpointHeight), True)
|
|
checkpoint = int(checkpointBlock['acc_checkpoint'], 16)
|
|
# parse checksum and get checksumblock
|
|
pos = zerocoinDenomList.index(utxo['denomination'])
|
|
checksum = (checkpoint >> (32 * (len(zerocoinDenomList) - 1 - pos))) & 0xFFFFFFFF
|
|
checksumBlock = self.node.getchecksumblock(hex(checksum), utxo['denomination'], True)
|
|
# get block hash and block time
|
|
txBlockhash = checksumBlock['hash']
|
|
txBlocktime = checksumBlock['time']
|
|
else:
|
|
# get raw transaction for current input
|
|
utxo_tx = self.node.getrawtransaction(utxo['txid'], 1)
|
|
# get block hash and block time
|
|
txBlocktime = utxo_tx['blocktime']
|
|
txBlockhash = utxo_tx['blockhash']
|
|
|
|
# get Stake Modifier
|
|
stakeModifier = int(self.node.getblock(txBlockhash)['modifier'], 16)
|
|
# assemble prevout object
|
|
utxo_to_stakingPrevOuts(utxo, stakingPrevOuts, txBlocktime, stakeModifier, zpos)
|
|
|
|
return stakingPrevOuts
|
|
|
|
|
|
|
|
def log_data_dir_size(self):
|
|
''' Prints the size of the '/regtest/blocks' directory.
|
|
:param:
|
|
:return:
|
|
'''
|
|
init_size = dir_size(self.node.datadir + "/regtest/blocks")
|
|
self.log.info("Size of data dir: %s kilobytes" % str(init_size))
|
|
|
|
|
|
|
|
def test_spam(self, name, staking_utxo_list,
|
|
fRandomHeight=False, randomRange=0, randomRange2=0,
|
|
fDoubleSpend=False, fMustPass=False, fZPoS=False,
|
|
spending_utxo_list=[]):
|
|
''' General method to create, send and test the spam blocks
|
|
:param name: (string) chain branch (usually either "Main" or "Forked")
|
|
staking_utxo_list: (string list) utxos to use for staking
|
|
fRandomHeight: (bool) send blocks at random height
|
|
randomRange: (int) if fRandomHeight=True, height is >= current-randomRange
|
|
randomRange2: (int) if fRandomHeight=True, height is < current-randomRange2
|
|
fDoubleSpend: (bool) if true, stake input is double spent in block.vtx
|
|
fMustPass: (bool) if true, the blocks must be stored on disk
|
|
fZPoS: (bool) stake the block with zerocoin
|
|
spending_utxo_list: (string list) utxos to use for spending
|
|
:return: err_msgs: (string list) reports error messages from the test
|
|
or an empty list if test is successful
|
|
'''
|
|
# Create empty error messages list
|
|
err_msgs = []
|
|
# Log initial datadir size
|
|
self.log_data_dir_size()
|
|
# Get latest block number and hash
|
|
block_count = self.node.getblockcount()
|
|
pastBlockHash = self.node.getblockhash(block_count)
|
|
randomCount = block_count
|
|
self.log.info("Current height: %d" % block_count)
|
|
for i in range(0, self.NUM_BLOCKS):
|
|
if i !=0:
|
|
self.log.info("Sent %d blocks out of %d" % (i, self.NUM_BLOCKS))
|
|
|
|
# if fRandomHeight=True get a random block number (in range) and corresponding hash
|
|
if fRandomHeight:
|
|
randomCount = randint(block_count - randomRange, block_count - randomRange2)
|
|
pastBlockHash = self.node.getblockhash(randomCount)
|
|
|
|
# Get spending prevouts and staking prevouts for the height of current block
|
|
current_block_n = randomCount + 1
|
|
stakingPrevOuts = self.get_prevouts(staking_utxo_list, randomCount, zpos=fZPoS)
|
|
spendingPrevOuts = self.get_prevouts(spending_utxo_list, randomCount)
|
|
|
|
# Create the spam block
|
|
block = self.create_spam_block(pastBlockHash, stakingPrevOuts, current_block_n,
|
|
fStakeDoubleSpent=fDoubleSpend, fZPoS=fZPoS, spendingPrevOuts=spendingPrevOuts)
|
|
|
|
# Log time and size of the block
|
|
block_time = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(block.nTime))
|
|
block_size = len(block.serialize())/1000
|
|
self.log.info("Sending block %d [%s...] - nTime: %s - Size (kb): %.2f",
|
|
current_block_n, block.hash[:7], block_time, block_size)
|
|
|
|
# Try submitblock
|
|
var = self.node.submitblock(bytes_to_hex_str(block.serialize()))
|
|
time.sleep(1)
|
|
if (not fMustPass and var not in [None, "bad-txns-invalid-zagr"]) or (fMustPass and var != "inconclusive"):
|
|
self.log.error("submitblock [fMustPass=%s] result: %s" % (str(fMustPass), str(var)))
|
|
err_msgs.append("submitblock %d: %s" % (current_block_n, str(var)))
|
|
|
|
# Try sending the message block
|
|
msg = msg_block(block)
|
|
try:
|
|
self.test_nodes[0].handle_connect()
|
|
self.test_nodes[0].send_message(msg)
|
|
time.sleep(2)
|
|
block_ret = self.node.getblock(block.hash)
|
|
if not fMustPass and block_ret is not None:
|
|
self.log.error("Error, block stored in %s chain" % name)
|
|
err_msgs.append("getblock %d: result not None" % current_block_n)
|
|
if fMustPass:
|
|
if block_ret is None:
|
|
self.log.error("Error, block NOT stored in %s chain" % name)
|
|
err_msgs.append("getblock %d: result is None" % current_block_n)
|
|
else:
|
|
self.log.info("Good. Block IS stored on disk.")
|
|
|
|
except JSONRPCException as e:
|
|
exc_msg = str(e)
|
|
if exc_msg == "Can't read block from disk (-32603)":
|
|
if fMustPass:
|
|
self.log.warning("Bad! Block was NOT stored to disk.")
|
|
err_msgs.append(exc_msg)
|
|
else:
|
|
self.log.info("Good. Block was not stored on disk.")
|
|
else:
|
|
self.log.warning(exc_msg)
|
|
err_msgs.append(exc_msg)
|
|
|
|
except Exception as e:
|
|
exc_msg = str(e)
|
|
self.log.error(exc_msg)
|
|
err_msgs.append(exc_msg)
|
|
|
|
|
|
self.log.info("Sent all %s blocks." % str(self.NUM_BLOCKS))
|
|
# Log final datadir size
|
|
self.log_data_dir_size()
|
|
# Return errors list
|
|
return err_msgs
|