dominusnode 2.0.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.
- dominusnode/__init__.py +45 -0
- dominusnode/_ops.py +122 -0
- dominusnode/client.py +192 -0
- dominusnode/constants.py +24 -0
- dominusnode/errors.py +153 -0
- dominusnode/http_client.py +354 -0
- dominusnode/messages.py +79 -0
- dominusnode/pow.py +33 -0
- dominusnode/proxy_fetch.py +341 -0
- dominusnode/py.typed +0 -0
- dominusnode/resources.py +791 -0
- dominusnode/secret.py +60 -0
- dominusnode/signer.py +56 -0
- dominusnode/ssrf.py +176 -0
- dominusnode/token_manager.py +150 -0
- dominusnode/types.py +99 -0
- dominusnode/validation.py +117 -0
- dominusnode-2.0.0.dist-info/METADATA +177 -0
- dominusnode-2.0.0.dist-info/RECORD +22 -0
- dominusnode-2.0.0.dist-info/WHEEL +5 -0
- dominusnode-2.0.0.dist-info/licenses/LICENSE +21 -0
- dominusnode-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""HTTP layer (internal): auth modes, no redirects, 10 MB cap, 429 retry, 401 refresh, error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable, Dict, Optional, Union
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .constants import (
|
|
16
|
+
MAX_RATE_LIMIT_RETRIES,
|
|
17
|
+
MAX_RESPONSE_BYTES,
|
|
18
|
+
MAX_RETRY_WAIT_SECONDS,
|
|
19
|
+
MIN_RETRY_WAIT_SECONDS,
|
|
20
|
+
REQUEST_TIMEOUT_SECONDS,
|
|
21
|
+
USER_AGENT,
|
|
22
|
+
)
|
|
23
|
+
from .errors import (
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
AuthorizationError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
Details,
|
|
28
|
+
DominusNodeError,
|
|
29
|
+
InsufficientBalanceError,
|
|
30
|
+
NetworkError,
|
|
31
|
+
NotFoundError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
ServerError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
invalid_arg,
|
|
36
|
+
scrub_credentials,
|
|
37
|
+
)
|
|
38
|
+
from .token_manager import AsyncTokenManager, TokenManager
|
|
39
|
+
|
|
40
|
+
DANGEROUS_KEYS = frozenset({"__proto__", "constructor", "prototype"})
|
|
41
|
+
_NO_BODY = object()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def strip_dangerous_keys(obj: Any, depth: int = 0) -> Any:
|
|
45
|
+
"""Recursively delete ``__proto__``/``constructor``/``prototype`` keys (depth limit 50)."""
|
|
46
|
+
if depth > 50:
|
|
47
|
+
return obj
|
|
48
|
+
if isinstance(obj, dict):
|
|
49
|
+
for k in list(obj.keys()):
|
|
50
|
+
if k in DANGEROUS_KEYS:
|
|
51
|
+
del obj[k]
|
|
52
|
+
else:
|
|
53
|
+
strip_dangerous_keys(obj[k], depth + 1)
|
|
54
|
+
elif isinstance(obj, list):
|
|
55
|
+
for item in obj:
|
|
56
|
+
strip_dangerous_keys(item, depth + 1)
|
|
57
|
+
return obj
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def safe_json_loads(text: str) -> Any:
|
|
61
|
+
return strip_dangerous_keys(json.loads(text))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def retry_delay_seconds(retry_after_seconds: float, rand: Callable[[], float] = random.random) -> float:
|
|
65
|
+
"""min(retry_after, 10s), +/-10% jitter, floor 100ms."""
|
|
66
|
+
base = min(max(float(retry_after_seconds), 0.0), MAX_RETRY_WAIT_SECONDS)
|
|
67
|
+
jitter = base * 0.2 * (rand() - 0.5)
|
|
68
|
+
return max(MIN_RETRY_WAIT_SECONDS, round(base + jitter, 3))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_retry_after(value: Optional[str], fallback: int) -> int:
|
|
72
|
+
try:
|
|
73
|
+
n = int(str(value).strip())
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return fallback
|
|
76
|
+
return n if n >= 0 else fallback
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def raise_for_status(status: int, body: str, headers: Any) -> None:
|
|
80
|
+
message = body
|
|
81
|
+
code: Optional[str] = None
|
|
82
|
+
details: Optional[Details] = None
|
|
83
|
+
try:
|
|
84
|
+
parsed = safe_json_loads(body)
|
|
85
|
+
if isinstance(parsed, dict):
|
|
86
|
+
m = parsed.get("error", parsed.get("message"))
|
|
87
|
+
if isinstance(m, str):
|
|
88
|
+
message = m
|
|
89
|
+
if isinstance(parsed.get("code"), str):
|
|
90
|
+
code = parsed["code"]
|
|
91
|
+
elif parsed.get("mfaRequired") is True:
|
|
92
|
+
code = "MFA_REQUIRED"
|
|
93
|
+
for k in ("max", "cap", "limitPerDay"):
|
|
94
|
+
v = parsed.get(k)
|
|
95
|
+
if isinstance(v, (int, float, str)) and not isinstance(v, bool):
|
|
96
|
+
details = details or {}
|
|
97
|
+
details[k] = v
|
|
98
|
+
except ValueError:
|
|
99
|
+
pass
|
|
100
|
+
if not message:
|
|
101
|
+
message = f"HTTP {status}"
|
|
102
|
+
if len(message) > 500:
|
|
103
|
+
message = message[:500] + "... [truncated]"
|
|
104
|
+
if status == 400:
|
|
105
|
+
raise ValidationError(message, 400, code, details)
|
|
106
|
+
if status == 401:
|
|
107
|
+
raise AuthenticationError(message, code, details)
|
|
108
|
+
if status == 402:
|
|
109
|
+
raise InsufficientBalanceError(message, code, details)
|
|
110
|
+
if status == 403:
|
|
111
|
+
raise AuthorizationError(message, code, details)
|
|
112
|
+
if status == 404:
|
|
113
|
+
raise NotFoundError(message, code, details)
|
|
114
|
+
if status == 409:
|
|
115
|
+
raise ConflictError(message, code, details)
|
|
116
|
+
if status == 429:
|
|
117
|
+
raise RateLimitError(message, _parse_retry_after(headers.get("retry-after"), 60), code, details)
|
|
118
|
+
if status >= 500:
|
|
119
|
+
raise ServerError(message, status, code, details)
|
|
120
|
+
raise DominusNodeError(message, status, code, details)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Request:
|
|
125
|
+
"""One API call. ``auth``: "jwt" | "agent" | "none"."""
|
|
126
|
+
|
|
127
|
+
method: str
|
|
128
|
+
path: str
|
|
129
|
+
body: Any = _NO_BODY
|
|
130
|
+
auth: str = "jwt"
|
|
131
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
132
|
+
raw: bool = False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_base_url(base_url: str) -> str:
|
|
136
|
+
try:
|
|
137
|
+
u = urlsplit(base_url)
|
|
138
|
+
except (ValueError, TypeError):
|
|
139
|
+
raise ValidationError("base_url is not a valid URL", None) from None
|
|
140
|
+
if u.scheme not in ("http", "https") or not u.hostname:
|
|
141
|
+
raise ValidationError("base_url must be http(s)", None)
|
|
142
|
+
if "@" in u.netloc:
|
|
143
|
+
raise ValidationError("base_url must not contain credentials", None)
|
|
144
|
+
return base_url.rstrip("/")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _headers(req: Request, bearer: Optional[str]) -> Dict[str, str]:
|
|
148
|
+
h = {"User-Agent": USER_AGENT, "Accept": "application/json", **req.headers}
|
|
149
|
+
if req.body is not _NO_BODY:
|
|
150
|
+
h["Content-Type"] = "application/json"
|
|
151
|
+
if bearer:
|
|
152
|
+
h["Authorization"] = f"Bearer {bearer}"
|
|
153
|
+
return h
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _content(req: Request) -> Optional[bytes]:
|
|
157
|
+
return None if req.body is _NO_BODY else json.dumps(req.body).encode("utf-8")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _too_large_by_header(res: httpx.Response) -> bool:
|
|
161
|
+
try:
|
|
162
|
+
return int(res.headers.get("content-length", "0")) > MAX_RESPONSE_BYTES
|
|
163
|
+
except ValueError:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _finish(req: Request, res: httpx.Response, data: bytes) -> Any:
|
|
168
|
+
text = data.decode("utf-8", errors="replace")
|
|
169
|
+
if 300 <= res.status_code < 400:
|
|
170
|
+
raise NetworkError(f"Redirect (HTTP {res.status_code}) refused: the SDK never follows redirects")
|
|
171
|
+
if not (200 <= res.status_code < 300):
|
|
172
|
+
raise_for_status(res.status_code, text, res.headers)
|
|
173
|
+
if req.raw:
|
|
174
|
+
return text
|
|
175
|
+
if not text:
|
|
176
|
+
return {}
|
|
177
|
+
try:
|
|
178
|
+
return safe_json_loads(text)
|
|
179
|
+
except ValueError:
|
|
180
|
+
raise ServerError("Invalid JSON in response", res.status_code) from None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _not_authenticated() -> AuthenticationError:
|
|
184
|
+
return AuthenticationError("Not authenticated. Call connect_with_key() or wallet_auth.verify() first.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class SyncHttpClient:
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
base_url: str,
|
|
191
|
+
tokens: TokenManager,
|
|
192
|
+
api_key: Callable[[], Optional[str]],
|
|
193
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
self._base_url = _validate_base_url(base_url)
|
|
196
|
+
self._tokens = tokens
|
|
197
|
+
self._api_key = api_key
|
|
198
|
+
self._client = httpx.Client(
|
|
199
|
+
transport=transport, follow_redirects=False, timeout=REQUEST_TIMEOUT_SECONDS, trust_env=False
|
|
200
|
+
)
|
|
201
|
+
self.sleep: Callable[[float], None] = time.sleep
|
|
202
|
+
|
|
203
|
+
def close(self) -> None:
|
|
204
|
+
self._client.close()
|
|
205
|
+
|
|
206
|
+
def _bearer(self, mode: str, force: bool) -> Optional[str]:
|
|
207
|
+
if mode == "none":
|
|
208
|
+
return None
|
|
209
|
+
if mode == "agent":
|
|
210
|
+
key = self._api_key()
|
|
211
|
+
if key:
|
|
212
|
+
return key
|
|
213
|
+
if not self._tokens.has_tokens():
|
|
214
|
+
raise _not_authenticated()
|
|
215
|
+
return self._tokens.force_refresh() if force else self._tokens.get_valid_token()
|
|
216
|
+
|
|
217
|
+
def _send(self, req: Request, bearer: Optional[str]) -> httpx.Response:
|
|
218
|
+
try:
|
|
219
|
+
r = self._client.build_request(
|
|
220
|
+
req.method, self._base_url + req.path, headers=_headers(req, bearer), content=_content(req)
|
|
221
|
+
)
|
|
222
|
+
return self._client.send(r, stream=True)
|
|
223
|
+
except httpx.HTTPError as err:
|
|
224
|
+
raise NetworkError(scrub_credentials(str(err) or "Network request failed")) from None
|
|
225
|
+
|
|
226
|
+
def _read(self, res: httpx.Response) -> bytes:
|
|
227
|
+
try:
|
|
228
|
+
if _too_large_by_header(res):
|
|
229
|
+
raise ServerError("Response body too large", res.status_code)
|
|
230
|
+
buf = bytearray()
|
|
231
|
+
for chunk in res.iter_bytes():
|
|
232
|
+
buf += chunk
|
|
233
|
+
if len(buf) > MAX_RESPONSE_BYTES:
|
|
234
|
+
raise ServerError("Response body exceeds size limit", res.status_code)
|
|
235
|
+
return bytes(buf)
|
|
236
|
+
except httpx.HTTPError as err:
|
|
237
|
+
raise NetworkError(scrub_credentials(str(err) or "Network read failed")) from None
|
|
238
|
+
finally:
|
|
239
|
+
res.close()
|
|
240
|
+
|
|
241
|
+
def request(self, req: Request) -> Any:
|
|
242
|
+
if req.auth not in ("jwt", "agent", "none"):
|
|
243
|
+
raise invalid_arg("unknown auth mode")
|
|
244
|
+
bearer = self._bearer(req.auth, False)
|
|
245
|
+
res = self._send(req, bearer)
|
|
246
|
+
for _ in range(MAX_RATE_LIMIT_RETRIES):
|
|
247
|
+
if res.status_code != 429:
|
|
248
|
+
break
|
|
249
|
+
wait = retry_delay_seconds(_parse_retry_after(res.headers.get("retry-after"), 5))
|
|
250
|
+
res.close() # do not read the 429 body
|
|
251
|
+
self.sleep(wait)
|
|
252
|
+
bearer = self._bearer(req.auth, False)
|
|
253
|
+
res = self._send(req, bearer)
|
|
254
|
+
used_jwt = bearer is not None and not bearer.startswith("dn_")
|
|
255
|
+
if res.status_code == 401 and used_jwt and self._tokens.refresh_token:
|
|
256
|
+
res.close()
|
|
257
|
+
try:
|
|
258
|
+
refreshed: Optional[str] = self._bearer(req.auth, True)
|
|
259
|
+
except Exception:
|
|
260
|
+
refreshed = None
|
|
261
|
+
if not refreshed:
|
|
262
|
+
raise AuthenticationError("Session expired and token refresh failed")
|
|
263
|
+
res = self._send(req, refreshed)
|
|
264
|
+
return _finish(req, res, self._read(res))
|
|
265
|
+
|
|
266
|
+
def __repr__(self) -> str:
|
|
267
|
+
return f"SyncHttpClient({self._base_url})"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class AsyncHttpClient:
|
|
271
|
+
def __init__(
|
|
272
|
+
self,
|
|
273
|
+
base_url: str,
|
|
274
|
+
tokens: AsyncTokenManager,
|
|
275
|
+
api_key: Callable[[], Optional[str]],
|
|
276
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
277
|
+
) -> None:
|
|
278
|
+
self._base_url = _validate_base_url(base_url)
|
|
279
|
+
self._tokens = tokens
|
|
280
|
+
self._api_key = api_key
|
|
281
|
+
self._client = httpx.AsyncClient(
|
|
282
|
+
transport=transport, follow_redirects=False, timeout=REQUEST_TIMEOUT_SECONDS, trust_env=False
|
|
283
|
+
)
|
|
284
|
+
self.sleep: Callable[[float], Any] = asyncio.sleep
|
|
285
|
+
|
|
286
|
+
async def close(self) -> None:
|
|
287
|
+
await self._client.aclose()
|
|
288
|
+
|
|
289
|
+
async def _bearer(self, mode: str, force: bool) -> Optional[str]:
|
|
290
|
+
if mode == "none":
|
|
291
|
+
return None
|
|
292
|
+
if mode == "agent":
|
|
293
|
+
key = self._api_key()
|
|
294
|
+
if key:
|
|
295
|
+
return key
|
|
296
|
+
if not self._tokens.has_tokens():
|
|
297
|
+
raise _not_authenticated()
|
|
298
|
+
return await (self._tokens.force_refresh() if force else self._tokens.get_valid_token())
|
|
299
|
+
|
|
300
|
+
async def _send(self, req: Request, bearer: Optional[str]) -> httpx.Response:
|
|
301
|
+
try:
|
|
302
|
+
r = self._client.build_request(
|
|
303
|
+
req.method, self._base_url + req.path, headers=_headers(req, bearer), content=_content(req)
|
|
304
|
+
)
|
|
305
|
+
return await self._client.send(r, stream=True)
|
|
306
|
+
except httpx.HTTPError as err:
|
|
307
|
+
raise NetworkError(scrub_credentials(str(err) or "Network request failed")) from None
|
|
308
|
+
|
|
309
|
+
async def _read(self, res: httpx.Response) -> bytes:
|
|
310
|
+
try:
|
|
311
|
+
if _too_large_by_header(res):
|
|
312
|
+
raise ServerError("Response body too large", res.status_code)
|
|
313
|
+
buf = bytearray()
|
|
314
|
+
async for chunk in res.aiter_bytes():
|
|
315
|
+
buf += chunk
|
|
316
|
+
if len(buf) > MAX_RESPONSE_BYTES:
|
|
317
|
+
raise ServerError("Response body exceeds size limit", res.status_code)
|
|
318
|
+
return bytes(buf)
|
|
319
|
+
except httpx.HTTPError as err:
|
|
320
|
+
raise NetworkError(scrub_credentials(str(err) or "Network read failed")) from None
|
|
321
|
+
finally:
|
|
322
|
+
await res.aclose()
|
|
323
|
+
|
|
324
|
+
async def request(self, req: Request) -> Any:
|
|
325
|
+
if req.auth not in ("jwt", "agent", "none"):
|
|
326
|
+
raise invalid_arg("unknown auth mode")
|
|
327
|
+
bearer = await self._bearer(req.auth, False)
|
|
328
|
+
res = await self._send(req, bearer)
|
|
329
|
+
for _ in range(MAX_RATE_LIMIT_RETRIES):
|
|
330
|
+
if res.status_code != 429:
|
|
331
|
+
break
|
|
332
|
+
wait = retry_delay_seconds(_parse_retry_after(res.headers.get("retry-after"), 5))
|
|
333
|
+
await res.aclose()
|
|
334
|
+
await self.sleep(wait)
|
|
335
|
+
bearer = await self._bearer(req.auth, False)
|
|
336
|
+
res = await self._send(req, bearer)
|
|
337
|
+
used_jwt = bearer is not None and not bearer.startswith("dn_")
|
|
338
|
+
if res.status_code == 401 and used_jwt and self._tokens.refresh_token:
|
|
339
|
+
await res.aclose()
|
|
340
|
+
try:
|
|
341
|
+
refreshed: Optional[str] = await self._bearer(req.auth, True)
|
|
342
|
+
except Exception:
|
|
343
|
+
refreshed = None
|
|
344
|
+
if not refreshed:
|
|
345
|
+
raise AuthenticationError("Session expired and token refresh failed")
|
|
346
|
+
res = await self._send(req, refreshed)
|
|
347
|
+
data = await self._read(res)
|
|
348
|
+
return _finish(req, res, data)
|
|
349
|
+
|
|
350
|
+
def __repr__(self) -> str:
|
|
351
|
+
return f"AsyncHttpClient({self._base_url})"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
AnyHttpClient = Union[SyncHttpClient, AsyncHttpClient]
|
dominusnode/messages.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Byte-exact builders for every message the SDK asks a signer to sign.
|
|
2
|
+
|
|
3
|
+
The SDK never signs a server string blindly: it rebuilds the message from values
|
|
4
|
+
it knows and refuses (ProtocolError) if the server's text differs.
|
|
5
|
+
Server sources: packages/shared/src/attestation.ts, packages/dashboard/src/routes/v1/credits.ts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from .errors import ProtocolError
|
|
14
|
+
from .validation import UUID_RE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_attestation_message(
|
|
18
|
+
wallet: str, nonce: str, attestation_version: int, attestation_text: str, node_pubkey: Optional[str]
|
|
19
|
+
) -> str:
|
|
20
|
+
return "\n".join(
|
|
21
|
+
[
|
|
22
|
+
"DomiNode node attestation",
|
|
23
|
+
f"Version: {attestation_version}",
|
|
24
|
+
f"Wallet: {wallet.lower()}",
|
|
25
|
+
f"Nonce: {nonce}",
|
|
26
|
+
f"Node pubkey: {node_pubkey if node_pubkey is not None else 'none'}",
|
|
27
|
+
"",
|
|
28
|
+
attestation_text,
|
|
29
|
+
]
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_payout_wallet_message(user_id: str, wallet: str, address: str, chain: str, nonce: str) -> str:
|
|
34
|
+
return "\n".join(
|
|
35
|
+
[
|
|
36
|
+
"DominusNode payout wallet",
|
|
37
|
+
"Action: payout_wallet_set",
|
|
38
|
+
f"User: {user_id}",
|
|
39
|
+
f"Account wallet: {wallet.lower()}",
|
|
40
|
+
f"Payout address: {address.lower()}",
|
|
41
|
+
f"Chain: {chain}",
|
|
42
|
+
f"Nonce: {nonce}",
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_withdraw_message(user_id: str, wallet: str, amount_musd: str, idempotency_key: str, nonce: str) -> str:
|
|
48
|
+
return "\n".join(
|
|
49
|
+
[
|
|
50
|
+
"DominusNode withdrawal",
|
|
51
|
+
"Action: withdraw",
|
|
52
|
+
f"User: {user_id}",
|
|
53
|
+
f"Account wallet: {wallet.lower()}",
|
|
54
|
+
f"Amount (µUSD): {amount_musd}",
|
|
55
|
+
f"Idempotency key: {idempotency_key}",
|
|
56
|
+
f"Nonce: {nonce}",
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_USER_LINE = re.compile(r"^User: (.+)\Z")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def extract_user_id(message: str) -> str:
|
|
65
|
+
"""Take the user id from line 3 (``User: <uuid>``); it must be a UUID."""
|
|
66
|
+
lines = message.split("\n")
|
|
67
|
+
line = lines[2] if len(lines) > 2 else ""
|
|
68
|
+
m = _USER_LINE.match(line)
|
|
69
|
+
if not m or not UUID_RE.match(m.group(1)):
|
|
70
|
+
raise ProtocolError("challenge message has no valid User line")
|
|
71
|
+
return m.group(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def assert_same_message(server: Any, local: str, what: str) -> None:
|
|
75
|
+
if not isinstance(server, str) or server != local:
|
|
76
|
+
raise ProtocolError(
|
|
77
|
+
f"{what}: the server's message differs from the one built locally; refusing to sign "
|
|
78
|
+
"(wrong signer for the linked wallet, mismatched parameters, or protocol change)"
|
|
79
|
+
)
|
dominusnode/pow.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Node-registration proof of work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
from .errors import ProtocolError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def leading_zero_bits(digest: bytes) -> int:
|
|
11
|
+
bits = 0
|
|
12
|
+
for byte in digest:
|
|
13
|
+
if byte == 0:
|
|
14
|
+
bits += 8
|
|
15
|
+
continue
|
|
16
|
+
return bits + 8 - byte.bit_length()
|
|
17
|
+
return bits
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def solve_pow(prefix: str, difficulty: int, max_iterations: int = 200_000_000) -> str:
|
|
21
|
+
"""Smallest decimal n >= 0 with sha256(prefix + str(n)) having >= difficulty leading zero bits."""
|
|
22
|
+
if not isinstance(prefix, str) or not (1 <= len(prefix) <= 256):
|
|
23
|
+
raise ProtocolError("invalid PoW prefix")
|
|
24
|
+
if not isinstance(difficulty, int) or isinstance(difficulty, bool) or not (1 <= difficulty <= 32):
|
|
25
|
+
raise ProtocolError("invalid PoW difficulty")
|
|
26
|
+
base = hashlib.sha256(prefix.encode("utf-8"))
|
|
27
|
+
for n in range(max_iterations):
|
|
28
|
+
s = str(n)
|
|
29
|
+
h = base.copy()
|
|
30
|
+
h.update(s.encode("ascii"))
|
|
31
|
+
if leading_zero_bits(h.digest()) >= difficulty:
|
|
32
|
+
return s
|
|
33
|
+
raise ProtocolError("PoW not solved within iteration cap")
|