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,8 @@ #ifndef BITCOIN_AVALANCHE_AVALANCHE_H #define BITCOIN_AVALANCHE_AVALANCHE_H +#include +#include #include #include @@ -46,6 +48,21 @@ */ 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 = 10000 * 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_CONNECTED_STAKE_RATIO = + 0.8; + +/** Default number of seconds to wait between checks for an Avalanche quorum. */ +static constexpr size_t AVALANCHE_DEFAULT_QUORUM_CHECK_INTERVAL = 10; /** * Global avalanche instance. */ @@ -53,4 +70,6 @@ bool isAvalancheEnabled(const ArgsManager &argsman); +std::chrono::seconds quorumCheckInterval(const ArgsManager &argsman); + #endif // BITCOIN_AVALANCHE_AVALANCHE_H diff --git a/src/avalanche/avalanche.cpp b/src/avalanche/avalanche.cpp --- a/src/avalanche/avalanche.cpp +++ b/src/avalanche/avalanche.cpp @@ -9,3 +9,8 @@ bool isAvalancheEnabled(const ArgsManager &argsman) { return argsman.GetBoolArg("-enableavalanche", AVALANCHE_DEFAULT_ENABLED); } + +std::chrono::seconds quorumCheckInterval(const ArgsManager &argsman) { + return std::chrono::seconds(argsman.GetArg( + "-avaquorumcheckinterval", AVALANCHE_DEFAULT_QUORUM_CHECK_INTERVAL)); +} \ No newline at end of file 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,20 @@ /** Event loop machinery. */ EventLoop eventLoop; + /** Quorum management. */ + uint32_t minQuorumScore; + double minQuorumConnectedScoreRatio; + bool quorumAvailable; + std::chrono::seconds quorumCheckTimeoutDuration; + std::chrono::time_point nextQuorumCheckAt; /** 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 +210,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 @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include @@ -131,12 +133,17 @@ 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), + quorumAvailable(false), + quorumCheckTimeoutDuration(quorumCheckInterval(argsman)) { // Make sure we get notified of chain state changes. chainNotificationsHandler = chain.handleNotifications(std::make_shared(this)); @@ -241,9 +248,29 @@ } } + // Determine quorum parameters + Amount minQuorumStake = Amount::zero(); + std::string rawMinQuorumStake = gArgs.GetArg( + "-avaminquorumstake", AVALANCHE_DEFAULT_MIN_QUORUM_STAKE.ToString()); + if (!ParseMoney(rawMinQuorumStake, minQuorumStake)) { + error = _("The avalanche min quorum stake amount is invalid."); + return nullptr; + } + uint32_t minQuorumScore = Proof::amountToScore(minQuorumStake); + + double minQuorumNodeScoreRatio = + AVALANCHE_DEFAULT_MIN_QUORUM_CONNECTED_STAKE_RATIO; + if (gArgs.IsArgSet("-avaminquorumconnectedstakeratio") && + !ParseDouble(gArgs.GetArg("-avaminquorumconnectedstakeratio", ""), + &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( - argsman, chain, connman, std::move(peerData), std::move(sessionKey))); + argsman, chain, connman, std::move(peerData), std::move(sessionKey), + minQuorumScore, minQuorumNodeScoreRatio)); } bool Processor::addBlockToReconcile(const CBlockIndex *pindex) { @@ -665,6 +692,16 @@ return; } + // Don't poll if quorum hasn't been established yet + auto now = std::chrono::steady_clock::now(); + if (now > nextQuorumCheckAt) { + quorumAvailable = isQuorumAvailable(); + nextQuorumCheckAt = now + quorumCheckTimeoutDuration; + } + if (!quorumAvailable) { + return; + } + // First things first, check if we have requests that timed out and clear // them. clearTimedoutRequests(); @@ -727,4 +764,46 @@ } 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 + uint32_t totalScore = 0; + uint32_t connectedScore = 0; + + { + LOCK(cs_peerManager); + peerManager->forEachPeer([&totalScore, &connectedScore](Peer peer) { + totalScore += peer.getScore(); + if (peer.node_count > 0) { + connectedScore += peer.getScore(); + } + }); + } + + // Ensure enough is being staked overall + if (totalScore < minQuorumScore) { + LogPrint(BCLog::AVALANCHE, + "Quorum unavailable: Total score is %d; need at least %d\n", + totalScore, minQuorumScore); + return false; + } + + // Ensure we have nodes for enough of the overall stake + uint32_t minConnectedScore = + std::round(double(totalScore) * minQuorumConnectedScoreRatio); + + if (connectedScore < minConnectedScore) { + LogPrint( + BCLog::AVALANCHE, + "Quorum unavailable: Connected score is %d; need at least %d\n", + connectedScore, minConnectedScore); + 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("-avaminquorumconnectedstakeratio", "0"); + // 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("-avaminquorumconnectedstakeratio"); } 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,23 @@ 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("-avaquorumcheckinterval", + strprintf("Time to wait between checks for avalanche quorum " + "in seconds (default: %u)", + AVALANCHE_DEFAULT_QUORUM_CHECK_INTERVAL), + ArgsManager::ALLOW_INT, 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 @@ -928,6 +928,8 @@ struct AvalancheState { std::chrono::time_point last_poll; + std::chrono::time_point next_quorum_check_at; + bool quorum_available; }; AvalancheState m_avalanche_state; @@ -5000,6 +5002,8 @@ auto now = std::chrono::steady_clock::now(); int64_t cooldown = gArgs.GetArg("-avacooldown", AVALANCHE_DEFAULT_COOLDOWN); + bool quorum_available = false; + bool need_quorum_check = false; { LOCK(cs_main); @@ -5011,6 +5015,25 @@ } node_state.last_poll = now; + + // Get the currently known quorum availability and decide if we need + // to check again + quorum_available = node_state.quorum_available; + need_quorum_check = now > node_state.next_quorum_check_at; + } + + // If it's after our quorum check timeout, re-check the quorum and save + // it to the cache + if (need_quorum_check) { + quorum_available = g_avalanche && g_avalanche->isQuorumAvailable(); + + { + LOCK(cs_main); + auto &node_state = State(pfrom.GetId())->m_avalanche_state; + node_state.next_quorum_check_at = + now + quorumCheckInterval(gArgs); + node_state.quorum_available = quorum_available; + } } uint64_t round; @@ -5037,6 +5060,12 @@ // Default vote for unknown inv type uint32_t vote = -1; + // We don't vote definitively until we have an available quorum + if (!quorum_available) { + 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', '-avaminquorumconnectedstakeratio=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', + '-avaminquorumconnectedstakeratio=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,114 @@ +#!/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', + '-avaquorumcheckinterval=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, 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