diff --git a/src/masternodes/masternodes.cpp b/src/masternodes/masternodes.cpp index 84cc87f9966..57e991f4ff5 100644 --- a/src/masternodes/masternodes.cpp +++ b/src/masternodes/masternodes.cpp @@ -99,7 +99,7 @@ CMasternode::State CMasternode::GetState(int height) const // Special case for genesis block int activationDelay = height < EunosPayaHeight ? GetMnActivationDelay(height) : GetMnActivationDelay(creationHeight); if (creationHeight == 0 || height >= creationHeight + activationDelay) { - return State::ENABLED; + return State::ENABLED; } return State::PRE_ENABLED; } @@ -107,7 +107,7 @@ CMasternode::State CMasternode::GetState(int height) const if (resignHeight != -1) { // pre-resigned or resigned int resignDelay = height < EunosPayaHeight ? GetMnResignDelay(height) : GetMnResignDelay(resignHeight); if (height < resignHeight + resignDelay) { - return State::PRE_RESIGNED; + return State::PRE_RESIGNED; } return State::RESIGNED; } @@ -363,6 +363,35 @@ Res CMasternodesView::RemForcedRewardAddress(uint256 const & nodeId, int height) return Res::Ok(); } +Res CMasternodesView::UpdateMasternode(uint256 const & nodeId, char operatorType, const CKeyID& operatorAuthAddress, int height) { + // auth already checked! + auto node = GetMasternode(nodeId); + if (!node) { + return Res::Err("node %s does not exists", nodeId.ToString()); + } + + const auto state = node->GetState(height); + if (state != CMasternode::ENABLED) { + return Res::Err("node %s state is not 'ENABLED'", nodeId.ToString()); + } + + if (operatorType == node->operatorType && operatorAuthAddress == node->operatorAuthAddress) { + return Res::Err("The new operator is same as existing operator"); + } + + // Remove old record + EraseBy(node->operatorAuthAddress); + + node->operatorType = operatorType; + node->operatorAuthAddress = operatorAuthAddress; + + // Overwrite and create new record + WriteBy(nodeId, *node); + WriteBy(node->operatorAuthAddress, nodeId); + + return Res::Ok(); +} + void CMasternodesView::SetMasternodeLastBlockTime(const CKeyID & minter, const uint32_t &blockHeight, const int64_t& time) { auto nodeId = GetMasternodeIdByOperator(minter); diff --git a/src/masternodes/masternodes.h b/src/masternodes/masternodes.h index b2b587eb9aa..bb7d042e4d2 100644 --- a/src/masternodes/masternodes.h +++ b/src/masternodes/masternodes.h @@ -196,6 +196,7 @@ class CMasternodesView : public virtual CStorageView Res UnResignMasternode(uint256 const & nodeId, uint256 const & resignTx); Res SetForcedRewardAddress(uint256 const & nodeId, const char rewardAddressType, CKeyID const & rewardAddress, int height); Res RemForcedRewardAddress(uint256 const & nodeId, int height); + Res UpdateMasternode(uint256 const & nodeId, char operatorType, const CKeyID& operatorAuthAddress, int height); // Get blocktimes for non-subnode and subnode with fork logic std::vector GetBlockTimes(const CKeyID& keyID, const uint32_t blockHeight, const int32_t creationHeight, const uint16_t timelock); diff --git a/src/masternodes/mn_checks.cpp b/src/masternodes/mn_checks.cpp index 0d54062306a..772a792ee98 100644 --- a/src/masternodes/mn_checks.cpp +++ b/src/masternodes/mn_checks.cpp @@ -28,8 +28,9 @@ std::string ToString(CustomTxType type) { { case CustomTxType::CreateMasternode: return "CreateMasternode"; case CustomTxType::ResignMasternode: return "ResignMasternode"; - case CustomTxType::SetForcedRewardAddress: return "SetForcedRewardAddress"; + case CustomTxType::SetForcedRewardAddress: return "SetForcedRewardAddress"; case CustomTxType::RemForcedRewardAddress: return "RemForcedRewardAddress"; + case CustomTxType::UpdateMasternode: return "UpdateMasternode"; case CustomTxType::CreateToken: return "CreateToken"; case CustomTxType::UpdateToken: return "UpdateToken"; case CustomTxType::UpdateTokenAny: return "UpdateTokenAny"; @@ -124,6 +125,7 @@ CCustomTxMessage customTypeToMessage(CustomTxType txType) { case CustomTxType::ResignMasternode: return CResignMasterNodeMessage{}; case CustomTxType::SetForcedRewardAddress: return CSetForcedRewardAddressMessage{}; case CustomTxType::RemForcedRewardAddress: return CRemForcedRewardAddressMessage{}; + case CustomTxType::UpdateMasternode: return CUpdateMasterNodeMessage{}; case CustomTxType::CreateToken: return CCreateTokenMessage{}; case CustomTxType::UpdateToken: return CUpdateTokenPreAMKMessage{}; case CustomTxType::UpdateTokenAny: return CUpdateTokenMessage{}; @@ -259,6 +261,11 @@ class CCustomMetadataParseVisitor : public boost::static_visitor return !res ? res : serialize(obj); } + Res operator()(CUpdateMasterNodeMessage& obj) const { + auto res = isPostFortCanningFork(); + return !res ? res : serialize(obj); + } + Res operator()(CCreateTokenMessage& obj) const { auto res = isPostAMKFork(); return !res ? res : serialize(obj); @@ -910,6 +917,11 @@ class CCustomTxApplyVisitor : public CCustomTxVisitor return mnview.RemForcedRewardAddress(obj.nodeId, height); } + Res operator()(const CUpdateMasterNodeMessage& obj) const { + auto res = HasCollateralAuth(obj.mnId); + return !res ? res : mnview.UpdateMasternode(obj.mnId, obj.operatorType, obj.operatorAuthAddress, height); + } + Res operator()(const CCreateTokenMessage& obj) const { auto res = CheckTokenCreationTx(); if (!res) { diff --git a/src/masternodes/mn_checks.h b/src/masternodes/mn_checks.h index abde35d05cf..31131c35a85 100644 --- a/src/masternodes/mn_checks.h +++ b/src/masternodes/mn_checks.h @@ -37,8 +37,9 @@ enum class CustomTxType : uint8_t Reject = 1, // Invalid TX type. Returned by GuessCustomTxType on invalid custom TX. // masternodes: - CreateMasternode = 'C', - ResignMasternode = 'R', + CreateMasternode = 'C', + ResignMasternode = 'R', + UpdateMasternode = 'm', SetForcedRewardAddress = 'F', RemForcedRewardAddress = 'f', // custom tokens: @@ -83,7 +84,7 @@ enum class CustomTxType : uint8_t // Loans LoanSetCollateralToken = 'c', LoanSetLoanToken = 'g', - LoanUpdateLoanToken = 'f', + LoanUpdateLoanToken = 'x', LoanScheme = 'L', DefaultLoanScheme = 'd', DestroyLoanScheme = 'D', @@ -92,7 +93,7 @@ enum class CustomTxType : uint8_t UpdateVault = 'v', DepositToVault = 'S', WithdrawFromVault = 'J', - LoanTakeLoan = 'F', + LoanTakeLoan = 'X', LoanPaybackLoan = 'H', AuctionBid = 'I' }; @@ -104,6 +105,7 @@ inline CustomTxType CustomTxCodeToType(uint8_t ch) { case CustomTxType::ResignMasternode: case CustomTxType::SetForcedRewardAddress: case CustomTxType::RemForcedRewardAddress: + case CustomTxType::UpdateMasternode: case CustomTxType::CreateToken: case CustomTxType::MintToken: case CustomTxType::UpdateToken: @@ -230,6 +232,20 @@ struct CRemForcedRewardAddressMessage { } }; +struct CUpdateMasterNodeMessage { + uint256 mnId; + char operatorType; + CKeyID operatorAuthAddress; + + ADD_SERIALIZE_METHODS; + template + inline void SerializationOp(Stream& s, Operation ser_action) { + READWRITE(mnId); + READWRITE(operatorType); + READWRITE(operatorAuthAddress); + } +}; + struct CCreateTokenMessage : public CToken { using CToken::CToken; @@ -305,6 +321,7 @@ typedef boost::variant< CResignMasterNodeMessage, CSetForcedRewardAddressMessage, CRemForcedRewardAddressMessage, + CUpdateMasterNodeMessage, CCreateTokenMessage, CUpdateTokenPreAMKMessage, CUpdateTokenMessage, diff --git a/src/masternodes/rpc_customtx.cpp b/src/masternodes/rpc_customtx.cpp index b480885740f..86fac6a5c97 100644 --- a/src/masternodes/rpc_customtx.cpp +++ b/src/masternodes/rpc_customtx.cpp @@ -67,7 +67,7 @@ class CCustomTxRpcVisitor : public boost::static_visitor void operator()(const CCreateMasterNodeMessage& obj) const { rpcInfo.pushKV("collateralamount", ValueFromAmount(GetMnCollateralAmount(height))); - rpcInfo.pushKV("masternodeoperator", EncodeDestination(obj.operatorType == 1 ? + rpcInfo.pushKV("masternodeoperator", EncodeDestination(obj.operatorType == PKHashType ? CTxDestination(PKHash(obj.operatorAuthAddress)) : CTxDestination(WitnessV0KeyHash(obj.operatorAuthAddress)))); rpcInfo.pushKV("timelock", CMasternode::GetTimelockToString(static_cast(obj.timelock))); @@ -90,6 +90,13 @@ class CCustomTxRpcVisitor : public boost::static_visitor rpcInfo.pushKV("mc_id", obj.nodeId.GetHex()); } + void operator()(const CUpdateMasterNodeMessage& obj) const { + rpcInfo.pushKV("id", obj.mnId.GetHex()); + rpcInfo.pushKV("masternodeoperator", EncodeDestination(obj.operatorType == PKHashType ? + CTxDestination(PKHash(obj.operatorAuthAddress)) : + CTxDestination(WitnessV0KeyHash(obj.operatorAuthAddress)))); + } + void operator()(const CCreateTokenMessage& obj) const { rpcInfo.pushKV("creationTx", tx.GetHash().GetHex()); tokenInfo(obj); diff --git a/src/masternodes/rpc_masternodes.cpp b/src/masternodes/rpc_masternodes.cpp index 567d7b58003..6debab356e5 100644 --- a/src/masternodes/rpc_masternodes.cpp +++ b/src/masternodes/rpc_masternodes.cpp @@ -207,7 +207,7 @@ UniValue createmasternode(const JSONRPCRequest& request) UniValue setforcedrewardaddress(const JSONRPCRequest& request) { - CWallet* const pwallet = GetWallet(request); + auto pwallet = GetWallet(request); RPCHelpMan{"setforcedrewardaddress", "\nCreates (and submits to local node and network) a set forced reward address transaction with given masternode id and reward address\n" @@ -241,7 +241,6 @@ UniValue setforcedrewardaddress(const JSONRPCRequest& request) } pwallet->BlockUntilSyncedToCurrentChain(); - LockedCoinsScopedGuard lcGuard(pwallet); RPCTypeCheck(request.params, { UniValue::VSTR, UniValue::VSTR, UniValue::VARR }, true); @@ -311,7 +310,7 @@ UniValue setforcedrewardaddress(const JSONRPCRequest& request) if (optAuthTx) AddCoins(coins, *optAuthTx, targetHeight); auto metadata = ToByteVector(CDataStream{SER_NETWORK, PROTOCOL_VERSION, msg}); - execTestTx(CTransaction(rawTx), targetHeight, metadata, CSetForcedRewardAddressMessage{}, coins); + execTestTx(CTransaction(rawTx), targetHeight, optAuthTx); } return signsend(rawTx, pwallet, optAuthTx)->GetHash().GetHex(); @@ -319,7 +318,7 @@ UniValue setforcedrewardaddress(const JSONRPCRequest& request) UniValue remforcedrewardaddress(const JSONRPCRequest& request) { - CWallet* const pwallet = GetWallet(request); + auto pwallet = GetWallet(request); RPCHelpMan{"remforcedrewardaddress", "\nCreates (and submits to local node and network) a remove forced reward address transaction with given masternode id\n" @@ -352,7 +351,6 @@ UniValue remforcedrewardaddress(const JSONRPCRequest& request) } pwallet->BlockUntilSyncedToCurrentChain(); - LockedCoinsScopedGuard lcGuard(pwallet); RPCTypeCheck(request.params, { UniValue::VSTR, UniValue::VARR }, true); @@ -411,7 +409,7 @@ UniValue remforcedrewardaddress(const JSONRPCRequest& request) if (optAuthTx) AddCoins(coins, *optAuthTx, targetHeight); auto metadata = ToByteVector(CDataStream{SER_NETWORK, PROTOCOL_VERSION, msg}); - execTestTx(CTransaction(rawTx), targetHeight, metadata, CRemForcedRewardAddressMessage{}, coins); + execTestTx(CTransaction(rawTx), targetHeight, optAuthTx); } return signsend(rawTx, pwallet, optAuthTx)->GetHash().GetHex(); @@ -504,6 +502,122 @@ UniValue resignmasternode(const JSONRPCRequest& request) return signsend(rawTx, pwallet, optAuthTx)->GetHash().GetHex(); } +UniValue updatemasternode(const JSONRPCRequest& request) +{ + auto pwallet = GetWallet(request); + + RPCHelpMan{"updatemasternode", + "\nCreates (and submits to local node and network) a masternode update transaction which update the masternode operator addresses, spending the given inputs..\n" + "The last optional argument (may be empty array) is an array of specific UTXOs to spend." + + HelpRequiringPassphrase(pwallet) + "\n", + { + {"mn_id", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The Masternode's ID"}, + {"operatorAddress", RPCArg::Type::STR, RPCArg::Optional::NO, "The new masternode operator auth address (P2PKH only, unique)"}, + {"inputs", RPCArg::Type::ARR, RPCArg::Optional::OMITTED_NAMED_ARG, "A json array of json objects", + { + {"", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED, "", + { + {"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The transaction id"}, + {"vout", RPCArg::Type::NUM, RPCArg::Optional::NO, "The output number"}, + }, + }, + }, + }, + }, + RPCResult{ + "\"hash\" (string) The hex-encoded hash of broadcasted transaction\n" + }, + RPCExamples{ + HelpExampleCli("updatemasternode", "mn_id operatorAddress '[{\"txid\":\"id\",\"vout\":0}]'") + + HelpExampleRpc("updatemasternode", "mn_id operatorAddress '[{\"txid\":\"id\",\"vout\":0}]'") + }, + }.Check(request); + + if (pwallet->chain().isInitialBlockDownload()) { + throw JSONRPCError(RPC_CLIENT_IN_INITIAL_DOWNLOAD, + "Cannot update Masternode while still in Initial Block Download"); + } + pwallet->BlockUntilSyncedToCurrentChain(); + + bool forkCanning; + { + LOCK(cs_main); + forkCanning = ::ChainActive().Tip()->height >= Params().GetConsensus().FortCanningHeight; + } + + if (!forkCanning) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "updatemasternode cannot be called before Fortcanning hard fork"); + } + + RPCTypeCheck(request.params, { UniValue::VSTR, UniValue::VSTR, UniValue::VARR }, true); + if (request.params[0].isNull() || request.params[1].isNull()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Invalid parameters, at least argument 2 must be non-null"); + } + + std::string const nodeIdStr = request.params[0].getValStr(); + uint256 const nodeId = uint256S(nodeIdStr); + CTxDestination ownerDest; + int targetHeight; + { + LOCK(cs_main); + auto nodePtr = pcustomcsview->GetMasternode(nodeId); + if (!nodePtr) { + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("The masternode %s does not exist", nodeIdStr)); + } + ownerDest = nodePtr->ownerType == 1 ? CTxDestination(PKHash(nodePtr->ownerAuthAddress)) : CTxDestination(WitnessV0KeyHash(nodePtr->ownerAuthAddress)); + + targetHeight = ::ChainActive().Height() + 1; + } + + std::string operatorAddress = request.params[1].getValStr(); + CTxDestination operatorDest = DecodeDestination(operatorAddress); + + // check type here cause need operatorAuthKey. all other validation (for owner for ex.) in further apply/create + if (operatorDest.which() != PKHashType && operatorDest.which() != WitV0KeyHashType) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "operatorAddress (" + operatorAddress + ") does not refer to a P2PKH or P2WPKH address"); + } + + const auto txVersion = GetTransactionVersion(targetHeight); + CMutableTransaction rawTx(txVersion); + + CTransactionRef optAuthTx; + std::set auths{GetScriptForDestination(ownerDest)}; + rawTx.vin = GetAuthInputsSmart(pwallet, rawTx.nVersion, auths, false, optAuthTx, request.params[2]); + + // Return change to owner address + CCoinControl coinControl; + if (IsValidDestination(ownerDest)) { + coinControl.destChange = ownerDest; + } + + CKeyID const operatorAuthKey = operatorDest.which() == PKHashType ? CKeyID(*boost::get(&operatorDest)) : CKeyID(*boost::get(&operatorDest)); + + CDataStream metadata(DfTxMarker, SER_NETWORK, PROTOCOL_VERSION); + metadata << static_cast(CustomTxType::UpdateMasternode) + << nodeId + << static_cast(operatorDest.which()) << operatorAuthKey; + + CScript scriptMeta; + scriptMeta << OP_RETURN << ToByteVector(metadata); + + rawTx.vout.push_back(CTxOut(0, scriptMeta)); + + fund(rawTx, pwallet, optAuthTx, &coinControl); + + // check execution + { + LOCK(cs_main); + CCoinsViewCache coins(&::ChainstateActive().CoinsTip()); + if (optAuthTx) + AddCoins(coins, *optAuthTx, targetHeight); + auto stream = CDataStream{SER_NETWORK, PROTOCOL_VERSION, nodeId, static_cast(operatorDest.which()), operatorAuthKey}; + + auto metadata = ToByteVector(stream); + execTestTx(CTransaction(rawTx), targetHeight, optAuthTx); + } + return signsend(rawTx, pwallet, optAuthTx)->GetHash().GetHex(); +} + UniValue listmasternodes(const JSONRPCRequest& request) { auto pwallet = GetWallet(request); @@ -885,6 +999,7 @@ static const CRPCCommand commands[] = // --------------- ---------------------- --------------------- ---------- {"masternodes", "createmasternode", &createmasternode, {"ownerAddress", "operatorAddress", "inputs"}}, {"masternodes", "resignmasternode", &resignmasternode, {"mn_id", "inputs"}}, + {"masternodes", "updatemasternode", &updatemasternode, {"mn_id", "operatorAddress", "inputs"}}, {"masternodes", "listmasternodes", &listmasternodes, {"pagination", "verbose"}}, {"masternodes", "getmasternode", &getmasternode, {"mn_id"}}, {"masternodes", "getmasternodeblocks", &getmasternodeblocks, {"identifier", "depth"}}, diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 4102e8fba7c..cfea83e3969 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -178,6 +178,7 @@ static const CRPCConvertParam vRPCConvertParams[] = { "resignmasternode", 1, "inputs" }, { "setforcedrewardaddress", 2, "inputs" }, { "remforcedrewardaddress", 1, "inputs" }, + { "updatemasternode", 2, "inputs" }, { "listmasternodes", 0, "pagination" }, { "listmasternodes", 1, "verbose" }, { "getmasternodeblocks", 0, "identifier"}, diff --git a/test/functional/feature_update_mn.py b/test/functional/feature_update_mn.py new file mode 100644 index 00000000000..94b7cacecb8 --- /dev/null +++ b/test/functional/feature_update_mn.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# Copyright (c) 2014-2019 The Bitcoin Core developers +# Copyright (c) DeFi Blockchain Developers +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. +"""Test the masternodes RPC. + +- verify basic MN creation and resign +""" + +from test_framework.test_framework import DefiTestFramework + +from test_framework.util import ( + assert_equal, + assert_raises_rpc_error, +) + +class MasternodesRpcBasicTest (DefiTestFramework): + def set_test_params(self): + self.num_nodes = 3 + self.setup_clean_chain = True + self.extra_args = [['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50', '-bayfrontgardensheight=50', '-dakotaheight=136', '-eunosheight=140', '-eunospayaheight=140', '-fortcanningheight=145'], + ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50', '-bayfrontgardensheight=50', '-dakotaheight=136', '-eunosheight=140', '-eunospayaheight=140', '-fortcanningheight=145'], + ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50', '-bayfrontgardensheight=50', '-dakotaheight=136', '-eunosheight=140', '-eunospayaheight=140', '-fortcanningheight=145']] + + def run_test(self): + assert_equal(len(self.nodes[0].listmasternodes()), 8) + self.nodes[0].generate(100) + self.sync_all() + + # CREATION: + #======================== + + collateral0 = self.nodes[0].getnewaddress("", "legacy") + + # Create node0 + self.nodes[0].generate(1) + collateral1 = self.nodes[1].getnewaddress("", "legacy") + assert_raises_rpc_error(-8, "Address ({}) is not owned by the wallet".format(collateral1), self.nodes[0].createmasternode, collateral1) + + idnode0 = self.nodes[0].createmasternode( + collateral0 + ) + + # Create and sign (only) collateral spending tx + spendTx = self.nodes[0].createrawtransaction([{'txid':idnode0, 'vout':1}],[{collateral0:9.999}]) + signedTx = self.nodes[0].signrawtransactionwithwallet(spendTx) + assert_equal(signedTx['complete'], True) + + self.nodes[0].generate(1) + # At this point, mn was created + assert_equal(self.nodes[0].listmasternodes({}, False)[idnode0], "PRE_ENABLED") + assert_equal(self.nodes[0].getmasternode(idnode0)[idnode0]["state"], "PRE_ENABLED") + self.nodes[0].generate(10) + assert_equal(self.nodes[0].listmasternodes({}, False)[idnode0], "ENABLED") + assert_equal(self.nodes[1].listmasternodes()[idnode0]["operatorAuthAddress"], collateral0) + + self.sync_all() + + # UPDATEING + #======================== + assert_raises_rpc_error(-8, "updatemasternode cannot be called before Fortcanning hard fork", self.nodes[0].updatemasternode, idnode0, collateral0) + + self.nodes[0].generate(50) + + assert_raises_rpc_error(-32600, "The new operator is same as existing operator", self.nodes[0].updatemasternode, idnode0, collateral0) + + # node 1 try to update node 0 which should be rejected. + assert_raises_rpc_error(-5, "Incorrect authorization", self.nodes[1].updatemasternode, idnode0, collateral1) + + self.nodes[0].updatemasternode(idnode0, collateral1) + self.nodes[0].generate(1) + self.sync_all() + + assert_equal(self.nodes[1].listmasternodes()[idnode0]["operatorAuthAddress"], collateral1) + + # RESIGNING: + #======================== + + # Funding auth address and successful resign + self.nodes[0].sendtoaddress(collateral0, 1) + self.nodes[0].generate(1) + # resignTx + self.nodes[0].resignmasternode(idnode0) + self.nodes[0].generate(1) + assert_equal(self.nodes[0].listmasternodes()[idnode0]['state'], "PRE_RESIGNED") + self.nodes[0].generate(40) + self.sync_all() + assert_equal(self.nodes[0].listmasternodes()[idnode0]['state'], "RESIGNED") + + # Spend unlocked collateral + # This checks two cases at once: + # 1) Finally, we should not fail on accept to mempool + # 2) But we don't mine blocks after it, so, after chain reorg (on 'REVERTING'), we should not fail: tx should be removed from mempool! + self.nodes[0].sendrawtransaction(signedTx['hex']) + # Don't mine here, check mempool after reorg! + # self.nodes[0].generate(1) + +if __name__ == '__main__': + MasternodesRpcBasicTest ().main () diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 08947295ae2..88896f434f9 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -270,6 +270,7 @@ 'feature_burn_address.py', 'feature_eunos_balances.py', 'feature_sendutxosfrom.py', + 'feature_update_mn.py', 'feature_block_reward.py', # Don't append tests at the end to avoid merge conflicts # Put them in a random line within the section that fits their approximate run-time