DIP-0026: Multi-Party Payouts (implementation + tests complete; draft for review)#1
Draft
hilawe wants to merge 14 commits into
Draft
DIP-0026: Multi-Party Payouts (implementation + tests complete; draft for review)#1hilawe wants to merge 14 commits into
hilawe wants to merge 14 commits into
Conversation
Scaffolding for DIP-0026 multi-party masternode payouts. Introduces a new
special-transaction version ProTxVersion::MultiPayout (v4) for CProRegTx and
CProUpRegTx, gated behind a new EHF-style hard-fork deployment DEPLOYMENT_V25.
No payout-share fields or payout logic yet: a v4 ProTx is rejected until the
fork activates, so there is no behavior change on existing networks.
- consensus/params.h, deploymentinfo.cpp: register DEPLOYMENT_V25 ("v25").
- chainparams.cpp: V25 params for all four networks (bit 13, EHF); NEVER_ACTIVE
on main/testnet/devnet, activatable on regtest.
- evo/providertx.h: ProTxVersion::MultiPayout=4; extend GetMax() with an
is_multi_payout axis; compile-time static_asserts pin the version tiers.
- evo/providertx.cpp: GetMaxFromDeployment gates v4 on DEPLOYMENT_V25 for
ProRegTx/ProUpRegTx only, and enforces V24-before-V25 for ProRegTx so a v4
reg-tx can never outrun extended-address support. Fix the CanStorePlatform
netinfo-version gate (== ExtAddr -> >= ExtAddr) so v4 keeps extended netInfo.
- rpc/blockchain.cpp: report v25 in getblockchaininfo EHF softforks.
- test: update GetMax call sites; add v25 to rpc_blockchain.py expected softforks.
Add the multi-party payout data model and wire its serialization into v4
ProRegTx/ProUpRegTx, with no validation or payout logic yet.
- evo/providertx.h: new PayoutShare {CScript scriptPayout; uint16_t
payoutShareReward;} with the DIP0026 wire format (CompactSize scriptlen +
script bytes + uint16 reward); TOTAL_BASIS_POINTS=10000. Add
std::vector<PayoutShare> payoutShares to CProRegTx and CProUpRegTx,
serialized at the same wire position as scriptPayout but only for
nVersion >= MultiPayout; pre-v4 serialization is byte-for-byte unchanged.
Add GetPayoutShares() for a uniform view (synthesizes a single full share
from scriptPayout for older versions).
- evo/providertx.cpp: PayoutShare::ToString().
- test: new evo_providertx_tests (round-trip + wire-format + accessor
coverage for PayoutShare, the share vector, and v2/v3/v4 ProReg/ProUpReg).
Verified on macOS: 6 new tests pass; evo_dip3_activation_tests and the other
evo serialization suites pass unchanged (no v<4 regression).
Extend CDeterministicMNState with the multi-party payout representation and apply it through the ProUpRegTx path; no payout or validation logic yet. - evo/dmnstate.h: add std::vector<PayoutShare> payoutShares to CDeterministicMNState; serialize it in place of scriptPayout only for nVersion >= MultiPayout (pre-v4 byte-for-byte unchanged); init from CProRegTx; add GetPayoutShares() for a uniform view. Add a new last diff field bit Field_payoutShares (+ member-tuple entry) so existing diff bits are stable. Legacy diff format left untouched. - evo/specialtxman.cpp: ProUpRegTx apply keeps the payout representation consistent with the state version - a v4 update stores payoutShares, clears the single scriptPayout, and bumps nVersion so shares persist; older updates keep the single-payout path. (Version-transition validation guarding this - v4 only targets v3+ MNs, no v4 downgrade - lands in P4.) - test: state v3/v4 round-trips and a diff round-trip exercising the new Field_payoutShares bit. Verified on macOS: 9 evo_providertx_tests pass; full evo regression (26 cases incl. evo_dip3_activation_tests) green - no v<4 MN-state serialization regression.
Validate the multi-party payout structure and the v4 version transitions, and
keep payout shares safe across operator service updates and revocations.
- evo/providertx.{h,cpp}: CheckPayoutShares() enforces, for v4 ProRegTx/
ProUpRegTx, that payoutShares is non-empty and <= MAX_PAYOUT_SHARES (32),
every share is p2pkh/p2sh with a nonzero reward <= 10000 bp, scripts are
unique, and rewards sum to exactly 10000. Wired into both IsTriviallyValid
(replacing the single scriptPayout check); the payout-key-reuse check now
iterates every share via GetPayoutShares(). Uniqueness and the nonzero-reward
rule are stricter than the three DIP0026 conditions (companion dips PR to come).
- evo/specialtxman.cpp: IsVersionChangeValid now (1) requires a v4 ProUpRegTx to
target an MN already at >= ExtAddr so the version bump can't reinterpret a
non-extended netInfo, and (2) forbids a ProUpRegTx downgrading a v4 MN back to
single-payout. The ProUpServTx apply keeps a multi-payout MN at >= MultiPayout
so a service update can't drop its shares.
- evo/dmnstate.h: ResetOperatorFields (operator revocation) preserves MultiPayout
when shares are present (payout is an owner-side property surviving operator
changes).
- test: reject-matrix covering all eight failure modes plus valid v<4 and v4
cases (including exactly 32 shares).
Verified on macOS: 11 evo_providertx_tests pass; evo_dip3_activation_tests +
evo_trivialvalidation + SML/reallocation suites (17 cases) green.
Fixes from a multi-agent adversarial review of the P3/P4 version-conflation logic. Two were consensus-critical: - CheckProUpRegTx unconditionally ran ExtractDestination on the (empty for v4) scriptPayout, rejecting every valid v4 ProUpRegTx. Make the payout-key-reuse check version-aware: iterate GetPayoutShares() (matching CProRegTx:: IsTriviallyValid) so each share is checked against the owner/voting keys. - A sub-ExtAddr ProUpServTx applied to a v4 MN bumped nVersion to MultiPayout (to preserve payoutShares) while keeping the tx's non-extended netInfo, producing a v4 state with a non-extended netInfo object that serializes/ deserializes asymmetrically and corrupts the MN-list state and its merkle root. IsVersionChangeValid now requires a ProUpServTx targeting a multi-payout MN to itself be >= ExtAddr. - Defensive: CheckPayoutShares rejects cross-version field mixing (a v4 object with a non-empty scriptPayout, or a v<4 object carrying payoutShares). Tests: reject-matrix extended for the cross-version mixing rules. macOS: 11 evo_providertx_tests + 17-case evo regression green. (The v4 ProUpRegTx and v4-MN ProUpServTx paths get end-to-end coverage in the P9 functional test.)
For external (off-chain) collateral, a ProRegTx is authorized by signing CProRegTx::MakeSignString with the collateral key (HW-wallet-friendly text). Make it version-aware per DIP0026: for v4 the payee portion becomes address0|reward0|...|addressN|rewardN (payoutSharesStr) instead of the single payout address; pre-v4 output is byte-identical. Tests: verify the exact v4 prefix (independently rebuilt from the shares, operator reward, owner and voting addresses) and that v3 output is unchanged, each followed by the 64-hex payload hash.
Distribute the owner-side masternode block reward across the DIP0026 payout
shares, and harden the coinbase validator.
- masternode/payments.{h,cpp}: SplitMasternodeReward() deterministically splits
the reward - floor each share by basis points, then hand the leftover satoshis
out one per share in payoutShares order - so the outputs sum to exactly the
reward and every node computes the identical set. GetBlockTxOuts uses
GetPayoutShares(), so a pre-v4 masternode produces the same single output as
before. Zero-amount outputs are omitted.
- IsTransactionValid: make the coinbase payee check multiplicity-correct (count,
not any_of existence), so two identical expected outputs both have to appear.
Closes a potential underpayment when a payout share collides with the
operator/platform output, and the same latent edge in the pre-DIP0026
two-party payout.
Tests: split rounding/sum-invariant (outputs sum to exactly the reward across a
wide amount range), legacy single-share equivalence, zero/edge handling,
determinism. macOS: 16 evo_providertx_tests + block_reward_reallocation_tests +
evo_dip3_activation_tests (30 cases) green - no v<4 payout regression.
End-to-end v4 split payout on regtest is covered in P9 (needs V25 EHF
activation); the reward-split function and validator are unit-proven here.
From the adversarial review of the reward split: - The multiplicity-correct coinbase check is a consensus change relative to the historical existence check (it would reject a block underpaying a masternode whose owner and operator scripts collide). Gate it behind DEPLOYMENT_V25 so pre-fork validation stays byte-identical and there is no upgrade-window split risk; the legacy any_of existence check is used until activation. - Add assert(MoneyRange(masternodeReward)) to SplitMasternodeReward as a cheap overflow/sanity guard, matching PlatformShare. macOS: 30-case suite green (split property tests + block_reward_reallocation + evo_dip3_activation), no v<4 regression.
Let protx register*/update_registrar accept multi-party payouts.
- rpc/evo.cpp: ParsePayoutParam() accepts the payoutAddress argument as either a
single address string (single payout) or a {"address": basisPoints} object
(multi-party payout). It returns the ProTx version to use: a single payout is
capped below MultiPayout (ExtAddr for register, BasicBLS for update_registrar)
so it is never auto-selected as v4, and a shares object selects MultiPayout
(requiring DIP0026/v25). This keeps pre-v25 behaviour byte-for-byte identical
(the cap is a no-op when v25 is inactive) and, crucially, keeps the single-
address RPC working post-v25 (without the cap it would build a v4 tx with a
single scriptPayout, which validation now rejects).
- protx register/register_fund/register_prepare and update_registrar build
payoutShares from the object form; update_registrar inherits the masternode's
current payout + matching version when omitted.
- Help text documents the object form.
Share order follows JSON key order (preserved on-chain; drives the payout-split
remainder distribution). Builds clean on macOS + Linux; existing evo suites
green (29 cases). End-to-end RPC exercise (register a v4 MN, mine, verify the
split) lands in the P9 functional test.
Display and wallet-propagation surfaces for multi-party payouts. All are display/wallet-only: the SML payout is mem-only and not part of the merkle root, so v4 masternodes do not change any consensus hash. - evo/core_write.cpp, providertx.h: PayoutShare::ToJson(); emit a payoutShares array (only for v4, so pre-v4 JSON is byte-identical) in CProRegTx, CProUpRegTx and CDeterministicMNState ToJson; add the matching optional RPCResult declarations (required because the functional test framework runs with rpcdoccheck=1). - evo/dmnstate.cpp: CDeterministicMNStateDiff::ToJson renders payoutShares when the Field_payoutShares bit is set. - evo/specialtx_filter.cpp: add each payout share script to the bloom/GCS filters (iterate GetPayoutShares()) so SPV wallets watching a payee still match a v4 ProRegTx/ProUpRegTx. - rpc/masternode.cpp: GetRequiredPaymentsString iterates the shares instead of the single scriptPayout, fixing a NONFATAL_UNREACHABLE that would throw on a v4 masternode. Deferred: SML extended-JSON payout enrichment (optional) and the Qt GUI payout column (needs a masternode-interface accessor + a GUI build to compile-test). macOS: 17 evo_providertx_tests (incl. a PayoutShare::ToJson test) + the evo and payments regression suites (33 cases) green; no pre-v4 display regression.
The version-inherit ternary mixed a uint16_t (std::min result) with the ProTxVersion enum, which g++ flags under -Wextra (enumerated and non-enumerated type in conditional expression) and would fail an -Werror CI build. Cast the enum branch to uint16_t. g++-only: clang did not emit it. Swept the whole DIP-0026 diff on g++ and confirmed this was the only warning; the rest is warning-clean.
Add test/functional/feature_dip0026_multipayout.py and register it in
test_runner.py. The test exercises the v4 (MultiPayout) ProTx feature on
regtest from activation through the on-chain reward split.
It forces v24 and v25 active via the 9-field -vbparams form (useehf=0) so
both activate by plain miner signaling (no MNHF/EHF quorum), then:
- asserts an object-form payout is rejected by the RPC before v25 is
active (ParsePayoutParam error path),
- upgrades two running masternodes to v3 (ProUpServTx) and converts them
to 3-way and 2-way multi-payout via update_registrar with the object
form, checking the resulting v4 state and share order,
- mines until each is the coinbase payee and verifies the owner reward
is split across the share addresses exactly (floor plus 1-satoshi
remainder in canonical order), summing to the owner reward to the
satoshi, cross-checked against both getblocktemplate and the mined
coinbase vouts.
The CheckPayoutShares reject matrix (empty/oversize set, non-p2pkh/p2sh
payee, reward out of (0,10000], duplicate scripts, sum != 10000, and
cross-version field mixing) is covered exhaustively at the unit level in
src/test/evo_providertx_tests.cpp, and those rules run at both mempool
acceptance and block connect via CheckSpecialTx, so a malformed v4 ProTx
cannot be mined.
ParsePayoutParam (the protx register*/update_registrar payout parser) now
rejects, at RPC-parse time, the multi-payout object-form sets that the
consensus rule CheckPayoutShares would reject and that the RPC can express:
- shares not summing to exactly 10000 basis points,
- more than 32 shares (PayoutShare::MAX_PAYOUT_SHARES),
- a duplicate payout script (the JSON reader does not collapse repeated
object keys, so a raw request can carry the same payee twice).
Per-share range (1..10000) and address validity were already checked. This
makes a malformed payout fail immediately with a clear error instead of only
later at mempool/block validation. It cannot reject a valid set, and the
consensus rule CheckPayoutShares remains the authoritative gate (it runs at
both mempool acceptance and block connect).
The functional test feature_dip0026_multipayout.py gains a post-activation
reject-matrix covering the dict-expressible cases (sum too low / too high, a
share out of range, a zero-reward share, an empty object, and more than 32
shares), run on a pristine masternode before the conversions. The duplicate
-script and non-p2pkh/p2sh rules, which a Python dict cannot express, stay
covered by the unit reject-matrix in src/test/evo_providertx_tests.cpp.
It also fixes a pre-existing seed-dependent flake: the v2->v3 ProUpServTx
upgrade and the v4 conversions now top up the masternode fee-source address
before each update (the setup funding could otherwise be unavailable),
mirroring feature_dip3_deterministicmns.py. Verified across multiple PRNG
seeds, including one that previously failed with "No funds at specified
address".
ParsePayoutParam only recognized the {"address": basisPoints} payout form when it
arrived as a UniValue object, which is the case over JSON-RPC and named arguments.
dash-cli passes the payout argument as a plain string (it is not in the
rpc/client.cpp conversion table), so the documented object form sent via dash-cli
arrived as a string, fell through to the single-address branch, and failed with
"invalid payout address: {...}".
Accept the object whether it arrives as a UniValue object or as a JSON string: if a
string payout begins with '{', parse it and require a JSON object, otherwise treat it
as a single address. A base58/bech32 address never begins with '{', so the two forms
are unambiguous, and the legacy single-address CLI form is unchanged.
The functional test now sends one conversion's shares as a JSON string (the dash-cli
path) in addition to the dict form, so both arrival shapes are covered.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue being fixed or feature implemented
Implements DIP-0026 (Multi-Party Payouts). Today a masternode pays its owner-side block reward to
a single address (plus an optional operator cut). DIP-0026 lets that reward be split natively
on-chain among up to 32 payees, each assigned a share in basis points. This enables non-custodial
shared-masternode and staking arrangements, where each participant is paid directly by the protocol
instead of through an intermediary that collects and redistributes. The operator reward and operator
payout are unchanged and layer on top exactly as today.
There is no open issue; this PR tracks DIP-0026.
Adaptation reviewers should know: DIP-0026 (written 2021) specifies "version 2" of
ProRegTx/ProUpRegTx, but v2 (basic BLS) and v3 (extended addresses) are already in use. This
implementation introduces
ProTxVersion::MultiPayout = 4, gated behind a new EHF deploymentDEPLOYMENT_V25(version bit 13). A v4 ProRegTx additionally requiresv24active, enforced incode rather than relying on chainparams ordering.
What was done
All behavior is gated behind
DEPLOYMENT_V25and is inert until activation.DEPLOYMENT_V25on all four networks (chainparams.cpp), reported ingetblockchaininfo;ProTxVersion::MultiPayoutgated via the existingGetMaxFromDeploymentpath.PayoutShare { CScript scriptPayout; uint16_t payoutShareReward; }struct;
std::vector<PayoutShare> payoutShareson CProRegTx/CProUpRegTx, serialized only fornVersion >= MultiPayout. v<4 wire format is byte-identical (verified by round-trip vectors).
CDeterministicMNStatekeepsscriptPayoutfor v<4 and addspayoutShares(version-gated), with a
GetPayoutShares()accessor so consumers have one code path; a new diffbit tracks the field.
CheckPayoutShares, in both IsTriviallyValid paths): for v4, reject an empty or >32set, a non-p2pkh/p2sh payee, a reward of 0 or >10000, duplicate scripts, or shares not summing to
exactly 10000. Version-transition guards permit v2/v3 -> v4 and forbid a v4 -> single-payout
downgrade.
MakeSignString): the external-collateral message embeds the shares in the DIPformat. Test vectors included.
GetBlockTxOuts): the owner reward is split by floor(reward * bps / 10000) withthe leftover duffs distributed one per share in canonical order, summing to the owner reward
exactly. Zero-amount outputs are skipped.
protx register/register_fund/register_prepare/update_registraraccept the payoutas a single address (unchanged) or a
{address: basisPoints}object. Over dash-cli the object ispassed as a quoted JSON string (ParsePayoutParam accepts a JSON object or a JSON-object string; a
bare address is still parsed as a single payout).
bloom/GCS filters render or match all share scripts; pre-v4 JSON is byte-identical.
Consensus-safety notes for reviewers:
byte-for-byte identical to develop; the dip3 and llmq functional suites pass unchanged.
CSimplifiedMNListEntry::scriptPayoutis memory-only and not in the leaf hash, so v4masternodes do not change
merkleRootMNList; shares are deliberately kept out of SML serialization.IsTransactionValidis made multiplicity-correct (the coinbase must containeach expected output at least as many times as it is expected), required so a v4 payout share
cannot collide with the operator or platform output and be satisfied only once. The stricter check
is gated behind V25, so pre-activation consensus is preserved byte-for-byte.
Stricter than the DIP text (deliberate): this forbids zero-reward shares (the DIP defines the reward
as "0 to 10000", so this contradicts the field definition) and forbids duplicate payout scripts (the
DIP is silent). A companion dashpay/dips edit is prepared to align the spec, held pending maintainer
input on reject-vs-merge for duplicates and forbid-vs-allow for zero-reward shares.
The branch is organized as one reviewable commit per concept (activation; data model; MN state;
validation; sign-string; coinbase split; RPC; display; functional test; plus follow-ups).
How Has This Been Tested?
src/test/evo_providertx_tests.cpp): serialization round-trips (v3/v4 ProReg/ProUpReg, MNstate, diff), the
CheckPayoutSharesaccept case (including exactly 32 shares) and the full rejectmatrix,
MakeSignStringv3/v4 vectors, and a reward-split rounding property test (sum invariant +determinism). Existing evo suites (
evo_deterministicmns_tests,evo_simplifiedmns_tests,block_reward_reallocation_tests) updated and passing.test/functional/feature_dip0026_multipayout.py): end-to-end on regtest, with V24/V25forced active via the 9-field
-vbparamsform (useehf=0). It asserts the pre-activation RPCrejection, converts running masternodes to 3-way and 2-way multi-payout (one via a JSON object, one
via a JSON string to cover the dash-cli path), mines until each is the coinbase payee and asserts
the split to the duff against both
getblocktemplateand the mined coinbase, and runs an RPCreject matrix. Verified across multiple PRNG seeds.
feature_dip3_deterministicmnsandfeature_llmq_{simplepose, signing, chainlocks}pass with these changes (descriptor wallets).
the object form, observe the coinbase split the owner reward 2500/2500/5000 to the duff).
Testing environment: regtest in a Linux container; unit tests on macOS.
Breaking Changes
This is a consensus change activated by a new hard-fork deployment (
DEPLOYMENT_V25, EHF, versionbit 13). Before activation there is no behavior change: v4 transactions are rejected and all
serialized forms for existing masternodes are byte-for-byte identical to develop. After activation,
masternodes may use v4 ProRegTx/ProUpRegTx to pay up to 32 payees and the coinbase splits the owner
reward accordingly. The coinbase payout validator becomes multiplicity-correct (gated behind V25).
There is no wire or RPC breakage for pre-v4 masternodes, and the legacy single-address payout
continues to work over both RPC and dash-cli.
Checklist
doc/release-notes-<PR#>.mdentrywill be added once the PR number is assigned)