diff --git a/src/avalanche/avalanche.h b/src/avalanche/avalanche.h --- a/src/avalanche/avalanche.h +++ b/src/avalanche/avalanche.h @@ -8,6 +8,8 @@ #include #include +#include + namespace avalanche { class Processor; } @@ -46,6 +48,25 @@ */ static constexpr size_t AVALANCHE_DEFAULT_COOLDOWN = 100; +/** + * Default minimum cumulative stake of all known peers that constitutes a usable + * quorum. + * + * FIXME: The default is set to 0 to allow existing tests to pass for now. We + * need to set a sane default and update tests later. + */ +static constexpr const Amount AVALANCHE_DEFAULT_MIN_QUORUM_STAKE = + Amount::zero(); + +/** + * Default minimum percentage of stake-weighted peers we must have a node for to + * constitute a usable quorum. + * + * FIXME: The default is set to 0 to allow existing tests to pass for now. We + * need to set a sane default and update tests later. + */ +static constexpr double AVALANCHE_DEFAULT_MIN_QUORUM_CONNECTED_STAKE_RATIO = 0; + /** * 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,21 @@ /** Event loop machinery. */ EventLoop eventLoop; + /** + * Quorum management. + */ + uint32_t minQuorumScore; + double minQuorumConnectedScoreRatio; + std::atomic quorumIsEstablished{false}; + /** Registered interfaces::Chain::Notifications handler. */ class NotificationsHandler; std::unique_ptr chainNotificationsHandler; Processor(const ArgsManager &argsman, interfaces::Chain &chain, CConnman *connmanIn, std::unique_ptr peerDataIn, - CKey sessionKeyIn); + CKey sessionKeyIn, uint32_t minQuorumPeerScoreIn, + double minQuorumNodeScoreRatioIn); public: ~Processor(); @@ -203,6 +211,8 @@ bool startEventLoop(CScheduler &scheduler); bool stopEventLoop(); + bool isQuorumEstablished(); + 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 @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include @@ -131,12 +133,15 @@ Processor::Processor(const ArgsManager &argsman, interfaces::Chain &chain, CConnman *connmanIn, std::unique_ptr peerDataIn, - CKey sessionKeyIn) + CKey sessionKeyIn, uint32_t minQuorumPeerScoreIn, + double minQuorumNodeScoreRatioIn) : connman(connmanIn), queryTimeoutDuration(argsman.GetArg( "-avatimeout", AVALANCHE_DEFAULT_QUERY_TIMEOUT.count())), round(0), peerManager(std::make_unique()), - peerData(std::move(peerDataIn)), sessionKey(std::move(sessionKeyIn)) { + peerData(std::move(peerDataIn)), sessionKey(std::move(sessionKeyIn)), + minQuorumScore(minQuorumPeerScoreIn), + minQuorumConnectedScoreRatio(minQuorumNodeScoreRatioIn) { // Make sure we get notified of chain state changes. chainNotificationsHandler = chain.handleNotifications(std::make_shared(this)); @@ -241,9 +246,27 @@ } } + // Determine quorum parameters + Amount minQuorumStake = AVALANCHE_DEFAULT_MIN_QUORUM_STAKE; + if (gArgs.IsArgSet("-avaminquorumstake") && + !ParseMoney(gArgs.GetArg("-avaminquorumstake", ""), minQuorumStake)) { + error = _("The avalanche min quorum stake amount is invalid."); + return nullptr; + } + + double minQuorumConnectedStakeRatio = + AVALANCHE_DEFAULT_MIN_QUORUM_CONNECTED_STAKE_RATIO; + if (gArgs.IsArgSet("-avaminquorumconnectedstakeratio") && + !ParseDouble(gArgs.GetArg("-avaminquorumconnectedstakeratio", ""), + &minQuorumConnectedStakeRatio)) { + error = _("The avalanche min quorum connected stake ratio is invalid."); + return nullptr; + } + // We can't use std::make_unique with a private constructor return std::unique_ptr(new Processor( - argsman, chain, connman, std::move(peerData), std::move(sessionKey))); + argsman, chain, connman, std::move(peerData), std::move(sessionKey), + Proof::amountToScore(minQuorumStake), minQuorumConnectedStakeRatio)); } bool Processor::addBlockToReconcile(const CBlockIndex *pindex) { @@ -667,6 +690,11 @@ return; } + // Don't poll if quorum hasn't been established yet + if (!isQuorumEstablished()) { + return; + } + // First things first, check if we have requests that timed out and clear // them. clearTimedoutRequests(); @@ -729,4 +757,39 @@ } 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::isQuorumEstablished() { + if (quorumIsEstablished) { + return true; + } + + // Get the registered proof score and registered score we have nodes for + uint32_t totalPeersScore; + uint32_t connectedPeersScore; + + { + LOCK(cs_peerManager); + totalPeersScore = peerManager->getTotalPeersScore(); + connectedPeersScore = peerManager->getConnectedPeersScore(); + } + + // Ensure enough is being staked overall + if (totalPeersScore < minQuorumScore) { + return false; + } + + // Ensure we have connected score for enough of the overall score + uint32_t minConnectedScore = + std::round(double(totalPeersScore) * minQuorumConnectedScoreRatio); + if (connectedPeersScore < minConnectedScore) { + return false; + } + + quorumIsEstablished = true; + return true; +} + } // namespace avalanche 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 @@ -32,6 +32,21 @@ namespace avalanche { namespace { + struct TestPeerManager { + static PeerId getPeerIdForProofId(PeerManager &pm, + const ProofId &proofid) { + auto &pview = pm.peers.get(); + auto it = pview.find(proofid); + return it == pview.end() ? NO_PEER : it->peerid; + } + + static PeerId registerAndGetPeerId(PeerManager &pm, + const ProofRef &proof) { + pm.registerProof(proof); + return getPeerIdForProofId(pm, proof->getId()); + } + }; + struct AvalancheTest { static void runEventLoop(avalanche::Processor &p) { p.runEventLoop(); } @@ -44,6 +59,13 @@ } static uint64_t getRound(const Processor &p) { return p.round; } + + static uint32_t getMinQuorumScore(const Processor &p) { + return p.minQuorumScore; + } + static double getMinQuorumConnectedScoreRatio(const Processor &p) { + return p.minQuorumConnectedScoreRatio; + } }; } // namespace } // namespace avalanche @@ -660,7 +682,7 @@ BOOST_CHECK_EQUAL(updates.size(), 0); } - // Running two iterration of the event loop so that vote gets triggered on A + // Running two iterration of the event loop so that vote gets triggered on // and B. NodeId firstNodeid = getSuitableNodeToQuery(); runEventLoop(); @@ -1194,4 +1216,76 @@ BOOST_CHECK_EQUAL(m_processor->getConfidence(proofB), 0); } +BOOST_AUTO_TEST_CASE(quorum_detection) { + // Set min quorum parameters on a new node that hasn't latched an + // established quorum yet + int minStake = 2000000; + const auto currency = Currency::get(); + uint32_t minScore = Proof::amountToScore(minStake * currency.baseunit); + gArgs.ForceSetArg("-avaminquorumstake", std::to_string(minStake)); + gArgs.ForceSetArg("-avaminquorumconnectedstakeratio", "0.5"); + + bilingual_str error; + std::unique_ptr p = Processor::MakeProcessor( + *m_node.args, *m_node.chain, m_node.connman.get(), error); + BOOST_CHECK(!p->isQuorumEstablished()); + BOOST_CHECK_EQUAL(AvalancheTest::getMinQuorumScore(*p), minScore); + BOOST_CHECK_EQUAL(AvalancheTest::getMinQuorumConnectedScoreRatio(*p), 0.5); + + // Add part of the required stake and make sure we still report no quorum + PeerId peerid1; + auto proof1 = buildRandomProof(minScore / 2); + p->withPeerManager([&](avalanche::PeerManager &pm) { + peerid1 = TestPeerManager::registerAndGetPeerId(pm, proof1); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore / 2); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(!p->isQuorumEstablished()); + + // Add the rest of the stake, but we still have no connected stake + PeerId peerid2; + auto proof2 = buildRandomProof(minScore / 2); + p->withPeerManager([&](avalanche::PeerManager &pm) { + peerid2 = TestPeerManager::registerAndGetPeerId(pm, proof2); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(!p->isQuorumEstablished()); + + // Adding a node should cause the quorum to be detected and locked-in + p->withPeerManager([&](avalanche::PeerManager &pm) { + pm.addNode(0, proof1->getId()); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), minScore / 2); + }); + BOOST_CHECK(p->isQuorumEstablished()); + + // Go back to not having enough connected nodes, but we've already latched + // the quorum as established + p->withPeerManager([&](avalanche::PeerManager &pm) { + pm.removeNode(0); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(p->isQuorumEstablished()); + + // Remove peers one at a time and ensure the quorum stays established + p->withPeerManager([&](avalanche::PeerManager &pm) { + pm.removePeer(peerid2); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore / 2); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(p->isQuorumEstablished()); + + p->withPeerManager([&](avalanche::PeerManager &pm) { + pm.removePeer(peerid1); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), 0); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(p->isQuorumEstablished()); + + gArgs.ClearForcedArg("-avaminquorumstake"); + gArgs.ClearForcedArg("-avaminquorumconnectedstakeratio"); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/init.cpp b/src/init.cpp --- a/src/init.cpp +++ b/src/init.cpp @@ -1324,6 +1324,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: %s)", + FormatMoney(AVALANCHE_DEFAULT_MIN_QUORUM_STAKE)), + ArgsManager::ALLOW_ANY, OptionsCategory::AVALANCHE); + argsman.AddArg( + "-avaminquorumconnectedstakeratio", + strprintf("Minimum proportion of known stake we" + " need nodes for to have a usable quorum (default: %s)", + AVALANCHE_DEFAULT_MIN_QUORUM_CONNECTED_STAKE_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 @@ -5018,6 +5018,9 @@ node_state.last_poll = now; } + const bool quorum_established = + g_avalanche && g_avalanche->isQuorumEstablished(); + uint64_t round; Unserialize(vRecv, round); @@ -5042,6 +5045,12 @@ // Default vote for unknown inv type uint32_t vote = -1; + // We don't vote definitively until we have an established quorum + if (!quorum_established) { + 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_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,112 @@ +#!/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', + '-avatimeout=0', + '-avaminquorumstake=100000000', + '-avaminquorumconnectedstakeratio=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 which drops us below the threshold, but we've + # latched that the quorum is established + self.mock_forward(1) + peers[1]['node'].peer_disconnect() + peers[1]['node'].wait_for_disconnect() + poll_and_assert_response(AvalancheVoteError.ACCEPTED) + + # 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()