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,12 +151,17 @@ /** 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::unique_ptr peerDataIn, CKey sessionKeyIn, + uint32_t minQuorumPeerScoreIn, double minQuorumNodeScoreRatioIn); public: ~Processor(); @@ -202,6 +207,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 @@ -130,10 +132,14 @@ }; Processor::Processor(interfaces::Chain &chain, CConnman *connmanIn, - std::unique_ptr peerDataIn, CKey sessionKeyIn) + std::unique_ptr peerDataIn, CKey sessionKeyIn, + uint32_t minQuorumPeerScoreIn, + double minQuorumNodeScoreRatioIn) : connman(connmanIn), queryTimeoutDuration(AVALANCHE_DEFAULT_QUERY_TIMEOUT), 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)); @@ -238,9 +244,32 @@ } } + // 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))); + chain, connman, std::move(peerData), std::move(sessionKey), + minQuorumScore, minQuorumNodeScoreRatio)); } bool Processor::addBlockToReconcile(const CBlockIndex *pindex) { @@ -662,6 +691,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(); @@ -724,4 +758,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 @@ -101,7 +101,11 @@ total += s.getStake().getAmount(); } - score = uint32_t((100 * total) / COIN); + score = amountToScore(total); +} + +uint32_t Proof::amountToScore(Amount amount) { + return (100 * amount) / COIN; } bool Proof::verify(ProofValidationState &state) 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 @@ -1325,6 +1325,18 @@ strprintf("Enable avalanche proof replacement (default: %u)", AVALANCHE_DEFAULT_PROOF_REPLACEMENT_ENABLED), ArgsManager::ALLOW_BOOL, 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 @@ -3463,6 +3463,7 @@ }); }; +uint32_t avalanchePollCount = 0; void PeerManagerImpl::ProcessMessage( const Config &config, CNode &pfrom, const std::string &msg_type, CDataStream &vRecv, const std::chrono::microseconds time_received, @@ -5037,6 +5038,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: { @@ -5059,6 +5066,17 @@ // Send the query to the node. g_avalanche->sendResponse( &pfrom, avalanche::Response(round, cooldown, std::move(votes))); + + // HACK,WIP: Remove before merging. + // This is a hack to make the quorum detection functional tests work as + // expected until I can figure out how to correctly cause a node + // disconnection from the tests. + if (++avalanchePollCount == 3) { + g_avalanche->withPeerManager( + [&](avalanche::PeerManager &pm) { pm.removeNode(2); }); + } + // END HACK + return; } 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'], ] self.supports_cli = False @@ -299,7 +301,8 @@ 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']) 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,100 @@ +#!/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 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', + '-avaminquorumstake=100000000', + '-avaminquorumnoderatio=0.8'], + ] + self.supports_cli = False + + def run_test(self): + # 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) + + # TODO: Make peers[1]'s node disconnect + + # Now that we've lost a node we have the min stake threshold but the + # ratio with nodes is now too low + poll_and_assert_response(AvalancheVoteError.UNKNOWN) + + # Reconnect node and re-establish quorum + 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