Page MenuHomePhabricator

No OneTemporary

diff --git a/test/functional/abc_p2p_avalanche_contender_voting.py b/test/functional/abc_p2p_avalanche_contender_voting.py
index f593e9f9c..f132bcbb0 100644
--- a/test/functional/abc_p2p_avalanche_contender_voting.py
+++ b/test/functional/abc_p2p_avalanche_contender_voting.py
@@ -1,781 +1,787 @@
# Copyright (c) 2024 The Bitcoin developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Test the resolution of stake contender preconsensus via avalanche."""
import math
import time
from test_framework.authproxy import JSONRPCException
from test_framework.avatools import (
AvaP2PInterface,
assert_response,
avalanche_proof_from_hex,
build_msg_avaproofs,
can_find_inv_in_poll,
create_coinbase_stakes,
gen_proof,
get_ava_p2p_interface,
get_proof_ids,
)
from test_framework.key import ECKey, ECPubKey
from test_framework.messages import (
MSG_AVA_STAKE_CONTENDER,
NODE_AVALANCHE,
NODE_NETWORK,
AvalancheContenderVoteError,
AvalancheDelegation,
AvalancheVote,
FromHex,
hash256,
ser_uint256,
)
from test_framework.p2p import p2p_lock
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import assert_equal, assert_greater_than, uint256_hex
from test_framework.wallet_util import bytes_to_wif
QUORUM_NODE_COUNT = 16
AVALANCHE_CLEANUP_INTERVAL = 5 * 60
AVALANCHE_MAX_PERIODIC_NETWORKING_INTERVAL = 5 * 60
class AvalancheContenderVotingTest(BitcoinTestFramework):
def set_test_params(self):
self.setup_clean_chain = True
self.num_nodes = 1
self.noban_tx_relay = True
self.extra_args = [
[
"-avalanchestakingpreconsensus=1",
"-avalanchestakingrewards=1",
"-avaproofstakeutxodustthreshold=1000000",
"-avaproofstakeutxoconfirmations=1",
"-avacooldown=0",
"-avaminquorumstake=0",
"-avaminavaproofsnodecount=0",
"-avastalevotethreshold=160",
"-avastalevotefactor=1",
"-simplegbt",
],
]
self.supports_cli = False
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
def run_test(self):
node = self.nodes[0]
# Set mock time so we can control when proofs will be considered for staking rewards
now = int(time.time())
node.setmocktime(now)
# Build a fake quorum of nodes.
def get_quorum(stake_utxo_confirmations=1, proof_data=None):
def new_ava_interface(node, i):
# Generate a unique payout script for each proof so we can accurately test the stake winners
if not proof_data:
payoutAddress = node.getnewaddress()
peer = get_ava_p2p_interface(
self,
node,
payoutAddress=payoutAddress,
stake_utxo_confirmations=stake_utxo_confirmations,
)
else:
assert_greater_than(len(proof_data), i)
peer = AvaP2PInterface()
peer.master_privkey = proof_data[i]["privkey"]
peer.proof = proof_data[i]["proof"]
assert node.verifyavalancheproof(peer.proof.serialize().hex())
delegation_hex = node.delegateavalancheproof(
uint256_hex(peer.proof.limited_proofid),
bytes_to_wif(peer.master_privkey.get_bytes()),
peer.delegated_privkey.get_pubkey().get_bytes().hex(),
)
assert node.verifyavalanchedelegation(delegation_hex)
peer.delegation = FromHex(AvalancheDelegation(), delegation_hex)
node.add_p2p_connection(
peer, services=NODE_NETWORK | NODE_AVALANCHE
)
peer.nodeid = node.getpeerinfo()[-1]["id"]
def avapeer_connected():
node_list = []
try:
node_list = node.getavalanchepeerinfo(
uint256_hex(peer.proof.proofid)
)[0]["node_list"]
except BaseException:
pass
return peer.nodeid in node_list
self.wait_until(avapeer_connected)
# This test depends on each proof being added to the contender cache before
# the next block arrives, so we wait until that happens.
blockhash = node.getbestblockhash()
self.wait_until(
lambda: node.getstakecontendervote(
blockhash, uint256_hex(peer.proof.proofid)
)
== AvalancheContenderVoteError.PENDING
)
return peer
return [new_ava_interface(node, i) for i in range(0, QUORUM_NODE_COUNT)]
# Pick one node from the quorum for polling.
quorum = get_quorum()
tip = node.getbestblockhash()
poll_node = quorum[0]
assert node.getavalancheinfo()["ready_to_poll"] is True
def has_finalized_proof(proofid):
- can_find_inv_in_poll(quorum, proofid)
+ can_find_inv_in_poll(
+ quorum,
+ proofid,
+ response_map={
+ MSG_AVA_STAKE_CONTENDER: AvalancheContenderVoteError.UNKNOWN
+ },
+ )
return node.getrawavalancheproof(uint256_hex(proofid))["finalized"]
for peer in quorum:
self.wait_until(lambda: has_finalized_proof(peer.proof.proofid))
# Get the key so we can verify signatures.
avakey = ECPubKey()
avakey.set(bytes.fromhex(node.getavalanchekey()))
def make_contender_id(prevblockhash, proofid):
return int.from_bytes(
hash256(ser_uint256(int(prevblockhash, 16)) + ser_uint256(proofid)),
"little",
)
# Unknown contender
unknown_contender_id = 0x123
poll_node.send_poll([unknown_contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(AvalancheContenderVoteError.UNKNOWN, unknown_contender_id)],
)
self.log.info("Check votes after contender promotion")
def proof_reward_rank(contender_id, proof_score):
return (256.0 - math.log2(contender_id)) / proof_score
def get_all_contender_ids(tip, proofs=None):
# Determine all possible contenders IDs for the given block.
# The first 12 (best scores) will be polled.
if not proofs:
proofs = [peer.proof for peer in quorum]
proofs_and_cids = [
(proof, make_contender_id(tip, proof.proofid)) for proof in proofs
]
proofs_and_cids = sorted(
proofs_and_cids,
key=lambda proof_and_cid: proof_reward_rank(
proof_and_cid[1], proof_and_cid[0].get_score()
),
)
return [proof_and_cid[1] for proof_and_cid in proofs_and_cids]
def vote_all_contenders(
winners, winnerVote=AvalancheContenderVoteError.ACCEPTED
):
for n in quorum:
poll = n.get_avapoll_if_available()
# That node has not received a poll
if poll is None:
continue
votes = []
for inv in poll.invs:
- r = AvalancheContenderVoteError.ACCEPTED
+ r = AvalancheContenderVoteError.UNKNOWN
# Only accept contenders that should be winners
if inv.type == MSG_AVA_STAKE_CONTENDER:
r = (
winnerVote
if inv.hash in winners
else AvalancheContenderVoteError.INVALID
)
votes.append(AvalancheVote(r, inv.hash))
n.send_avaresponse(poll.round, votes, n.delegated_privkey)
def finalize_contenders(tip, winner_contenders):
loser_contenders = get_all_contender_ids(tip)[:12]
for winner in winner_contenders:
loser_contenders.remove(winner)
with node.wait_for_debug_log(
[
f"Avalanche finalized contender {uint256_hex(cid)}".encode()
for cid in winner_contenders
]
+ [
f"Avalanche invalidated contender {uint256_hex(cid)}".encode()
for cid in loser_contenders
],
chatty_callable=lambda: vote_all_contenders(winner_contenders),
):
pass
# Some contenders may have been finalized already while finalizing the proofs.
# Mine a block to trigger contender promotion and start from a clean slate.
tip = self.generate(node, 1)[0]
# Finalize any contenders that might have been polled since the quorum became active
# so we do not have any unanswered polls before calling find_polled_contenders.
finalize_contenders(tip, [])
# Mining a block will promote contenders to the new block
tip = self.generate(node, 1)[0]
# Unknown contender is still unknown
poll_node.send_poll([unknown_contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(AvalancheContenderVoteError.UNKNOWN, unknown_contender_id)],
)
# All contenders are pending. They cannot be winners yet since mock time
# has not advanced past the staking rewards minimum registration delay.
for contender_id in get_all_contender_ids(tip):
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(AvalancheContenderVoteError.PENDING, contender_id)],
)
def find_polled_contenders(local_winner_contender_id=None):
# Answer polls until contenders start polling
for n in quorum:
poll = n.get_avapoll_if_available()
if poll is None:
continue
votes = []
polled_contenders = []
for inv in poll.invs:
votes.append(
AvalancheVote(AvalancheContenderVoteError.ACCEPTED, inv.hash)
)
if inv.type == MSG_AVA_STAKE_CONTENDER:
polled_contenders.append(inv.hash)
n.send_avaresponse(poll.round, votes, n.delegated_privkey)
if local_winner_contender_id:
# Local winner must be polled
if local_winner_contender_id not in polled_contenders:
return False
# Max number of contenders was polled
if len(polled_contenders) == 12:
return True
return False
# Contenders get polled even though there is no local staking reward winner yet.
# This helps in the case that the local winner fails to compute, but the network
# can still finalize a winner. For example, a poorly connected node could have
# proofs go dangling and then come back, but their registration times would be
# too early to be selected for staking rewards for a short time.
self.wait_until(lambda: find_polled_contenders())
# Finalize contenders so we do not have any unanswered polls before calling find_polled_contenders again
finalize_contenders(tip, [])
self.log.info("Check votes after staking rewards have been computed")
# Advance time past the staking rewards minimum registration delay and
# mine a block.
now += 90 * 60 + 1
node.setmocktime(now)
tip = self.generate(node, 1)[0]
# Staking rewards has been computed. Check vote for all contenders.
contenders = get_all_contender_ids(tip)
staking_reward = node.getstakingreward(tip)
local_winner_payout_script = staking_reward[0]["hex"]
local_winner_proofid = int(staking_reward[0]["proofid"], 16)
local_winner_cid = make_contender_id(tip, local_winner_proofid)
poll_node.send_poll(contenders, inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[
AvalancheVote(
(
AvalancheContenderVoteError.ACCEPTED
if cid == local_winner_cid
else AvalancheContenderVoteError.INVALID
),
cid,
)
for cid in contenders
],
)
self.wait_until(lambda: find_polled_contenders(local_winner_cid))
# Manually set a winner that isn't the local winner
manual_winner = (
quorum[0].proof
if local_winner_proofid != quorum[0].proof.proofid
else quorum[1].proof
)
manual_winner_cid = make_contender_id(tip, manual_winner.proofid)
node.setstakingreward(tip, manual_winner.payout_script.hex())
poll_node.send_poll([manual_winner_cid], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(AvalancheContenderVoteError.ACCEPTED, manual_winner_cid)],
)
self.log.info("Vote on contenders: manual winner + local winner")
def check_stake_winners(
tip, exp_manual_winners, exp_accepted_winners, exp_rejected_winners
):
reward = node.getstakingreward(tip)
winners = []
for winner in reward:
winners.append((int(winner["proofid"], 16), winner["hex"]))
# Sort winners by rank, but manual winners are always first if they exist
exp_accepted_winners = sorted(
set(exp_accepted_winners),
key=lambda w: proof_reward_rank(make_contender_id(tip, w[0]), 5000),
)
exp_rejected_winners = sorted(
set(exp_rejected_winners),
key=lambda w: proof_reward_rank(make_contender_id(tip, w[0]), 5000),
)
exp_winners = (
exp_manual_winners + exp_accepted_winners + exp_rejected_winners
)
assert_equal(exp_winners, winners)
# Check gbt contains the best winner
gbt = node.getblocktemplate()
assert "stakingrewards" in gbt
assert_equal(gbt["stakingrewards"]["script"], exp_winners[0][1])
# Check poll statuses for sanity
poll_ids = []
expected = []
for w in exp_accepted_winners:
contender_id = make_contender_id(tip, w[0])
poll_ids.append(contender_id)
expected.append(
AvalancheVote(AvalancheContenderVoteError.ACCEPTED, contender_id)
)
for w in exp_rejected_winners:
contender_id = make_contender_id(tip, w[0])
poll_ids.append(contender_id)
expected.append(
AvalancheVote(AvalancheContenderVoteError.INVALID, contender_id)
)
poll_node.send_poll(poll_ids, inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(poll_node, avakey, expected)
# Manual winner should already be a winner even though it isn't finalized
check_stake_winners(tip, [(0, manual_winner.payout_script.hex())], [], [])
# Finalize the local winner and invalidate contender associated with
# the manual winner. Although we don't normally want to poll for manual
# winners, the polling was kicked off before the manual winner was set.
finalize_contenders(tip, [local_winner_cid])
check_stake_winners(
tip,
[(0, manual_winner.payout_script.hex())],
[(local_winner_proofid, local_winner_payout_script)],
[],
)
self.log.info("Vote on contenders: local winner only")
tip = self.generate(node, 1)[0]
staking_reward = node.getstakingreward(tip)
local_winner_payout_script = staking_reward[0]["hex"]
local_winner_proofid = int(staking_reward[0]["proofid"], 16)
local_winner_cid = make_contender_id(tip, local_winner_proofid)
# Local winner is the stake winner even though we haven't finalized it yet
check_stake_winners(
tip, [], [(local_winner_proofid, local_winner_payout_script)], []
)
finalize_contenders(tip, [local_winner_cid])
# Sanity check there are no other winners
check_stake_winners(
tip, [], [(local_winner_proofid, local_winner_payout_script)], []
)
for numWinners in range(1, 4):
self.log.info(
f"Vote on contenders: {numWinners} winner(s) other than local winner"
)
tip = self.generate(node, 1)[0]
staking_reward = node.getstakingreward(tip)
local_winner_payout_script = staking_reward[0]["hex"]
local_winner_proofid = int(staking_reward[0]["proofid"], 16)
local_winner_cid = make_contender_id(tip, local_winner_proofid)
# Local winner is the stake winner before we finalize
check_stake_winners(
tip, [], [(local_winner_proofid, local_winner_payout_script)], []
)
# Finalize some winners
contenders = get_all_contender_ids(tip)[:12]
contenders.remove(local_winner_cid)
finalize_contenders(tip, contenders[:numWinners])
# Sanity check the winners. The local winner remains even though it was invalidated, however it is sorted last.
winners = []
for winner_cid in contenders[:numWinners]:
proof = next(
(
peer.proof
for peer in quorum
if make_contender_id(tip, peer.proof.proofid) == winner_cid
)
)
winners.append((proof.proofid, proof.payout_script.hex()))
check_stake_winners(
tip, [], winners, [(local_winner_proofid, local_winner_payout_script)]
)
self.log.info("Vote on contenders: zero winners")
tip = self.generate(node, 1)[0]
staking_reward = node.getstakingreward(tip)
local_winner_payout_script = staking_reward[0]["hex"]
local_winner_proofid = int(staking_reward[0]["proofid"], 16)
# Local winner is the stake winner before we finalize
check_stake_winners(
tip, [], [(local_winner_proofid, local_winner_payout_script)], []
)
# Invalidate all contenders
finalize_contenders(tip, [])
# Local winner did not change
check_stake_winners(
tip, [], [], [(local_winner_proofid, local_winner_payout_script)]
)
self.log.info("Vote on contenders: stale contenders")
tip = self.generate(node, 1)[0]
staking_reward = node.getstakingreward(tip)
local_winner_payout_script = staking_reward[0]["hex"]
local_winner_proofid = int(staking_reward[0]["proofid"], 16)
# Local winner is the stake winner before we finalize
check_stake_winners(
tip, [], [(local_winner_proofid, local_winner_payout_script)], []
)
# Stale all contenders
contenders = get_all_contender_ids(tip)[:12]
with node.wait_for_debug_log(
[
f"Avalanche stalled contender {uint256_hex(cid)}".encode()
for cid in contenders
],
chatty_callable=lambda: vote_all_contenders(
contenders, AvalancheContenderVoteError.PENDING
),
):
pass
# Local winner did not change because it was not replaced with a finalized contender
check_stake_winners(
tip, [], [(local_winner_proofid, local_winner_payout_script)], []
)
self.log.info("Check votes after node restart")
# Build the proofs for the quorum so we don't mine any block after the
# restart, and make them mature. Note that the proof staked amount is
# only 45M XEC each because of the staking rewards in the blocks
# coinbase.
proof_data = []
for _ in range(QUORUM_NODE_COUNT):
payout_address = node.getnewaddress()
privkey, proof = gen_proof(self, node, payoutAddress=payout_address)
proof_data.append(
{
"privkey": privkey,
"proof": proof,
"payout_address": payout_address,
}
)
self.generate(node, 3)
tip_before_restart = node.getbestblockhash()
# From there the check_stake_winners function won't work as it assumes
# the staked amount is 50M XEC per proof
del check_stake_winners
# Restart the node. Persisted ava peers should be re-added to the cache.
self.restart_node(
0,
extra_args=self.extra_args[0]
+ [
# After restart we will have a new quorum worth 16 * 45M XEC,
# but also dangling proofs worth 16* 50M XEC due to avapeeers
# persistency
"-avaminquorumconnectedstakeratio=0.4",
"-avaproofstakeutxoconfirmations=3",
],
)
now = int(time.time())
node.setmocktime(now)
old_quorum = quorum
quorum = get_quorum(stake_utxo_confirmations=3, proof_data=proof_data)
poll_node = quorum[0]
assert node.getavalancheinfo()["ready_to_poll"] is True
# Make sure we mined no block since restarting
tip = node.getbestblockhash()
assert_equal(tip, tip_before_restart)
# Even though we haven't mined a block since restarting, contenders are
# immediately polled once quorum is established.
self.wait_until(lambda: find_polled_contenders())
for peer in quorum:
self.wait_until(lambda: has_finalized_proof(peer.proof.proofid))
def peer_has_getavaproofs():
with p2p_lock:
for peer in quorum:
if peer.message_count.get("getavaproofs", 0) > 0:
return peer
return None
# Trigger periodic request for getavaproofs
node.mockscheduler(AVALANCHE_MAX_PERIODIC_NETWORKING_INTERVAL)
self.wait_until(lambda: peer_has_getavaproofs() is not None)
# Send proofs to node so they are marked as remote and will be promoted. But skip
# the last proof so we can test that proofs missing promotion are re-added to the cache.
prefilled_proofs = sorted(
[peer.proof for peer in old_quorum[:-1]], key=lambda p: p.proofid
)
peer_with_getavaproofs = peer_has_getavaproofs()
peer_with_getavaproofs.send_message(
build_msg_avaproofs(prefilled_proofs, prefilled_proofs)
)
with p2p_lock:
# Reset the count so we don't pick this peer again unless it received another
# getavaproofs message.
peer_with_getavaproofs.message_count["getavaproofs"] = 0
# Get the key so we can verify signatures.
avakey = ECPubKey()
avakey.set(bytes.fromhex(node.getavalanchekey()))
# It is possible staking rewards are not ready depending if they were computed before or
# after proofs were finalized.
def expected_contender_poll_response(tip):
try:
if len(node.getstakingreward(tip)) > 0:
return AvalancheContenderVoteError.ACCEPTED
except JSONRPCException:
# An exception is thrown if staking rewards cannot be computed
pass
return AvalancheContenderVoteError.PENDING
expected_response = expected_contender_poll_response(tip)
# Sanity check that new quorum contenders can be polled even though we have not mined a block yet
for contender_id in get_all_contender_ids(tip):
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(expected_response, contender_id)],
)
# Proofs from the prior quorum that were persisted were loaded back into the contender cache
for contender_id in get_all_contender_ids(
tip, [p.proof for p in old_quorum[:-1]]
):
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(expected_response, contender_id)],
)
# Make proof dangling
now += 15 * 60 + 1
node.setmocktime(now)
node.mockscheduler(AVALANCHE_CLEANUP_INTERVAL)
self.wait_until(lambda: old_quorum[-1].proof.proofid not in get_proof_ids(node))
# Trigger contenders promotion
tip = self.generate(node, 1)[0]
expected_response = expected_contender_poll_response(tip)
# Check last proof was not promoted
contender_id = make_contender_id(tip, old_quorum[-1].proof.proofid)
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(AvalancheContenderVoteError.UNKNOWN, contender_id)],
)
# Sanity check
for contender_id in get_all_contender_ids(tip):
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(expected_response, contender_id)],
)
# All proofs from the prior quorum were promoted except the last
for contender_id in get_all_contender_ids(
tip, [p.proof for p in old_quorum[:-1]]
):
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(expected_response, contender_id)],
)
# Set last proof as remote
prefilled_proofs = [old_quorum[-1].proof]
peer_with_getavaproofs = peer_has_getavaproofs()
peer_with_getavaproofs.send_message(
build_msg_avaproofs(prefilled_proofs, prefilled_proofs)
)
with p2p_lock:
# Reset the count so we don't pick this peer again unless it received another
# getavaproofs message.
peer_with_getavaproofs.message_count["getavaproofs"] = 0
# Trigger contenders promotion
tip = self.generate(node, 1)[0]
expected_response = expected_contender_poll_response(tip)
# Sanity check
for contender_id in get_all_contender_ids(tip):
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(expected_response, contender_id)],
)
# All proofs from the prior quorum were promoted
for contender_id in get_all_contender_ids(tip, [p.proof for p in old_quorum]):
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(expected_response, contender_id)],
)
self.log.info("Check votes when immature proof matures")
# Build a valid but immature proof
addrkey0 = node.get_deterministic_priv_key()
stakes = create_coinbase_stakes(node, [tip], addrkey0.key)
privkey = ECKey()
privkey.generate()
immature_proof = avalanche_proof_from_hex(
node.buildavalancheproof(
0, 0, bytes_to_wif(privkey.get_bytes()), stakes, node.getnewaddress()
)
)
# Send the proof to node
peer_has_getavaproofs().send_message(
build_msg_avaproofs([immature_proof], [immature_proof])
)
def check_immature_proofs(immature_proofs):
return sorted(node.getavalancheproofs()["immature"]) == sorted(
immature_proofs
)
# Verify the proof is immature
self.wait_until(
lambda: check_immature_proofs([uint256_hex(immature_proof.proofid)])
)
# For the block where the immature proof was introduced, the proof's
# contender vote is unknown (contender id is not in the cache, and we
# can't check if the proofid is immature because polling does not reveal
# a contender's proofid.
contender_id = make_contender_id(tip, immature_proof.proofid)
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(AvalancheContenderVoteError.UNKNOWN, contender_id)],
)
# Trigger contenders promotion
tip = self.generate(node, 1)[0]
# The proof is not mature yet. Contender status should still be unknown.
contender_id = make_contender_id(tip, immature_proof.proofid)
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(AvalancheContenderVoteError.UNKNOWN, contender_id)],
)
# Trigger contenders promotion and mature the proof
tip = self.generate(node, 1)[0]
expected_response = expected_contender_poll_response(tip)
self.wait_until(lambda: check_immature_proofs([]))
# The proof is now mature so it has been added to the contender cache.
# Its vote status is pending because staking rewards are not active yet.
contender_id = make_contender_id(tip, immature_proof.proofid)
poll_node.send_poll([contender_id], inv_type=MSG_AVA_STAKE_CONTENDER)
assert_response(
poll_node,
avakey,
[AvalancheVote(expected_response, contender_id)],
)
if __name__ == "__main__":
AvalancheContenderVotingTest().main()
diff --git a/test/functional/test_framework/avatools.py b/test/functional/test_framework/avatools.py
index b3b1b488e..33b6ee5c4 100644
--- a/test/functional/test_framework/avatools.py
+++ b/test/functional/test_framework/avatools.py
@@ -1,532 +1,536 @@
# Copyright (c) 2021 The Bitcoin ABC developers
# Distributed under the MIT software license, see the accompanying
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
"""Utilities for avalanche tests."""
import random
import struct
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from .authproxy import JSONRPCException
from .key import ECKey, ECPubKey
from .messages import (
MSG_AVA_PROOF,
MSG_BLOCK,
NODE_AVALANCHE,
NODE_NETWORK,
AvalancheDelegation,
AvalanchePrefilledProof,
AvalancheProof,
AvalancheResponse,
AvalancheVote,
AvalancheVoteError,
CInv,
CTransaction,
FromHex,
TCPAvalancheResponse,
ToHex,
calculate_shortid,
hash256,
msg_avahello,
msg_avapoll,
msg_avaproof,
msg_avaproofs,
msg_notfound,
msg_tcpavaresponse,
)
from .p2p import P2PInterface, p2p_lock
if TYPE_CHECKING:
from .test_framework import BitcoinTestFramework
from .test_node import ADDRESS_ECREG_UNSPENDABLE, TestNode
from .util import assert_equal, satoshi_round, uint256_hex, wait_until_helper
from .wallet_util import bytes_to_wif
def avalanche_proof_from_hex(proof_hex: str) -> AvalancheProof:
return FromHex(AvalancheProof(), proof_hex)
def create_coinbase_stakes(
node: TestNode, blockhashes: List[str], priv_key: str, amount: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Returns a list of dictionaries representing stakes, in a format
compatible with the buildavalancheproof RPC, using only coinbase
transactions.
:param node: Test node used to get the block and coinbase data.
:param blockhashes: List of block hashes, whose coinbase tx will be used
as a stake.
:param priv_key: Private key controlling the coinbase UTXO
:param amount: If specified, this overwrites the amount information
in the coinbase dicts.
"""
blocks = [node.getblock(h, 2) for h in blockhashes]
coinbases = [
{
"height": b["height"],
"txid": b["tx"][0]["txid"],
"n": 0,
"value": b["tx"][0]["vout"][0]["value"],
}
for b in blocks
]
return [
{
"txid": coinbase["txid"],
"vout": coinbase["n"],
"amount": amount or coinbase["value"],
"height": coinbase["height"],
"iscoinbase": True,
"privatekey": priv_key,
}
for coinbase in coinbases
]
def get_utxos_in_blocks(node: TestNode, blockhashes: List[str]) -> List[Dict]:
"""Return all UTXOs in the specified list of blocks."""
utxos = filter(
lambda u: node.gettransaction(u["txid"])["blockhash"] in blockhashes,
node.listunspent(),
)
return list(utxos)
def create_stakes(
test_framework: "BitcoinTestFramework",
node: TestNode,
blockhashes: List[str],
count: int,
sync_fun=None,
) -> List[Dict[str, Any]]:
"""
Create a list of stakes by splitting existing UTXOs from a specified list
of blocks into 10 new coins.
This function can generate more valid stakes than `get_coinbase_stakes`
does, because on the regtest chain halving happens every 150 blocks so
the coinbase amount is below the dust threshold after only 900 blocks.
:param node: Test node used to generate blocks and send transactions
:param blockhashes: List of block hashes whose UTXOs will be split.
:param count: Number of stakes to return.
"""
assert 10 * len(blockhashes) >= count
utxos = get_utxos_in_blocks(node, blockhashes)
addresses = [node.getnewaddress() for _ in range(10)]
private_keys = {addr: node.dumpprivkey(addr) for addr in addresses}
for u in utxos:
inputs = [{"txid": u["txid"], "vout": u["vout"]}]
outputs = {addr: satoshi_round(u["amount"] / 10) for addr in addresses}
raw_tx = node.createrawtransaction(inputs, outputs)
ctx = FromHex(CTransaction(), raw_tx)
ctx.vout[0].nValue -= node.calculate_fee(ctx)
signed_tx = node.signrawtransactionwithwallet(ToHex(ctx))["hex"]
node.sendrawtransaction(signed_tx)
# confirm the transactions
new_blocks = []
while node.getmempoolinfo()["size"] > 0:
new_blocks += test_framework.generate(
node, 1, sync_fun=test_framework.no_op if sync_fun is None else sync_fun
)
utxos = get_utxos_in_blocks(node, new_blocks)
stakes = []
# cache block heights
heights = {}
for utxo in utxos[:count]:
blockhash = node.gettransaction(utxo["txid"])["blockhash"]
if blockhash not in heights:
heights[blockhash] = node.getblock(blockhash, 1)["height"]
stakes.append(
{
"txid": utxo["txid"],
"vout": utxo["vout"],
"amount": utxo["amount"],
"iscoinbase": utxo["label"] == "coinbase",
"height": heights[blockhash],
"privatekey": private_keys[utxo["address"]],
}
)
return stakes
def get_proof_ids(node):
return [int(peer["proofid"], 16) for peer in node.getavalanchepeerinfo()]
def wait_for_proof(node, proofid_hex, expect_status="boundToPeer", timeout=60):
"""
Wait for the proof to be known by the node. The expect_status is checked
once after the proof is found and can be one of the following: "immature",
"boundToPeer", "conflicting" or "finalized".
"""
ret = {}
def proof_found():
nonlocal ret
try:
ret = node.getrawavalancheproof(proofid_hex)
return True
except JSONRPCException:
return False
wait_until_helper(proof_found, timeout=timeout)
assert ret.get(expect_status, False) is True
class NoHandshakeAvaP2PInterface(P2PInterface):
"""P2PInterface with avalanche capabilities"""
def __init__(self):
self.round = 0
self.avahello = None
self.avaresponses = []
self.avapolls = []
self.nodeid: Optional[int] = None
super().__init__()
def peer_connect(self, *args, **kwargs):
create_conn = super().peer_connect(*args, **kwargs)
# Save the nonce and extra entropy so they can be reused later.
self.local_nonce = self.on_connection_send_msg.nNonce
self.local_extra_entropy = self.on_connection_send_msg.nExtraEntropy
return create_conn
def peer_accept_connection(self, *args, **kwargs):
create_conn = super().peer_accept_connection(*args, **kwargs)
# Save the nonce and extra entropy so they can be reused later.
self.local_nonce = self.on_connection_send_msg.nNonce
self.local_extra_entropy = self.on_connection_send_msg.nExtraEntropy
return create_conn
def on_version(self, message):
super().on_version(message)
# Save the nonce and extra entropy so they can be reused later.
self.remote_nonce = message.nNonce
self.remote_extra_entropy = message.nExtraEntropy
def on_avaresponse(self, message):
self.avaresponses.append(message.response)
def on_avapoll(self, message):
self.avapolls.append(message.poll)
def on_avahello(self, message):
assert self.avahello is None
self.avahello = message
def send_avaresponse(self, avaround, votes, privkey):
response = AvalancheResponse(avaround, 0, votes)
sig = privkey.sign_schnorr(response.get_hash())
msg = msg_tcpavaresponse()
msg.response = TCPAvalancheResponse(response, sig)
self.send_message(msg)
def wait_for_avaresponse(self, timeout=5):
self.wait_until(lambda: len(self.avaresponses) > 0, timeout=timeout)
with p2p_lock:
return self.avaresponses.pop(0)
def send_poll(self, hashes, inv_type=MSG_BLOCK):
msg = msg_avapoll()
msg.poll.round = self.round
self.round += 1
for h in hashes:
msg.poll.invs.append(CInv(inv_type, h))
self.send_message(msg)
def send_proof(self, proof):
msg = msg_avaproof()
msg.proof = proof
self.send_message(msg)
def get_avapoll_if_available(self):
with p2p_lock:
return self.avapolls.pop(0) if len(self.avapolls) > 0 else None
def wait_for_avahello(self, timeout=5):
self.wait_until(lambda: self.avahello is not None, timeout=timeout)
with p2p_lock:
return self.avahello
def build_avahello(
self, delegation: AvalancheDelegation, delegated_privkey: ECKey
) -> msg_avahello:
local_sighash = hash256(
delegation.getid()
+ struct.pack(
"<QQQQ",
self.local_nonce,
self.remote_nonce,
self.local_extra_entropy,
self.remote_extra_entropy,
)
)
msg = msg_avahello()
msg.hello.delegation = delegation
msg.hello.sig = delegated_privkey.sign_schnorr(local_sighash)
return msg
def send_avahello(self, delegation_hex: str, delegated_privkey: ECKey):
delegation = FromHex(AvalancheDelegation(), delegation_hex)
msg = self.build_avahello(delegation, delegated_privkey)
self.send_message(msg)
return msg.hello.delegation.proofid
def send_avaproof(self, proof: AvalancheProof):
msg = msg_avaproof()
msg.proof = proof
self.send_message(msg)
class AvaP2PInterface(NoHandshakeAvaP2PInterface):
def __init__(
self, test_framework=None, node=None, payoutAddress=ADDRESS_ECREG_UNSPENDABLE
):
if (test_framework is not None and node is None) or (
node is not None and test_framework is None
):
raise AssertionError(
"test_framework and node should both be either set or None"
)
super().__init__()
self.master_privkey = None
self.proof = None
self.delegated_privkey = ECKey()
self.delegated_privkey.generate()
self.delegation = None
if test_framework is not None and node is not None:
self.master_privkey, self.proof = gen_proof(
test_framework, node, payoutAddress=payoutAddress
)
delegation_hex = node.delegateavalancheproof(
uint256_hex(self.proof.limited_proofid),
bytes_to_wif(self.master_privkey.get_bytes()),
self.delegated_privkey.get_pubkey().get_bytes().hex(),
)
assert node.verifyavalanchedelegation(delegation_hex)
self.delegation = FromHex(AvalancheDelegation(), delegation_hex)
def on_version(self, message):
super().on_version(message)
avahello = msg_avahello()
if self.delegation is not None:
avahello = self.build_avahello(self.delegation, self.delegated_privkey)
elif self.proof is not None:
avahello = self.build_avahello(
AvalancheDelegation(
self.proof.limited_proofid,
self.master_privkey.get_pubkey().get_bytes(),
),
self.master_privkey,
)
self.send_message(avahello)
def on_getdata(self, message):
super().on_getdata(message)
not_found = []
for inv in message.inv:
if (
inv.type == MSG_AVA_PROOF
and self.proof is not None
and inv.hash == self.proof.proofid
):
self.send_avaproof(self.proof)
else:
not_found.append(inv)
if len(not_found) > 0:
self.send_message(msg_notfound(not_found))
def get_ava_p2p_interface_no_handshake(
node: TestNode, services=NODE_NETWORK | NODE_AVALANCHE
) -> NoHandshakeAvaP2PInterface:
"""Build and return a NoHandshakeAvaP2PInterface connected to the specified
TestNode.
"""
n = NoHandshakeAvaP2PInterface()
node.add_p2p_connection(n, services=services)
n.wait_for_verack()
n.nodeid = node.getpeerinfo()[-1]["id"]
return n
def get_ava_p2p_interface(
test_framework: "BitcoinTestFramework",
node: TestNode,
services=NODE_NETWORK | NODE_AVALANCHE,
stake_utxo_confirmations=1,
sync_fun=None,
payoutAddress=ADDRESS_ECREG_UNSPENDABLE,
) -> AvaP2PInterface:
"""Build and return an AvaP2PInterface connected to the specified TestNode."""
n = AvaP2PInterface(test_framework, node, payoutAddress=payoutAddress)
# Make sure the proof utxos are mature
if stake_utxo_confirmations > 1:
test_framework.generate(
node,
stake_utxo_confirmations - 1,
sync_fun=test_framework.no_op if sync_fun is None else sync_fun,
)
assert node.verifyavalancheproof(n.proof.serialize().hex())
proofid_hex = uint256_hex(n.proof.proofid)
node.add_p2p_connection(n, services=services)
n.nodeid = node.getpeerinfo()[-1]["id"]
def avapeer_connected():
node_list = []
try:
node_list = node.getavalanchepeerinfo(proofid_hex)[0]["node_list"]
except BaseException:
pass
return n.nodeid in node_list
wait_until_helper(avapeer_connected, timeout=5)
return n
def gen_proof(
test_framework,
node,
coinbase_utxos=1,
expiry=0,
sync_fun=None,
payoutAddress=ADDRESS_ECREG_UNSPENDABLE,
):
blockhashes = test_framework.generate(
node,
coinbase_utxos,
sync_fun=test_framework.no_op if sync_fun is None else sync_fun,
)
privkey = ECKey()
privkey.generate()
stakes = create_coinbase_stakes(
node, blockhashes, node.get_deterministic_priv_key().key
)
proof_hex = node.buildavalancheproof(
42, expiry, bytes_to_wif(privkey.get_bytes()), stakes, payoutAddress
)
return privkey, avalanche_proof_from_hex(proof_hex)
def build_raw_msg_avaproofs(
proofs: List[AvalancheProof],
prefilled_proofs: Optional[List[AvalanchePrefilledProof]] = None,
key_pair: Optional[List[int]] = None,
) -> msg_avaproofs:
if key_pair is None:
key_pair = [random.randint(0, 2**64 - 1)] * 2
msg = msg_avaproofs()
msg.key0 = key_pair[0]
msg.key1 = key_pair[1]
msg.prefilled_proofs = prefilled_proofs or []
msg.shortids = [
calculate_shortid(msg.key0, msg.key1, proof.proofid) for proof in proofs
]
return msg
def build_msg_avaproofs(
proofs: List[AvalancheProof],
prefilled_proofs: Optional[List[AvalancheProof]] = None,
key_pair: Optional[List[int]] = None,
) -> msg_avaproofs:
proofids = sorted([p.proofid for p in proofs])
indexed_prefilled_proofs = []
if prefilled_proofs:
for proof in sorted(prefilled_proofs, key=lambda p: p.proofid):
indexed_prefilled_proofs.append(
AvalanchePrefilledProof(proofids.index(proof.proofid), proof)
)
return build_raw_msg_avaproofs(proofs, indexed_prefilled_proofs, key_pair)
def can_find_inv_in_poll(
quorum,
inv_hash,
response=AvalancheVoteError.ACCEPTED,
other_response=AvalancheVoteError.ACCEPTED,
unexpected_hashes=None,
+ response_map={},
):
found_hash = False
for n in quorum:
poll = n.get_avapoll_if_available()
# That node has not received a poll
if poll is None:
continue
# We got a poll, check for the hash and repond
votes = []
for inv in poll.invs:
# Vote to everything but our searched inv
r = other_response
+ if response_map.get(inv.type, None):
+ r = response_map[inv.type]
+
# Look for what we expect
if inv.hash == inv_hash:
r = response
found_hash = True
assert inv.hash not in (
unexpected_hashes or []
), f"Unexpected inv hash {inv.hash} found in list {unexpected_hashes}"
votes.append(AvalancheVote(r, inv.hash))
n.send_avaresponse(poll.round, votes, n.delegated_privkey)
return found_hash
def assert_response(
poll_node: AvaP2PInterface, avakey: ECPubKey, expected: List[AvalancheVote]
):
response = poll_node.wait_for_avaresponse()
r = response.response
# Verify signature.
assert avakey.verify_schnorr(response.sig, r.get_hash())
# Verify correct votes list
votes = r.votes
assert_equal(len(votes), len(expected))
for i in range(0, len(votes)):
assert_equal(repr(votes[i]), repr(expected[i]))

File Metadata

Mime Type
text/x-diff
Expires
Thu, May 22, 01:34 (19 h, 28 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5866278
Default Alt Text
(48 KB)

Event Timeline