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 +65 -0
- ourdash/core/__init__.py +8 -0
- ourdash/core/addresses.py +126 -0
- ourdash/core/chain.py +303 -0
- ourdash/core/network.py +186 -0
- ourdash/core/rpc.py +253 -0
- ourdash/core/transactions.py +516 -0
- ourdash/core/wallet.py +216 -0
- ourdash/errors.py +77 -0
- ourdash/platform/__init__.py +8 -0
- ourdash/platform/dapi.py +242 -0
- ourdash/platform/drive.py +332 -0
- ourdash/platform/models.py +151 -0
- ourdash/platform/proofs.py +144 -0
- ourdash/platform/seeds.py +169 -0
- ourdash/redact.py +83 -0
- ourdash/utils/__init__.py +75 -0
- ourdash-0.1.0.dist-info/METADATA +120 -0
- ourdash-0.1.0.dist-info/RECORD +20 -0
- ourdash-0.1.0.dist-info/WHEEL +4 -0
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
|
+
]
|
ourdash/core/__init__.py
ADDED
|
@@ -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
|
ourdash/core/network.py
ADDED
|
@@ -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
|