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.
@@ -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]
@@ -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")