Skip to content

v24.0.0-rc.1: check a participant's agreed terms before its funding inputs are signed into a shared masternode registration #7761

Description

@hilawe

Is your feature request related to a problem? Please describe.

This is release-candidate feedback on the shared masternode registration workflow in v24.0.0-rc.1.
It is not a consensus issue. Consent works per share. protx shared_combine refuses a registration
that lacks any share's consent, and the node rejects one whose consents do not match its terms
(bad-protx-shares-sig).

The gap is in the direct RPC workflow that the protx shared_register_prepare help describes. A
participant whose share is not in the table is never asked for a consent, and its funding inputs are
signed with signrawtransactionwithwallet, which does not compare the transaction with anything the
participant agreed to. So the transaction a participant reviews and the one whose inputs it signs can
differ while spending the same inputs.

On rc.1 regtest, with the participants in separate wallets, a table that gave participant A's 600 DASH
to owner, refund and reward addresses from the coordinator's wallet was accepted after A's wallet
signed A's funding input. A's wallet had answered protx shared_sign with "none of the share owner
keys were found in this wallet", and nothing at the input-signing step warned.

Describe the solution you'd like

An explicit check, within the shared registration workflow, of a participant's agreed terms before
its funding inputs are signed. For example, a mode of protx shared_sign, or a separate RPC, that
takes the participant's agreed terms (its own share, the share table, and the early-exit period and
penalty) and refuses unless the prepared transaction matches them and the participant's inputs fund
only its contribution plus its agreed share of the fee. The shared_register_prepare help could then
direct each participant to run it before signrawtransactionwithwallet.

Describe alternatives you've considered

  • A warning from signrawtransactionwithwallet when it signs inputs into a shared ProRegTx whose table
    holds no share with keys from this wallet. It needs no new input, but it assumes the funding wallet
    also holds the share's keys, which a participant who keeps them elsewhere may not.
  • Documentation only, in the shared_register_prepare help and the release notes, telling each
    participant to compare the prepared transaction with its agreed terms before signing inputs. It is
    the cheapest, and it relies on every participant doing that by hand.
  • A check outside Core. My own client runs one before signing funding inputs (the share table must
    equal the agreed terms, and the wallet's net outflow must equal its contribution plus its agreed fee
    share), and it refuses the transaction in the reproduction below. It protects only users of that
    client.

Additional context

The Qt workflow from #7701 keeps each participant's details and compares them with the transaction
before signing (MnShareSession::adoptLockedTerms, MnShareSession::payloadMatchesEnvelope), in
addition to its amount checks (SharedMnCreateDialog::checkOwnFunding). This report covers the
direct RPC workflow only. I have not demonstrated the same behavior through Qt.

Reproduction outline, on regtest with v24 active:

  1. Three wallets, coord, A and B. Send A 601 DASH and B 401 DASH, and confirm.
  2. createrawtransaction spending A's and B's outputs, with a change output to each.
  3. protx shared_register_prepare with two shares, 600 DASH naming owner, refund and reward
    addresses from coord's wallet, and 400 DASH naming B's.
  4. protx shared_sign in coord's and B's wallets, then protx shared_combine.
  5. signrawtransactionwithwallet in A's wallet, then in B's, then sendrawtransaction.

The script below runs exactly these steps on a throwaway regtest node in a temporary data directory
(RPC port 29710, no peer listening) and removes it afterwards. Run it with
DASH_BIN=/path/to/dashcore-24.0.0-rc.1/bin python3 repro_shared_register_unlisted_participant.py.

repro_shared_register_unlisted_participant.py
#!/usr/bin/env python3
"""Reproduction for a dashpay/dash v24.0.0-rc.1 release-candidate report.

A participant's funding inputs are signed, with signrawtransactionwithwallet, into a shared masternode
registration whose share table has no share for that participant, and the registration is accepted.

It starts a throwaway regtest node in a temporary data directory (RPC port 29710, not listening for
peers), creates three wallets (coord, A, B) and uses only these RPC commands: createrawtransaction,
protx shared_register_prepare, protx shared_sign, protx shared_combine, signrawtransactionwithwallet,
sendrawtransaction, protx info and getaddressinfo. The node is stopped and the directory removed at
the end.

usage: DASH_BIN=/path/to/dashcore-24.0.0-rc.1/bin python3 repro_shared_register_unlisted_participant.py
Exits 0 when the behavior reproduces, 1 when it does not.
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time

BIN = os.environ.get("DASH_BIN", "")
DATADIR = tempfile.mkdtemp(prefix="shared-reg-repro-")
NODE = ["-regtest", f"-datadir={DATADIR}", "-rpcport=29710"]
COIN = 100_000_000


def cli(*args, wallet=None, check=True):
    cmd = [os.path.join(BIN, "dash-cli"), *NODE] + ([f"-rpcwallet={wallet}"] if wallet else [])
    p = subprocess.run(cmd + [a if isinstance(a, str) else json.dumps(a) for a in args], capture_output=True, text=True)
    if check and p.returncode != 0:
        sys.exit(f"unexpected failure of `{' '.join(map(str, args[:2]))}`: {p.stderr.strip()}")
    try:
        return json.loads(p.stdout) if p.returncode == 0 else p
    except ValueError:
        return p.stdout.strip()


def main():
    subprocess.run([os.path.join(BIN, "dashd"), *NODE, "-port=29711", "-listen=0", "-daemon=1",
                    "-fallbackfee=0.0001", "-vbparams=v24:0:9999999999:0:4:3:3:1:0"], check=True, capture_output=True)
    for _ in range(60):
        if subprocess.run([os.path.join(BIN, "dash-cli"), *NODE, "getblockcount"], capture_output=True).returncode == 0:
            break
        time.sleep(1)
    print(cli("-version").splitlines()[0])
    for w in ("coord", "A", "B"):
        cli("createwallet", w)
    mine = cli("getnewaddress", wallet="coord")
    cli("generatetoaddress", "620", mine)
    print("1. v24 active:", cli("getdeploymentinfo")["deployments"]["v24"]["active"])

    funds = {}
    for w, amount in (("A", "601"), ("B", "401")):
        addr = cli("getnewaddress", wallet=w)
        txid = cli("sendtoaddress", addr, amount, wallet="coord")
        funds[w] = (txid, addr)
    cli("generatetoaddress", "1", mine)
    inputs = []
    for w, (txid, addr) in funds.items():
        vout = next(o["n"] for o in cli("getrawtransaction", txid, "true")["vout"] if o["scriptPubKey"].get("address") == addr)
        inputs.append({"txid": txid, "vout": vout})
    change = {cli("getnewaddress", wallet="A"): 0.9999, cli("getnewaddress", wallet="B"): 0.9999}
    funding = cli("createrawtransaction", inputs, change)
    print("2. funding transaction spends one output of A (601 DASH) and one of B (401 DASH), change back to each")

    coord = {k: cli("getnewaddress", wallet="coord") for k in ("owner", "refund", "reward")}
    b = {k: cli("getnewaddress", wallet="B") for k in ("owner", "refund", "reward")}
    table = [{"amount": 600 * COIN, "ownerAddress": coord["owner"], "refundAddress": coord["refund"], "rewardAddress": coord["reward"]},
             {"amount": 400 * COIN, "ownerAddress": b["owner"], "refundAddress": b["refund"], "rewardAddress": b["reward"]}]
    prep = cli("protx", "shared_register_prepare", funding, table, "", cli("bls", "generate")["public"],
               cli("getnewaddress", wallet="coord"), "0", "100", str(10 * COIN), wallet="coord")
    print("3. shared_register_prepare: the 600 DASH share names coord's keys, the 400 DASH share names B's; A has no share")

    a_sign = cli("protx", "shared_sign", prep["tx"], wallet="A", check=False)
    print("4. shared_sign in A's wallet:", a_sign.stderr.strip().splitlines()[-1] if hasattr(a_sign, "stderr") else "signatures returned")
    sigs = cli("protx", "shared_sign", prep["tx"], wallet="coord")["signatures"] + cli("protx", "shared_sign", prep["tx"], wallet="B")["signatures"]
    combined = cli("protx", "shared_combine", prep["tx"], sigs, wallet="coord")
    print("   shared_sign in coord's and B's wallets, then shared_combine: combined")

    a_out = cli("signrawtransactionwithwallet", combined, wallet="A")
    a_input = next(v for v in cli("decoderawtransaction", a_out["hex"])["vin"] if v["txid"] == funds["A"][0])
    print("5. signrawtransactionwithwallet in A's wallet: A's input signed =", bool(a_input["scriptSig"]["hex"]),
          "| complete =", a_out["complete"], "(B's input still unsigned)")
    b_out = cli("signrawtransactionwithwallet", a_out["hex"], wallet="B")
    print("   signrawtransactionwithwallet in B's wallet: complete =", b_out["complete"])
    sent = cli("sendrawtransaction", b_out["hex"], check=False)
    accepted = isinstance(sent, str) and len(sent) == 64
    print("   sendrawtransaction:", "accepted" if accepted else sent.stderr.strip().splitlines()[-1])
    if not accepted:
        return 1
    cli("generatetoaddress", "1", mine)
    share0 = cli("protx", "info", sent)["state"]["shares"][0]
    in_coord = cli("getaddressinfo", share0["refundAddress"], wallet="coord")["ismine"]
    in_a = cli("getaddressinfo", share0["refundAddress"], wallet="A")["ismine"]
    print(f"6. protx info: share 0 is {share0['amount'] // COIN} DASH, refund address in coord's wallet = {in_coord}, in A's wallet = {in_a}")
    reproduced = in_coord and not in_a
    print("RESULT:", "reproduced" if reproduced else "not reproduced")
    return 0 if reproduced else 1


if __name__ == "__main__":
    if not BIN or not os.path.exists(os.path.join(BIN, "dashd")):
        sys.exit("set DASH_BIN to the directory holding dashd and dash-cli (v24.0.0-rc.1)")
    try:
        code = main()
    finally:
        subprocess.run([os.path.join(BIN, "dash-cli"), *NODE, "stop"], capture_output=True)
        time.sleep(3)
        shutil.rmtree(DATADIR, ignore_errors=True)
    sys.exit(code)

Expected output, from the release build on 2026-09-27 (exit status 0):

Dash Core RPC client version v24.0.0-rc.1
1. v24 active: True
2. funding transaction spends one output of A (601 DASH) and one of B (401 DASH), change back to each
3. shared_register_prepare: the 600 DASH share names coord's keys, the 400 DASH share names B's; A has no share
4. shared_sign in A's wallet: none of the share owner keys were found in this wallet
   shared_sign in coord's and B's wallets, then shared_combine: combined
5. signrawtransactionwithwallet in A's wallet: A's input signed = True | complete = False (B's input still unsigned)
   signrawtransactionwithwallet in B's wallet: complete = True
   sendrawtransaction: accepted
6. protx info: share 0 is 600 DASH, refund address in coord's wallet = True, in A's wallet = False
RESULT: reproduced

I found no matching open report.

System information

Dash Core v24.0.0-rc.1, the release build dashcore-24.0.0-rc.1-aarch64-linux-gnu.tar.gz (detached
signature and SHA256SUMS verified), on regtest in a Debian bookworm container.

Activity

  1. added a commit that references this issue on Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions