Skip to content

DIP-0026: Multi-Party Payouts (implementation + tests complete; draft for review)#1

Draft
hilawe wants to merge 14 commits into
developfrom
dip0026-multi-party-payouts
Draft

DIP-0026: Multi-Party Payouts (implementation + tests complete; draft for review)#1
hilawe wants to merge 14 commits into
developfrom
dip0026-multi-party-payouts

Conversation

@hilawe

@hilawe hilawe commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Draft on the fork for review. P1-P9 plus the follow-ups are complete; the end-to-end regtest test and the dip3/llmq regression pass are green. This draft lives on the fork for review ahead of submission to dashpay/dash (still pending). The body below follows the dashpay/dash PR template.

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 deployment
DEPLOYMENT_V25 (version bit 13). A v4 ProRegTx additionally requires v24 active, enforced in
code rather than relying on chainparams ordering.

What was done

All behavior is gated behind DEPLOYMENT_V25 and is inert until activation.

  • Activation: DEPLOYMENT_V25 on all four networks (chainparams.cpp), reported in
    getblockchaininfo; ProTxVersion::MultiPayout gated via the existing GetMaxFromDeployment path.
  • Data model and serialization: a PayoutShare { CScript scriptPayout; uint16_t payoutShareReward; }
    struct; std::vector<PayoutShare> payoutShares on CProRegTx/CProUpRegTx, serialized only for
    nVersion >= MultiPayout. v<4 wire format is byte-identical (verified by round-trip vectors).
  • Masternode state: CDeterministicMNState keeps scriptPayout for v<4 and adds payoutShares
    (version-gated), with a GetPayoutShares() accessor so consumers have one code path; a new diff
    bit tracks the field.
  • Validation (CheckPayoutShares, in both IsTriviallyValid paths): for v4, reject an empty or >32
    set, 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.
  • payloadSig (MakeSignString): the external-collateral message embeds the shares in the DIP
    format. Test vectors included.
  • Coinbase split (GetBlockTxOuts): the owner reward is split by floor(reward * bps / 10000) with
    the leftover duffs distributed one per share in canonical order, summing to the owner reward
    exactly. Zero-amount outputs are skipped.
  • RPC: protx register / register_fund / register_prepare / update_registrar accept the payout
    as a single address (unchanged) or a {address: basisPoints} object. Over dash-cli the object is
    passed 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).
  • Display and propagation (non-consensus): JSON output, the masternode-list RPC, and the SPV
    bloom/GCS filters render or match all share scripts; pre-v4 JSON is byte-identical.

Consensus-safety notes for reviewers:

  • Pre-activation: v4 transactions are rejected and the ProTx, MN-state, and SML serialized forms are
    byte-for-byte identical to develop; the dip3 and llmq functional suites pass unchanged.
  • SML: CSimplifiedMNListEntry::scriptPayout is memory-only and not in the leaf hash, so v4
    masternodes do not change merkleRootMNList; shares are deliberately kept out of SML serialization.
  • Coinbase validator: IsTransactionValid is made multiplicity-correct (the coinbase must contain
    each 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?

  • Unit (src/test/evo_providertx_tests.cpp): serialization round-trips (v3/v4 ProReg/ProUpReg, MN
    state, diff), the CheckPayoutShares accept case (including exactly 32 shares) and the full reject
    matrix, MakeSignString v3/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.
  • Functional (test/functional/feature_dip0026_multipayout.py): end-to-end on regtest, with V24/V25
    forced active via the 9-field -vbparams form (useehf=0). It asserts the pre-activation RPC
    rejection, 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 getblocktemplate and the mined coinbase, and runs an RPC
    reject matrix. Verified across multiple PRNG seeds.
  • Regression: feature_dip3_deterministicmns and feature_llmq_{simplepose, signing, chainlocks}
    pass with these changes (descriptor wallets).
  • Manual: a full dash-cli walkthrough on regtest (register a v3 masternode, set the multi-payout via
    the object form, observe the coinbase split the owner reward 2500/2500/5000 to the duff).
  • Build: Linux (g++) and macOS (clang), with zero warnings in the changed files.

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, version
bit 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

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation (a doc/release-notes-<PR#>.md entry
    will be added once the PR number is assigned)
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

hilawe added 13 commits June 6, 2026 01:01
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".
@hilawe hilawe changed the title DIP-0026: Multi-Party Payouts (WIP: P1-7 of 10) DIP-0026: Multi-Party Payouts (implementation + tests complete; draft for review) Jun 6, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant