Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions doc/release-notes-7437.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Decentralized Masternode Shares

This release implements the Decentralized Masternode Shares DIP, activating
together with DIP-0026 multi-party payouts as part of the v24 hard fork
(`DEPLOYMENT_V24`). Before activation there is no behavior change.

## Consensus changes (active with v24)

- A version 4 ProRegTx may carry a collateral share table: 2 to 8 participants
fund the masternode collateral atomically in one registration, each recording
an immutable amount, refund script and share owner key, plus an updatable
reward script. Every participant consents by signing a digest that binds the
exact funding inputs, all outputs, the share table, the penalty terms and the
registrar configuration.
- The shared collateral is paid to the 7-byte template script
`04445348437551` (`0x04 "DSHC" OP_DROP OP_TRUE`). From activation, an output
paying this exact script is valid only as the collateral of a valid shared
registration, and spending such an output is valid only via a ProDisTx.
Template outputs mined before activation become permanently unspendable.
- Three new special transaction types:
- **ProDisTx (type 10)** dissolves a shared masternode, refunding every
participant's principal to its immutable refund script. Exactly one
signature (unilateral, penalized during the configured early period) or one
per share (unanimous, penalty-free). Validity is monotone: a ProDisTx that
is valid at some height is valid at every later height, which makes offline
"standby dissolutions" safe.
- **ProUpShareTx (type 11)** lets one share owner update their reward script.
- **ProUpSharedRegTx (type 12)** updates the operator key and/or voting key
with a signature from every share owner. A plain ProUpRegTx is invalid for
shared masternodes.
- The owner reward of a shared masternode is split across the share table
proportionally to the recorded contributions (sequential floor, remainder to
the last entry), paying each share's reward script (or its refund script when
none is set). Operator rewards are unchanged.
- Withdrawal (asset unlock) transactions may not pay the template script.

## Relay policy changes

- The template output relays only inside a ProRegTx, and a template prevout is
accepted only inside a ProDisTx; both remain nonstandard everywhere else.

## New RPCs

- `protx register_shared_prepare` builds an unsigned shared registration from a
caller-supplied funding transaction.
- `protx shared_sign` signs a shared registration, dissolution or shared
registrar update with every share owner key the wallet holds.
- `protx shared_combine` combines collected signatures and optionally submits.
- `protx dissolve` creates, signs and submits a unilateral ProDisTx (or, with
`submit=false`, returns hex suitable for offline standby storage).
- `protx dissolve_prepare` builds an unsigned unanimous ProDisTx.
- `protx update_share` updates one share's reward address.
- `protx update_shared_registrar_prepare` builds an unsigned ProUpSharedRegTx.
1 change: 1 addition & 0 deletions src/Makefile.am
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ BITCOIN_CORE_H = \
evo/mnhftx.h \
evo/netinfo.h \
evo/providertx.h \
evo/sharedcollateral.h \
evo/simplifiedmns.h \
evo/smldiff.h \
evo/specialtx.h \
Expand Down
1 change: 1 addition & 0 deletions src/Makefile.test.include
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ BITCOIN_TESTS =\
test/evo_islock_tests.cpp \
test/evo_mnhf_tests.cpp \
test/evo_netinfo_tests.cpp \
test/evo_sharedmn_tests.cpp \
test/evo_simplifiedmns_tests.cpp \
test/evo_trivialvalidation.cpp \
test/evo_utils_tests.cpp \
Expand Down
40 changes: 39 additions & 1 deletion src/common/bloom.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,16 @@ bool CBloomFilter::CheckSpecialTransactionMatchesAndUpdate(const CTransaction &t
const auto owner_payouts = GetOwnerPayouts(opt_proTx->nVersion, opt_proTx->scriptPayout, opt_proTx->payouts);
const bool found_payout = std::any_of(owner_payouts.begin(), owner_payouts.end(),
[&](const auto& payout) { return CheckScript(payout.scriptPayout); });
const bool found_share = std::any_of(opt_proTx->shares.begin(), opt_proTx->shares.end(),
[&](const auto& share) {
return CheckScript(share.scriptRefund) ||
CheckScript(share.RewardScript()) ||
contains(share.keyIDOwner);
});
if(contains(opt_proTx->collateralOutpoint) ||
contains(opt_proTx->keyIDOwner) ||
contains(opt_proTx->keyIDVoting) ||
found_payout) {
found_payout || found_share) {
if ((nFlags & BLOOM_UPDATE_MASK) == BLOOM_UPDATE_ALL)
insert(tx.GetHash());
return true;
Expand Down Expand Up @@ -182,6 +188,38 @@ bool CBloomFilter::CheckSpecialTransactionMatchesAndUpdate(const CTransaction &t
}
return false;
}
case(TRANSACTION_PROVIDER_DISSOLVE): {
// the refund payments are literal transaction outputs, matched by the generic output loop
if (const auto opt_proTx = GetTxPayload<CProDisTx>(tx)) {
if(contains(opt_proTx->proTxHash))
return true;
}
return false;
}
case(TRANSACTION_PROVIDER_UPDATE_SHARE): {
if (const auto opt_proTx = GetTxPayload<CProUpShareTx>(tx)) {
if(contains(opt_proTx->proTxHash))
return true;
if(CheckScript(opt_proTx->scriptReward)) {
if ((nFlags & BLOOM_UPDATE_MASK) == BLOOM_UPDATE_ALL)
insert(opt_proTx->proTxHash);
return true;
}
}
return false;
}
case(TRANSACTION_PROVIDER_UPDATE_SHARED_REGISTRAR): {
if (const auto opt_proTx = GetTxPayload<CProUpSharedRegTx>(tx)) {
if(contains(opt_proTx->proTxHash))
return true;
if(contains(opt_proTx->keyIDVoting)) {
if ((nFlags & BLOOM_UPDATE_MASK) == BLOOM_UPDATE_ALL)
insert(opt_proTx->proTxHash);
return true;
}
}
return false;
}
case(TRANSACTION_ASSET_LOCK): {
// inputs of Asset Lock transactions are standard. But some outputs are special
if (const auto opt_assetlockTx = GetTxPayload<CAssetLockPayload>(tx)) {
Expand Down
12 changes: 12 additions & 0 deletions src/core_write.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,18 @@ void TxToUniv(const CTransaction& tx, const uint256& block_hash, UniValue& entry
if (const auto opt_assetUnlockTx = GetTxPayload<CAssetUnlockPayload>(tx)) {
entry.pushKV("assetUnlockTx", opt_assetUnlockTx->ToJson());
}
} else if (tx.nType == TRANSACTION_PROVIDER_DISSOLVE) {
if (const auto opt_proTx = GetTxPayload<CProDisTx>(tx)) {
entry.pushKV("proDisTx", opt_proTx->ToJson());
}
} else if (tx.nType == TRANSACTION_PROVIDER_UPDATE_SHARE) {
if (const auto opt_proTx = GetTxPayload<CProUpShareTx>(tx)) {
entry.pushKV("proUpShareTx", opt_proTx->ToJson());
}
} else if (tx.nType == TRANSACTION_PROVIDER_UPDATE_SHARED_REGISTRAR) {
if (const auto opt_proTx = GetTxPayload<CProUpSharedRegTx>(tx)) {
entry.pushKV("proUpSharedRegTx", opt_proTx->ToJson());
}
}

if (have_undo) {
Expand Down
5 changes: 5 additions & 0 deletions src/evo/assetlocktx.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ bool CheckAssetUnlockTx(const BlockManager& blockman, const llmq::CQuorumManager
return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetunlocktx-too-many-outs");
}

// A withdrawal paying the shared-collateral template is rejected by the generic template
// creation rule (CheckSharedCollateralTemplateOutputs), which is gated on v24. It is
// deliberately NOT re-checked here: this function has no v24 activation status available, and
// an unconditional rejection would diverge from pre-activation consensus and split the chain.

const auto opt_assetUnlockTx = GetTxPayload<CAssetUnlockPayload>(tx);
if (!opt_assetUnlockTx) {
return state.Invalid(TxValidationResult::TX_BAD_SPECIAL, "bad-assetunlocktx-payload");
Expand Down
107 changes: 105 additions & 2 deletions src/evo/core_write.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const std::map<std::string, RPCResult> RPCRESULT_MAP{{
RESULT_MAP_ENTRY("collateralHash", RPCResult::Type::STR_HEX, "Collateral transaction hash"),
RESULT_MAP_ENTRY("collateralIndex", RPCResult::Type::NUM, "Collateral transaction output index"),
RESULT_MAP_ENTRY("consecutivePayments", RPCResult::Type::NUM, "Consecutive payments masternode has received in payment cycle"),
RESULT_MAP_ENTRY("earlyPenalty", RPCResult::Type::NUM, "Penalty in duffs for unilateral dissolution during the early period"),
RESULT_MAP_ENTRY("earlyPeriodBlocks", RPCResult::Type::NUM, "Length in blocks of the early period during which unilateral dissolution is penalized"),
RESULT_MAP_ENTRY("height", RPCResult::Type::NUM, "Block height"),
RESULT_MAP_ENTRY("inputsHash", RPCResult::Type::STR_HEX, "Hash of all the outpoints of the transaction inputs"),
RESULT_MAP_ENTRY("lastPaidHeight", RPCResult::Type::NUM, "Height masternode was last paid"),
Expand Down Expand Up @@ -77,6 +79,19 @@ const std::map<std::string, RPCResult> RPCRESULT_MAP{{
RESULT_MAP_ENTRY("registeredHeight", RPCResult::Type::NUM, "Height masternode was registered"),
RESULT_MAP_ENTRY("revocationReason", RPCResult::Type::NUM, "Reason for ProUpRegTx revocation"),
RESULT_MAP_ENTRY("service", RPCResult::Type::STR, "IP address and port of the masternode (DEPRECATED, returned only if config option -deprecatedrpc=service is passed)"),
{"shares",
{RPCResult::Type::ARR, "shares", "Collateral shares of a shared masternode",
{
{RPCResult::Type::OBJ, "", "",
{
{RPCResult::Type::NUM, "amount", "Collateral contribution in duffs"},
{RPCResult::Type::STR, "refundAddress", "Dash address the principal is refunded to at dissolution"},
{RPCResult::Type::STR_HEX, "refundScript", "Refund scriptPubKey"},
{RPCResult::Type::STR, "rewardAddress", "Dash address this share's owner rewards are paid to"},
{RPCResult::Type::STR_HEX, "rewardScript", "Reward scriptPubKey"},
{RPCResult::Type::STR, "ownerAddress", "Dash address of the share owner key"},
}},
}}},
RESULT_MAP_ENTRY("type", RPCResult::Type::NUM, "Masternode type"),
RESULT_MAP_ENTRY("type_str", RPCResult::Type::STR, "Masternode type (human-readable string)"),
RESULT_MAP_ENTRY("version", RPCResult::Type::NUM, "Special transaction version"),
Expand Down Expand Up @@ -229,6 +244,9 @@ RPCResult CDeterministicMNState::GetJsonHelp(const std::string& key, bool option
GetRpcResult("platformHTTPPort", /*optional=*/true),
GetRpcResult("payoutAddress", /*optional=*/true),
GetRpcResult("payouts", /*optional=*/true),
GetRpcResult("shares", /*optional=*/true),
GetRpcResult("earlyPeriodBlocks", /*optional=*/true),
GetRpcResult("earlyPenalty", /*optional=*/true),
GetRpcResult("pubKeyOperator"),
GetRpcResult("operatorPayoutAddress", /*optional=*/true),
}};
Expand Down Expand Up @@ -260,7 +278,11 @@ UniValue CDeterministicMNState::ToJson(MnType nType) const
}

CTxDestination dest;
if (nVersion >= ProTxVersion::MultiPayout) {
if (IsShared()) {
obj.pushKV("shares", ShareListToJson(shares));
obj.pushKV("earlyPeriodBlocks", static_cast<int64_t>(nEarlyPeriodBlocks));
obj.pushKV("earlyPenalty", nEarlyPenalty);
} else if (nVersion >= ProTxVersion::MultiPayout) {
Comment on lines +281 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Omit the nonexistent single owner from shared JSON.

Shared validation requires keyIDOwner to be null, but both serializers already emit it as a zero-hash Dash address. Emit ownerAddress only for non-shared records and mark it optional in the corresponding RPC help.

Also applies to: 366-370

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/evo/core_write.cpp` around lines 281 - 285, Update the serializers around
IsShared() in src/evo/core_write.cpp and the corresponding RPC help: emit
ownerAddress only for non-shared records, omit it entirely for shared records
whose keyIDOwner is null, and mark the field optional in the RPC documentation.

obj.pushKV("payouts", PayoutListToJson(payouts));
} else if (ExtractDestination(scriptPayout, dest)) {
obj.pushKV("payoutAddress", EncodeDestination(dest));
Expand Down Expand Up @@ -290,6 +312,9 @@ RPCResult CDeterministicMNStateDiff::GetJsonHelp(const std::string& key, bool op
GetRpcResult("votingAddress", /*optional=*/true),
GetRpcResult("payoutAddress", /*optional=*/true),
GetRpcResult("payouts", /*optional=*/true),
GetRpcResult("shares", /*optional=*/true),
GetRpcResult("earlyPeriodBlocks", /*optional=*/true),
GetRpcResult("earlyPenalty", /*optional=*/true),
GetRpcResult("operatorPayoutAddress", /*optional=*/true),
GetRpcResult("pubKeyOperator", /*optional=*/true),
GetRpcResult("platformNodeID", /*optional=*/true),
Expand All @@ -313,6 +338,9 @@ RPCResult CProRegTx::GetJsonHelp(const std::string& key, bool optional)
GetRpcResult("votingAddress"),
GetRpcResult("payoutAddress", /*optional=*/true),
GetRpcResult("payouts", /*optional=*/true),
GetRpcResult("shares", /*optional=*/true),
GetRpcResult("earlyPeriodBlocks", /*optional=*/true),
GetRpcResult("earlyPenalty", /*optional=*/true),
GetRpcResult("pubKeyOperator"),
GetRpcResult("operatorReward"),
GetRpcResult("platformNodeID", /*optional=*/true),
Expand All @@ -335,7 +363,11 @@ UniValue CProRegTx::ToJson() const
ret.pushKV("addresses", GetNetInfoWithLegacyFields(*this, nType));
ret.pushKV("ownerAddress", EncodeDestination(PKHash(keyIDOwner)));
ret.pushKV("votingAddress", EncodeDestination(PKHash(keyIDVoting)));
if (nVersion >= ProTxVersion::MultiPayout) {
if (IsShared()) {
ret.pushKV("shares", ShareListToJson(shares));
ret.pushKV("earlyPeriodBlocks", static_cast<int64_t>(nEarlyPeriodBlocks));
ret.pushKV("earlyPenalty", nEarlyPenalty);
} else if (nVersion >= ProTxVersion::MultiPayout) {
ret.pushKV("payouts", PayoutListToJson(payouts));
} else if (CTxDestination dest; ExtractDestination(scriptPayout, dest)) {
ret.pushKV("payoutAddress", EncodeDestination(dest));
Expand Down Expand Up @@ -404,6 +436,77 @@ UniValue CProUpRevTx::ToJson() const
return ret;
}

RPCResult CProDisTx::GetJsonHelp(const std::string& key, bool optional)
{
return {RPCResult::Type::OBJ, key, optional, key.empty() ? "" : "The shared masternode dissolution special transaction",
{
GetRpcResult("version"),
GetRpcResult("proTxHash"),
{RPCResult::Type::NUM, "actorIndex", "Index into the share table of the participant paying the penalty (if any) and the fee"},
{RPCResult::Type::NUM, "sigCount", "Number of signatures: 1 = unilateral, one per share = unanimous"},
}};
}

UniValue CProDisTx::ToJson() const
{
UniValue ret(UniValue::VOBJ);
ret.pushKV("version", nVersion);
ret.pushKV("proTxHash", proTxHash.ToString());
ret.pushKV("actorIndex", actorIndex);
ret.pushKV("sigCount", static_cast<uint64_t>(vchSigs.size()));
return ret;
}

RPCResult CProUpShareTx::GetJsonHelp(const std::string& key, bool optional)
{
return {RPCResult::Type::OBJ, key, optional, key.empty() ? "" : "The shared masternode share update special transaction",
{
GetRpcResult("version"),
GetRpcResult("proTxHash"),
{RPCResult::Type::NUM, "shareIndex", "Index into the share table of the share being updated"},
{RPCResult::Type::STR, "rewardAddress", /*optional=*/true, "New Dash address for this share's owner rewards; omitted when reverting to the refund script"},
GetRpcResult("inputsHash"),
}};
}

UniValue CProUpShareTx::ToJson() const
{
UniValue ret(UniValue::VOBJ);
ret.pushKV("version", nVersion);
ret.pushKV("proTxHash", proTxHash.ToString());
ret.pushKV("shareIndex", shareIndex);
if (CTxDestination dest; !scriptReward.empty() && ExtractDestination(scriptReward, dest)) {
ret.pushKV("rewardAddress", EncodeDestination(dest));
}
ret.pushKV("inputsHash", inputsHash.ToString());
return ret;
}

RPCResult CProUpSharedRegTx::GetJsonHelp(const std::string& key, bool optional)
{
return {RPCResult::Type::OBJ, key, optional, key.empty() ? "" : "The shared masternode registrar update special transaction",
{
GetRpcResult("version"),
GetRpcResult("proTxHash"),
GetRpcResult("votingAddress"),
GetRpcResult("pubKeyOperator"),
GetRpcResult("inputsHash"),
{RPCResult::Type::NUM, "sigCount", "Number of signatures; must equal the share count"},
}};
}

UniValue CProUpSharedRegTx::ToJson() const
{
UniValue ret(UniValue::VOBJ);
ret.pushKV("version", nVersion);
ret.pushKV("proTxHash", proTxHash.ToString());
ret.pushKV("votingAddress", EncodeDestination(PKHash(keyIDVoting)));
ret.pushKV("pubKeyOperator", pubKeyOperator.ToString());
ret.pushKV("inputsHash", inputsHash.ToString());
ret.pushKV("sigCount", static_cast<uint64_t>(vchSigs.size()));
return ret;
}

RPCResult CProUpServTx::GetJsonHelp(const std::string& key, bool optional)
{
return {RPCResult::Type::OBJ, key, optional, key.empty() ? "" : "The masternode update service special transaction",
Expand Down
25 changes: 23 additions & 2 deletions src/evo/deterministicmns.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,19 @@ void CDeterministicMNList::AddMN(const CDeterministicMNCPtr& dmn, bool fBumpTota
strprintf("%s: Can't add a masternode %s with invalid address", __func__, dmn->proTxHash.ToString()));
}
}
if (!AddUniqueProperty(*dmn, dmn->pdmnState->keyIDOwner)) {
if (dmn->pdmnState->IsShared()) {
// A shared masternode has a null keyIDOwner; each share owner key takes its place. Share
// owner keys deliberately land in the same uniqueness namespace as keyIDOwner
// (GetUniquePropertyHash is an untagged SerializeHash of the value), which is what makes
// owner-key reuse between shared and non-shared masternodes impossible in both directions.
for (const auto& share : dmn->pdmnState->shares) {
if (!AddUniqueProperty(*dmn, share.keyIDOwner)) {
mnUniquePropertyMap = mnUniquePropertyMapSaved;
throw(std::runtime_error(strprintf("%s: Can't add a masternode %s with a duplicate share ownerKeyID=%s", __func__,
dmn->proTxHash.ToString(), EncodeDestination(PKHash(share.keyIDOwner)))));
}
}
} else if (!AddUniqueProperty(*dmn, dmn->pdmnState->keyIDOwner)) {
mnUniquePropertyMap = mnUniquePropertyMapSaved;
throw(std::runtime_error(strprintf("%s: Can't add a masternode %s with a duplicate keyIDOwner=%s", __func__,
dmn->proTxHash.ToString(), EncodeDestination(PKHash(dmn->pdmnState->keyIDOwner)))));
Expand Down Expand Up @@ -588,7 +600,16 @@ void CDeterministicMNList::RemoveMN(const uint256& proTxHash)
dmn->proTxHash.ToString()));
}
}
if (!DeleteUniqueProperty(*dmn, dmn->pdmnState->keyIDOwner)) {
if (dmn->pdmnState->IsShared()) {
// Shared masternodes have a null keyIDOwner; the share owner keys were registered instead
for (const auto& share : dmn->pdmnState->shares) {
if (!DeleteUniqueProperty(*dmn, share.keyIDOwner)) {
mnUniquePropertyMap = mnUniquePropertyMapSaved;
throw(std::runtime_error(strprintf("%s: Can't delete a masternode %s with a share ownerKeyID=%s", __func__,
proTxHash.ToString(), EncodeDestination(PKHash(share.keyIDOwner)))));
}
}
} else if (!DeleteUniqueProperty(*dmn, dmn->pdmnState->keyIDOwner)) {
mnUniquePropertyMap = mnUniquePropertyMapSaved;
throw(std::runtime_error(strprintf("%s: Can't delete a masternode %s with a keyIDOwner=%s", __func__,
proTxHash.ToString(), EncodeDestination(PKHash(dmn->pdmnState->keyIDOwner)))));
Expand Down
Loading
Loading