diff --git a/src/avalanche/avalanche.h b/src/avalanche/avalanche.h --- a/src/avalanche/avalanche.h +++ b/src/avalanche/avalanche.h @@ -5,6 +5,7 @@ #ifndef BITCOIN_AVALANCHE_AVALANCHE_H #define BITCOIN_AVALANCHE_AVALANCHE_H +#include #include #include @@ -46,6 +47,18 @@ */ static constexpr size_t AVALANCHE_DEFAULT_COOLDOWN = 100; +/** + * Default minimum cumulative stake of all known peers that constitutes a usable + * quorum. + */ +static constexpr const Amount AVALANCHE_DEFAULT_MIN_QUORUM_STAKE = 0 * COIN; + +/** + * Default minimum percentage of stake-weighted peers we must have a node for to + * constitute a usable quorum. + */ +static constexpr double AVALANCHE_DEFAULT_MIN_QUORUM_NODE_RATIO = 0.8; + /** * Global avalanche instance. */ diff --git a/src/avalanche/processor.h b/src/avalanche/processor.h --- a/src/avalanche/processor.h +++ b/src/avalanche/processor.h @@ -151,13 +151,18 @@ /** Event loop machinery. */ EventLoop eventLoop; + /** Quorum parameters. */ + uint32_t minQuorumPeerScore; + double minQuorumNodeScoreRatio; + /** Registered interfaces::Chain::Notifications handler. */ class NotificationsHandler; std::unique_ptr chainNotificationsHandler; Processor(interfaces::Chain &chain, CConnman *connmanIn, std::unique_ptr peerDataIn, CKey sessionKeyIn, - std::chrono::milliseconds queryTimeoutDurationIn); + std::chrono::milliseconds queryTimeoutDurationIn, + uint32_t minQuorumPeerScoreIn, double minQuorumNodeScoreRatioIn); public: ~Processor(); @@ -203,6 +208,8 @@ bool startEventLoop(CScheduler &scheduler); bool stopEventLoop(); + bool isQuorumAvailable(); + private: void runEventLoop(); void clearTimedoutRequests(); diff --git a/src/avalanche/processor.cpp b/src/avalanche/processor.cpp --- a/src/avalanche/processor.cpp +++ b/src/avalanche/processor.cpp @@ -2,6 +2,7 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. +#include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include @@ -131,10 +133,14 @@ Processor::Processor(interfaces::Chain &chain, CConnman *connmanIn, std::unique_ptr peerDataIn, CKey sessionKeyIn, - std::chrono::milliseconds queryTimeoutDurationIn) + std::chrono::milliseconds queryTimeoutDurationIn, + uint32_t minQuorumPeerScoreIn, + double minQuorumNodeScoreRatioIn) : connman(connmanIn), queryTimeoutDuration(queryTimeoutDurationIn), round(0), peerManager(std::make_unique()), - peerData(std::move(peerDataIn)), sessionKey(std::move(sessionKeyIn)) { + peerData(std::move(peerDataIn)), sessionKey(std::move(sessionKeyIn)), + minQuorumPeerScore(minQuorumPeerScoreIn), + minQuorumNodeScoreRatio(minQuorumNodeScoreRatioIn) { // Make sure we get notified of chain state changes. chainNotificationsHandler = chain.handleNotifications(std::make_shared(this)); @@ -243,10 +249,32 @@ gArgs.GetArg("-avaquerytimeout", count_microseconds(AVALANCHE_DEFAULT_QUERY_TIMEOUT))}; + // Determine quorum parameters + Amount minQuorumStake = Amount::zero(); + if (gArgs.IsArgSet("-avaminquorumstake")) { + if (!ParseMoney(gArgs.GetArg("-avaminquorumstake", ""), + minQuorumStake)) { + error = _("The avalanche min quorum stake amount is invalid."); + return nullptr; + } + } else { + minQuorumStake = AVALANCHE_DEFAULT_MIN_QUORUM_STAKE; + } + uint32_t minQuorumScore = Proof::amountToScore(minQuorumStake); + + double minQuorumNodeScoreRatio = AVALANCHE_DEFAULT_MIN_QUORUM_NODE_RATIO; + if (gArgs.IsArgSet("-avaminquorumnoderatio")) { + if (!ParseDouble(gArgs.GetArg("-avaminquorumnoderatio", ""), + &minQuorumNodeScoreRatio)) { + error = _("The avalanche min quorum node ratio is invalid."); + return nullptr; + } + } + // We can't use std::make_unique with a private constructor - return std::unique_ptr( - new Processor(chain, connman, std::move(peerData), - std::move(sessionKey), queryTimeout)); + return std::unique_ptr(new Processor( + chain, connman, std::move(peerData), std::move(sessionKey), + queryTimeout, minQuorumScore, minQuorumNodeScoreRatio)); } bool Processor::addBlockToReconcile(const CBlockIndex *pindex) { @@ -668,6 +696,11 @@ return; } + // Don't poll if quorum hasn't been established yet + if (!isQuorumAvailable()) { + return; + } + // First things first, check if we have requests that timed out and clear // them. clearTimedoutRequests(); @@ -730,4 +763,47 @@ } while (nodeid != NO_NODE); } +/* + * Returns a bool indicating whether we have a usable Avalanche quorum enabling + * us to take decisions based on polls. + */ +bool Processor::isQuorumAvailable() { + // Sum the score of all peers and, separately, the stake of peers with nodes + uint64_t peerScore = 0; + uint64_t nodeScore = 0; + + { + LOCK(cs_peerManager); + peerManager->forEachPeer([&peerScore, &nodeScore](Peer peer) { + peerScore += peer.getScore(); + if (peer.node_count > 0) { + nodeScore += peer.getScore(); + } + }); + } + + // Ensure enough is being staked overall + if (peerScore < minQuorumPeerScore) { + LogPrint(BCLog::AVALANCHE, + "Quorum unavailable: Total peer score size is %d; need at " + "least %d\n", + peerScore, minQuorumPeerScore); + return false; + } + + // Ensure we have nodes for enough of the overall stake + uint64_t minNodeScore = + std::round(double(peerScore) * minQuorumNodeScoreRatio); + + if (nodeScore < minNodeScore) { + LogPrint(BCLog::AVALANCHE, + "Quorum unavailable: Total node score size is %d; need at " + "least %d\n", + nodeScore, minNodeScore); + return false; + } + + return true; +} + } // namespace avalanche diff --git a/src/avalanche/proof.h b/src/avalanche/proof.h --- a/src/avalanche/proof.h +++ b/src/avalanche/proof.h @@ -145,6 +145,8 @@ static bool FromHex(Proof &proof, const std::string &hexProof, bilingual_str &errorOut); + static uint32_t amountToScore(Amount amount); + uint64_t getSequence() const { return sequence; } int64_t getExpirationTime() const { return expirationTime; } const CPubKey &getMaster() const { return master; } diff --git a/src/avalanche/proof.cpp b/src/avalanche/proof.cpp --- a/src/avalanche/proof.cpp +++ b/src/avalanche/proof.cpp @@ -102,7 +102,11 @@ total += s.getStake().getAmount(); } - score = uint32_t((100 * total) / COIN); + score = amountToScore(total); +} + +uint32_t Proof::amountToScore(Amount amount) { + return (100 * amount) / COIN; } Amount Proof::getStakedAmount() const { diff --git a/src/avalanche/test/processor_tests.cpp b/src/avalanche/test/processor_tests.cpp --- a/src/avalanche/test/processor_tests.cpp +++ b/src/avalanche/test/processor_tests.cpp @@ -91,6 +91,9 @@ *m_node.scheduler, *m_node.chainman, *m_node.mempool, false); m_node.chain = interfaces::MakeChain(m_node, config.GetChainParams()); + gArgs.ForceSetArg("-avaminquorumstake", "0"); + gArgs.ForceSetArg("-avaminquorumnoderatio", "0.3"); + // Get the processor ready. bilingual_str error; m_processor = Processor::MakeProcessor(*m_node.args, *m_node.chain, @@ -101,6 +104,8 @@ ~AvalancheTestingSetup() { m_connman->ClearNodes(); SyncWithValidationInterfaceQueue(); + gArgs.ClearForcedArg("-avaminquorumstake"); + gArgs.ClearForcedArg("-avaminquorumnoderatio"); } CNode *ConnectNode(ServiceFlags nServices) { diff --git a/src/init.cpp b/src/init.cpp --- a/src/init.cpp +++ b/src/init.cpp @@ -1331,6 +1331,18 @@ "Number of milliseconds before timing out a poll (default: %u)", count_milliseconds(AVALANCHE_DEFAULT_QUERY_TIMEOUT)), ArgsManager::ALLOW_ANY, OptionsCategory::AVALANCHE); + argsman.AddArg( + "-avaminquorumstake", + strprintf( + "Minimum amount of known stake for a usable quorum (default: %u)", + FormatMoney(AVALANCHE_DEFAULT_MIN_QUORUM_STAKE)), + ArgsManager::ALLOW_ANY, OptionsCategory::AVALANCHE); + argsman.AddArg( + "-avaminquorumnoderatio", + strprintf("Minimum proportion of known stake we" + " need nodes for to have a usable quorum (default: %u)", + AVALANCHE_DEFAULT_MIN_QUORUM_NODE_RATIO), + ArgsManager::ALLOW_STRING, OptionsCategory::AVALANCHE); argsman.AddArg( "-avacooldown", strprintf("Mandatory cooldown between two avapoll (default: %u)", diff --git a/src/net_processing.cpp b/src/net_processing.cpp --- a/src/net_processing.cpp +++ b/src/net_processing.cpp @@ -5037,6 +5037,12 @@ // Default vote for unknown inv type uint32_t vote = -1; + // We don't vote definitively until we have an available quorum + if (!g_avalanche || !g_avalanche->isQuorumAvailable()) { + votes.emplace_back(vote, inv.hash); + continue; + } + // If inv's type is known, get a vote for its hash switch (inv.type) { case MSG_BLOCK: { diff --git a/test/functional/abc_p2p_avalanche_proof_voting.py b/test/functional/abc_p2p_avalanche_proof_voting.py --- a/test/functional/abc_p2p_avalanche_proof_voting.py +++ b/test/functional/abc_p2p_avalanche_proof_voting.py @@ -39,7 +39,9 @@ self.peer_replacement_cooldown = 2000 self.extra_args = [ ['-enableavalanche=1', '-enableavalancheproofreplacement=1', - f'-avalancheconflictingproofcooldown={self.conflicting_proof_cooldown}', f'-avalanchepeerreplacementcooldown={self.peer_replacement_cooldown}', '-avacooldown=0'], + f'-avalancheconflictingproofcooldown={self.conflicting_proof_cooldown}', + f'-avalanchepeerreplacementcooldown={self.peer_replacement_cooldown}', + '-avacooldown=0', '-avaminquorumstake=0', '-avaminquorumnoderatio=0'], ] self.supports_cli = False @@ -299,7 +301,9 @@ self.restart_node(0, extra_args=['-enableavalanche=1', '-avacooldown=0', '-avalancheconflictingproofcooldown=0', - '-whitelist=noban@127.0.0.1', ]) + '-whitelist=noban@127.0.0.1', + '-avaminquorumstake=0', + '-avaminquorumnoderatio=0']) ava_node = get_ava_p2p_interface(node) diff --git a/test/functional/abc_p2p_avalanche_quorum.py b/test/functional/abc_p2p_avalanche_quorum.py new file mode 100755 --- /dev/null +++ b/test/functional/abc_p2p_avalanche_quorum.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# Copyright (c) 2020-2022 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 quorum detection of avalanche.""" + +from time import time + +from test_framework.avatools import ( + create_coinbase_stakes, + get_ava_p2p_interface, +) +from test_framework.key import ECKey, ECPubKey +from test_framework.messages import AvalancheVote, AvalancheVoteError +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal +from test_framework.wallet_util import bytes_to_wif + + +class AvalancheQuorumTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [ + ['-enableavalanche=1', + '-avacooldown=0', + '-avaquerytimeout=0', + '-avaminquorumstake=100000000', + '-avaminquorumnoderatio=0.8'], + ] + self.supports_cli = False + + def mock_forward(self, delta): + self.mock_time += delta + self.nodes[0].setmocktime(self.mock_time) + + def run_test(self): + self.mock_time = int(time()) + self.mock_forward(0) + + # Create a local node to poll from and a helper to send polls from it + # and assert on the response + node = self.nodes[0] + poll_node = get_ava_p2p_interface(node) + poll_node_pubkey = ECPubKey() + poll_node_pubkey.set(bytes.fromhex(node.getavalanchekey())) + + def poll_and_assert_response(expected): + # Send poll for best block + block = int(node.getbestblockhash(), 16) + poll_node.send_poll([block]) + + # Get response and check that the vote is what we expect + response = poll_node.wait_for_avaresponse() + r = response.response + assert poll_node_pubkey.verify_schnorr(response.sig, r.get_hash()) + assert_equal(len(r.votes), 1) + + actual = repr(r.votes[0]) + expected = repr(AvalancheVote(expected, block)) + assert_equal(actual, expected) + + # Create peers to poll + num_quorum_peers = 2 + coinbase_key = node.get_deterministic_priv_key() + blocks = node.generatetoaddress(num_quorum_peers, coinbase_key.address) + peers = [] + for i in range(0, num_quorum_peers): + keyHex = "12b004fff7f4b69ef8650e767f18f11ede158148b425660723b9f9a66e61f75" + \ + str(i) + k = ECKey() + k.set(bytes.fromhex(keyHex), True) + stakes = create_coinbase_stakes( + node, [blocks[i]], coinbase_key.key) + proof = node.buildavalancheproof(1, 1, bytes_to_wif(k.get_bytes()), + stakes) + peers.append({'key': k, 'proof': proof, 'stake': stakes}) + + def addavalanchenode(peer): + pubkey = peer['key'].get_pubkey().get_bytes().hex() + assert node.addavalanchenode( + peer['node'].nodeid, pubkey, peer['proof']) is True + + # Start polling. The response should be UNKNOWN because there's no + # score + poll_and_assert_response(AvalancheVoteError.UNKNOWN) + + # Create one peer with half the score and add one node + peers[0]['node'] = get_ava_p2p_interface(node) + addavalanchenode(peers[0]) + poll_and_assert_response(AvalancheVoteError.UNKNOWN) + + # Create a second peer with the other half and add one node + peers[1]['node'] = get_ava_p2p_interface(node) + addavalanchenode(peers[1]) + poll_and_assert_response(AvalancheVoteError.ACCEPTED) + + # Disconnect peer 1's node, so we have the min stake threshold but the + # ratio with nodes is now too low. Forward the time to allow an + # eventLoop run + self.mock_forward(1) + peers[1]['node'].peer_disconnect() + peers[1]['node'].wait_for_disconnect() + poll_and_assert_response(AvalancheVoteError.UNKNOWN) + + # Reconnect node and re-establish quorum + peers[1]['node'] = get_ava_p2p_interface(node) + addavalanchenode(peers[1]) + poll_and_assert_response(AvalancheVoteError.ACCEPTED) + + +if __name__ == '__main__': + AvalancheQuorumTest().main() diff --git a/test/functional/abc_p2p_avalanche_voting.py b/test/functional/abc_p2p_avalanche_voting.py --- a/test/functional/abc_p2p_avalanche_voting.py +++ b/test/functional/abc_p2p_avalanche_voting.py @@ -23,7 +23,7 @@ self.setup_clean_chain = True self.num_nodes = 2 self.extra_args = [ - ['-enableavalanche=1', '-avacooldown=0'], + ['-enableavalanche=1', '-avacooldown=0', '-avaminquorumstake=0'], ['-enableavalanche=1', '-avacooldown=0', '-noparkdeepreorg', '-maxreorgdepth=-1']] self.supports_cli = False self.rpc_timeout = 120