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/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
+ )