Skip to content

feat(gui): masternode and shared masternode management - #68

Open
PastaPastaPasta wants to merge 24 commits into
claude/masternode-shares-dip-c40ac2from
decentralized-mn-ui
Open

feat(gui): masternode and shared masternode management#68
PastaPastaPasta wants to merge 24 commits into
claude/masternode-shares-dip-c40ac2from
decentralized-mn-ui

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Issue being fixed or feature implemented

dash-qt has never had an action-oriented masternode UI — the legacy "start alias" GUI was removed with the legacy masternode code (dashpay#2600), and today's Masternodes tab is a read-only list. Meanwhile dashpay#7437 adds shared (multi-party) masternodes, whose multi-step, multi-wallet RPC flows (register_shared_prepareshared_signshared_combine → funding signatures → broadcast) are impractical to drive by hand from the debug console for the non-expert participants they are designed for.

Stacked on dashpay#7437. This PR's base is the dashpay#7437 branch, so it shows only the 14 GUI commits; it will be retargeted to develop once dashpay#7437 merges.

What was done?

GUI for creating and maintaining masternodes, evonodes and shared masternodes, stacked on dashpay#7437:

  • Recognition (interfaces + list): MnEntry exposes the shared state (share table, penalty terms); the masternode list gets a "Shared" type, type filter, share-aware text search and owned-row attribution via share owner keys (keyIDOwner is null for shared MNs); the details dialog shows the share table with per-share amounts/addresses, "(you)" markers, penalty terms and the early-period countdown.
  • ProTxSender: worker-thread bridge that drives the wallet-side protx RPC handlers in-process with named arguments (executeRpc is synchronous and takes wallet/validation locks, so it stays off the GUI thread) and translates consensus reject strings into actionable messages. The write path deliberately reuses the RPC layer as its contract instead of extracting feat!: implement Decentralized Masternode Shares DIP dashpay/dash#7437's file-static tx-assembly helpers into typed interfaces — that refactor would enlarge the base PR's review surface and conflict with every feat!: implement Decentralized Masternode Shares DIP dashpay/dash#7437 push while it is still under review; typed interfaces::Wallet protx virtuals are the intended follow-up once feat!: implement Decentralized Masternode Shares DIP dashpay/dash#7437 merges.
  • Registration wizard for regular masternodes and evonodes with all three collateral paths: fund from wallet (register_fund), existing exact-denomination UTXO (register), and external/hardware-held collateral (register_prepare → out-of-band message signing → register_submit). Generates owner/voting addresses and the operator BLS keypair (or accepts a hosting provider's public key); the operator secret is shown exactly once behind a type-back confirmation with the matching masternodeblsprivkey dash.conf line.
  • Maintenance dialogs: Update Service (incl. evonode platform fields; mandatory fee source for shared MNs), Update Registrar (never offered for shared MNs; PoSe-ban warning on operator change), Revoke (reason dropdown, de-dramatized copy).
  • Shared masternode creation as a resumable session dialog (not a wizard — the flow is asynchronous and multi-wallet): a JSON session envelope passes between participants; the share-table editor mirrors IsShareListTriviallyValid live; funding uses a chip model (each participant contributes one output of exactly their share; the coordinator adds the fee input and takes the only change) so the final transaction is verifiable at a glance; every imported signature is verified natively at import time against the recomputed consent hash, so signing a stale draft is caught immediately with who must re-sign, instead of surfacing as bad-protx-shares-sig at broadcast; contributed coins are locked in the wallet and the session is declared dead if a funding input gets spent.
  • Shared maintenance/dissolution: per-share reward-address change; unanimous operator/voting rotation (pinned to the prepare wallet whose fee inputs the transaction spends); dissolution with a live payout preview mirroring the consensus split math, an explicit early-penalty acceptance, and a standby pair generator — one transaction broadcastable immediately (pays the penalty forever, since the penalty is baked into the outputs at build time) and one penalty-free transaction valid from the early-period boundary — with the Dissolve menu entry warning until both are saved.
  • Unit tests for the session engine (validation mirror, envelope round-trip, payout preview vs consensus math, import-time signature verification incl. stale-signature rejection).

Shared-MN entry points are gated on v24 activation at the tip (Node::isV24Active()), with explanatory tooltips pre-activation.

How Has This Been Tested?

  • Built on macOS (arm64, Qt 5.15); test_dash-qt passes incl. the new MnShareSessionTests.
  • Driven manually against a regtest datadir produced by feature_masternode_shares.py with v24 active and a live 3-share shared masternode; screenshots below.
  • lint-qt-translation, lint-include-guards, lint-format-strings, lint-whitespace, lint-circular-dependencies all clean.

Breaking Changes

None. GUI-only on top of dashpay#7437; no RPC or consensus changes.

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
  • I have assigned this pull request to a milestone

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d6182370-d3b8-45a6-b9a3-9012f05d81f7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

PastaPastaPasta and others added 14 commits August 12, 2026 09:46
Add isShared(), the typed collateral share table (amount, owner key,
refund/reward scripts), and the early-period penalty terms to
interfaces::MnEntry so the GUI can recognize and display shared
masternodes. Also add Node::isV24Active() so wallet UI can gate
shared-masternode actions on hard-fork activation at the tip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shared masternodes now display as their own type, get a dedicated type
filter (the Regular filter shows only single-owner masternodes), match
the text filter by share owner/refund/reward addresses, and count as
owned when the wallet holds any share owner key (keyIDOwner is null for
shared masternodes and rewards may be redirected away from the wallet).

The details dialog gains the collateral share table with per-share
amounts, addresses and 'you' markers, the early-exit penalty terms and
the remaining early-period countdown.

MasternodeEntry::toTie() now also covers payout/voting/operator key and
share-table state so reconcile() refreshes rows after ProUpRegTx,
ProUpShareTx and ProUpSharedRegTx updates instead of serving stale
entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scaffolding for the masternode management dialogs added in the following
commits: executes protx (and related) commands via the in-process RPC
dispatcher on a dedicated worker thread (executeRpc is synchronous and
takes wallet/validation locks, so it must stay off the GUI thread),
routes to the dialog's wallet by percent-encoded wallet URI, dispatches
with named arguments so the GUI is independent of RPC parameter order,
and maps RPC and consensus-reject errors to actionable user messages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A multi-page wizard on the Masternodes tab covering all three
registration paths: funding the collateral from the wallet
(protx register_fund/_evo), using an existing exact-denomination UTXO
held by the wallet (protx register/_evo), and external collateral held
in another wallet or on a hardware device
(protx register_prepare -> out-of-band message signing ->
protx register_submit).

The keys page generates fresh owner/voting addresses from the wallet
and either generates a basic-scheme operator BLS keypair or accepts a
hosting provider's public key. The result page shows the operator
secret exactly once behind a type-back confirmation gate together with
the matching masternodeblsprivkey dash.conf line. Evonodes get a
platform page whose address-list shape follows v24 activation.

Also adds reusable widgets: a fee-source picker with per-address
spendable balances (special transactions fund from exactly one
address), an operator-key widget and service-address-list validation
helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Role-aware maintenance actions in the masternode list context menu.
Update Service covers regular, evonode (with platform fields via
protx update_service_evo) and shared masternodes; for shared
masternodes an explicit fee source is required because they have no
payout address to fall back to. Update Registrar requires the owner
key in the wallet and is never offered for shared masternodes, which
rotate keys unanimously instead; it warns that an operator key change
PoSe-bans the node until a new ProUpServTx. Revoke offers the four
DIP3 reasons with copy explaining it is mainly needed before
re-registering with the same address or operator key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Creating a shared masternode is an asynchronous multi-wallet flow, so
this is a resumable session dialog rather than a wizard. MnShareSession
is the widget-free engine holding the session envelope (a JSON file the
participants pass around): the share table and terms with a live mirror
of the consensus share-validation rules, the chip-model funding
transaction assembly with fixed input sequences, and native consent
verification - every imported signature is checked against the
recomputed consent hash (CHashSigner::VerifyHashCanonical) at import
time, so a signature over a stale draft is flagged with who must
re-sign instead of surfacing as a consensus error after broadcast.

The dialog walks Draft -> Frozen -> Signing -> Combined -> funding
signatures -> Broadcast with the consent check-phrase displayed
prominently from freeze onward, locks contributed coins in the wallet,
re-checks funding inputs on every import and declares the session dead
if one was spent, and requires an explicit unfreeze (discarding the
collected signatures) to edit frozen terms. The entry button is gated
on v24 activation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Context-menu actions on owned shared rows: Change My Reward Address
(protx update_share, the only solo update a share owner can make),
Rotate Operator/Voting Keys (the unanimous
update_shared_registrar_prepare flow, pinned to the wallet whose fee
inputs the prepared transaction spends), and Dissolve with three modes:

- Dissolve now: unilateral ProDisTx with a live payout preview that
  mirrors the consensus math exactly (actor receives share minus
  penalty minus fee, the others receive principal plus a pro-rata
  bonus). During the early period the penalty must be explicitly
  accepted, and when the boundary is near the dialog nudges toward
  waiting or the unanimous mode. payPenalty is passed explicitly so a
  boundary crossing between preview and submit hard-errors instead of
  silently charging.
- Unanimous: penalty-free at any height, using a lightweight signature
  envelope with the same native import-time verification as creation
  (digest recomputed locally and cross-checked against the RPC's
  signHash).
- Standby: generates the labeled PAIR of never-expiring recovery
  transactions - one broadcastable immediately that always pays the
  penalty, one penalty-free that only becomes valid at the early-period
  boundary - because the penalty is baked into the transaction outputs
  at build time. The Dissolve menu entry warns until both variants have
  been saved to a file.

Dissolution actions are unavailable until the registration has a
confirmation (same-block dissolution is consensus-invalid).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers the draft-side mirror of IsShareListTriviallyValid, envelope
serialization round-trips with network and stage rejection, the
unilateral-dissolution payout preview against the consensus split
rules, and the import-time signature path: freeze cross-checks the
consent hash against the prepared transaction, a valid consent
signature is accepted exactly once, and signatures under a wrong index
or over a stale digest are rejected without being counted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The share table and terms a participant reviews (the envelope's shares[]
and terms{}) were never checked against the CProRegTx payload the wallet
actually signs. A malicious coordinator could circulate an envelope that
displays benign terms while its prepared transaction assigns a victim's
owner key a small share refunding to the attacker: every internal check
passed (the consent hash is recomputed from the payload and the check
phrase matches for everyone), so the victim would review the shown terms,
sign the payload digest, fund the inputs, and lose money to the terms
that were actually registered.

freeze(), a non-Draft fromJson() and verifySignature() now decode the
payload and require its share table (amount, owner key, refund and reward
scripts) and terms (voting key, operator key, operator reward, early
period and penalty) to equal the envelope's displayed values, so a
mismatched session can never freeze, import or show a valid signature.

Also hardens the engine against related tampering: mergeEnvelope now
validates an adopted further-advanced transaction against the consent
hash instead of copying it blindly (a forged 'broadcast' copy could
otherwise overwrite a good transaction and fake completion), a Draft
merge compares the full share/term/funding state rather than only the
funding transaction, duplicate and out-of-range signature entries are
rejected on import, PenaltyPreviewFor guards against a zero non-actor
total, fromJson rejects out-of-range input indices and negative change,
and validateShares rejects null owner keys (bad-protx-shares-key-null).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses review findings on the session dialog: the session is now
declared dead only when a funding input is provably spent (a wallet
coin reported spent) rather than merely absent from the confirmed UTXO
set, so importing before another participant's chip confirms no longer
shows a false dead page; contributed coin locks are released on
discard, on adopting a superseding imported copy, and from the dead
page, so a dismissed session no longer leaks locks forever (Save keeps
them for resuming); freezing gates on client-resolvable funding
sufficiency and warns on an excessive implied fee; an imported
session's agreed operator key is shown read-only and used for freeze
instead of being silently replaced by a locally generated key; and
imported participant labels render as plain text so a crafted label
cannot inject HTML into a message box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unilateral-dissolution tab derived payPenalty from the block height
captured when the dialog opened; a penalty-free boundary crossing while
the dialog (or its confirmation prompt) was open could pay an avoidable
penalty. It now re-reads the tip height at submit and, if the boundary
was crossed, refreshes the preview and asks the user to re-review
instead of submitting. The liveness check no longer bricks a
just-prepared unanimous/rotation session whose own fee input is still
unconfirmed, the dialog can no longer be dismissed via Esc or the
window close button while an RPC is running, and the standby 'stored'
flag is now persisted per variant so saving the two standby
transactions across separate dialog sessions still clears the warning.
Also dedupes the repeated status-label, sign/combine, and standby A/B
scaffolding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dismissal

The fee-source picker summed balances by listCoins' parent-address key,
which overstated what FundSpecialTx (which selects only coins whose
destination equals the fee address) can actually spend; it now groups
by each coin's own destination. Update Service for an evonode was
impossible before v24 because the dialog only offered the v3
address-list form; it now shows platform port spinboxes pre-activation,
matching the wizard. Update Service no longer prefills only the primary
service address (which would silently drop registered secondary
addresses on submit) and instead asks for the full list. The
maintenance dialogs can no longer be dismissed while their RPC runs,
the existing-collateral combo is filtered to the P2PKH outputs
protx register accepts, the platform page requires both address fields
together, and the discard-prepared warning no longer wrongly claims fee
inputs stay locked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v24 gate on the Shared Masternode button was dead code: WalletView
calls setWalletModel before the client model is wired, so the branch
that consulted clientModel->node().isV24Active() always saw a null
client model. The enable state is now computed from both models and
re-evaluated on every tip change until activation. MasternodeEntry's
change-detection tie now includes the ProUpServTx-mutable network and
platform address fields so the list refreshes after a service update
instead of showing stale data, and the 'Filter by Owner Address' action
is disabled for shared rows (whose owner address is the null-key
placeholder shared by every shared masternode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PastaPastaPasta and others added 10 commits August 12, 2026 11:39
The operator BLS key a wallet generates for a masternode registration
existed only on screen: the user had to copy it before closing the
dialog or lose it. The wallet can now keep it, so a wallet backup covers
it like every other key.

The secret is stored keyed by its public key, in the clear only while
the wallet itself is unencrypted, and encrypted under the wallet's
master key otherwise (refusing to store while locked). EncryptWallet
converts any plaintext records as part of the same transaction that
encrypts the key managers - without that leg, keys stored before the
user ran encryptwallet would stay readable in the wallet file forever.
The records are deliberately not key types, so a corrupt one cannot
abort a wallet load, and not legacy types, so migration does not wipe
them.

Owner and voting keys needed nothing here: they are ordinary wallet
addresses off the HD seed already.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A masternode registration appeared in the Transactions tab as a
"Payment to yourself" whose amount was only the network fee, with
nothing to identify it — a user who had just registered could not find
the transaction at all.

Provider special transactions now get their own record types:
Masternode Registration, Masternode Update (the service, registrar,
revoke, share and shared-registrar updates, which are metadata-only and
indistinguishable to a history reader) and Masternode Dissolution.
Classification happens before the CoinJoin and self-payment heuristics
that used to swallow them, they are labelled and coloured accordingly,
and the type filter gains a Masternode entry.

The amount is left alone: for a self-funded registration the collateral
stays in the wallet, so the fee is the real change in balance, and the
tooltip says so. The CoinJoin filter rows are now looked up by their
mask instead of hard-coded indices, so adding a row cannot desynchronize
them again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Feedback on the running wizard: the pages were cramped and the review
was a wall of text.

Each choice is now a card whose body only expands when it is selected,
so the page spends its height on the option being configured instead of
three open bodies at once, and the pages share one spacing rhythm. The
review is a set of sections mirroring the wizard's own pages, with a
label and value column that line up across every card, values selectable
and monospaced where they are addresses or keys, and the 96-character
operator key wrapped rather than cut off. The closing line now says what
the button does.

The voting address gains the same "Use new address" button as the owner
address, and the keys page says that both come from this wallet's HD
seed. The operator key can be generated and saved in the wallet (the new
default when the wallet can store it), generated without saving, or
supplied as a public key by whoever runs the node; the result page tells
the user which happened and only asks them to confirm a hand-copy when
the key exists nowhere else. It also points at the Transactions tab,
where the registration now appears under its own name.

The collateral picker shows why it is empty instead of an empty
dropdown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stored random operator key is only as recoverable as the wallet file
that holds it. An HD wallet can do better: derive the key, and the
recovery phrase alone brings it back.

The derivation follows DashSync exactly, so a key made here is the key
the mobile wallets expect: m/9'/coin'/3'/3'/index over the BIP39 seed,
coin type 5 on mainnet and 1 everywhere else, built with dashbls'
ExtendedPrivateKey::FromSeed and legacy child derivation at every level,
with an unhardened leaf. The known-answer test pins it to the operator
key inside DashSync's own expected ProRegTx, so a change that breaks
mobile compatibility fails the build rather than stranding a user's key.

Seed access spans both wallet types through a new ScriptPubKeyMan pair:
legacy reads its decrypted HD chain, descriptor wallets go through the
mnemonic. Wallets with no reachable seed - watch-only, external signer,
imported descriptors - keep the existing stored-key path.

Only the index is written to the wallet, never the secret, and reading
a key back re-derives it and checks the public key matches before
handing anything out, so a foreign or corrupted index record cannot
make the wallet produce a key under a public key it does not control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The wizard's operator key now defaults to being derived from this
wallet's recovery phrase whenever the wallet has a seed, and says so:
restoring the phrase restores the key. Generating and saving in the
wallet remains for wallets that cannot derive, and generating without
saving remains for anyone who wants the key to exist nowhere else. The
key is derived on reaching the review, so the key shown is the key
registered, and the wizard holds that unlock through registration
instead of asking twice.

The masternode list gains Show Operator Key, which answers the question
the old success page raised and never answered: where the saved key
lives afterwards. It matches a masternode's registered operator key
against the keys this wallet holds, and after an unlock shows the
public key, the secret and the ready-made dash.conf line, each with its
own Copy button, and states whether the key came from the recovery
phrase or the wallet file.

Long keys are shown in fixed-width groups as plain selectable values
rather than in a text box that looked editable and clipped its
contents; Copy always puts the unbroken key on the clipboard. The
confirmation gate now appears only when the key really is shown once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drops the stored-key path entirely. It only ever served wallets that can
spend but have no reachable seed - descriptor wallets built from a raw
hdseed or from imported descriptors, and pre-HD legacy wallets - and for
those, showing the key once and letting the owner save it is enough.
Removing it deletes two wallet record types, the secret encryption path
and the EncryptWallet leg that existed solely to stop those records
sitting in plaintext: the riskiest code in the feature, gone rather than
guarded.

What remains is derivation, extended to work for an operator running
several masternodes and for a wallet restored from its recovery phrase:

- deriving takes the set of operator keys already registered on-chain
  and skips any index that would collide with one, so a restored wallet
  with no records of its own cannot hand out a key another masternode
  already uses;
- looking a key up no longer depends on the wallet's own records at all:
  when no index record matches, it walks a bounded range of indexes and
  records the one it finds, so the recovery phrase alone is enough to
  get an operator key back. Both bounds are the same constant, which is
  the invariant - an index is never issued that recovery could not find.

Tests cover three registrations getting distinct indexes and keys, the
restored-wallet collision case, and recovery with no records at all. The
DashSync known-answer test is unchanged, which is what keeps the keys
interchangeable with the mobile wallets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the Generate and save in this wallet option, leaving derive from
the recovery phrase (the default whenever the wallet has a seed),
generate without saving, and use someone else's public key. A wallet
that cannot derive now says so on the keys page instead of silently
offering a weaker option.

When deriving, the wizard passes the operator keys already registered
on-chain to the wallet, so a wallet restored from its recovery phrase
never derives a key an existing masternode is already using. Show
Operator Key uses the wallet's cheap have-key check to decide whether to
offer itself and reports plainly when a key turns out not to belong to
this wallet, since the lookup can now recover keys that have no record
in the wallet file.

The masternode model gained an accessor for the registered operator key,
so the list no longer re-parses its JSON on every right-click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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