diff --git a/doc/release-notes-7353.md b/doc/release-notes-7353.md new file mode 100644 index 000000000000..9cbe92587786 --- /dev/null +++ b/doc/release-notes-7353.md @@ -0,0 +1,27 @@ +Notable changes +=============== + +Consensus / Masternodes +----------------------- + +- DIP-0026 (Multi-Party Payouts) is implemented behind a new version-bits deployment, `v25` + (EHF, version bit 13), which additionally requires `v24` (extended addresses) to be active. + Once `v25` activates, a masternode can split its owner-side block reward on-chain among up to + 32 payees instead of a single owner payout, using a new version 4 ProRegTx/ProUpRegTx. All + pre-activation behavior is unchanged: version 4 transactions are rejected until `v25` is active, + and the serialized forms for existing (pre-v4) masternodes are byte-for-byte identical. The + coinbase splits the owner reward across the payees by basis points (floor with a deterministic + one-duff remainder), summing to the owner reward exactly. (#7353) + +RPC +--- + +- `protx register`, `protx register_fund`, `protx register_prepare` and `protx update_registrar` + now accept the `payoutAddress` argument either as a single address (unchanged) or, once DIP-0026 + (`v25`) is active, as a JSON object mapping each payout address to its share in basis points + (1-10000), e.g. `{"XaddrA": 6000, "XaddrB": 4000}`. The shares must be unique and sum to exactly + 10000. Over dash-cli the object is passed as a quoted JSON string. (#7353) + +- `protx info`, `protx list` and `masternodelist` now report a `payoutShares` array for version 4 + masternodes; the single `payoutAddress` field is omitted for those entries. Output for pre-v4 + masternodes is unchanged. (#7353) diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 375c9940d059..d76789b37321 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -113,6 +113,7 @@ BITCOIN_TESTS =\ test/evo_islock_tests.cpp \ test/evo_mnhf_tests.cpp \ test/evo_netinfo_tests.cpp \ + test/evo_providertx_tests.cpp \ test/evo_simplifiedmns_tests.cpp \ test/evo_trivialvalidation.cpp \ test/evo_utils_tests.cpp \ diff --git a/src/chainparams.cpp b/src/chainparams.cpp index d7d491ba83e0..e37a92905ec6 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -218,6 +218,15 @@ class CMainParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].bit = 13; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; // TODO + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; // TODO + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nWindowSize = 4032; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdStart = 3226; // 80% of 4032 + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdMin = 2420; // 60% of 4032 + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_V25].useEHF = true; + consensus.nMinimumChainWork = uint256S("0x00000000000000000000000000000000000000000000b9040746437784aaec47"); // 2471728 consensus.defaultAssumeValid = uint256S("0x000000000000001a19ad7270422a00f86123ea94e0b295a3a796d6861bd7b032"); // 2471728 @@ -419,6 +428,15 @@ class CTestNetParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].bit = 13; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; // TODO + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nWindowSize = 100; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdStart = 80; // 80% of 100 + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdMin = 60; // 60% of 100 + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_V25].useEHF = true; + consensus.nMinimumChainWork = uint256S("0x000000000000000000000000000000000000000000000000036c8f738da818d2"); // 1400000 consensus.defaultAssumeValid = uint256S("0x000000541a23f9db7411cddbe50f9f1ebd4aa7108ebdcad62214753f648c0239"); // 1400000 @@ -594,6 +612,15 @@ class CDevNetParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].bit = 13; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nStartTime = Consensus::BIP9Deployment::NEVER_ACTIVE; // TODO + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nWindowSize = 120; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdStart = 96; // 80% of 120 + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdMin = 72; // 60% of 120 + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_V25].useEHF = true; + consensus.nMinimumChainWork = uint256{}; consensus.defaultAssumeValid = uint256{}; @@ -831,6 +858,15 @@ class CRegTestParams : public CChainParams { consensus.vDeployments[Consensus::DEPLOYMENT_V24].nFalloffCoeff = 5; // this corresponds to 10 periods consensus.vDeployments[Consensus::DEPLOYMENT_V24].useEHF = true; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].bit = 13; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nStartTime = 0; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nTimeout = Consensus::BIP9Deployment::NO_TIMEOUT; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nWindowSize = 250; + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdStart = 250 / 5 * 4; // 80% of window size + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nThresholdMin = 250 / 5 * 3; // 60% of window size + consensus.vDeployments[Consensus::DEPLOYMENT_V25].nFalloffCoeff = 5; // this corresponds to 10 periods + consensus.vDeployments[Consensus::DEPLOYMENT_V25].useEHF = true; + consensus.nMinimumChainWork = uint256{}; consensus.defaultAssumeValid = uint256{}; diff --git a/src/consensus/params.h b/src/consensus/params.h index 37b5c75f2896..06ee9a09ce3d 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -41,6 +41,7 @@ constexpr bool ValidDeployment(BuriedDeployment dep) { return dep <= DEPLOYMENT_ enum DeploymentPos : uint16_t { DEPLOYMENT_TESTDUMMY, DEPLOYMENT_V24, // Deployment of doubling withdrawal limit, extended addresses + DEPLOYMENT_V25, // Deployment of DIP0026 multi-party masternode payouts // NOTE: Also add new deployments to VersionBitsDeploymentInfo in deploymentinfo.cpp MAX_VERSION_BITS_DEPLOYMENTS }; diff --git a/src/deploymentinfo.cpp b/src/deploymentinfo.cpp index 856403e32889..0ce08a6e7186 100644 --- a/src/deploymentinfo.cpp +++ b/src/deploymentinfo.cpp @@ -15,6 +15,10 @@ const struct VBDeploymentInfo VersionBitsDeploymentInfo[Consensus::MAX_VERSION_B /*.name =*/"v24", /*.gbt_force =*/true, }, + { + /*.name =*/"v25", + /*.gbt_force =*/true, + }, }; std::string DeploymentName(Consensus::BuriedDeployment dep) diff --git a/src/evo/core_write.cpp b/src/evo/core_write.cpp index 1ccfc998840b..4da3b53f9266 100644 --- a/src/evo/core_write.cpp +++ b/src/evo/core_write.cpp @@ -85,6 +85,21 @@ RPCResult GetRpcResult(const std::string& key, bool optional, const std::string& __FILE__, __LINE__, __func__); } +// DIP0026: the multi-party payout shares array, present only for v4 (MultiPayout) entities. +RPCResult GetPayoutSharesResult() +{ + return {RPCResult::Type::ARR, "payoutShares", /*optional=*/true, + "DIP0026 multi-party payout shares (present only for v4 masternodes/transactions)", + { + {RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR, "payoutAddress", /*optional=*/true, "Dash address for this payout share"}, + {RPCResult::Type::STR_HEX, "payoutScript", /*optional=*/true, "Payout script, if no address could be extracted"}, + {RPCResult::Type::NUM, "payoutShareReward", "Reward share in basis points (1-10000)"}, + }}, + }}; +} + RPCResult CAssetLockPayload::GetJsonHelp(const std::string& key, bool optional) { return {RPCResult::Type::OBJ, key, optional, key.empty() ? "" : "The asset lock special transaction", @@ -212,6 +227,7 @@ RPCResult CDeterministicMNState::GetJsonHelp(const std::string& key, bool option GetRpcResult("platformP2PPort", /*optional=*/true), GetRpcResult("platformHTTPPort", /*optional=*/true), GetRpcResult("payoutAddress", /*optional=*/true), + GetPayoutSharesResult(), GetRpcResult("pubKeyOperator"), GetRpcResult("operatorPayoutAddress", /*optional=*/true), }}; @@ -246,6 +262,11 @@ UniValue CDeterministicMNState::ToJson(MnType nType) const if (ExtractDestination(scriptPayout, dest)) { obj.pushKV("payoutAddress", EncodeDestination(dest)); } + if (nVersion >= ProTxVersion::MultiPayout) { + UniValue shares(UniValue::VARR); + for (const auto& share : payoutShares) shares.push_back(share.ToJson()); + obj.pushKV("payoutShares", shares); + } obj.pushKV("pubKeyOperator", pubKeyOperator.ToString()); if (ExtractDestination(scriptOperatorPayout, dest)) { obj.pushKV("operatorPayoutAddress", EncodeDestination(dest)); @@ -270,6 +291,7 @@ RPCResult CDeterministicMNStateDiff::GetJsonHelp(const std::string& key, bool op GetRpcResult("ownerAddress", /*optional=*/true), GetRpcResult("votingAddress", /*optional=*/true), GetRpcResult("payoutAddress", /*optional=*/true), + GetPayoutSharesResult(), GetRpcResult("operatorPayoutAddress", /*optional=*/true), GetRpcResult("pubKeyOperator", /*optional=*/true), GetRpcResult("platformNodeID", /*optional=*/true), @@ -292,6 +314,7 @@ RPCResult CProRegTx::GetJsonHelp(const std::string& key, bool optional) GetRpcResult("ownerAddress"), GetRpcResult("votingAddress"), GetRpcResult("payoutAddress", /*optional=*/true), + GetPayoutSharesResult(), GetRpcResult("pubKeyOperator"), GetRpcResult("operatorReward"), GetRpcResult("platformNodeID", /*optional=*/true), @@ -301,6 +324,18 @@ RPCResult CProRegTx::GetJsonHelp(const std::string& key, bool optional) }}; } +UniValue PayoutShare::ToJson() const +{ + UniValue obj(UniValue::VOBJ); + if (CTxDestination dest; ExtractDestination(scriptPayout, dest)) { + obj.pushKV("payoutAddress", EncodeDestination(dest)); + } else { + obj.pushKV("payoutScript", HexStr(scriptPayout)); + } + obj.pushKV("payoutShareReward", payoutShareReward); + return obj; +} + UniValue CProRegTx::ToJson() const { UniValue ret(UniValue::VOBJ); @@ -317,6 +352,11 @@ UniValue CProRegTx::ToJson() const if (CTxDestination dest; ExtractDestination(scriptPayout, dest)) { ret.pushKV("payoutAddress", EncodeDestination(dest)); } + if (nVersion >= ProTxVersion::MultiPayout) { + UniValue shares(UniValue::VARR); + for (const auto& share : payoutShares) shares.push_back(share.ToJson()); + ret.pushKV("payoutShares", shares); + } ret.pushKV("pubKeyOperator", pubKeyOperator.ToString()); ret.pushKV("operatorReward", (double)nOperatorReward / 100); if (nType == MnType::Evo) { @@ -338,6 +378,7 @@ RPCResult CProUpRegTx::GetJsonHelp(const std::string& key, bool optional) GetRpcResult("proTxHash"), GetRpcResult("votingAddress"), GetRpcResult("payoutAddress", /*optional=*/true), + GetPayoutSharesResult(), GetRpcResult("pubKeyOperator"), GetRpcResult("inputsHash"), }}; @@ -352,6 +393,11 @@ UniValue CProUpRegTx::ToJson() const if (CTxDestination dest; ExtractDestination(scriptPayout, dest)) { ret.pushKV("payoutAddress", EncodeDestination(dest)); } + if (nVersion >= ProTxVersion::MultiPayout) { + UniValue shares(UniValue::VARR); + for (const auto& share : payoutShares) shares.push_back(share.ToJson()); + ret.pushKV("payoutShares", shares); + } ret.pushKV("pubKeyOperator", pubKeyOperator.ToString()); ret.pushKV("inputsHash", inputsHash.ToString()); return ret; diff --git a/src/evo/dmnstate.cpp b/src/evo/dmnstate.cpp index 8d0601b5a6bf..d3859125d6d7 100644 --- a/src/evo/dmnstate.cpp +++ b/src/evo/dmnstate.cpp @@ -73,6 +73,11 @@ UniValue CDeterministicMNStateDiff::ToJson(MnType nType) const obj.pushKV("payoutAddress", EncodeDestination(dest)); } } + if (fields & Field_payoutShares) { + UniValue shares(UniValue::VARR); + for (const auto& share : state.payoutShares) shares.push_back(share.ToJson()); + obj.pushKV("payoutShares", shares); + } if (fields & Field_scriptOperatorPayout) { CTxDestination dest; if (ExtractDestination(state.scriptOperatorPayout, dest)) { diff --git a/src/evo/dmnstate.h b/src/evo/dmnstate.h index bb1a23f2074c..ff6832a61b7d 100644 --- a/src/evo/dmnstate.h +++ b/src/evo/dmnstate.h @@ -56,7 +56,8 @@ class CDeterministicMNState CBLSLazyPublicKey pubKeyOperator; CKeyID keyIDVoting; std::shared_ptr netInfo{nullptr}; - CScript scriptPayout; + CScript scriptPayout; // used for nVersion < MultiPayout + std::vector payoutShares; // DIP0026, used for nVersion >= MultiPayout CScript scriptOperatorPayout; uint160 platformNodeID{}; @@ -72,6 +73,7 @@ class CDeterministicMNState keyIDVoting(proTx.keyIDVoting), netInfo(proTx.netInfo), scriptPayout(proTx.scriptPayout), + payoutShares(proTx.payoutShares), platformNodeID(proTx.platformNodeID), platformP2PPort(proTx.platformP2PPort), platformHTTPPort(proTx.platformHTTPPort) @@ -98,8 +100,15 @@ class CDeterministicMNState READWRITE( obj.keyIDVoting, NetInfoSerWrapper(const_cast&>(obj.netInfo), - obj.nVersion >= ProTxVersion::ExtAddr), - obj.scriptPayout, + obj.nVersion >= ProTxVersion::ExtAddr)); + // DIP0026: v4+ stores an array of payout shares in place of the single scriptPayout. + // Pre-v4 serialization is byte-for-byte unchanged. + if (obj.nVersion < ProTxVersion::MultiPayout) { + READWRITE(obj.scriptPayout); + } else { + READWRITE(obj.payoutShares); + } + READWRITE( obj.scriptOperatorPayout, obj.platformNodeID); if (obj.nVersion < ProTxVersion::ExtAddr) { @@ -111,7 +120,11 @@ class CDeterministicMNState void ResetOperatorFields() { - nVersion = ProTxVersion::LegacyBLS; + // DIP0026: the multi-party payout is an owner-side property and must survive an operator + // revocation. Keep nVersion at MultiPayout when payout shares are present so they are + // still serialized; the (empty) netInfo is rebuilt as the matching extended type. Reset + // to LegacyBLS as before when there are no shares. + nVersion = payoutShares.empty() ? ProTxVersion::LegacyBLS : ProTxVersion::MultiPayout; pubKeyOperator = CBLSLazyPublicKey(); netInfo = NetInfoInterface::MakeNetInfo(nVersion); scriptOperatorPayout = CScript(); @@ -147,6 +160,15 @@ class CDeterministicMNState h.Finalize(confirmedHashWithProRegTxHash.begin()); } + // Uniform view of the owner-side payout regardless of version: for nVersion >= MultiPayout + // returns the stored shares; for older versions synthesizes a single full share from + // scriptPayout. Downstream payout code (masternode/payments) uses this for one code path. + [[nodiscard]] std::vector GetPayoutShares() const + { + if (nVersion >= ProTxVersion::MultiPayout) return payoutShares; + return {PayoutShare{scriptPayout, PayoutShare::TOTAL_BASIS_POINTS}}; + } + public: std::string ToString() const; [[nodiscard]] static RPCResult GetJsonHelp(const std::string& key, bool optional); @@ -176,6 +198,7 @@ class CDeterministicMNStateDiff Field_platformNodeID = 0x10000, Field_platformP2PPort = 0x20000, Field_platformHTTPPort = 0x40000, + Field_payoutShares = 0x80000, // DIP0026; appended last to keep existing diff bits stable }; private: @@ -207,7 +230,8 @@ class CDeterministicMNStateDiff DMN_STATE_MEMBER(nConsecutivePayments), DMN_STATE_MEMBER(platformNodeID), DMN_STATE_MEMBER(platformP2PPort), - DMN_STATE_MEMBER(platformHTTPPort) + DMN_STATE_MEMBER(platformHTTPPort), + DMN_STATE_MEMBER(payoutShares) ); #undef DMN_STATE_MEMBER diff --git a/src/evo/providertx.cpp b/src/evo/providertx.cpp index b2c3691aa4f5..661f3fa26ca9 100644 --- a/src/evo/providertx.cpp +++ b/src/evo/providertx.cpp @@ -7,6 +7,8 @@ #include #include +#include + #include #include #include @@ -21,10 +23,23 @@ template const ChainstateManager& chainman, std::optional is_basic_override) { constexpr bool is_extaddr_eligible{std::is_same_v, CProRegTx> || std::is_same_v, CProUpServTx>}; - return ProTxVersion::GetMax( - is_basic_override ? *is_basic_override - : DeploymentActiveAfter(pindexPrev, chainman.GetConsensus(), Consensus::DEPLOYMENT_V19), - is_extaddr_eligible ? DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V24) : false); + // DIP0026 multi-party payouts apply to the owner-side payout, which is only carried by + // ProRegTx and ProUpRegTx. ProUpServTx (operator payout) and ProUpRevTx are not eligible. + constexpr bool is_multipayout_eligible{std::is_same_v, CProRegTx> || std::is_same_v, CProUpRegTx>}; + + const bool is_basic{is_basic_override ? *is_basic_override + : DeploymentActiveAfter(pindexPrev, chainman.GetConsensus(), Consensus::DEPLOYMENT_V19)}; + const bool is_extaddr{is_extaddr_eligible && DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V24)}; + bool is_multipayout{is_multipayout_eligible && DeploymentActiveAfter(pindexPrev, chainman, Consensus::DEPLOYMENT_V25)}; + + // A v4 CProRegTx (MultiPayout > ExtAddr) implies extended-address netInfo, so multi-payout + // must never outrun the extended-address fork for an extaddr-eligible type. We enforce this + // in code rather than relying on chainparams ordering of V24/V25. CProUpRegTx carries no + // netInfo and is not extaddr-eligible, so it may reach v4 on DEPLOYMENT_V25 alone. + if (is_extaddr_eligible && is_multipayout && !is_extaddr) { + is_multipayout = false; + } + return ProTxVersion::GetMax(is_basic, is_extaddr, is_multipayout); } template uint16_t GetMaxFromDeployment(gsl::not_null pindexPrev, const ChainstateManager& chainman, @@ -40,6 +55,14 @@ template uint16_t GetMaxFromDeployment(gsl::not_null is_basic_override); } // namespace ProTxVersion +std::string PayoutShare::ToString() const +{ + CTxDestination dest; + const std::string payee{ExtractDestination(scriptPayout, dest) ? EncodeDestination(dest) + : HexStr(scriptPayout)}; + return strprintf("%s:%d", payee, payoutShareReward); +} + template bool IsNetInfoTriviallyValid(const ProTx& proTx, TxValidationState& state) { @@ -64,6 +87,52 @@ bool IsNetInfoTriviallyValid(const ProTx& proTx, TxValidationState& state) return true; } +// DIP0026: validate the owner-side payout for a ProRegTx/ProUpRegTx. For nVersion < MultiPayout +// this is the single scriptPayout (must be p2pkh or p2sh). For nVersion >= MultiPayout it is the +// payoutShares set, which must be non-empty and at most MAX_PAYOUT_SHARES, each share a p2pkh/p2sh +// payee with a nonzero reward not exceeding TOTAL_BASIS_POINTS, with no duplicate scripts, summing +// to exactly TOTAL_BASIS_POINTS. (Uniqueness and the nonzero-reward rule are stricter than the +// three DIP0026 conditions; see the implementation notes / companion dips PR.) +bool CheckPayoutShares(uint16_t nVersion, const CScript& scriptPayout, + const std::vector& payoutShares, TxValidationState& state) +{ + if (nVersion < ProTxVersion::MultiPayout) { + // Pre-v4 carries a single scriptPayout and no shares; reject any cross-version mix. + if (!payoutShares.empty()) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payout-shares-unexpected"); + } + if (!scriptPayout.IsPayToPublicKeyHash() && !scriptPayout.IsPayToScriptHash()) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee"); + } + return true; + } + // v4 carries the shares and no single scriptPayout. + if (!scriptPayout.empty()) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payout-script-unexpected"); + } + if (payoutShares.empty() || payoutShares.size() > PayoutShare::MAX_PAYOUT_SHARES) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payout-shares-count"); + } + int64_t total{0}; + std::set seen; + for (const auto& share : payoutShares) { + if (!share.scriptPayout.IsPayToPublicKeyHash() && !share.scriptPayout.IsPayToScriptHash()) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee"); + } + if (share.payoutShareReward == 0 || share.payoutShareReward > PayoutShare::TOTAL_BASIS_POINTS) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payout-share-reward"); + } + if (!seen.insert(share.scriptPayout).second) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payout-share-duplicate"); + } + total += share.payoutShareReward; + } + if (total != PayoutShare::TOTAL_BASIS_POINTS) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payout-shares-sum"); + } + return true; +} + bool CProRegTx::IsTriviallyValid(gsl::not_null pindexPrev, const ChainstateManager& chainman, TxValidationState& state) const { @@ -86,10 +155,11 @@ bool CProRegTx::IsTriviallyValid(gsl::not_null pindexPrev, c if (pubKeyOperator.IsLegacy() != (nVersion == ProTxVersion::LegacyBLS)) { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-operator-pubkey"); } - if (!scriptPayout.IsPayToPublicKeyHash() && !scriptPayout.IsPayToScriptHash()) { - return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee"); + if (!CheckPayoutShares(nVersion, scriptPayout, payoutShares, state)) { + // pass the state returned by the helper above + return false; } - if (netInfo->CanStorePlatform() != (nVersion == ProTxVersion::ExtAddr)) { + if (netInfo->CanStorePlatform() != (nVersion >= ProTxVersion::ExtAddr)) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-netinfo-version"); } if (!netInfo->IsEmpty() && !IsNetInfoTriviallyValid(*this, state)) { @@ -102,14 +172,17 @@ bool CProRegTx::IsTriviallyValid(gsl::not_null pindexPrev, c } } - CTxDestination payoutDest; - if (!ExtractDestination(scriptPayout, payoutDest)) { - // should not happen as we checked script types before - return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-dest"); - } - // don't allow reuse of payout key for other keys (don't allow people to put the payee key onto an online server) - if (payoutDest == CTxDestination(PKHash(keyIDOwner)) || payoutDest == CTxDestination(PKHash(keyIDVoting))) { - return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-reuse"); + // don't allow reuse of a payout key for the owner/voting keys (don't allow people to put the + // payee key onto an online server). For v4 this applies to every payout share. + for (const auto& share : GetPayoutShares()) { + CTxDestination payoutDest; + if (!ExtractDestination(share.scriptPayout, payoutDest)) { + // should not happen as we checked script types before + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-dest"); + } + if (payoutDest == CTxDestination(PKHash(keyIDOwner)) || payoutDest == CTxDestination(PKHash(keyIDVoting))) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-reuse"); + } } if (nOperatorReward > 10000) { @@ -125,12 +198,21 @@ std::string CProRegTx::MakeSignString() const // We only include the important stuff in the string form... - CTxDestination destPayout; std::string strPayout; - if (ExtractDestination(scriptPayout, destPayout)) { - strPayout = EncodeDestination(destPayout); + if (nVersion >= ProTxVersion::MultiPayout) { + // DIP0026: payoutSharesStr = address(share0)|reward0|...|address(shareN)|rewardN + for (size_t i = 0; i < payoutShares.size(); ++i) { + if (i > 0) strPayout += "|"; + CTxDestination dest; + const std::string addr{ExtractDestination(payoutShares[i].scriptPayout, dest) + ? EncodeDestination(dest) + : HexStr(payoutShares[i].scriptPayout)}; + strPayout += addr + "|" + strprintf("%d", payoutShares[i].payoutShareReward); + } } else { - strPayout = HexStr(scriptPayout); + CTxDestination destPayout; + strPayout = ExtractDestination(scriptPayout, destPayout) ? EncodeDestination(destPayout) + : HexStr(scriptPayout); } s += strPayout + "|"; @@ -171,7 +253,7 @@ bool CProUpServTx::IsTriviallyValid(gsl::not_null pindexPrev if (nVersion < ProTxVersion::BasicBLS && nType == MnType::Evo) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-evo-version"); } - if (netInfo->CanStorePlatform() != (nVersion == ProTxVersion::ExtAddr)) { + if (netInfo->CanStorePlatform() != (nVersion >= ProTxVersion::ExtAddr)) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-netinfo-version"); } if (netInfo->IsEmpty()) { @@ -223,8 +305,9 @@ bool CProUpRegTx::IsTriviallyValid(gsl::not_null pindexPrev, if (pubKeyOperator.IsLegacy() != (nVersion == ProTxVersion::LegacyBLS)) { return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-operator-pubkey"); } - if (!scriptPayout.IsPayToPublicKeyHash() && !scriptPayout.IsPayToScriptHash()) { - return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee"); + if (!CheckPayoutShares(nVersion, scriptPayout, payoutShares, state)) { + // pass the state returned by the helper above + return false; } return true; } diff --git a/src/evo/providertx.h b/src/evo/providertx.h index 68c6fe3c7aa2..0cadae0240da 100644 --- a/src/evo/providertx.h +++ b/src/evo/providertx.h @@ -27,19 +27,28 @@ struct RPCResult; namespace ProTxVersion { enum : uint16_t { - LegacyBLS = 1, - BasicBLS = 2, - ExtAddr = 3, + LegacyBLS = 1, + BasicBLS = 2, + ExtAddr = 3, + MultiPayout = 4, // DIP0026 multi-party payouts (gated by DEPLOYMENT_V25) }; /** Get highest permissible ProTx version based on flags set. */ -[[nodiscard]] constexpr uint16_t GetMax(const bool is_basic_scheme_active, const bool is_extended_addr) +[[nodiscard]] constexpr uint16_t GetMax(const bool is_basic_scheme_active, const bool is_extended_addr, + const bool is_multi_payout) { if (is_basic_scheme_active) { + // DIP0026 multi-party payouts (v4) build on top of basic BLS and are the highest + // version. For CProRegTx the extended-address (v3) features are implied because + // DEPLOYMENT_V25 only activates after DEPLOYMENT_V24; CProUpRegTx carries no netInfo + // and so transitions straight from v2 to v4. is_basic_scheme_active could be set to + // false due to RPC specialization, so it gates the whole block to avoid accidentally + // upgrading a legacy BLS node due to a later fork activation. + if (is_multi_payout) { + return ProTxVersion::MultiPayout; + } if (is_extended_addr) { - // Requires *both* forks to be active to use extended addresses. is_basic_scheme_active could - // be set to false due to RPC specialization, so we must evaluate is_extended_addr *last* to - // avoid accidentally upgrading a legacy BLS node to basic BLS due to v24 activation. + // Requires *both* forks to be active to use extended addresses. return ProTxVersion::ExtAddr; } return ProTxVersion::BasicBLS; @@ -47,6 +56,17 @@ enum : uint16_t { return ProTxVersion::LegacyBLS; } +// Compile-time verification of the version-tier logic (DIP0026). These guarantee the gating +// can never silently regress: basic-BLS gates everything, and multi-payout (v4) is the highest +// version, reachable with or without extended addresses (CProUpRegTx carries no netInfo so it +// goes v2 -> v4 directly; for CProRegTx, DEPLOYMENT_V25 only activates after DEPLOYMENT_V24). +static_assert(GetMax(/*basic=*/false, /*extaddr=*/false, /*multipayout=*/false) == LegacyBLS); +static_assert(GetMax(/*basic=*/false, /*extaddr=*/true, /*multipayout=*/true ) == LegacyBLS); +static_assert(GetMax(/*basic=*/true, /*extaddr=*/false, /*multipayout=*/false) == BasicBLS); +static_assert(GetMax(/*basic=*/true, /*extaddr=*/true, /*multipayout=*/false) == ExtAddr); +static_assert(GetMax(/*basic=*/true, /*extaddr=*/false, /*multipayout=*/true ) == MultiPayout); +static_assert(GetMax(/*basic=*/true, /*extaddr=*/true, /*multipayout=*/true ) == MultiPayout); + /** Get highest permissible ProTx version based on deployment status * Note: The override is needed because some RPCs need to use deployment status information for everything *except* * the BLS version upgrade since they are specializations for a specific BLS version. This is a one-off. @@ -57,6 +77,52 @@ template std::optional is_basic_override = std::nullopt); } // namespace ProTxVersion +/** + * DIP0026: a single multi-party payout share. The owner-side masternode block reward is split + * across a set of these. `payoutShareReward` is expressed in basis points (0..10000); the full + * set carried by a v4 ProRegTx/ProUpRegTx must sum to exactly TOTAL_BASIS_POINTS. + * + * Wire format (matches DIP0026): CompactSize scriptPayout length, scriptPayout bytes, uint16 + * payoutShareReward. A std::vector therefore serializes as a CompactSize count + * followed by that many encoded shares, which is exactly the DIP `payoutSharesSize` + + * `payoutShares[]` layout. + */ +class PayoutShare +{ +public: + static constexpr uint16_t TOTAL_BASIS_POINTS{10000}; + static constexpr size_t MAX_PAYOUT_SHARES{32}; // DIP0026: at most 32 shares per ProRegTx/ProUpRegTx + + CScript scriptPayout; + uint16_t payoutShareReward{0}; + + PayoutShare() = default; + PayoutShare(CScript script, uint16_t reward) : scriptPayout(std::move(script)), payoutShareReward(reward) {} + + SERIALIZE_METHODS(PayoutShare, obj) + { + READWRITE(obj.scriptPayout, obj.payoutShareReward); + } + + friend bool operator==(const PayoutShare& a, const PayoutShare& b) + { + return a.scriptPayout == b.scriptPayout && a.payoutShareReward == b.payoutShareReward; + } + + std::string ToString() const; + [[nodiscard]] UniValue ToJson() const; +}; + +/** + * DIP0026: validate the owner-side payout for a ProRegTx/ProUpRegTx. For nVersion < MultiPayout + * this is the single scriptPayout (must be p2pkh/p2sh). For nVersion >= MultiPayout it is the + * payoutShares set: non-empty and at most PayoutShare::MAX_PAYOUT_SHARES, each a p2pkh/p2sh payee + * with a nonzero reward not exceeding TOTAL_BASIS_POINTS, no duplicate scripts, summing to exactly + * TOTAL_BASIS_POINTS. Sets `state` and returns false on the first violation. + */ +bool CheckPayoutShares(uint16_t nVersion, const CScript& scriptPayout, + const std::vector& payoutShares, TxValidationState& state); + class CProRegTx { public: @@ -74,7 +140,8 @@ class CProRegTx CBLSLazyPublicKey pubKeyOperator; CKeyID keyIDVoting; uint16_t nOperatorReward{0}; - CScript scriptPayout; + CScript scriptPayout; // used for nVersion < MultiPayout + std::vector payoutShares; // DIP0026, used for nVersion >= MultiPayout uint256 inputsHash; // replay protection std::vector vchSig; @@ -84,7 +151,7 @@ class CProRegTx obj.nVersion ); if (obj.nVersion == 0 || - obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true)) { + obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true, /*is_multi_payout=*/true)) { // unknown version, bail out early return; } @@ -98,10 +165,16 @@ class CProRegTx obj.keyIDOwner, CBLSLazyPublicKeyVersionWrapper(const_cast(obj.pubKeyOperator), (obj.nVersion == ProTxVersion::LegacyBLS)), obj.keyIDVoting, - obj.nOperatorReward, - obj.scriptPayout, - obj.inputsHash + obj.nOperatorReward ); + // DIP0026: v4+ replaces the single scriptPayout with an array of payout shares at the + // same wire position. Pre-v4 serialization is byte-for-byte unchanged. + if (obj.nVersion < ProTxVersion::MultiPayout) { + READWRITE(obj.scriptPayout); + } else { + READWRITE(obj.payoutShares); + } + READWRITE(obj.inputsHash); if (obj.nType == MnType::Evo) { READWRITE( obj.platformNodeID); @@ -116,6 +189,15 @@ class CProRegTx } } + // Uniform view of the owner-side payout regardless of version: for nVersion >= MultiPayout + // returns the stored shares; for older versions synthesizes a single full share from + // scriptPayout. Lets downstream payout/validation code have a single code path. + [[nodiscard]] std::vector GetPayoutShares() const + { + if (nVersion >= ProTxVersion::MultiPayout) return payoutShares; + return {PayoutShare{scriptPayout, PayoutShare::TOTAL_BASIS_POINTS}}; + } + // When signing with the collateral key, we don't sign the hash but a generated message instead // This is needed for HW wallet support which can only sign text messages as of now std::string MakeSignString() const; @@ -151,7 +233,7 @@ class CProUpServTx obj.nVersion ); if (obj.nVersion == 0 || - obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true)) { + obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true, /*is_multi_payout=*/true)) { // unknown version, bail out early return; } @@ -201,7 +283,8 @@ class CProUpRegTx uint16_t nMode{0}; // only 0 supported for now CBLSLazyPublicKey pubKeyOperator; CKeyID keyIDVoting; - CScript scriptPayout; + CScript scriptPayout; // used for nVersion < MultiPayout + std::vector payoutShares; // DIP0026, used for nVersion >= MultiPayout uint256 inputsHash; // replay protection std::vector vchSig; @@ -211,7 +294,7 @@ class CProUpRegTx obj.nVersion ); if (obj.nVersion == 0 || - obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true)) { + obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true, /*is_multi_payout=*/true)) { // unknown version, bail out early return; } @@ -219,10 +302,16 @@ class CProUpRegTx obj.proTxHash, obj.nMode, CBLSLazyPublicKeyVersionWrapper(const_cast(obj.pubKeyOperator), (obj.nVersion == ProTxVersion::LegacyBLS)), - obj.keyIDVoting, - obj.scriptPayout, - obj.inputsHash + obj.keyIDVoting ); + // DIP0026: v4+ replaces the single scriptPayout with an array of payout shares at the + // same wire position. Pre-v4 serialization is byte-for-byte unchanged. + if (obj.nVersion < ProTxVersion::MultiPayout) { + READWRITE(obj.scriptPayout); + } else { + READWRITE(obj.payoutShares); + } + READWRITE(obj.inputsHash); if (!(s.GetType() & SER_GETHASH)) { READWRITE( obj.vchSig @@ -230,6 +319,13 @@ class CProUpRegTx } } + // Uniform view of the owner-side payout regardless of version (see CProRegTx::GetPayoutShares). + [[nodiscard]] std::vector GetPayoutShares() const + { + if (nVersion >= ProTxVersion::MultiPayout) return payoutShares; + return {PayoutShare{scriptPayout, PayoutShare::TOTAL_BASIS_POINTS}}; + } + std::string ToString() const; [[nodiscard]] static RPCResult GetJsonHelp(const std::string& key, bool optional); @@ -265,7 +361,7 @@ class CProUpRevTx obj.nVersion ); if (obj.nVersion == 0 || - obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true)) { + obj.nVersion > ProTxVersion::GetMax(/*is_basic_scheme_active=*/true, /*is_extended_addr=*/true, /*is_multi_payout=*/true)) { // unknown version, bail out early return; } diff --git a/src/evo/specialtx_filter.cpp b/src/evo/specialtx_filter.cpp index b61173dc7587..9f6a76858139 100644 --- a/src/evo/specialtx_filter.cpp +++ b/src/evo/specialtx_filter.cpp @@ -72,8 +72,11 @@ void ExtractSpecialTxFilterElements(const CTransaction& tx, const std::function< // Add voting key ID AddHashElement(opt_proTx->keyIDVoting, addElement); - // Add payout script - AddScriptElement(opt_proTx->scriptPayout, addElement); + // Add payout script(s): DIP0026 v4 carries one script per payout share (the single + // scriptPayout is empty for v4), so iterate the version-uniform accessor. + for (const auto& share : opt_proTx->GetPayoutShares()) { + AddScriptElement(share.scriptPayout, addElement); + } } break; } @@ -95,8 +98,11 @@ void ExtractSpecialTxFilterElements(const CTransaction& tx, const std::function< // Add voting key ID AddHashElement(opt_proTx->keyIDVoting, addElement); - // Add payout script - AddScriptElement(opt_proTx->scriptPayout, addElement); + // Add payout script(s): DIP0026 v4 carries one script per payout share (the single + // scriptPayout is empty for v4), so iterate the version-uniform accessor. + for (const auto& share : opt_proTx->GetPayoutShares()) { + AddScriptElement(share.scriptPayout, addElement); + } } break; } diff --git a/src/evo/specialtxman.cpp b/src/evo/specialtxman.cpp index 65b1e6c4e2c6..4529b5d6ccce 100644 --- a/src/evo/specialtxman.cpp +++ b/src/evo/specialtxman.cpp @@ -349,6 +349,13 @@ bool CSpecialTxProcessor::RebuildListFromBlock(const CBlock& block, gsl::not_nul // Extended addresses support in v24 means that the version can be updated newState->nVersion = opt_proTx->nVersion; } + // DIP0026: a ProUpServTx (max ExtAddr) must not downgrade a multi-party-payout MN + // below MultiPayout, which would silently drop its payout shares on serialization. + // ExtAddr and MultiPayout both imply extended netInfo, so keeping the higher version + // is consistent with the ProUpServTx's (extended) netInfo set just below. + if (!newState->payoutShares.empty()) { + newState->nVersion = std::max(newState->nVersion, ProTxVersion::MultiPayout); + } newState->netInfo = opt_proTx->netInfo; newState->scriptOperatorPayout = opt_proTx->scriptOperatorPayout; if (opt_proTx->nType == MnType::Evo) { @@ -396,7 +403,21 @@ bool CSpecialTxProcessor::RebuildListFromBlock(const CBlock& block, gsl::not_nul newState->pubKeyOperator = opt_proTx->pubKeyOperator; } newState->keyIDVoting = opt_proTx->keyIDVoting; - newState->scriptPayout = opt_proTx->scriptPayout; + // DIP0026: keep the state's payout representation consistent with its version. A v4 + // ProUpRegTx carries payoutShares (and an empty scriptPayout); applying it stores the + // shares, clears the single-payout field, and bumps the state version so the shares + // persist (state serialization gates payoutShares on nVersion >= MultiPayout). + // Validation (CheckProUpRegTx) guarantees a v4 ProUpRegTx only targets a v3+ MN and + // that a v4 MN is never downgraded, so the version bump cannot corrupt netInfo or + // silently drop a payout. + if (opt_proTx->nVersion >= ProTxVersion::MultiPayout) { + newState->payoutShares = opt_proTx->payoutShares; + newState->scriptPayout = CScript(); + newState->nVersion = std::max(newState->nVersion, opt_proTx->nVersion); + } else { + newState->scriptPayout = opt_proTx->scriptPayout; + newState->payoutShares.clear(); + } newList.UpdateMN(opt_proTx->proTxHash, newState); @@ -912,6 +933,31 @@ static bool IsVersionChangeValid(gsl::not_null pindexPrev, c return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-version-tx-type"); } + // DIP0026: a v4 (MultiPayout) ProUpRegTx upgrades the MN state to v4, which implies + // extended-address netInfo. Require the target MN to already be at least ExtAddr (v3) so the + // version bump cannot reinterpret a non-extended netInfo. Only ProUpRegTx reaches v4 here; + // ProRegTx (new registration) does not pass through this function. + if (tx_version >= ProTxVersion::MultiPayout && state_version < ProTxVersion::ExtAddr) { + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-version-upgrade"); + } + // DIP0026: once an MN uses multi-party payouts (v4), a ProUpRegTx must not downgrade it back + // to a single payout, which would silently drop the shares. Scoped to ProUpRegTx; a + // ProUpServTx/ProUpRevTx legitimately carries a lower version and its apply path preserves + // the multi-payout state version separately. + if (tx_type == TRANSACTION_PROVIDER_UPDATE_REGISTRAR && + state_version >= ProTxVersion::MultiPayout && tx_version < ProTxVersion::MultiPayout) { + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-version-downgrade"); + } + // DIP0026: a multi-party-payout MN (state >= MultiPayout) implies extended netInfo. A + // ProUpServTx carries the netInfo and the apply path keeps the state at >= MultiPayout to + // preserve the payout shares, so the ProUpServTx must itself be extended (>= ExtAddr); + // otherwise the resulting state would pair nVersion >= MultiPayout with a non-extended + // netInfo object and corrupt the serialized MN-list state (and its merkle root). + if (tx_type == TRANSACTION_PROVIDER_UPDATE_SERVICE && + state_version >= ProTxVersion::MultiPayout && tx_version < ProTxVersion::ExtAddr) { + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-protx-version-tx-type"); + } + return true; } @@ -1140,12 +1186,6 @@ bool CheckProUpRegTx(const CTransaction& tx, gsl::not_null p return false; } - CTxDestination payoutDest; - if (!ExtractDestination(opt_ptx->scriptPayout, payoutDest)) { - // should not happen as we checked script types before - return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-dest"); - } - auto mnList = dmnman.GetListForBlock(pindexPrev); auto dmn = mnList.GetMN(opt_ptx->proTxHash); if (!dmn) { @@ -1157,10 +1197,19 @@ bool CheckProUpRegTx(const CTransaction& tx, gsl::not_null p return false; } - // don't allow reuse of payee key for other keys (don't allow people to put the payee key onto an online server) - if (payoutDest == CTxDestination(PKHash(dmn->pdmnState->keyIDOwner)) || - payoutDest == CTxDestination(PKHash(opt_ptx->keyIDVoting))) { - return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-reuse"); + // don't allow reuse of a payout key for the owner/voting keys (don't allow people to put the + // payee key onto an online server). For DIP0026 v4 this applies to every payout share; the + // uniform accessor yields the single scriptPayout for older versions. + for (const auto& share : opt_ptx->GetPayoutShares()) { + CTxDestination payoutDest; + if (!ExtractDestination(share.scriptPayout, payoutDest)) { + // should not happen as we checked script types in IsTriviallyValid + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-dest"); + } + if (payoutDest == CTxDestination(PKHash(dmn->pdmnState->keyIDOwner)) || + payoutDest == CTxDestination(PKHash(opt_ptx->keyIDVoting))) { + return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-protx-payee-reuse"); + } } Coin coin; diff --git a/src/masternode/payments.cpp b/src/masternode/payments.cpp index be5635d15a27..013ce51fc4c3 100644 --- a/src/masternode/payments.cpp +++ b/src/masternode/payments.cpp @@ -30,6 +30,37 @@ CAmount PlatformShare(const CAmount reward) return platformReward; } +std::vector SplitMasternodeReward(const CAmount masternodeReward, const std::vector& shares) +{ + std::vector outs; + if (masternodeReward <= 0 || shares.empty()) return outs; + // masternodeReward is a per-block owner reward, far below the level where reward * 10000 could + // overflow int64; assert the consensus money range as a cheap guard (matching PlatformShare). + assert(MoneyRange(masternodeReward)); + + std::vector amounts(shares.size(), 0); + CAmount distributed = 0; + for (size_t i = 0; i < shares.size(); ++i) { + amounts[i] = masternodeReward * shares[i].payoutShareReward / PayoutShare::TOTAL_BASIS_POINTS; + distributed += amounts[i]; + } + // The shares' basis points sum to TOTAL_BASIS_POINTS, so the flooring above loses strictly + // less than one satoshi per share; the leftover (< shares.size()) is handed out one satoshi + // per share in payoutShares order. The outputs therefore sum to exactly masternodeReward and + // every node computes the identical set. + for (size_t i = 0; i < shares.size() && distributed < masternodeReward; ++i) { + amounts[i] += 1; + distributed += 1; + } + assert(distributed == masternodeReward); + + outs.reserve(shares.size()); + for (size_t i = 0; i < shares.size(); ++i) { + if (amounts[i] > 0) outs.emplace_back(amounts[i], shares[i].scriptPayout); + } + return outs; +} + [[nodiscard]] bool CMNPaymentsProcessor::GetBlockTxOuts(const CBlockIndex* pindexPrev, const CAmount blockSubsidy, const CAmount feeReward, std::vector& voutMasternodePaymentsRet) { @@ -71,8 +102,11 @@ CAmount PlatformShare(const CAmount reward) masternodeReward -= operatorReward; } - if (masternodeReward > 0) { - voutMasternodePaymentsRet.emplace_back(masternodeReward, dmnPayee->pdmnState->scriptPayout); + // DIP0026: split the owner-side reward across the masternode's payout shares. For a pre-v4 + // masternode GetPayoutShares() returns a single full share, so this reproduces the legacy + // single output exactly (one output of masternodeReward to scriptPayout). + for (const auto& txout : SplitMasternodeReward(masternodeReward, dmnPayee->pdmnState->GetPayoutShares())) { + voutMasternodePaymentsRet.push_back(txout); } if (operatorReward > 0) { voutMasternodePaymentsRet.emplace_back(operatorReward, dmnPayee->pdmnState->scriptOperatorPayout); @@ -122,8 +156,23 @@ CAmount PlatformShare(const CAmount reward) return true; } + // The multiplicity-correct check below is a consensus change relative to the historical + // existence check: it can reject a block that underpays a masternode whose owner and operator + // scripts collide (two identical expected outputs paid only once). To avoid an upgrade-window + // split it is gated behind DIP0026 (DEPLOYMENT_V25); before activation the legacy existence + // check is preserved byte-for-byte. Multi-party payouts only exist once V25 is active anyway. + const bool multipayout_active{DeploymentActiveAfter(pindexPrev, m_chainman, Consensus::DEPLOYMENT_V25)}; + for (const auto& txout : voutMasternodePayments) { - bool found = std::ranges::any_of(txNew.vout, [&txout](const auto& txout2) { return txout == txout2; }); + bool found; + if (multipayout_active) { + // The coinbase must contain each expected payee output at least as many times as it is + // expected, so two identical expected outputs (e.g. a DIP0026 payout share paying the + // same script+amount as the operator/platform output) cannot be satisfied by one. + found = std::ranges::count(txNew.vout, txout) >= std::ranges::count(voutMasternodePayments, txout); + } else { + found = std::ranges::any_of(txNew.vout, [&txout](const auto& txout2) { return txout == txout2; }); + } if (!found) { std::string str_payout; if (CTxDestination dest; ExtractDestination(txout.scriptPubKey, dest)) { diff --git a/src/masternode/payments.h b/src/masternode/payments.h index d11da4dd3e87..30be5df22c1a 100644 --- a/src/masternode/payments.h +++ b/src/masternode/payments.h @@ -16,6 +16,7 @@ class CDeterministicMNManager; class ChainstateManager; class CTransaction; class CTxOut; +class PayoutShare; struct CMutableTransaction; @@ -30,6 +31,16 @@ namespace Consensus { struct Params; } */ CAmount PlatformShare(const CAmount masternodeReward); +/** + * DIP0026: split a masternode (owner-side) reward across its payout shares. Deterministic so + * every node computes the identical output set: floor each share by basis points, then hand out + * the leftover satoshis one per share in payoutShares order until exhausted, so the outputs sum + * to exactly `masternodeReward`. A single full share (the GetPayoutShares() view of a pre-v4 + * masternode) reproduces the legacy single-output payout exactly. Zero-amount outputs are + * omitted. Returns empty if masternodeReward <= 0 or there are no shares. + */ +std::vector SplitMasternodeReward(const CAmount masternodeReward, const std::vector& shares); + class CMNPaymentsProcessor { private: diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index eca003933174..05eee0e5964a 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -1609,6 +1609,7 @@ RPCHelpMan getblockchaininfo() } for (auto ehf_deploy : { /* sorted by activation block */ Consensus::DEPLOYMENT_V24, + Consensus::DEPLOYMENT_V25, Consensus::DEPLOYMENT_TESTDUMMY }) { SoftForkDescPushBack(&tip, ehfSignals, softforks, chainman, ehf_deploy); } diff --git a/src/rpc/evo.cpp b/src/rpc/evo.cpp index 735f524e808b..3e361c255a4c 100644 --- a/src/rpc/evo.cpp +++ b/src/rpc/evo.cpp @@ -146,12 +146,19 @@ static RPCArg GetRpcArg(const std::string& strParamName) }, {"payoutAddress_register", {"payoutAddress", RPCArg::Type::STR, RPCArg::Optional::NO, - "The Dash address to use for masternode reward payments."} + "The Dash address to use for masternode reward payments.\n" + "Once DIP0026 (v25) is active, this may instead be a JSON object that splits the\n" + "reward across multiple payees, mapping each Dash address to its share in basis\n" + "points (1-10000); the shares must be unique and sum to exactly 10000, e.g.\n" + "{\"XaddrA\": 6000, \"XaddrB\": 4000}."} }, {"payoutAddress_update", {"payoutAddress", RPCArg::Type::STR, RPCArg::Optional::NO, "The Dash address to use for masternode reward payments.\n" - "If set to an empty string, the currently active payout address is reused."} + "If set to an empty string, the currently active payout (single address or shares) is reused.\n" + "Once DIP0026 (v25) is active, this may instead be a JSON object mapping each Dash\n" + "address to its share in basis points (1-10000), unique and summing to 10000, e.g.\n" + "{\"XaddrA\": 6000, \"XaddrB\": 4000}."} }, {"proTxHash", {"proTxHash", RPCArg::Type::STR, RPCArg::Optional::NO, @@ -388,6 +395,95 @@ enum class ProTxRegisterAction }; } // anonumous namespace +// DIP0026: parse the payout RPC parameter, which is either a single address string (legacy +// single payout) or a {"address": basisPoints, ...} object (multi-party payout). Sets either +// scriptPayout (single) or payoutShares (multi) and returns the ProTx version to use: +// - single payout: never selects MultiPayout, so it is capped at single_cap_version (the +// highest non-multi-payout version valid for the tx type: ExtAddr for ProRegTx, BasicBLS for +// ProUpRegTx). This also keeps the existing pre-v25 behaviour byte-for-byte. +// - multi-party payout: requires max_version >= MultiPayout (i.e. DIP0026/v25 available) and +// selects MultiPayout. Share order follows the JSON key order, which is preserved on-chain. +static uint16_t ParsePayoutParam(const UniValue& param, const uint16_t max_version, + const uint16_t single_cap_version, CScript& scriptPayoutRet, + std::vector& payoutSharesRet) +{ + scriptPayoutRet = CScript(); + payoutSharesRet.clear(); + + // The DIP0026 multi-payout form is a {"address": basisPoints} object. It can arrive either as a + // UniValue object (named arguments / JSON-RPC callers) or as a JSON string, because dash-cli + // passes this argument as a string (the payout arg is not in the rpc/client.cpp conversion + // table). Accept both. A single payout address is base58/bech32 and never begins with '{', so + // the single-address and object forms are unambiguous. + UniValue parsed_obj; + if (param.isStr()) { + const std::string& s{param.get_str()}; + const size_t first{s.find_first_not_of(" \t\n\r")}; + if (first != std::string::npos && s[first] == '{') { + if (!parsed_obj.read(s) || !parsed_obj.isObject()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "payout must be a single address or a JSON object like " + "{\"address\": basisPoints}"); + } + } + } + const UniValue& shares{param.isObject() ? param : parsed_obj}; + + if (shares.isObject()) { + if (max_version < ProTxVersion::MultiPayout) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + "multi-party payouts (DIP0026) are not available yet (v25 is not active)"); + } + // Fail fast on the consensus payout-share rules (CheckPayoutShares, src/evo/providertx.cpp) + // that are reachable through the object form, so a malformed set is rejected here at the RPC + // instead of only later at mempool/block validation. A valid destination always yields a + // p2pkh/p2sh script, so the payee-type rule needs no separate check. The JSON reader does NOT + // collapse duplicate object keys, so a raw request can carry the same payee twice; we reject + // duplicate scripts to mirror consensus. CheckPayoutShares remains the authoritative gate. + int64_t total_bps{0}; + std::set seen_scripts; + for (const std::string& addr : shares.getKeys()) { + const CTxDestination dest = DecodeDestination(addr); + if (!IsValidDestination(dest)) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, strprintf("invalid payout address: %s", addr)); + } + const int64_t bps = shares[addr].getInt(); + if (bps <= 0 || bps > PayoutShare::TOTAL_BASIS_POINTS) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("payout share for %s must be between 1 and %d basis points", addr, + PayoutShare::TOTAL_BASIS_POINTS)); + } + const CScript script = GetScriptForDestination(dest); + if (!seen_scripts.insert(script).second) { + throw JSONRPCError(RPC_INVALID_PARAMETER, strprintf("duplicate payout address: %s", addr)); + } + total_bps += bps; + payoutSharesRet.push_back(PayoutShare{script, static_cast(bps)}); + } + if (payoutSharesRet.empty()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "payout shares object must not be empty"); + } + if (payoutSharesRet.size() > PayoutShare::MAX_PAYOUT_SHARES) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("too many payout shares: %d (maximum is %d)", payoutSharesRet.size(), + PayoutShare::MAX_PAYOUT_SHARES)); + } + if (total_bps != PayoutShare::TOTAL_BASIS_POINTS) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("payout shares must sum to %d basis points (got %d)", + PayoutShare::TOTAL_BASIS_POINTS, total_bps)); + } + return ProTxVersion::MultiPayout; + } + + const CTxDestination dest = DecodeDestination(param.get_str()); + if (!IsValidDestination(dest)) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, strprintf("invalid payout address: %s", param.get_str())); + } + scriptPayoutRet = GetScriptForDestination(dest); + return std::min(max_version, single_cap_version); +} + static UniValue protx_register_common_wrapper(const JSONRPCRequest& request, const bool specific_legacy_bls_scheme, ProTxRegisterAction action, @@ -743,10 +839,9 @@ static UniValue protx_register_common_wrapper(const JSONRPCRequest& request, } ptx.nOperatorReward = operatorReward; - CTxDestination payoutDest = DecodeDestination(request.params[paramIdx + 5].get_str()); - if (!IsValidDestination(payoutDest)) { - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, strprintf("invalid payout address: %s", request.params[paramIdx + 5].get_str())); - } + // DIP0026: the payout is either a single address (string) or a {"address": basisPoints} + // object. Captured here, before the EvoNode params shift paramIdx, and parsed below. + const UniValue& payoutParam = request.params[paramIdx + 5]; if (isEvoRequested) { if (!IsHex(request.params[paramIdx + 6].get_str())) { @@ -760,13 +855,21 @@ static UniValue protx_register_common_wrapper(const JSONRPCRequest& request, } ptx.keyIDVoting = keyIDVoting; - ptx.scriptPayout = GetScriptForDestination(payoutDest); + // A single payout caps the version at ExtAddr (never auto-selects MultiPayout); a shares + // object selects MultiPayout (requires v25). ptx.nVersion currently holds the deployment max. + ptx.nVersion = ParsePayoutParam(payoutParam, /*max_version=*/ptx.nVersion, ProTxVersion::ExtAddr, + ptx.scriptPayout, ptx.payoutShares); if (action != ProTxRegisterAction::Fund) { // make sure fee calculation works ptx.vchSig.resize(65); } + // The fund/fee-source address defaults to the (first) payout address. + CTxDestination payoutDest; + ExtractDestination(ptx.payoutShares.empty() ? ptx.scriptPayout : ptx.payoutShares.front().scriptPayout, + payoutDest); + CTxDestination fundDest = payoutDest; if (!request.params[paramIdx + 6].isNull()) { fundDest = DecodeDestination(request.params[paramIdx + 6].get_str()); @@ -1164,15 +1267,23 @@ static RPCHelpMan protx_update_registrar_wrapper(const bool specific_legacy_bls_ ptx.keyIDVoting = ParsePubKeyIDFromAddress(request.params[2].get_str(), "voting address"); } - CTxDestination payoutDest; - ExtractDestination(ptx.scriptPayout, payoutDest); - if (!request.params[3].get_str().empty()) { - payoutDest = DecodeDestination(request.params[3].get_str()); - if (!IsValidDestination(payoutDest)) { - throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, strprintf("invalid payout address: %s", request.params[3].get_str())); - } - ptx.scriptPayout = GetScriptForDestination(payoutDest); + // DIP0026: the payout is either a single address (string) or a {"address": basisPoints} + // object. If omitted, keep the masternode's current payout and a matching version. + const UniValue& payoutParam = request.params[3]; + if (!payoutParam.isNull() && !(payoutParam.isStr() && payoutParam.get_str().empty())) { + ptx.nVersion = ParsePayoutParam(payoutParam, /*max_version=*/ptx.nVersion, ProTxVersion::BasicBLS, + ptx.scriptPayout, ptx.payoutShares); + } else { + ptx.scriptPayout = dmn->pdmnState->scriptPayout; + ptx.payoutShares = dmn->pdmnState->payoutShares; + ptx.nVersion = dmn->pdmnState->payoutShares.empty() + ? std::min(ptx.nVersion, ProTxVersion::BasicBLS) + : static_cast(ProTxVersion::MultiPayout); } + // representative payout dest for the fee-source default (single address, or the first share) + CTxDestination payoutDest; + ExtractDestination(ptx.payoutShares.empty() ? ptx.scriptPayout : ptx.payoutShares.front().scriptPayout, + payoutDest); { const auto pkhash{PKHash(dmn->pdmnState->keyIDOwner)}; diff --git a/src/rpc/masternode.cpp b/src/rpc/masternode.cpp index 14e940b61136..c5385a55765c 100644 --- a/src/rpc/masternode.cpp +++ b/src/rpc/masternode.cpp @@ -228,16 +228,21 @@ static std::string GetRequiredPaymentsString(governance::SuperblockManager& supe { std::string strPayments = "Unknown"; if (payee) { - CTxDestination dest; - if (!ExtractDestination(payee->pdmnState->scriptPayout, dest)) { - NONFATAL_UNREACHABLE(); + // DIP0026: a v4 masternode pays multiple shares (its single scriptPayout is empty), so + // iterate the version-uniform accessor and join the payee addresses. + strPayments.clear(); + for (const auto& share : payee->pdmnState->GetPayoutShares()) { + CTxDestination dest; + if (ExtractDestination(share.scriptPayout, dest)) { + if (!strPayments.empty()) strPayments += ", "; + strPayments += EncodeDestination(dest); + } } - strPayments = EncodeDestination(dest); + if (strPayments.empty()) strPayments = "Unknown"; if (payee->nOperatorReward != 0 && payee->pdmnState->scriptOperatorPayout != CScript()) { - if (!ExtractDestination(payee->pdmnState->scriptOperatorPayout, dest)) { - NONFATAL_UNREACHABLE(); + if (CTxDestination dest; ExtractDestination(payee->pdmnState->scriptOperatorPayout, dest)) { + strPayments += ", " + EncodeDestination(dest); } - strPayments += ", " + EncodeDestination(dest); } } if (superblocks.IsSuperblockTriggered(tip_mn_list, nBlockHeight)) { diff --git a/src/test/block_reward_reallocation_tests.cpp b/src/test/block_reward_reallocation_tests.cpp index 97c29c62c9d0..dca62e913e89 100644 --- a/src/test/block_reward_reallocation_tests.cpp +++ b/src/test/block_reward_reallocation_tests.cpp @@ -115,7 +115,7 @@ static CMutableTransaction CreateProRegTx(const CChain& active_chain, const CTxM operatorKeyRet.MakeNewKey(); CProRegTx proTx; - proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); proTx.netInfo = NetInfoInterface::MakeNetInfo(proTx.nVersion); proTx.collateralOutpoint.n = 0; BOOST_CHECK_EQUAL(proTx.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, strprintf("1.1.1.1:%d", port)), diff --git a/src/test/evo_deterministicmns_tests.cpp b/src/test/evo_deterministicmns_tests.cpp index af286a51f148..bde861d511b1 100644 --- a/src/test/evo_deterministicmns_tests.cpp +++ b/src/test/evo_deterministicmns_tests.cpp @@ -106,7 +106,7 @@ static CMutableTransaction CreateProRegTx(const CChain& active_chain, const CTxM operatorKeyRet.MakeNewKey(); CProRegTx proTx; - proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); proTx.netInfo = NetInfoInterface::MakeNetInfo(proTx.nVersion); proTx.collateralOutpoint.n = 0; BOOST_CHECK_EQUAL(proTx.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, strprintf("1.1.1.1:%d", port)), @@ -130,7 +130,7 @@ static CMutableTransaction CreateProRegTx(const CChain& active_chain, const CTxM static CMutableTransaction CreateProUpServTx(const CChain& active_chain, const CTxMemPool& mempool, SimpleUTXOMap& utxos, const uint256& proTxHash, const CBLSSecretKey& operatorKey, int port, const CScript& scriptOperatorPayout, const CKey& coinbaseKey) { CProUpServTx proTx; - proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); proTx.netInfo = NetInfoInterface::MakeNetInfo(proTx.nVersion); proTx.proTxHash = proTxHash; BOOST_CHECK_EQUAL(proTx.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, strprintf("1.1.1.1:%d", port)), @@ -152,7 +152,7 @@ static CMutableTransaction CreateProUpServTx(const CChain& active_chain, const C static CMutableTransaction CreateProUpRegTx(const CChain& active_chain, const CTxMemPool& mempool, SimpleUTXOMap& utxos, const uint256& proTxHash, const CKey& mnKey, const CBLSPublicKey& pubKeyOperator, const CKeyID& keyIDVoting, const CScript& scriptPayout, const CKey& coinbaseKey) { CProUpRegTx proTx; - proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); proTx.proTxHash = proTxHash; proTx.pubKeyOperator.Set(pubKeyOperator, bls::bls_legacy_scheme.load()); proTx.keyIDVoting = keyIDVoting; @@ -173,7 +173,7 @@ static CMutableTransaction CreateProUpRegTx(const CChain& active_chain, const CT static CMutableTransaction CreateProUpRevTx(const CChain& active_chain, const CTxMemPool& mempool, SimpleUTXOMap& utxos, const uint256& proTxHash, const CBLSSecretKey& operatorKey, const CKey& coinbaseKey) { CProUpRevTx proTx; - proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + proTx.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); proTx.proTxHash = proTxHash; CMutableTransaction tx; @@ -641,7 +641,7 @@ void FuncTestMempoolReorg(TestChainSetup& setup) BOOST_CHECK_EQUAL(block->GetHash(), chainman.ActiveChain().Tip()->GetBlockHash()); CProRegTx payload; - payload.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + payload.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); payload.netInfo = NetInfoInterface::MakeNetInfo(payload.nVersion); BOOST_CHECK_EQUAL(payload.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, "1.1.1.1:1"), NetInfoStatus::Success); payload.keyIDOwner = ownerKey.GetPubKey().GetID(); @@ -717,7 +717,7 @@ void FuncTestMempoolDualProregtx(TestChainSetup& setup) auto scriptPayout = GetScriptForDestination(PKHash(payoutKey.GetPubKey())); CProRegTx payload; - payload.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + payload.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); payload.netInfo = NetInfoInterface::MakeNetInfo(payload.nVersion); BOOST_CHECK_EQUAL(payload.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, "1.1.1.1:2"), NetInfoStatus::Success); payload.keyIDOwner = ownerKey.GetPubKey().GetID(); @@ -787,7 +787,7 @@ void FuncVerifyDB(TestChainSetup& setup) BOOST_CHECK_EQUAL(block->GetHash(), chainman.ActiveChain().Tip()->GetBlockHash()); CProRegTx payload; - payload.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false); + payload.nVersion = ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false); payload.netInfo = NetInfoInterface::MakeNetInfo(payload.nVersion); BOOST_CHECK_EQUAL(payload.netInfo->AddEntry(NetInfoPurpose::CORE_P2P, "1.1.1.1:1"), NetInfoStatus::Success); payload.keyIDOwner = ownerKey.GetPubKey().GetID(); @@ -852,7 +852,7 @@ static CDeterministicMNCPtr create_mock_mn(uint64_t internal_id) dmnState->pubKeyOperator.Set(operatorKey.GetPublicKey(), bls::bls_legacy_scheme.load()); dmnState->keyIDVoting = ownerKey.GetPubKey().GetID(); dmnState->netInfo = NetInfoInterface::MakeNetInfo( - ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false)); + ProTxVersion::GetMax(!bls::bls_legacy_scheme, /*is_extended_addr=*/false, /*is_multi_payout=*/false)); BOOST_CHECK_EQUAL(dmnState->netInfo->AddEntry(NetInfoPurpose::CORE_P2P, "1.1.1.1:1"), NetInfoStatus::Success); auto dmn = std::make_shared(internal_id, MnType::Regular); diff --git a/src/test/evo_providertx_tests.cpp b/src/test/evo_providertx_tests.cpp new file mode 100644 index 000000000000..ed989d69c0c8 --- /dev/null +++ b/src/test/evo_providertx_tests.cpp @@ -0,0 +1,401 @@ +// Copyright (c) 2025 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include