ourdash 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ourdash/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """ourdash — Modern Python SDK for Dash (Dash crypto from dash.org, not Plotly Dash).
2
+
3
+ Top-level re-exports of the stable v0.1 surface (reads + offline payments +
4
+ proof verdicts). Import here for the common case::
5
+
6
+ from ourdash import DashRPC, Wallet, validate_address
7
+
8
+ Deeper models and facades stay importable from their owning modules
9
+ (``ourdash.core.*``, ``ourdash.platform.*``).
10
+ """
11
+
12
+ from ourdash.core.addresses import AddressInfo as AddressInfo
13
+ from ourdash.core.addresses import validate_address as validate_address
14
+ from ourdash.core.rpc import DashRPC as DashRPC
15
+ from ourdash.core.rpc import DashRPCConfig as DashRPCConfig
16
+ from ourdash.core.transactions import ConfirmationStatus as ConfirmationStatus
17
+ from ourdash.core.transactions import SignedTx as SignedTx
18
+ from ourdash.core.transactions import UnsignedTx as UnsignedTx
19
+ from ourdash.core.wallet import Wallet as Wallet
20
+ from ourdash.errors import AddressError as AddressError
21
+ from ourdash.errors import ConfigError as ConfigError
22
+ from ourdash.errors import DAPIError as DAPIError
23
+ from ourdash.errors import OurdashError as OurdashError
24
+ from ourdash.errors import PaymentError as PaymentError
25
+ from ourdash.errors import ProofError as ProofError
26
+ from ourdash.errors import ProofUnavailableError as ProofUnavailableError
27
+ from ourdash.errors import RpcAuthError as RpcAuthError
28
+ from ourdash.errors import RpcConnectionError as RpcConnectionError
29
+ from ourdash.errors import RpcError as RpcError
30
+ from ourdash.errors import RpcTimeoutError as RpcTimeoutError
31
+ from ourdash.errors import WalletError as WalletError
32
+ from ourdash.platform.dapi import DAPIClient as DAPIClient
33
+ from ourdash.platform.dapi import DAPIConfig as DAPIConfig
34
+ from ourdash.platform.models import ProofVerdict as ProofVerdict
35
+ from ourdash.platform.models import ProvenResult as ProvenResult
36
+
37
+ __version__ = "0.1.0"
38
+
39
+ __all__ = [
40
+ "__version__",
41
+ "DashRPC",
42
+ "DashRPCConfig",
43
+ "DAPIClient",
44
+ "DAPIConfig",
45
+ "AddressInfo",
46
+ "validate_address",
47
+ "Wallet",
48
+ "UnsignedTx",
49
+ "SignedTx",
50
+ "ConfirmationStatus",
51
+ "ProofVerdict",
52
+ "ProvenResult",
53
+ "OurdashError",
54
+ "RpcError",
55
+ "RpcAuthError",
56
+ "RpcConnectionError",
57
+ "RpcTimeoutError",
58
+ "ConfigError",
59
+ "AddressError",
60
+ "WalletError",
61
+ "PaymentError",
62
+ "DAPIError",
63
+ "ProofError",
64
+ "ProofUnavailableError",
65
+ ]
@@ -0,0 +1,8 @@
1
+ """Dash Core modules: RPC client, addresses, transactions, wallet, network.
2
+
3
+ Research entrypoints:
4
+ - dashd JSON-RPC via `dash-cli help` / docs.dash.org Core API
5
+ - X11 + ChainLocks + InstantSend specifics vs Bitcoin Core fork
6
+ """
7
+
8
+ from __future__ import annotations
@@ -0,0 +1,126 @@
1
+ """Dash address validation (Dash crypto from dash.org — not Plotly Dash).
2
+
3
+ Pure-offline Base58Check validation reporting validity, network, and kind
4
+ before any funds move. Version bytes are fixed per Dash Core v23.1.8
5
+ ``src/chainparams.cpp`` ``base58Prefixes[PUBKEY_ADDRESS]`` /
6
+ ``[SCRIPT_ADDRESS]``: mainnet P2PKH ``0x4c`` (76, addresses start with
7
+ ``'X'``), mainnet P2SH ``0x10`` (16, start with ``'7'``), testnet P2PKH
8
+ ``0x8c`` (140, start with ``'y'``), testnet P2SH ``0x13`` (19, start with
9
+ ``'8'``/``'9'``). Regtest and devnet reuse the testnet prefixes.
10
+
11
+ Only P2PKH/P2SH Base58Check is handled here (v0.1 scope). Checksum rule:
12
+ 4-byte double-SHA-256 checksum over a 21-byte payload (1 version byte +
13
+ 20-byte hash).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Literal, cast
19
+
20
+ from pydantic import BaseModel
21
+
22
+ from ourdash.errors import AddressError
23
+ from ourdash.redact import redact
24
+ from ourdash.utils import b58check_decode, b58check_encode
25
+
26
+ Network = Literal["mainnet", "testnet", "regtest", "devnet"]
27
+ AddressKind = Literal["p2pkh", "p2sh"]
28
+ AddressReason = Literal[
29
+ "ok", "bad_charset", "bad_length", "bad_checksum", "wrong_network", "unknown_prefix"
30
+ ]
31
+
32
+ _NETWORKS: tuple[str, ...] = ("mainnet", "testnet", "regtest", "devnet")
33
+
34
+ # Version byte -> (family, kind). regtest/devnet reuse the testnet prefixes.
35
+ _VERSION_TABLE: dict[int, tuple[str, AddressKind]] = {
36
+ 0x4C: ("mainnet", "p2pkh"),
37
+ 0x10: ("mainnet", "p2sh"),
38
+ 0x8C: ("testnet", "p2pkh"),
39
+ 0x13: ("testnet", "p2sh"),
40
+ }
41
+
42
+ # Network -> P2PKH version byte (single source of version-byte truth).
43
+ _P2PKH_VERSION: dict[str, int] = {
44
+ "mainnet": 0x4C,
45
+ "testnet": 0x8C,
46
+ "regtest": 0x8C,
47
+ "devnet": 0x8C,
48
+ }
49
+
50
+ MAINNET_P2PKH_VERSION = 0x4C
51
+ MAINNET_P2SH_VERSION = 0x10
52
+ TESTNET_P2PKH_VERSION = 0x8C
53
+ TESTNET_P2SH_VERSION = 0x13
54
+
55
+ _PAYLOAD_LEN = 21 # 1 version byte + 20-byte hash
56
+
57
+
58
+ class AddressInfo(BaseModel):
59
+ """Validation verdict for one Dash address input."""
60
+
61
+ valid: bool
62
+ network: Network | None = None
63
+ kind: AddressKind | None = None
64
+ reason: AddressReason = "ok"
65
+
66
+
67
+ def _family_of(network: str) -> str:
68
+ """Map a network to its prefix family (``mainnet`` vs ``testnet``)."""
69
+ if network == "mainnet":
70
+ return "mainnet"
71
+ return "testnet" # testnet, regtest, devnet share prefixes
72
+
73
+
74
+ def validate_address(addr: str, expected_network: str | None = None) -> AddressInfo:
75
+ """Validate a Dash address, reporting network and kind.
76
+
77
+ Without ``expected_network``, a testnet-family prefix reports the
78
+ ``testnet`` family label. With ``expected_network="regtest"`` (or
79
+ ``"devnet"``), the prefix is validated against that network and the
80
+ specific network is reported. A prefix from the other family returns
81
+ ``valid=False, reason="wrong_network"`` — never raised.
82
+
83
+ Raises :class:`ourdash.errors.AddressError` only for non-string or
84
+ empty input (and for an unknown ``expected_network`` value).
85
+ """
86
+ if not isinstance(addr, str) or not addr:
87
+ raise AddressError(f"invalid address input {redact(addr)!r}")
88
+ if expected_network is not None and expected_network not in _NETWORKS:
89
+ raise AddressError(f"unknown network {redact(expected_network)!r}")
90
+
91
+ try:
92
+ payload = b58check_decode(addr)
93
+ except AddressError as exc:
94
+ message = str(exc)
95
+ if "bad charset" in message:
96
+ return AddressInfo(valid=False, reason="bad_charset")
97
+ if "bad length" in message:
98
+ return AddressInfo(valid=False, reason="bad_length")
99
+ return AddressInfo(valid=False, reason="bad_checksum")
100
+ if len(payload) != _PAYLOAD_LEN:
101
+ return AddressInfo(valid=False, reason="bad_length")
102
+
103
+ entry = _VERSION_TABLE.get(payload[0])
104
+ if entry is None:
105
+ return AddressInfo(valid=False, reason="unknown_prefix")
106
+ family, kind = entry
107
+
108
+ if expected_network is None:
109
+ return AddressInfo(valid=True, network=cast(Network, family), kind=kind)
110
+ if _family_of(expected_network) != family:
111
+ return AddressInfo(
112
+ valid=False,
113
+ network=cast(Network, family),
114
+ kind=kind,
115
+ reason="wrong_network",
116
+ )
117
+ return AddressInfo(valid=True, network=cast(Network, expected_network), kind=kind)
118
+
119
+
120
+ def derive_p2pkh(pubkey_hash20: bytes, network: str = "mainnet") -> str:
121
+ """Build the P2PKH address for a 20-byte hash on ``network``."""
122
+ if not isinstance(pubkey_hash20, bytes) or len(pubkey_hash20) != 20:
123
+ raise AddressError("pubkey hash must be exactly 20 bytes")
124
+ if network not in _P2PKH_VERSION:
125
+ raise AddressError(f"unknown network {redact(network)!r}")
126
+ return b58check_encode(bytes([_P2PKH_VERSION[network]]) + pubkey_hash20)
ourdash/core/chain.py ADDED
@@ -0,0 +1,303 @@
1
+ """Typed Core read facade: chain info, blocks, transactions, balances, addresses.
2
+
3
+ Dash here is Dash crypto from dash.org — not Plotly Dash.
4
+
5
+ One typed read surface over the developer's own node. Every function takes a
6
+ :class:`ourdash.core.rpc.DashRPC` and returns a pydantic model with a
7
+ :meth:`to_dict` plain-object view (stdlib types only) for analysts' own
8
+ tooling. Node error objects propagate as the Phase 1 taxonomy
9
+ (:class:`ourdash.errors.RpcError` with the node code preserved); malformed
10
+ node payloads raise :class:`ourdash.errors.RpcError` chained from the
11
+ validation failure. Unknown extras in node responses are ignored; the
12
+ documented fields below are required.
13
+
14
+ Wallet selection: every wallet-aware function (``get_transaction``,
15
+ ``get_balance``, ``get_new_address``) accepts ``wallet: str | None``. When
16
+ set, the call runs against a client bound to that wallet's ``-rpcwallet``
17
+ endpoint, built internally from the passed client's config — the caller's
18
+ client is never mutated. An unknown wallet surfaces the node's error as
19
+ :class:`ourdash.errors.RpcError` with its code preserved.
20
+
21
+ Method allowlist (Dash Core v23.1.8 ``dash-cli help``, retrieved 2026-09-05):
22
+ ``getblockchaininfo``, ``getblockhash``, ``getblock``, ``getrawtransaction``,
23
+ ``gettransaction``, ``getbalances``, ``getbalance``,
24
+ ``getunconfirmedbalance``, ``getnewaddress``, ``getchaintips``,
25
+ ``masternode list``, ``getbestchainlock``, ``getblockcount``. No
26
+ ``dashd``/``dash-cli`` binary exists in this environment, so the allowlist is
27
+ pinned from the v23.1.8 sources/docs rather than a live ``dash-cli help``
28
+ dump; only the methods above are called, and only the documented fields are
29
+ asserted.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from collections.abc import Mapping
36
+ from dataclasses import replace
37
+ from typing import Any
38
+
39
+ from pydantic import BaseModel, ConfigDict, ValidationError
40
+
41
+ from ourdash.core.addresses import validate_address
42
+ from ourdash.core.rpc import DashRPC
43
+ from ourdash.errors import RpcError
44
+ from ourdash.redact import RedactionFilter
45
+
46
+ logger = logging.getLogger(__name__)
47
+ logger.addFilter(RedactionFilter())
48
+
49
+
50
+ def _bound(rpc: DashRPC, wallet: str | None) -> DashRPC:
51
+ """Return ``rpc`` or a copy bound to ``wallet``'s ``-rpcwallet`` endpoint."""
52
+ if wallet is None:
53
+ return rpc
54
+ return DashRPC(replace(rpc.config, wallet=wallet))
55
+
56
+
57
+ def _mapping(value: Any, method: str) -> Mapping[str, Any]:
58
+ if not isinstance(value, Mapping):
59
+ raise RpcError(f"{method} returned a malformed response")
60
+ return value
61
+
62
+
63
+ class BlockchainInfo(BaseModel):
64
+ """Chain state: name, height, sync progress, difficulty."""
65
+
66
+ model_config = ConfigDict(frozen=True, extra="ignore")
67
+
68
+ chain: str
69
+ blocks: int
70
+ headers: int
71
+ bestblockhash: str
72
+ difficulty: float
73
+ verificationprogress: float
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ """Return a plain-object view (stdlib types only)."""
77
+ return self.model_dump(mode="python")
78
+
79
+
80
+ class BlockInfo(BaseModel):
81
+ """One block: hash, height, time, transaction list/count, ChainLock flag."""
82
+
83
+ model_config = ConfigDict(frozen=True, extra="ignore")
84
+
85
+ hash: str
86
+ height: int
87
+ time: int
88
+ txids: list[str]
89
+ tx_count: int
90
+ chainlock: bool | None = None
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ """Return a plain-object view (stdlib types only)."""
94
+ return self.model_dump(mode="python")
95
+
96
+
97
+ class TransactionInfo(BaseModel):
98
+ """One transaction: confirmations, amounts, InstantSend flag."""
99
+
100
+ model_config = ConfigDict(frozen=True, extra="ignore")
101
+
102
+ txid: str
103
+ confirmations: int = 0
104
+ amount: float | None = None
105
+ fee: float | None = None
106
+ instantlock: bool = False
107
+ blockhash: str | None = None
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ """Return a plain-object view (stdlib types only)."""
111
+ return self.model_dump(mode="python")
112
+
113
+
114
+ class Balance(BaseModel):
115
+ """Wallet balance split: confirmed vs unconfirmed (plus immature mining)."""
116
+
117
+ model_config = ConfigDict(frozen=True, extra="ignore")
118
+
119
+ confirmed: float
120
+ unconfirmed: float = 0.0
121
+ immature: float | None = None
122
+ wallet: str | None = None
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ """Return a plain-object view (stdlib types only)."""
126
+ return self.model_dump(mode="python")
127
+
128
+
129
+ def get_blockchain_info(rpc: DashRPC) -> BlockchainInfo:
130
+ """Read chain state (height, chain name, progress, difficulty)."""
131
+ data = _mapping(rpc.call("getblockchaininfo"), "getblockchaininfo")
132
+ try:
133
+ return BlockchainInfo(
134
+ chain=data["chain"],
135
+ blocks=int(data["blocks"]),
136
+ headers=int(data["headers"]),
137
+ bestblockhash=data["bestblockhash"],
138
+ difficulty=float(data["difficulty"]),
139
+ verificationprogress=float(data["verificationprogress"]),
140
+ )
141
+ except (KeyError, TypeError, ValueError, ValidationError) as exc:
142
+ raise RpcError(f"getblockchaininfo returned a malformed response: {exc}") from exc
143
+
144
+
145
+ def get_block(rpc: DashRPC, hash_or_height: str | int, verbosity: int = 1) -> BlockInfo:
146
+ """Read one block by hash (64-hex) or by height (int or decimal string).
147
+
148
+ Heights resolve via ``getblockhash`` first. ``verbosity`` 1 returns txids;
149
+ 2 returns full tx objects (reduced to their txids here). Verbosity 0
150
+ (raw hex) is refused pre-network — use verbosity 1 or 2.
151
+ """
152
+ if isinstance(hash_or_height, bool):
153
+ raise RpcError("block selector must be a hash string or a height int")
154
+ if verbosity not in (1, 2):
155
+ raise RpcError("get_block needs verbosity 1 or 2 (0 returns raw hex)")
156
+ block_hash: str
157
+ if isinstance(hash_or_height, int):
158
+ resolved: Any = rpc.call("getblockhash", hash_or_height)
159
+ if not isinstance(resolved, str) or not resolved:
160
+ raise RpcError("getblockhash returned a malformed response")
161
+ block_hash = resolved
162
+ elif isinstance(hash_or_height, str) and hash_or_height.isdigit():
163
+ # Decimal-string height (a 64-char all-digit hash is ~impossible).
164
+ resolved = rpc.call("getblockhash", int(hash_or_height))
165
+ if not isinstance(resolved, str) or not resolved:
166
+ raise RpcError("getblockhash returned a malformed response")
167
+ block_hash = resolved
168
+ elif isinstance(hash_or_height, str) and hash_or_height:
169
+ block_hash = hash_or_height
170
+ else:
171
+ raise RpcError("block selector must be a hash string or a height int")
172
+ data = _mapping(rpc.call("getblock", block_hash, verbosity), "getblock")
173
+ raw_tx = data.get("tx", [])
174
+ if not isinstance(raw_tx, list):
175
+ raise RpcError("getblock returned a malformed response")
176
+ txids: list[str] = []
177
+ for entry in raw_tx:
178
+ txid: Any = entry.get("txid", entry.get("hash")) if isinstance(entry, Mapping) else entry
179
+ if not isinstance(txid, str) or not txid:
180
+ raise RpcError("getblock returned a malformed response")
181
+ txids.append(txid)
182
+ raw_count = data.get("nTx")
183
+ tx_count = int(raw_count) if isinstance(raw_count, int) else len(txids)
184
+ lock_raw = data.get("chainlock", data.get("chainLock"))
185
+ chainlock = bool(lock_raw) if lock_raw is not None else None
186
+ try:
187
+ return BlockInfo(
188
+ hash=data["hash"],
189
+ height=int(data["height"]),
190
+ time=int(data["time"]),
191
+ txids=txids,
192
+ tx_count=tx_count,
193
+ chainlock=chainlock,
194
+ )
195
+ except (KeyError, TypeError, ValueError, ValidationError) as exc:
196
+ raise RpcError(f"getblock returned a malformed response: {exc}") from exc
197
+
198
+
199
+ def _transaction_from_mapping(txid: str, data: Mapping[str, Any]) -> TransactionInfo:
200
+ """Build a :class:`TransactionInfo` from either verbose shape.
201
+
202
+ Accepts the ``getrawtransaction``-verbose shape and the wallet
203
+ ``gettransaction`` shape (``instantlock`` / ``instantlock_internal``).
204
+ """
205
+ raw_confirmations = data.get("confirmations", 0)
206
+ raw_amount = data.get("amount")
207
+ raw_fee = data.get("fee")
208
+ raw_blockhash = data.get("blockhash")
209
+ try:
210
+ return TransactionInfo(
211
+ txid=str(data.get("txid", txid)),
212
+ confirmations=int(raw_confirmations),
213
+ amount=float(raw_amount) if raw_amount is not None else None,
214
+ fee=float(raw_fee) if raw_fee is not None else None,
215
+ instantlock=bool(data.get("instantlock", False))
216
+ or bool(data.get("instantlock_internal", False)),
217
+ blockhash=str(raw_blockhash) if raw_blockhash is not None else None,
218
+ )
219
+ except (TypeError, ValueError, ValidationError) as exc:
220
+ raise RpcError(f"transaction payload is malformed: {exc}") from exc
221
+
222
+
223
+ def get_transaction(rpc: DashRPC, txid: str, wallet: str | None = None) -> TransactionInfo:
224
+ """Read one transaction (amounts, fee, confirmations, InstantSend flag).
225
+
226
+ With ``wallet`` set, reads the wallet ``gettransaction`` view on that
227
+ wallet's endpoint. Without ``wallet``, tries verbose
228
+ ``getrawtransaction`` (needs txindex for confirmed txs) and falls back to
229
+ the default-wallet ``gettransaction`` view.
230
+ """
231
+ if not isinstance(txid, str) or len(txid) != 64:
232
+ raise RpcError("txid must be 64 hex chars")
233
+ try:
234
+ bytes.fromhex(txid)
235
+ except ValueError:
236
+ raise RpcError("txid must be 64 hex chars") from None
237
+ target = _bound(rpc, wallet)
238
+ if wallet is not None:
239
+ return _transaction_from_mapping(
240
+ txid, _mapping(target.call("gettransaction", txid), "gettransaction")
241
+ )
242
+ try:
243
+ raw: Any = target.call("getrawtransaction", txid, True)
244
+ except RpcError as first:
245
+ try:
246
+ raw = target.call("gettransaction", txid)
247
+ except RpcError:
248
+ raise first from None
249
+ return _transaction_from_mapping(txid, _mapping(raw, "getrawtransaction"))
250
+
251
+
252
+ def get_balance(rpc: DashRPC, wallet: str | None = None) -> Balance:
253
+ """Read a wallet balance split (confirmed / unconfirmed / immature).
254
+
255
+ Uses ``getbalances`` when the node offers it; older nodes fall back to
256
+ ``getbalance`` plus ``getunconfirmedbalance``. Unbound (``wallet=None``)
257
+ reads the node's default wallet view.
258
+ """
259
+ target = _bound(rpc, wallet)
260
+ try:
261
+ data = _mapping(target.call("getbalances"), "getbalances")
262
+ mine = _mapping(data.get("mine"), "getbalances")
263
+ try:
264
+ return Balance(
265
+ confirmed=float(mine["trusted"]),
266
+ unconfirmed=float(mine.get("untrusted_pending", 0.0)),
267
+ immature=float(mine["immature"]) if mine.get("immature") is not None else None,
268
+ wallet=wallet,
269
+ )
270
+ except (TypeError, ValueError, ValidationError) as exc:
271
+ raise RpcError(f"getbalances returned a malformed response: {exc}") from exc
272
+ except RpcError as first:
273
+ if first.message and "malformed" in first.message:
274
+ raise
275
+ try:
276
+ confirmed: Any = target.call("getbalance")
277
+ except RpcError as second:
278
+ raise second from first
279
+ try:
280
+ unconfirmed_raw: Any = target.call("getunconfirmedbalance")
281
+ except RpcError:
282
+ unconfirmed_raw = 0.0
283
+ try:
284
+ return Balance(
285
+ confirmed=float(confirmed),
286
+ unconfirmed=float(unconfirmed_raw),
287
+ wallet=wallet,
288
+ )
289
+ except (TypeError, ValueError, ValidationError) as exc:
290
+ raise RpcError(f"getbalance returned a malformed response: {exc}") from exc
291
+
292
+
293
+ def get_new_address(rpc: DashRPC, label: str | None = None, wallet: str | None = None) -> str:
294
+ """Request a fresh receiving address (Phase-2-validated before return)."""
295
+ target = _bound(rpc, wallet)
296
+ result: Any = target.call("getnewaddress", label) if label else target.call("getnewaddress")
297
+ if not isinstance(result, str) or not result:
298
+ raise RpcError("getnewaddress returned a malformed response")
299
+ info = validate_address(result)
300
+ if not info.valid:
301
+ raise RpcError(f"node returned an invalid address (reason={info.reason})")
302
+ logger.debug("new address issued wallet=%s", wallet)
303
+ return result
@@ -0,0 +1,186 @@
1
+ """Network health reads over JSON-RPC: chain tips, masternode-list diffs, ChainLock tip.
2
+
3
+ Dash here is Dash crypto from dash.org — not Plotly Dash.
4
+
5
+ REST-vs-ZMQ split (hard v0.1 boundary): Dash Core's read-only HTTP REST
6
+ mechanism and its real-time ZMQ push-feed mechanism are two SEPARATE opt-in
7
+ mechanisms; both are trusted-network-only features — whether served from the
8
+ same port or a separate port (same-port-or-separate-port trusted-network-only
9
+ deployments) — and both carry user-visible warnings. v0.1 implements NEITHER
10
+ as a transport: there is no REST client and no ZMQ subscriber in this module
11
+ (and no new dependencies for either). This module exposes JSON-RPC helpers
12
+ only. Any future REST or ZMQ addition requires an explicit opt-in constructor
13
+ plus a trusted-network warning; removing this boundary wording breaks
14
+ ``tests/test_network.py::test_rest_zmq_boundary_docstring``.
15
+
16
+ This module owns the ONLY global ChainLock-tip reader
17
+ (:func:`chainlock_tip_status`). Per-transaction finality
18
+ (:func:`ourdash.core.transactions.confirm_status`) is owned by Phase 3 and is
19
+ not modified here.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from collections.abc import Mapping
26
+ from typing import Any
27
+
28
+ from pydantic import BaseModel, ConfigDict, ValidationError
29
+
30
+ from ourdash.core.rpc import DashRPC
31
+ from ourdash.errors import RpcError
32
+ from ourdash.redact import RedactionFilter
33
+
34
+ logger = logging.getLogger(__name__)
35
+ logger.addFilter(RedactionFilter())
36
+
37
+
38
+ def _mapping(value: Any, method: str) -> Mapping[str, Any]:
39
+ if not isinstance(value, Mapping):
40
+ raise RpcError(f"{method} returned a malformed response")
41
+ return value
42
+
43
+
44
+ class ChainTip(BaseModel):
45
+ """One chain tip: height, hash, branch length, sync status."""
46
+
47
+ model_config = ConfigDict(frozen=True, extra="ignore")
48
+
49
+ height: int
50
+ hash: str
51
+ branchlen: int
52
+ status: str
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ """Return a plain-object view (stdlib types only)."""
56
+ return self.model_dump(mode="python")
57
+
58
+
59
+ class MasternodeDiff(BaseModel):
60
+ """Masternode-list change between two snapshots (proTxHash identifiers)."""
61
+
62
+ model_config = ConfigDict(frozen=True, extra="ignore")
63
+
64
+ added: list[str]
65
+ removed: list[str]
66
+ changed: list[str]
67
+
68
+ def to_dict(self) -> dict[str, Any]:
69
+ """Return a plain-object view (stdlib types only)."""
70
+ return self.model_dump(mode="python")
71
+
72
+
73
+ class ChainLockStatus(BaseModel):
74
+ """Global ChainLock tip: locked height/hash plus lag behind the node tip."""
75
+
76
+ model_config = ConfigDict(frozen=True, extra="ignore")
77
+
78
+ height: int
79
+ blockhash: str
80
+ behind_by: int
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ """Return a plain-object view (stdlib types only)."""
84
+ return self.model_dump(mode="python")
85
+
86
+
87
+ def get_chain_tips(rpc: DashRPC) -> list[ChainTip]:
88
+ """Read ``getchaintips`` (each tip: height, hash, branch length, status)."""
89
+ raw: Any = rpc.call("getchaintips")
90
+ if not isinstance(raw, list):
91
+ raise RpcError("getchaintips returned a malformed response")
92
+ tips: list[ChainTip] = []
93
+ for entry in raw:
94
+ data = _mapping(entry, "getchaintips")
95
+ try:
96
+ tips.append(
97
+ ChainTip(
98
+ height=int(data["height"]),
99
+ hash=data["hash"],
100
+ branchlen=int(data["branchlen"]),
101
+ status=data["status"],
102
+ )
103
+ )
104
+ except (KeyError, TypeError, ValueError, ValidationError) as exc:
105
+ raise RpcError(f"getchaintips returned a malformed response: {exc}") from exc
106
+ logger.debug("chain tips read count=%d", len(tips))
107
+ return tips
108
+
109
+
110
+ def get_masternode_list(rpc: DashRPC) -> dict[str, Any]:
111
+ """Read the full ``masternode list json`` snapshot keyed by proTxHash."""
112
+ try:
113
+ raw: Any = rpc.call("masternode", "list", "json")
114
+ except RpcError as first:
115
+ if first.code == -32601: # pre-0.13 alias for the same snapshot
116
+ raw = rpc.call("masternodelist", "json")
117
+ else:
118
+ raise
119
+ data = _mapping(raw, "masternode list")
120
+ return dict(data)
121
+
122
+
123
+ def diff_masternode_lists(before: Mapping[str, Any], after: Mapping[str, Any]) -> MasternodeDiff:
124
+ """Diff two ``masternode list`` snapshots (e.g. taken at different heights).
125
+
126
+ Identifiers are the deterministic proTxHash keys: ``added`` holds keys
127
+ only in ``after``, ``removed`` keys only in ``before``, ``changed`` keys
128
+ in both whose quorum-relevant entry differs. All three lists are sorted.
129
+ """
130
+ if not isinstance(before, Mapping) or not isinstance(after, Mapping):
131
+ raise RpcError("masternode snapshots must be mappings keyed by proTxHash")
132
+ before_keys = set(before.keys())
133
+ after_keys = set(after.keys())
134
+ added = sorted(str(key) for key in after_keys - before_keys)
135
+ removed = sorted(str(key) for key in before_keys - after_keys)
136
+ changed = sorted(str(key) for key in before_keys & after_keys if before[key] != after[key])
137
+ return MasternodeDiff(added=added, removed=removed, changed=changed)
138
+
139
+
140
+ def masternode_list_diff(
141
+ rpc: DashRPC, base_snapshot: Mapping[str, Any] | None = None
142
+ ) -> MasternodeDiff:
143
+ """Diff the live masternode list against ``base_snapshot`` (or empty).
144
+
145
+ Capture ``base_snapshot`` via :func:`get_masternode_list` at a base
146
+ height, mine/wait, then call again: the result reports which masternodes
147
+ appeared, vanished, or changed entries between the two snapshots.
148
+ """
149
+ current = get_masternode_list(rpc)
150
+ base: Mapping[str, Any] = base_snapshot if base_snapshot is not None else {}
151
+ result = diff_masternode_lists(base, current)
152
+ logger.debug(
153
+ "masternode diff added=%d removed=%d changed=%d",
154
+ len(result.added),
155
+ len(result.removed),
156
+ len(result.changed),
157
+ )
158
+ return result
159
+
160
+
161
+ def chainlock_tip_status(rpc: DashRPC) -> ChainLockStatus:
162
+ """Read the global ChainLock tip (locked height/hash + lag behind tip)."""
163
+ lock = _mapping(rpc.call("getbestchainlock"), "getbestchainlock")
164
+ try:
165
+ node_height_raw: Any = rpc.call("getblockcount")
166
+ if not isinstance(node_height_raw, int) or isinstance(node_height_raw, bool):
167
+ raise RpcError("getblockcount returned a malformed response")
168
+ node_height = int(node_height_raw)
169
+ except RpcError as exc:
170
+ if "malformed" in str(exc):
171
+ raise
172
+ info = _mapping(rpc.call("getblockchaininfo"), "getblockchaininfo")
173
+ try:
174
+ node_height = int(info["blocks"])
175
+ except (KeyError, TypeError, ValueError) as nested:
176
+ raise RpcError(f"getblockchaininfo returned a malformed response: {nested}") from nested
177
+ try:
178
+ status = ChainLockStatus(
179
+ height=int(lock["height"]),
180
+ blockhash=lock["blockhash"],
181
+ behind_by=node_height - int(lock["height"]),
182
+ )
183
+ except (KeyError, TypeError, ValueError, ValidationError) as exc:
184
+ raise RpcError(f"getbestchainlock returned a malformed response: {exc}") from exc
185
+ logger.debug("chainlock tip height=%d behind_by=%d", status.height, status.behind_by)
186
+ return status