Repository navigation
v24.0.0-rc.1: check a participant's agreed terms before its funding inputs are signed into a shared masternode registration #7761
Description
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
signrawtransactionwithwalletwhen 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_preparehelp 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:
- Three wallets, coord, A and B. Send A 601 DASH and B 401 DASH, and confirm.
createrawtransactionspending A's and B's outputs, with a change output to each.protx shared_register_preparewith two shares, 600 DASH naming owner, refund and reward
addresses from coord's wallet, and 400 DASH naming B's.protx shared_signin coord's and B's wallets, thenprotx shared_combine.signrawtransactionwithwalletin A's wallet, then in B's, thensendrawtransaction.
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
Merge #7771: docs: clarify that shared registration consent is per sh…