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 minQuorumTotalScoreIn, + double minQuorumConnectedScoreRatioIn); 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 minQuorumTotalScoreIn, + double minQuorumConnectedScoreRatioIn) : 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(minQuorumTotalScoreIn), + minQuorumConnectedScoreRatio(minQuorumConnectedScoreRatioIn) { // Make sure we get notified of chain state changes. chainNotificationsHandler = chain.handleNotifications(std::make_shared(this)); @@ -241,9 +246,38 @@ } } + // 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; + } + + if (!MoneyRange(minQuorumStake)) { + error = _("The avalanche min quorum stake amount is out of range."); + 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; + } + + if (minQuorumConnectedStakeRatio < 0 || minQuorumConnectedStakeRatio > 1) { + error = _( + "The avalanche min quorum connected stake ratio is out of range."); + 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 +701,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 +768,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 @@ -44,6 +44,14 @@ } 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 @@ -1194,4 +1202,140 @@ BOOST_CHECK_EQUAL(m_processor->getConfidence(proofB), 0); } +BOOST_AUTO_TEST_CASE(quorum_detection) { + // Set min quorum parameters for our test + int minStake = 2000000; + gArgs.ForceSetArg("-avaminquorumstake", ToString(minStake)); + gArgs.ForceSetArg("-avaminquorumconnectedstakeratio", "0.5"); + + // Create a new processor with our given quorum parameters + const auto currency = Currency::get(); + uint32_t minScore = Proof::amountToScore(minStake * currency.baseunit); + + bilingual_str error; + std::unique_ptr processor = Processor::MakeProcessor( + *m_node.args, *m_node.chain, m_node.connman.get(), error); + + BOOST_CHECK(processor != nullptr); + BOOST_CHECK(!processor->isQuorumEstablished()); + BOOST_CHECK_EQUAL(AvalancheTest::getMinQuorumScore(*processor), minScore); + BOOST_CHECK_EQUAL( + AvalancheTest::getMinQuorumConnectedScoreRatio(*processor), 0.5); + + // Add part of the required stake and make sure we still report no quorum + auto proof1 = buildRandomProof(minScore / 2); + processor->withPeerManager([&](avalanche::PeerManager &pm) { + BOOST_CHECK(pm.registerProof(proof1)); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore / 2); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(!processor->isQuorumEstablished()); + + // Add the rest of the stake, but we still have no connected stake + auto proof2 = buildRandomProof(minScore / 2); + processor->withPeerManager([&](avalanche::PeerManager &pm) { + BOOST_CHECK(pm.registerProof(proof2)); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(!processor->isQuorumEstablished()); + + // Adding a node should cause the quorum to be detected and locked-in + processor->withPeerManager([&](avalanche::PeerManager &pm) { + pm.addNode(0, proof1->getId()); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), minScore / 2); + }); + BOOST_CHECK(processor->isQuorumEstablished()); + + // Go back to not having enough connected nodes, but we've already latched + // the quorum as established + processor->withPeerManager([&](avalanche::PeerManager &pm) { + pm.removeNode(0); + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(processor->isQuorumEstablished()); + + // Remove peers one at a time by orphaning their proofs, and ensure the + // quorum stays established + auto orphanProof = [&processor](ProofRef proof) { + { + LOCK(cs_main); + CCoinsViewCache &coins = ::ChainstateActive().CoinsTip(); + coins.SpendCoin(proof->getStakes()[0].getStake().getUTXO()); + } + processor->withPeerManager([&proof](avalanche::PeerManager &pm) { + pm.updatedBlockTip(); + BOOST_CHECK(pm.isOrphan(proof->getId())); + BOOST_CHECK(!pm.isBoundToPeer(proof->getId())); + }); + }; + + orphanProof(proof2); + processor->withPeerManager([&](avalanche::PeerManager &pm) { + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), minScore / 2); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(processor->isQuorumEstablished()); + + orphanProof(proof1); + processor->withPeerManager([&](avalanche::PeerManager &pm) { + BOOST_CHECK_EQUAL(pm.getTotalPeersScore(), 0); + BOOST_CHECK_EQUAL(pm.getConnectedPeersScore(), 0); + }); + BOOST_CHECK(processor->isQuorumEstablished()); + + gArgs.ClearForcedArg("-avaminquorumstake"); + gArgs.ClearForcedArg("-avaminquorumconnectedstakeratio"); +} + +BOOST_AUTO_TEST_CASE(quorum_detection_parameter_validation) { + // Create vector of tuples of + std::vector> tests = { + // Both parameters are invalid + {"", "", false}, + {"-1", "-1", false}, + + // Min stake is out of range + {"-1", "0", false}, + {"21000000001", "0", false}, + + // Min connected ratio is out of range + {"0", "-1", false}, + {"0", "1.1", false}, + + // Both parameters are valid + {"0", "0", true}, + {"1", "0.1", true}, + {"10", "0.5", true}, + {"10", "1", true}, + {ToString(MAX_MONEY / COIN), "0", true}, + }; + + // For each case set the parameters and check that making the processor + // succeeds or fails as expected + for (auto it = tests.begin(); it != tests.end(); ++it) { + gArgs.ForceSetArg("-avaminquorumstake", std::get<0>(*it)); + gArgs.ForceSetArg("-avaminquorumconnectedstakeratio", std::get<1>(*it)); + + bilingual_str error; + std::unique_ptr processor = Processor::MakeProcessor( + *m_node.args, *m_node.chain, m_node.connman.get(), error); + + if (std::get<2>(*it)) { + BOOST_CHECK(processor != nullptr); + BOOST_CHECK(error.empty()); + BOOST_CHECK_EQUAL(error.original, ""); + } else { + BOOST_CHECK(processor == nullptr); + BOOST_CHECK(!error.empty()); + BOOST_CHECK(error.original != ""); + } + } + + 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 @@ -5020,6 +5020,9 @@ node_state.last_poll = now; } + const bool quorum_established = + g_avalanche && g_avalanche->isQuorumEstablished(); + uint64_t round; Unserialize(vRecv, round); @@ -5044,6 +5047,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,111 @@ +#!/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'] + ] + + 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().key + blocks = node.generate(num_quorum_peers) + 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) + 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()