dominusnode 1.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 +151 -0
- dominusnode/admin.py +252 -0
- dominusnode/agent_wallet.py +240 -0
- dominusnode/auth.py +271 -0
- dominusnode/client.py +457 -0
- dominusnode/constants.py +18 -0
- dominusnode/errors.py +91 -0
- dominusnode/http_client.py +435 -0
- dominusnode/keys.py +88 -0
- dominusnode/plans.py +93 -0
- dominusnode/proxy.py +248 -0
- dominusnode/py.typed +0 -0
- dominusnode/sessions.py +55 -0
- dominusnode/slots.py +60 -0
- dominusnode/teams.py +339 -0
- dominusnode/token_manager.py +235 -0
- dominusnode/types.py +521 -0
- dominusnode/usage.py +230 -0
- dominusnode/wallet.py +189 -0
- dominusnode/wallet_auth.py +241 -0
- dominusnode/x402.py +88 -0
- dominusnode-1.0.0.dist-info/LICENSE +21 -0
- dominusnode-1.0.0.dist-info/METADATA +13 -0
- dominusnode-1.0.0.dist-info/RECORD +26 -0
- dominusnode-1.0.0.dist-info/WHEEL +5 -0
- dominusnode-1.0.0.dist-info/top_level.txt +1 -0
dominusnode/usage.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Usage resource -- usage stats, daily aggregation, top hosts, and CSV export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .http_client import AsyncHttpClient, SyncHttpClient
|
|
8
|
+
from .types import (
|
|
9
|
+
DailyUsage,
|
|
10
|
+
TopHost,
|
|
11
|
+
UsagePagination,
|
|
12
|
+
UsagePeriod,
|
|
13
|
+
UsageRecord,
|
|
14
|
+
UsageResponse,
|
|
15
|
+
UsageSummary,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_usage_response(data: dict) -> UsageResponse:
|
|
20
|
+
s = data["summary"]
|
|
21
|
+
summary = UsageSummary(
|
|
22
|
+
total_bytes=s["totalBytes"],
|
|
23
|
+
total_cost_cents=s["totalCostCents"],
|
|
24
|
+
request_count=s["requestCount"],
|
|
25
|
+
total_gb=s["totalGB"],
|
|
26
|
+
total_cost_usd=s["totalCostUsd"],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
records = [
|
|
30
|
+
UsageRecord(
|
|
31
|
+
id=r["id"],
|
|
32
|
+
session_id=r["sessionId"],
|
|
33
|
+
bytes_in=r["bytesIn"],
|
|
34
|
+
bytes_out=r["bytesOut"],
|
|
35
|
+
total_bytes=r["totalBytes"],
|
|
36
|
+
cost_cents=r["costCents"],
|
|
37
|
+
proxy_type=r.get("proxyType", ""),
|
|
38
|
+
target_host=r.get("targetHost", ""),
|
|
39
|
+
created_at=str(r.get("createdAt", "")),
|
|
40
|
+
)
|
|
41
|
+
for r in data.get("records", [])
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
p = data.get("pagination", {})
|
|
45
|
+
pagination = UsagePagination(
|
|
46
|
+
limit=p.get("limit", 0),
|
|
47
|
+
offset=p.get("offset", 0),
|
|
48
|
+
total=p.get("total", 0),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
pr = data.get("period", {})
|
|
52
|
+
period = UsagePeriod(
|
|
53
|
+
since=str(pr.get("since", "")),
|
|
54
|
+
until=str(pr.get("until", "")),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return UsageResponse(
|
|
58
|
+
summary=summary,
|
|
59
|
+
records=records,
|
|
60
|
+
pagination=pagination,
|
|
61
|
+
period=period,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _build_date_params(
|
|
66
|
+
since: Optional[str],
|
|
67
|
+
until: Optional[str],
|
|
68
|
+
) -> dict:
|
|
69
|
+
params: dict = {}
|
|
70
|
+
if since:
|
|
71
|
+
params["since"] = since
|
|
72
|
+
if until:
|
|
73
|
+
params["until"] = until
|
|
74
|
+
return params
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
78
|
+
# Sync
|
|
79
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class UsageResource:
|
|
83
|
+
"""Synchronous usage operations."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
86
|
+
self._http = http
|
|
87
|
+
|
|
88
|
+
def get(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
since: Optional[str] = None,
|
|
92
|
+
until: Optional[str] = None,
|
|
93
|
+
limit: int = 200,
|
|
94
|
+
offset: int = 0,
|
|
95
|
+
) -> UsageResponse:
|
|
96
|
+
"""Get usage records with summary."""
|
|
97
|
+
params = _build_date_params(since, until)
|
|
98
|
+
params["limit"] = limit
|
|
99
|
+
params["offset"] = offset
|
|
100
|
+
data = self._http.get("/api/usage", params=params)
|
|
101
|
+
return _parse_usage_response(data)
|
|
102
|
+
|
|
103
|
+
def get_daily(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
since: Optional[str] = None,
|
|
107
|
+
until: Optional[str] = None,
|
|
108
|
+
) -> List[DailyUsage]:
|
|
109
|
+
"""Get daily usage aggregation for charts."""
|
|
110
|
+
params = _build_date_params(since, until)
|
|
111
|
+
data = self._http.get("/api/usage/daily", params=params)
|
|
112
|
+
return [
|
|
113
|
+
DailyUsage(
|
|
114
|
+
date=d["date"],
|
|
115
|
+
total_bytes=d["totalBytes"],
|
|
116
|
+
total_gb=d["totalGB"],
|
|
117
|
+
total_cost_cents=d["totalCostCents"],
|
|
118
|
+
total_cost_usd=d["totalCostUsd"],
|
|
119
|
+
request_count=d["requestCount"],
|
|
120
|
+
)
|
|
121
|
+
for d in data.get("days", [])
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
def get_top_hosts(
|
|
125
|
+
self,
|
|
126
|
+
*,
|
|
127
|
+
since: Optional[str] = None,
|
|
128
|
+
until: Optional[str] = None,
|
|
129
|
+
limit: int = 10,
|
|
130
|
+
) -> List[TopHost]:
|
|
131
|
+
"""Get top target hosts by bandwidth."""
|
|
132
|
+
params = _build_date_params(since, until)
|
|
133
|
+
params["limit"] = limit
|
|
134
|
+
data = self._http.get("/api/usage/top-hosts", params=params)
|
|
135
|
+
return [
|
|
136
|
+
TopHost(
|
|
137
|
+
target_host=h["targetHost"],
|
|
138
|
+
total_bytes=h["totalBytes"],
|
|
139
|
+
total_gb=h["totalGB"],
|
|
140
|
+
request_count=h["requestCount"],
|
|
141
|
+
)
|
|
142
|
+
for h in data.get("hosts", [])
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
def export_csv(
|
|
146
|
+
self,
|
|
147
|
+
*,
|
|
148
|
+
since: Optional[str] = None,
|
|
149
|
+
until: Optional[str] = None,
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Export usage records as CSV text."""
|
|
152
|
+
params = _build_date_params(since, until)
|
|
153
|
+
resp = self._http.get_raw("/api/usage/export", params=params)
|
|
154
|
+
return resp.text
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
158
|
+
# Async
|
|
159
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AsyncUsageResource:
|
|
163
|
+
"""Asynchronous usage operations."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
166
|
+
self._http = http
|
|
167
|
+
|
|
168
|
+
async def get(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
since: Optional[str] = None,
|
|
172
|
+
until: Optional[str] = None,
|
|
173
|
+
limit: int = 200,
|
|
174
|
+
offset: int = 0,
|
|
175
|
+
) -> UsageResponse:
|
|
176
|
+
params = _build_date_params(since, until)
|
|
177
|
+
params["limit"] = limit
|
|
178
|
+
params["offset"] = offset
|
|
179
|
+
data = await self._http.get("/api/usage", params=params)
|
|
180
|
+
return _parse_usage_response(data)
|
|
181
|
+
|
|
182
|
+
async def get_daily(
|
|
183
|
+
self,
|
|
184
|
+
*,
|
|
185
|
+
since: Optional[str] = None,
|
|
186
|
+
until: Optional[str] = None,
|
|
187
|
+
) -> List[DailyUsage]:
|
|
188
|
+
params = _build_date_params(since, until)
|
|
189
|
+
data = await self._http.get("/api/usage/daily", params=params)
|
|
190
|
+
return [
|
|
191
|
+
DailyUsage(
|
|
192
|
+
date=d["date"],
|
|
193
|
+
total_bytes=d["totalBytes"],
|
|
194
|
+
total_gb=d["totalGB"],
|
|
195
|
+
total_cost_cents=d["totalCostCents"],
|
|
196
|
+
total_cost_usd=d["totalCostUsd"],
|
|
197
|
+
request_count=d["requestCount"],
|
|
198
|
+
)
|
|
199
|
+
for d in data.get("days", [])
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
async def get_top_hosts(
|
|
203
|
+
self,
|
|
204
|
+
*,
|
|
205
|
+
since: Optional[str] = None,
|
|
206
|
+
until: Optional[str] = None,
|
|
207
|
+
limit: int = 10,
|
|
208
|
+
) -> List[TopHost]:
|
|
209
|
+
params = _build_date_params(since, until)
|
|
210
|
+
params["limit"] = limit
|
|
211
|
+
data = await self._http.get("/api/usage/top-hosts", params=params)
|
|
212
|
+
return [
|
|
213
|
+
TopHost(
|
|
214
|
+
target_host=h["targetHost"],
|
|
215
|
+
total_bytes=h["totalBytes"],
|
|
216
|
+
total_gb=h["totalGB"],
|
|
217
|
+
request_count=h["requestCount"],
|
|
218
|
+
)
|
|
219
|
+
for h in data.get("hosts", [])
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
async def export_csv(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
since: Optional[str] = None,
|
|
226
|
+
until: Optional[str] = None,
|
|
227
|
+
) -> str:
|
|
228
|
+
params = _build_date_params(since, until)
|
|
229
|
+
resp = await self._http.get_raw("/api/usage/export", params=params)
|
|
230
|
+
return resp.text
|
dominusnode/wallet.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Wallet resource -- balance, transactions, top-ups, and forecasting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from .http_client import AsyncHttpClient, SyncHttpClient
|
|
9
|
+
from .types import CryptoInvoice, PaypalOrder, StripeCheckout, Wallet, WalletForecast, WalletTransaction
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validate_amount_cents(value: int, name: str = "amount_cents") -> None:
|
|
13
|
+
"""Validate that a financial amount is a positive integer within PostgreSQL int4 range."""
|
|
14
|
+
if not isinstance(value, int) or value <= 0 or value > 2_147_483_647:
|
|
15
|
+
raise ValueError(f"{name} must be a positive integer <= 2,147,483,647")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_wallet(data: dict) -> Wallet:
|
|
19
|
+
return Wallet(
|
|
20
|
+
balance_cents=data["balanceCents"],
|
|
21
|
+
balance_usd=data["balanceUsd"],
|
|
22
|
+
currency=data.get("currency", "usd"),
|
|
23
|
+
last_topped_up=data.get("lastToppedUp"),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_transaction(tx: dict) -> WalletTransaction:
|
|
28
|
+
return WalletTransaction(
|
|
29
|
+
id=tx["id"],
|
|
30
|
+
type=tx["type"],
|
|
31
|
+
amount_cents=tx["amountCents"],
|
|
32
|
+
amount_usd=tx["amountUsd"],
|
|
33
|
+
description=tx.get("description", ""),
|
|
34
|
+
payment_provider=tx.get("paymentProvider"),
|
|
35
|
+
created_at=str(tx.get("createdAt", "")),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
40
|
+
# Sync
|
|
41
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class WalletResource:
|
|
45
|
+
"""Synchronous wallet operations."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
48
|
+
self._http = http
|
|
49
|
+
|
|
50
|
+
def get_balance(self) -> Wallet:
|
|
51
|
+
"""Get current wallet balance."""
|
|
52
|
+
data = self._http.get("/api/wallet")
|
|
53
|
+
return _parse_wallet(data)
|
|
54
|
+
|
|
55
|
+
def get_transactions(self, *, limit: int = 50, offset: int = 0) -> List[WalletTransaction]:
|
|
56
|
+
"""Get wallet transaction history."""
|
|
57
|
+
data = self._http.get("/api/wallet/transactions", params={"limit": limit, "offset": offset})
|
|
58
|
+
return [_parse_transaction(tx) for tx in data.get("transactions", [])]
|
|
59
|
+
|
|
60
|
+
def top_up_stripe(self, amount_cents: int) -> StripeCheckout:
|
|
61
|
+
"""Create a Stripe checkout session for wallet top-up.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
amount_cents: Amount in cents (e.g. 1000 = $10.00).
|
|
65
|
+
"""
|
|
66
|
+
_validate_amount_cents(amount_cents, "amount_cents")
|
|
67
|
+
data = self._http.post("/api/wallet/topup/stripe", json={"amountCents": amount_cents})
|
|
68
|
+
return StripeCheckout(
|
|
69
|
+
session_id=data["sessionId"],
|
|
70
|
+
url=data["url"],
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def top_up_crypto(self, amount_usd: float, currency: str) -> CryptoInvoice:
|
|
74
|
+
"""Create a crypto invoice for wallet top-up.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
amount_usd: Amount in USD (min $10.00).
|
|
78
|
+
currency: Crypto currency code (btc, eth, xmr, sol, zec).
|
|
79
|
+
"""
|
|
80
|
+
if not isinstance(amount_usd, (int, float)) or math.isnan(amount_usd) or math.isinf(amount_usd):
|
|
81
|
+
raise ValueError("amount_usd must be a finite number")
|
|
82
|
+
if amount_usd < 10:
|
|
83
|
+
raise ValueError("Minimum crypto top-up is $10.00")
|
|
84
|
+
# Validate amount_usd converted to cents stays within int4 range
|
|
85
|
+
amount_cents_approx = int(amount_usd * 100)
|
|
86
|
+
_validate_amount_cents(amount_cents_approx, "amount_usd (as cents)")
|
|
87
|
+
data = self._http.post(
|
|
88
|
+
"/api/wallet/topup/crypto",
|
|
89
|
+
json={"amountUsd": amount_usd, "currency": currency},
|
|
90
|
+
)
|
|
91
|
+
return CryptoInvoice(
|
|
92
|
+
invoice_id=data["invoiceId"],
|
|
93
|
+
invoice_url=data["invoiceUrl"],
|
|
94
|
+
pay_currency=data["payCurrency"],
|
|
95
|
+
price_amount=data["priceAmount"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def top_up_paypal(self, amount_cents: int) -> PaypalOrder:
|
|
99
|
+
"""Create a PayPal order for wallet top-up.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
amount_cents: Amount in cents (min 500 = $5.00).
|
|
103
|
+
"""
|
|
104
|
+
_validate_amount_cents(amount_cents, "amount_cents")
|
|
105
|
+
if amount_cents < 500:
|
|
106
|
+
raise ValueError("Minimum PayPal top-up is $5.00 (500 cents)")
|
|
107
|
+
data = self._http.post("/api/wallet/topup/paypal", json={"amountCents": amount_cents})
|
|
108
|
+
return PaypalOrder(
|
|
109
|
+
order_id=data["orderId"],
|
|
110
|
+
approval_url=data["approvalUrl"],
|
|
111
|
+
amount_cents=data["amountCents"],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def get_forecast(self) -> WalletForecast:
|
|
115
|
+
"""Get wallet balance forecast based on recent usage."""
|
|
116
|
+
data = self._http.get("/api/wallet/forecast")
|
|
117
|
+
return WalletForecast(
|
|
118
|
+
daily_avg_cents=data["dailyAvgCents"],
|
|
119
|
+
days_remaining=data.get("daysRemaining"),
|
|
120
|
+
trend=data["trend"],
|
|
121
|
+
trend_pct=data["trendPct"],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
126
|
+
# Async
|
|
127
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AsyncWalletResource:
|
|
131
|
+
"""Asynchronous wallet operations."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
134
|
+
self._http = http
|
|
135
|
+
|
|
136
|
+
async def get_balance(self) -> Wallet:
|
|
137
|
+
data = await self._http.get("/api/wallet")
|
|
138
|
+
return _parse_wallet(data)
|
|
139
|
+
|
|
140
|
+
async def get_transactions(self, *, limit: int = 50, offset: int = 0) -> List[WalletTransaction]:
|
|
141
|
+
data = await self._http.get("/api/wallet/transactions", params={"limit": limit, "offset": offset})
|
|
142
|
+
return [_parse_transaction(tx) for tx in data.get("transactions", [])]
|
|
143
|
+
|
|
144
|
+
async def top_up_stripe(self, amount_cents: int) -> StripeCheckout:
|
|
145
|
+
_validate_amount_cents(amount_cents, "amount_cents")
|
|
146
|
+
data = await self._http.post("/api/wallet/topup/stripe", json={"amountCents": amount_cents})
|
|
147
|
+
return StripeCheckout(
|
|
148
|
+
session_id=data["sessionId"],
|
|
149
|
+
url=data["url"],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
async def top_up_crypto(self, amount_usd: float, currency: str) -> CryptoInvoice:
|
|
153
|
+
if not isinstance(amount_usd, (int, float)) or math.isnan(amount_usd) or math.isinf(amount_usd):
|
|
154
|
+
raise ValueError("amount_usd must be a finite number")
|
|
155
|
+
if amount_usd < 10:
|
|
156
|
+
raise ValueError("Minimum crypto top-up is $10.00")
|
|
157
|
+
# Validate amount_usd converted to cents stays within int4 range
|
|
158
|
+
amount_cents_approx = int(amount_usd * 100)
|
|
159
|
+
_validate_amount_cents(amount_cents_approx, "amount_usd (as cents)")
|
|
160
|
+
data = await self._http.post(
|
|
161
|
+
"/api/wallet/topup/crypto",
|
|
162
|
+
json={"amountUsd": amount_usd, "currency": currency},
|
|
163
|
+
)
|
|
164
|
+
return CryptoInvoice(
|
|
165
|
+
invoice_id=data["invoiceId"],
|
|
166
|
+
invoice_url=data["invoiceUrl"],
|
|
167
|
+
pay_currency=data["payCurrency"],
|
|
168
|
+
price_amount=data["priceAmount"],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def top_up_paypal(self, amount_cents: int) -> PaypalOrder:
|
|
172
|
+
_validate_amount_cents(amount_cents, "amount_cents")
|
|
173
|
+
if amount_cents < 500:
|
|
174
|
+
raise ValueError("Minimum PayPal top-up is $5.00 (500 cents)")
|
|
175
|
+
data = await self._http.post("/api/wallet/topup/paypal", json={"amountCents": amount_cents})
|
|
176
|
+
return PaypalOrder(
|
|
177
|
+
order_id=data["orderId"],
|
|
178
|
+
approval_url=data["approvalUrl"],
|
|
179
|
+
amount_cents=data["amountCents"],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def get_forecast(self) -> WalletForecast:
|
|
183
|
+
data = await self._http.get("/api/wallet/forecast")
|
|
184
|
+
return WalletForecast(
|
|
185
|
+
daily_avg_cents=data["dailyAvgCents"],
|
|
186
|
+
days_remaining=data.get("daysRemaining"),
|
|
187
|
+
trend=data["trend"],
|
|
188
|
+
trend_pct=data["trendPct"],
|
|
189
|
+
)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Wallet-based authentication using EIP-191 signed messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .http_client import AsyncHttpClient, SyncHttpClient
|
|
10
|
+
from .token_manager import AsyncTokenManager, TokenManager
|
|
11
|
+
from .types import LoginResult, User
|
|
12
|
+
|
|
13
|
+
# Ethereum address: 0x followed by 40 hex characters
|
|
14
|
+
_ETH_ADDRESS_RE = re.compile(r"^0x[0-9a-fA-F]{40}$")
|
|
15
|
+
|
|
16
|
+
# EIP-191 signature format: 0x prefix + 130 hex chars (65 bytes)
|
|
17
|
+
_EIP191_SIGNATURE_RE = re.compile(r"^0x[0-9a-fA-F]{130}$")
|
|
18
|
+
_MAX_SIGNATURE_LENGTH = 200
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _validate_address(address: str) -> None:
|
|
22
|
+
"""Validate Ethereum address format."""
|
|
23
|
+
if not address or not isinstance(address, str):
|
|
24
|
+
raise ValueError("address is required and must be a string")
|
|
25
|
+
if not _ETH_ADDRESS_RE.match(address):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
"address must be a valid Ethereum address (0x followed by 40 hex characters)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate_signature(signature: str) -> None:
|
|
32
|
+
"""Validate EIP-191 signature format."""
|
|
33
|
+
if not signature or not isinstance(signature, str):
|
|
34
|
+
raise ValueError("signature is required and must be a string")
|
|
35
|
+
# Max length check before regex
|
|
36
|
+
if len(signature) > _MAX_SIGNATURE_LENGTH:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"signature must not exceed {_MAX_SIGNATURE_LENGTH} characters"
|
|
39
|
+
)
|
|
40
|
+
# EIP-191 format validation
|
|
41
|
+
if not _EIP191_SIGNATURE_RE.match(signature):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"signature must be a valid EIP-191 signature (0x followed by 130 hex characters)"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class WalletChallenge:
|
|
49
|
+
"""Challenge returned by the wallet/challenge endpoint."""
|
|
50
|
+
challenge: str
|
|
51
|
+
expires_at: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, repr=False)
|
|
55
|
+
class WalletVerifyResult:
|
|
56
|
+
"""Result from wallet/verify -- includes JWT tokens and user.
|
|
57
|
+
|
|
58
|
+
When ``mfa_required`` is ``True``, the caller must complete MFA
|
|
59
|
+
verification using ``auth.verify_mfa(code, mfa_challenge_token=result.mfa_challenge_token)``
|
|
60
|
+
before the client is fully authenticated. In this case ``token`` and
|
|
61
|
+
``refresh_token`` will be empty strings.
|
|
62
|
+
"""
|
|
63
|
+
token: str
|
|
64
|
+
refresh_token: str
|
|
65
|
+
user: User
|
|
66
|
+
mfa_required: bool = False
|
|
67
|
+
mfa_challenge_token: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return (
|
|
71
|
+
f"WalletVerifyResult(user={self.user!r}, mfa_required={self.mfa_required}, "
|
|
72
|
+
f"token='[REDACTED]', refresh_token='[REDACTED]')"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class WalletLinkResult:
|
|
78
|
+
"""Result from wallet/link."""
|
|
79
|
+
success: bool
|
|
80
|
+
wallet_address: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _parse_verify_result(data: dict) -> WalletVerifyResult:
|
|
84
|
+
"""Parse verify response into typed result.
|
|
85
|
+
|
|
86
|
+
Detect MFA-required responses so callers know to complete MFA
|
|
87
|
+
instead of silently returning empty tokens.
|
|
88
|
+
"""
|
|
89
|
+
if data.get("mfaRequired"):
|
|
90
|
+
return WalletVerifyResult(
|
|
91
|
+
token="",
|
|
92
|
+
refresh_token="",
|
|
93
|
+
user=User(id="", email="", created_at=""),
|
|
94
|
+
mfa_required=True,
|
|
95
|
+
mfa_challenge_token=data.get("mfaChallengeToken"),
|
|
96
|
+
)
|
|
97
|
+
user_data = data.get("user", {})
|
|
98
|
+
user = User(
|
|
99
|
+
id=user_data.get("id", ""),
|
|
100
|
+
email=user_data.get("email", ""),
|
|
101
|
+
created_at=str(user_data.get("created_at", "")),
|
|
102
|
+
is_admin=user_data.get("is_admin", False),
|
|
103
|
+
)
|
|
104
|
+
return WalletVerifyResult(
|
|
105
|
+
token=data.get("token", ""),
|
|
106
|
+
refresh_token=data.get("refreshToken", ""),
|
|
107
|
+
user=user,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
112
|
+
# Sync
|
|
113
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class WalletAuthResource:
|
|
117
|
+
"""Synchronous wallet-based authentication operations."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, http: SyncHttpClient, tokens: TokenManager) -> None:
|
|
120
|
+
self._http = http
|
|
121
|
+
self._tokens = tokens
|
|
122
|
+
|
|
123
|
+
def challenge(self, address: str) -> WalletChallenge:
|
|
124
|
+
"""Request a signature challenge for the given Ethereum address.
|
|
125
|
+
|
|
126
|
+
No authentication required.
|
|
127
|
+
"""
|
|
128
|
+
_validate_address(address)
|
|
129
|
+
data = self._http.post(
|
|
130
|
+
"/api/auth/wallet/challenge",
|
|
131
|
+
json={"address": address},
|
|
132
|
+
requires_auth=False,
|
|
133
|
+
)
|
|
134
|
+
return WalletChallenge(
|
|
135
|
+
challenge=data["challenge"],
|
|
136
|
+
expires_at=data["expiresAt"],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def verify(self, address: str, signature: str, challenge: str = "") -> WalletVerifyResult:
|
|
140
|
+
"""Submit signed challenge to create account or login.
|
|
141
|
+
|
|
142
|
+
No authentication required. On success, JWT tokens are stored
|
|
143
|
+
for subsequent authenticated requests.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
address: Ethereum address (0x-prefixed, 40 hex chars)
|
|
147
|
+
signature: EIP-191 signature of the challenge message
|
|
148
|
+
challenge: Optional challenge string (server looks up internally)
|
|
149
|
+
"""
|
|
150
|
+
_validate_address(address)
|
|
151
|
+
_validate_signature(signature)
|
|
152
|
+
body: dict = {"address": address, "signature": signature}
|
|
153
|
+
if challenge:
|
|
154
|
+
body["challenge"] = challenge
|
|
155
|
+
data = self._http.post(
|
|
156
|
+
"/api/auth/wallet/verify",
|
|
157
|
+
json=body,
|
|
158
|
+
requires_auth=False,
|
|
159
|
+
)
|
|
160
|
+
result = _parse_verify_result(data)
|
|
161
|
+
if result.token and result.refresh_token:
|
|
162
|
+
self._tokens.set_tokens(result.token, result.refresh_token)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
def link(self, address: str, signature: str, challenge: str = "") -> WalletLinkResult:
|
|
166
|
+
"""Link an Ethereum wallet to the current account.
|
|
167
|
+
|
|
168
|
+
Requires JWT authentication.
|
|
169
|
+
"""
|
|
170
|
+
_validate_address(address)
|
|
171
|
+
_validate_signature(signature)
|
|
172
|
+
body: dict = {"address": address, "signature": signature}
|
|
173
|
+
if challenge:
|
|
174
|
+
body["challenge"] = challenge
|
|
175
|
+
data = self._http.post(
|
|
176
|
+
"/api/auth/wallet/link",
|
|
177
|
+
json=body,
|
|
178
|
+
)
|
|
179
|
+
return WalletLinkResult(
|
|
180
|
+
success=data.get("success", False),
|
|
181
|
+
wallet_address=data.get("walletAddress", ""),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
186
|
+
# Async
|
|
187
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class AsyncWalletAuthResource:
|
|
191
|
+
"""Asynchronous wallet-based authentication operations."""
|
|
192
|
+
|
|
193
|
+
def __init__(self, http: AsyncHttpClient, tokens: AsyncTokenManager) -> None:
|
|
194
|
+
self._http = http
|
|
195
|
+
self._tokens = tokens
|
|
196
|
+
|
|
197
|
+
async def challenge(self, address: str) -> WalletChallenge:
|
|
198
|
+
"""Request a signature challenge (no auth required)."""
|
|
199
|
+
_validate_address(address)
|
|
200
|
+
data = await self._http.post(
|
|
201
|
+
"/api/auth/wallet/challenge",
|
|
202
|
+
json={"address": address},
|
|
203
|
+
requires_auth=False,
|
|
204
|
+
)
|
|
205
|
+
return WalletChallenge(
|
|
206
|
+
challenge=data["challenge"],
|
|
207
|
+
expires_at=data["expiresAt"],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def verify(self, address: str, signature: str, challenge: str = "") -> WalletVerifyResult:
|
|
211
|
+
"""Submit signed challenge to create account or login (no auth required)."""
|
|
212
|
+
_validate_address(address)
|
|
213
|
+
_validate_signature(signature)
|
|
214
|
+
body: dict = {"address": address, "signature": signature}
|
|
215
|
+
if challenge:
|
|
216
|
+
body["challenge"] = challenge
|
|
217
|
+
data = await self._http.post(
|
|
218
|
+
"/api/auth/wallet/verify",
|
|
219
|
+
json=body,
|
|
220
|
+
requires_auth=False,
|
|
221
|
+
)
|
|
222
|
+
result = _parse_verify_result(data)
|
|
223
|
+
if result.token and result.refresh_token:
|
|
224
|
+
self._tokens.set_tokens(result.token, result.refresh_token)
|
|
225
|
+
return result
|
|
226
|
+
|
|
227
|
+
async def link(self, address: str, signature: str, challenge: str = "") -> WalletLinkResult:
|
|
228
|
+
"""Link an Ethereum wallet to the current account (requires auth)."""
|
|
229
|
+
_validate_address(address)
|
|
230
|
+
_validate_signature(signature)
|
|
231
|
+
body: dict = {"address": address, "signature": signature}
|
|
232
|
+
if challenge:
|
|
233
|
+
body["challenge"] = challenge
|
|
234
|
+
data = await self._http.post(
|
|
235
|
+
"/api/auth/wallet/link",
|
|
236
|
+
json=body,
|
|
237
|
+
)
|
|
238
|
+
return WalletLinkResult(
|
|
239
|
+
success=data.get("success", False),
|
|
240
|
+
wallet_address=data.get("walletAddress", ""),
|
|
241
|
+
)
|