diff --git a/src/llmq/dkgsessionhandler.cpp b/src/llmq/dkgsessionhandler.cpp index c9258ff353f9..7745a7890afb 100644 --- a/src/llmq/dkgsessionhandler.cpp +++ b/src/llmq/dkgsessionhandler.cpp @@ -6,13 +6,14 @@ #include #include +#include #include namespace llmq { CDKGSessionHandler::CDKGSessionHandler(const Consensus::LLMQParams& _params) : params{_params}, - // we allow size*2 messages as we need to make sure we see bad behavior (double messages) + // we allow size*2 messages per proTx as we need to make sure we see bad behavior (double messages) pendingContributions{(size_t)_params.size * 2}, pendingComplaints{(size_t)_params.size * 2}, pendingJustifications{(size_t)_params.size * 2}, @@ -25,22 +26,32 @@ CDKGSessionHandler::CDKGSessionHandler(const Consensus::LLMQParams& _params) : CDKGSessionHandler::~CDKGSessionHandler() = default; -void CDKGPendingMessages::PushPendingMessage(NodeId from, std::shared_ptr pm, const uint256& hash) +void CDKGPendingMessages::PushPendingMessage(NodeId from, const uint256& sender_protx, + std::shared_ptr pm, const uint256& hash) { LOCK(cs_messages); - if (messagesPerNode[from] >= maxMessagesPerNode) { - // TODO ban? - LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages, peer=%d\n", __func__, from); + // Check duplicates before the quota so resent hashes don't burn budget + if (seenMessages.count(hash) != 0) { + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); return; } - messagesPerNode[from]++; - if (!seenMessages.emplace(hash).second) { - LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- already seen %s, peer=%d\n", __func__, hash.ToString(), from); + // Callers always pass an identity (the MNAuth gate for remote, our own + // proTxHash for local); drop rather than share a null-keyed quota bucket + if (!Assume(!sender_protx.IsNull())) { + return; + } + auto& count = messagesPerProTx[sender_protx]; + if (count >= maxMessagesPerProTx) { + // TODO ban? + LogPrint(BCLog::LLMQ_DKG, "CDKGPendingMessages::%s -- too many messages from %s, peer=%d\n", __func__, + sender_protx.ToString(), from); return; } + count++; + seenMessages.emplace(hash); pendingMessages.emplace_back(std::make_pair(from, std::move(pm))); } @@ -67,7 +78,7 @@ void CDKGPendingMessages::Clear() { LOCK(cs_messages); pendingMessages.clear(); - messagesPerNode.clear(); + messagesPerProTx.clear(); seenMessages.clear(); } diff --git a/src/llmq/dkgsessionhandler.h b/src/llmq/dkgsessionhandler.h index be55bfcbaa8a..57e5ecaef322 100644 --- a/src/llmq/dkgsessionhandler.h +++ b/src/llmq/dkgsessionhandler.h @@ -9,7 +9,6 @@ #include #include -#include #include #include #include @@ -55,24 +54,31 @@ class CDKGPendingMessages using BinaryMessage = std::pair>; private: - const size_t maxMessagesPerNode; + const size_t maxMessagesPerProTx; mutable Mutex cs_messages; std::list pendingMessages GUARDED_BY(cs_messages); - std::map messagesPerNode GUARDED_BY(cs_messages); + // Keyed by proTxHash rather than NodeId so the quota survives reconnects, + // and cumulative for the round (not refunded on pop) so draining the queue + // does not regain retention slots. MNAuth pins keys to registered MNs. + Uint256HashMap messagesPerProTx GUARDED_BY(cs_messages); Uint256HashSet seenMessages GUARDED_BY(cs_messages); public: - explicit CDKGPendingMessages(size_t _maxMessagesPerNode) : - maxMessagesPerNode(_maxMessagesPerNode) {}; + explicit CDKGPendingMessages(size_t _maxMessagesPerProTx) : + maxMessagesPerProTx(_maxMessagesPerProTx) {}; /** * Enqueue a serialized DKG message under @p from with content hash @p hash. - * Caller is responsible for hashing the payload and (for real peers) - * routing the erase-request to PeerManager. Drops the message silently on - * per-node capacity overflow or duplicate hash. + * @p sender_protx keys the per-proTx quota: the sender's MNAuth-verified + * proTxHash for remote messages, or this node's own for messages it + * produced itself (@p from == -1). Drops the message silently on quota + * overflow or duplicate hash; quota-dropped messages are not marked seen, + * so another peer with budget can re-deliver them. Caller is responsible + * for hashing the payload and (for real peers) routing the erase-request + * to PeerManager. */ - void PushPendingMessage(NodeId from, std::shared_ptr pm, const uint256& hash) - EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); + void PushPendingMessage(NodeId from, const uint256& sender_protx, std::shared_ptr pm, + const uint256& hash) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); std::list PopPendingMessages(size_t maxCount) EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); bool HasSeen(const uint256& hash) const EXCLUSIVE_LOCKS_REQUIRED(!cs_messages); diff --git a/src/llmq/net_dkg.cpp b/src/llmq/net_dkg.cpp index a75f9da8dd5b..b42b0fdf021e 100644 --- a/src/llmq/net_dkg.cpp +++ b/src/llmq/net_dkg.cpp @@ -256,14 +256,14 @@ void RelayInvToParticipants(const CDKGSession& session, const CConnman& connman, } template -void EnqueueOwn(CDKGPendingMessages& pending, const Message& msg) +void EnqueueOwn(CDKGPendingMessages& pending, const uint256& own_protx, const Message& msg) { CDataStream ds(SER_NETWORK, PROTOCOL_VERSION); ds << msg; auto pm = std::make_shared(std::move(ds)); CHashWriter hw(SER_GETHASH, 0); hw.write(AsWritableBytes(Span{*pm})); - pending.PushPendingMessage(/*from=*/-1, std::move(pm), hw.GetHash()); + pending.PushPendingMessage(/*from=*/-1, own_protx, std::move(pm), hw.GetHash()); } template @@ -392,7 +392,9 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre // attacker-controlled payloads, so they must originate from an MNAuth-verified // masternode. qwatch is unauthenticated (any peer can set it via QWATCH) and is // only meaningful for pull/observation paths; it must not bypass this gate. - if (pfrom.GetVerifiedProRegTxHash().IsNull()) { + // Read once: the same value keys the retention quota below + const uint256 sender_protx = pfrom.GetVerifiedProRegTxHash(); + if (sender_protx.IsNull()) { m_peer_manager->PeerMisbehaving(pfrom.GetId(), 10, "DKG message from non-verified peer"); return; } @@ -524,7 +526,7 @@ void NetDKG::ProcessMessage(CNode& pfrom, const std::string& msg_type, CDataStre break; } Assume(pending != nullptr); - pending->PushPendingMessage(from, std::move(pm), hash); + pending->PushPendingMessage(from, sender_protx, std::move(pm), hash); }); if (!dispatched) { LogPrintf("NetDKG -- no session handlers for quorumIndex [%d]\n", quorumIndex); @@ -744,7 +746,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Contribute auto fContributeStart = [curSession, &handler]() { if (auto qc = curSession->Contribute(); qc) { - EnqueueOwn(handler.pendingContributions, *qc); + EnqueueOwn(handler.pendingContributions, curSession->ProTx(), *qc); } }; auto fContributeWait = [this, curSession, &handler, &active] { @@ -757,7 +759,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Complain auto fComplainStart = [curSession, &handler, &active]() { if (auto qc = curSession->VerifyAndComplain(active.connman); qc) { - EnqueueOwn(handler.pendingComplaints, *qc); + EnqueueOwn(handler.pendingComplaints, curSession->ProTx(), *qc); } }; auto fComplainWait = [this, curSession, &handler, &active] { @@ -769,7 +771,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Justify auto fJustifyStart = [curSession, &handler]() { if (auto qj = curSession->VerifyAndJustify(); qj) { - EnqueueOwn(handler.pendingJustifications, *qj); + EnqueueOwn(handler.pendingJustifications, curSession->ProTx(), *qj); } }; auto fJustifyWait = [this, curSession, &handler, &active] { @@ -781,7 +783,7 @@ void NetDKG::HandleDKGRound(ActiveDKGSessionHandler& handler) // Commit auto fCommitStart = [curSession, &handler]() { if (auto qc = curSession->VerifyAndCommit(); qc) { - EnqueueOwn(handler.pendingPrematureCommitments, *qc); + EnqueueOwn(handler.pendingPrematureCommitments, curSession->ProTx(), *qc); } }; auto fCommitWait = [this, curSession, &handler, &active] { diff --git a/src/test/llmq_dkg_tests.cpp b/src/test/llmq_dkg_tests.cpp index 9715a63f2719..127d9eaffaf2 100644 --- a/src/test/llmq_dkg_tests.cpp +++ b/src/test/llmq_dkg_tests.cpp @@ -3,6 +3,9 @@ // file COPYING or http://www.opensource.org/licenses/mit-license.php. #include +#include +#include +#include #include #include @@ -23,4 +26,98 @@ BOOST_AUTO_TEST_CASE(llmq_dkgerror) BOOST_REQUIRE(GetSimulatedErrorRate(llmq::DKGError::type::_COUNT) == 0.0); } +namespace { +std::shared_ptr MakeDKGMessage() +{ + return std::make_shared(SER_NETWORK, PROTOCOL_VERSION); +} + +uint256 MakeTestHash(uint8_t value) +{ + uint256 hash; + hash.begin()[0] = value; + return hash; +} +} // namespace + +BOOST_AUTO_TEST_CASE(pending_messages_own_messages_share_quota_path) +{ + using namespace llmq; + + const uint256 own_protx = MakeTestHash(0xee); + + // Own messages (from=-1) are enqueued under this node's proTxHash and + // charged like any other sender's. + CDKGPendingMessages pending{/*_maxMessagesPerProTx=*/2}; + pending.PushPendingMessage(/*from=*/-1, own_protx, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/-1, own_protx, MakeDKGMessage(), MakeTestHash(2)); + pending.PushPendingMessage(/*from=*/-1, own_protx, MakeDKGMessage(), MakeTestHash(3)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(2))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(3))); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(3).size(), 2U); +} + +BOOST_AUTO_TEST_CASE(pending_messages_quota_survives_reconnect) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + + CDKGPendingMessages pending{/*_maxMessagesPerProTx=*/2}; + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(1))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(2))); + + // Reconnecting mints a fresh NodeId but keeps the proTxHash, so the quota + // is already spent. + pending.PushPendingMessage(/*from=*/2, protx_a, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/3, protx_a, MakeDKGMessage(), MakeTestHash(4)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(3))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(4))); + + // Draining frees queue slots but does not refund the per-proTx quota. + BOOST_CHECK_EQUAL(pending.PopPendingMessages(5).size(), 2U); + pending.PushPendingMessage(/*from=*/4, protx_a, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(5))); + + // A new round resets everything. + pending.Clear(); + pending.PushPendingMessage(/*from=*/4, protx_a, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(5))); +} + +BOOST_AUTO_TEST_CASE(pending_messages_quota_is_per_protx) +{ + using namespace llmq; + + const uint256 protx_a = MakeTestHash(0xa1); + const uint256 protx_b = MakeTestHash(0xb1); + + CDKGPendingMessages pending{/*_maxMessagesPerProTx=*/2}; + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/1, protx_a, MakeDKGMessage(), MakeTestHash(2)); + + // Duplicates are rejected before charging the quota. + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(1)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(2)); + + // One proTx's spent quota has no effect on another's. + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(3)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(4)); + pending.PushPendingMessage(/*from=*/2, protx_b, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(3))); + BOOST_CHECK(pending.HasSeen(MakeTestHash(4))); + BOOST_CHECK(!pending.HasSeen(MakeTestHash(5))); + + // A quota-dropped hash is not marked seen, so a sender with remaining + // budget can still deliver it. + const uint256 protx_c = MakeTestHash(0xc1); + pending.PushPendingMessage(/*from=*/3, protx_c, MakeDKGMessage(), MakeTestHash(5)); + BOOST_CHECK(pending.HasSeen(MakeTestHash(5))); + + BOOST_CHECK_EQUAL(pending.PopPendingMessages(6).size(), 5U); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/functional/feature_llmq_dkg_intake.py b/test/functional/feature_llmq_dkg_intake.py index 6d955f262f73..514ef954cd43 100755 --- a/test/functional/feature_llmq_dkg_intake.py +++ b/test/functional/feature_llmq_dkg_intake.py @@ -12,18 +12,29 @@ from a verified peer. - structural pre-validation: malformed DKG payloads (valid quorum prefix, garbage body) are rejected before retention even from a verified peer. + - the per-proTx retention quota is keyed by the MNAuth-verified proTxHash, so + reconnecting under a fresh NodeId does not refill it. - a well-formed DKG message that the peer never announced and was never asked for is dropped before retention, even from a verified peer. The node must not crash; the sending peer must be scored (Misbehaving). """ -from test_framework.messages import ser_compact_size, ser_uint256 +from test_framework.messages import ( + CInv, + hash256, + msg_inv, + ser_compact_size, + ser_uint256, + uint256_from_str, +) from test_framework.p2p import P2PInterface from test_framework.test_framework import DashTestFramework from test_framework.util import wait_until_helper LLMQ_TEST = 100 +# protocol.h GetInventoryType: MSG_QUORUM_CONTRIB +MSG_QUORUM_CONTRIB = 23 # A masternode protx/operator-pubkey pair accepted by the regtest-only `mnauth` # debug RPC, used to mark a P2P connection as MNAuth-verified without BLS signing. @@ -32,6 +43,13 @@ DKG_PUSH_TYPES = [b"qcontrib", b"qcomplaint", b"qjustify", b"qpcommit"] +# LLMQ_TEST dkgInterval; phaseBlocks=2, so stage 0=Initialized, 2=Contribute, 4=Complain. +CYCLE_LENGTH = 24 + +# Mirrors CDKGPendingMessages in src/llmq/dkgsessionhandler.h: the handler is built +# with maxMessagesPerProTx = params.size * 2, per message type. +MAX_MESSAGES_PER_PROTX_FACTOR = 2 + class msg_dkg_raw: """A DKG push message carrying an arbitrary raw payload (for adversarial intake tests).""" @@ -48,10 +66,12 @@ def __repr__(self): return "msg_dkg_raw(type=%s, len=%d)" % (self.msgtype, len(self.payload)) -def get_p2p_id(node): +def get_p2p_id(node, uacomment=None): def get_id(): for p in node.getpeerinfo(): for p2p in node.p2ps: + if uacomment is not None and p2p.uacomment != uacomment: + continue if p["subver"] == p2p.strSubVer: return p["id"] return None @@ -68,6 +88,21 @@ def get_score(): wait_until_helper(lambda: get_score() == expected_score, timeout=10) +def send_requested_qcontrib(peer, payload): + """Announce, wait for GETDATA, then deliver a QCONTRIB payload. + + DKG objects only travel inv -> getdata -> object. NetDKG::ProcessMessage scores + unsolicited pushes before retention, so tests that need the message to reach the + pending queue must complete a real request first. Inventory hash matches + CHashWriter(SER_GETHASH, 0) over the raw wire bytes. + """ + inv_hash = uint256_from_str(hash256(payload)) + peer.send_message(msg_inv([CInv(MSG_QUORUM_CONTRIB, inv_hash)])) + peer.wait_for_getdata([inv_hash]) + peer.send_message(msg_dkg_raw(b"qcontrib", payload)) + peer.sync_with_ping() + + class DkgIntakeTest(DashTestFramework): def add_options(self, parser): self.add_wallet_options(parser) @@ -75,8 +110,9 @@ def add_options(self, parser): def set_test_params(self): # -whitelist keeps the adversarial peer connected even after it crosses the # discouragement threshold, so banscore stays observable for the score==100 cases. - # -debug=net surfaces the Misbehaving reason strings in debug.log. - extra_args = [["-whitelist=127.0.0.1", "-debug=net", "-deprecatedrpc=banscore"]] * 4 + # -debug=net surfaces the Misbehaving reason strings in debug.log, while + # -debug=llmq-dkg exposes queue-boundary behavior (quota drops). + extra_args = [["-whitelist=127.0.0.1", "-debug=net", "-debug=llmq-dkg", "-deprecatedrpc=banscore"]] * 4 self.set_dash_test_params(4, 3, extra_args=extra_args) def quorum_hash_prefix(self): @@ -85,12 +121,12 @@ def quorum_hash_prefix(self): # real in-progress quorum and reach the size/structural checks. return bytes([LLMQ_TEST]) + ser_uint256(int(self.quorum_hash, 16)) - def qcontrib_payload(self, blob_count): + def qcontrib_payload(self, blob_count, protx_hash=0): # CDKGContribution: llmqType, quorumHash, proTxHash, vvec, contributions, sig. # LLMQ_TEST uses threshold=2/minSize=2 by default, so blob_count=1 is # well-formed enough to deserialize but below the contribution lower bound. r = self.quorum_hash_prefix() - r += ser_uint256(0) # proTxHash + r += ser_uint256(protx_hash) r += ser_compact_size(2) + b"\x00" * (2 * 48) # BLSVerificationVector r += b"\x00" * 48 # CBLSIESMultiRecipientBlobs::ephemeralPubKey r += b"\x00" * 32 # CBLSIESMultiRecipientBlobs::ivSeed @@ -100,10 +136,10 @@ def qcontrib_payload(self, blob_count): r += b"\x00" * 96 # sig return r - def add_verified_peer(self, node): - peer = node.add_p2p_connection(P2PInterface()) - peer_id = get_p2p_id(node) - assert node.mnauth(peer_id, FAKE_PROTX, FAKE_PUBKEY) + def add_verified_peer(self, node, uacomment=None, protx=FAKE_PROTX): + peer = node.add_p2p_connection(P2PInterface(), uacomment=uacomment) + peer_id = get_p2p_id(node, uacomment) + assert node.mnauth(peer_id, protx, FAKE_PUBKEY) return peer, peer_id def run_test(self): @@ -120,6 +156,7 @@ def run_test(self): self.test_unverified_sender_rejected(mn_node) self.test_oversized_rejected(mn_node) self.test_malformed_rejected(mn_node) + self.test_late_messages_bounded(mn_node) self.test_under_min_contribution_blobs_rejected(mn_node) self.test_unrequested_rejected(mn_node) @@ -163,6 +200,74 @@ def test_malformed_rejected(self, node): wait_for_banscore(node, peer_id, 100) node.disconnect_p2ps() + def _start_fresh_dkg_cycle(self, nodes): + """Land on the base block of a fresh DKG cycle (phase 1 / Initialized).""" + skip_count = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) + # move_blocks (not plain generate) so mocktime keeps up with the DKG phase clock. + self.move_blocks(nodes, skip_count) + self.quorum_hash = self.nodes[0].getbestblockhash() + self.wait_for_quorum_phase(self.quorum_hash, 1, self.llmq_size, None, 0, self.mninfo) + + def _send_late_qcontrib(self, peer, nonce): + """Send a well-formed QCONTRIB that no on-time worker will drain. + + Unique proTxHash bytes per message so retention is bounded by the quotas + rather than by duplicate-hash suppression. + """ + send_requested_qcontrib(peer, self.qcontrib_payload(blob_count=2, protx_hash=nonce)) + + def test_late_messages_bounded(self, node): + self.log.info("Late QCONTRIB retention is bounded per proTx, then cleared at round start") + nodes = [self.nodes[0]] + [mn.get_node(self) for mn in self.mninfo] + self._start_fresh_dkg_cycle(nodes) + stage = self.nodes[0].getblockcount() % CYCLE_LENGTH + assert stage == 0, "expected DKG cycle base, got stage %d" % stage + # Park in Complain so nothing drains pendingContributions for the rest of the round. + complain_stage = 4 + self.move_blocks(nodes, complain_stage - stage) + assert self.nodes[0].getblockcount() % CYCLE_LENGTH == complain_stage + + # The per-proTx quota is keyed by the verified proTxHash, so a peer that + # reconnects under a fresh NodeId keeps spending the same budget. + protx_quota = MAX_MESSAGES_PER_PROTX_FACTOR * self.llmq_size + nonce = 0 + for i in range(protx_quota): + nonce += 1 + peer, peer_id = self.add_verified_peer(node, "dkg-reconnect-%d" % i) + self._send_late_qcontrib(peer, nonce) + wait_for_banscore(node, peer_id, 0) + peer.peer_disconnect() + peer.wait_for_disconnect() + + nonce += 1 + quota_peer, quota_peer_id = self.add_verified_peer(node, "dkg-reconnect-over") + with node.assert_debug_log(["too many messages from %s" % FAKE_PROTX]): + self._send_late_qcontrib(quota_peer, nonce) + wait_for_banscore(node, quota_peer_id, 0) + + # A distinct proTx has its own quota. Keep it connected to verify that + # round-start clearing does not score its retained message. + nonce += 1 + retained_peer, retained_peer_id = self.add_verified_peer(node, "dkg-retained", protx="%064x" % 0xd0) + self._send_late_qcontrib(retained_peer, nonce) + wait_for_banscore(node, retained_peer_id, 0) + + # Crossing the round boundary must clear the raw queue; the retained + # messages must never reach a worker (whose preverification would score + # their unknown proTxHashes). + remaining = CYCLE_LENGTH - (self.nodes[0].getblockcount() % CYCLE_LENGTH) + with node.assert_debug_log( + [], + unexpected_msgs=["failed preverification"], + timeout=60, + ): + self.move_blocks(nodes, remaining) + self.quorum_hash = self.nodes[0].getbestblockhash() + self.wait_for_quorum_phase(self.quorum_hash, 1, self.llmq_size, None, 0, self.mninfo) + wait_for_banscore(node, retained_peer_id, 0) + wait_for_banscore(node, quota_peer_id, 0) + node.disconnect_p2ps() + def test_under_min_contribution_blobs_rejected(self, node): self.log.info("QCONTRIB with fewer than minSize encrypted blobs is rejected (Misbehaving 100)") peer, peer_id = self.add_verified_peer(node)