xache 5.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,244 @@
1
+ """
2
+ Payment Handler for 402 Payment Flow per LLD §2.3
3
+ """
4
+
5
+ import asyncio
6
+ from typing import Dict, Optional, Any
7
+
8
+
9
+ class PaymentHandler:
10
+ """Payment handler for 402 payment flow"""
11
+
12
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
13
+ self.config = config or {}
14
+ self.pending_payments: Dict[str, Dict[str, Any]] = {}
15
+
16
+ async def handle_payment(
17
+ self,
18
+ challenge_id: str,
19
+ amount: str,
20
+ chain_hint: str,
21
+ pay_to: str,
22
+ description: str,
23
+ ) -> Dict[str, Any]:
24
+ """
25
+ Handle 402 payment challenge
26
+
27
+ Args:
28
+ challenge_id: Payment challenge ID
29
+ amount: Amount in USD
30
+ chain_hint: Blockchain hint (solana or base)
31
+ pay_to: Payment recipient address
32
+ description: Payment description
33
+
34
+ Returns:
35
+ Payment result with success status
36
+ """
37
+ # Check if payment already processed
38
+ if challenge_id in self.pending_payments:
39
+ return self.pending_payments[challenge_id]
40
+
41
+ try:
42
+ # Handle payment based on provider type
43
+ provider_type = self.config.get("type", "manual")
44
+
45
+ if provider_type == "manual":
46
+ result = await self._handle_manual_payment(
47
+ challenge_id, amount, chain_hint, pay_to, description
48
+ )
49
+ elif provider_type == "coinbase-commerce":
50
+ result = await self._handle_coinbase_payment(
51
+ challenge_id, amount, chain_hint, pay_to, description
52
+ )
53
+ else:
54
+ result = {
55
+ "success": False,
56
+ "error": f"Unknown payment provider: {provider_type}",
57
+ }
58
+
59
+ # Cache result
60
+ if result["success"]:
61
+ self.pending_payments[challenge_id] = result
62
+
63
+ return result
64
+
65
+ except Exception as e:
66
+ return {"success": False, "error": str(e)}
67
+
68
+ async def _handle_manual_payment(
69
+ self,
70
+ challenge_id: str,
71
+ amount: str,
72
+ chain_hint: str,
73
+ pay_to: str,
74
+ description: str,
75
+ ) -> Dict[str, Any]:
76
+ """Handle manual payment"""
77
+ print("\n╔════════════════════════════════════════════════════════════╗")
78
+ print("║ PAYMENT REQUIRED ║")
79
+ print("╚════════════════════════════════════════════════════════════╝")
80
+ print("")
81
+ print(f"Description: {description}")
82
+ print(f"Amount: {amount} USD")
83
+ print(f"Chain: {chain_hint}")
84
+ print(f"Pay To: {pay_to}")
85
+ print(f"Challenge: {challenge_id}")
86
+ print("")
87
+ print("Please complete the payment manually and then press Enter to continue...")
88
+ print("(Or press Ctrl+C to cancel)")
89
+ print("")
90
+
91
+ # Wait for user confirmation
92
+ await asyncio.sleep(2)
93
+
94
+ # Return success assuming user completed payment
95
+ # The server will verify the actual blockchain transaction
96
+ return {
97
+ "success": True,
98
+ "transaction_hash": f"manual-{challenge_id}",
99
+ }
100
+
101
+ async def _handle_coinbase_payment(
102
+ self,
103
+ challenge_id: str,
104
+ amount: str,
105
+ chain_hint: str,
106
+ pay_to: str,
107
+ description: str,
108
+ ) -> Dict[str, Any]:
109
+ """Handle Coinbase Commerce payment"""
110
+ import aiohttp
111
+
112
+ api_key = self.config.get("api_key")
113
+ if not api_key:
114
+ return {"success": False, "error": "Coinbase Commerce API key not configured"}
115
+
116
+ try:
117
+ # Create charge via Coinbase Commerce API
118
+ charge = await self._create_coinbase_charge(
119
+ challenge_id, amount, description, chain_hint
120
+ )
121
+
122
+ print("\n╔════════════════════════════════════════════════════════════╗")
123
+ print("║ PAYMENT REQUIRED ║")
124
+ print("╚════════════════════════════════════════════════════════════╝")
125
+ print("")
126
+ print(f"Description: {description}")
127
+ print(f"Amount: {amount} USD")
128
+ print(f"Chain: {chain_hint}")
129
+ print("")
130
+ print(f"Payment URL: {charge['hosted_url']}")
131
+ print("")
132
+ print("Please complete the payment and wait for confirmation...")
133
+ print("")
134
+
135
+ # Poll for payment confirmation
136
+ confirmed = await self._poll_coinbase_payment(charge["id"])
137
+
138
+ if confirmed:
139
+ return {
140
+ "success": True,
141
+ "transaction_hash": charge["id"],
142
+ }
143
+ else:
144
+ return {
145
+ "success": False,
146
+ "error": "Payment not confirmed",
147
+ }
148
+
149
+ except Exception as e:
150
+ return {
151
+ "success": False,
152
+ "error": f"Coinbase Commerce error: {str(e)}",
153
+ }
154
+
155
+ async def _create_coinbase_charge(
156
+ self, challenge_id: str, amount: str, description: str, chain_hint: str
157
+ ) -> Dict[str, Any]:
158
+ """Create Coinbase Commerce charge"""
159
+ import aiohttp
160
+
161
+ api_key = self.config.get("api_key")
162
+
163
+ async with aiohttp.ClientSession() as session:
164
+ async with session.post(
165
+ "https://api.commerce.coinbase.com/charges",
166
+ headers={
167
+ "Content-Type": "application/json",
168
+ "X-CC-Api-Key": api_key,
169
+ "X-CC-Version": "2018-03-22",
170
+ },
171
+ json={
172
+ "name": description,
173
+ "description": f"Xache Protocol payment - Challenge: {challenge_id}",
174
+ "pricing_type": "fixed_price",
175
+ "local_price": {
176
+ "amount": amount,
177
+ "currency": "USD",
178
+ },
179
+ "metadata": {
180
+ "challengeId": challenge_id,
181
+ "chainHint": chain_hint,
182
+ },
183
+ },
184
+ ) as response:
185
+ if response.status != 200 and response.status != 201:
186
+ error_text = await response.text()
187
+ raise Exception(f"Coinbase API error: {error_text}")
188
+
189
+ data = await response.json()
190
+ return data["data"]
191
+
192
+ async def _poll_coinbase_payment(
193
+ self, charge_id: str, max_attempts: int = 60
194
+ ) -> bool:
195
+ """Poll Coinbase Commerce for payment confirmation"""
196
+ import aiohttp
197
+
198
+ api_key = self.config.get("api_key")
199
+
200
+ for _ in range(max_attempts):
201
+ try:
202
+ async with aiohttp.ClientSession() as session:
203
+ async with session.get(
204
+ f"https://api.commerce.coinbase.com/charges/{charge_id}",
205
+ headers={
206
+ "X-CC-Api-Key": api_key,
207
+ "X-CC-Version": "2018-03-22",
208
+ },
209
+ ) as response:
210
+ if response.status != 200:
211
+ await asyncio.sleep(5)
212
+ continue
213
+
214
+ data = await response.json()
215
+ timeline = data["data"]["timeline"]
216
+
217
+ if timeline:
218
+ status = timeline[-1].get("status")
219
+
220
+ if status in ["COMPLETED", "RESOLVED"]:
221
+ return True
222
+
223
+ if status in ["EXPIRED", "CANCELED"]:
224
+ return False
225
+
226
+ # Wait 5 seconds before next poll
227
+ await asyncio.sleep(5)
228
+
229
+ except Exception as e:
230
+ print(f"Error polling payment status: {e}")
231
+ await asyncio.sleep(5)
232
+
233
+ return False
234
+
235
+ def mark_payment_complete(self, challenge_id: str, transaction_hash: str):
236
+ """Mark payment as completed (for testing)"""
237
+ self.pending_payments[challenge_id] = {
238
+ "success": True,
239
+ "transaction_hash": transaction_hash,
240
+ }
241
+
242
+ def clear_cache(self):
243
+ """Clear payment cache"""
244
+ self.pending_payments.clear()
@@ -0,0 +1,29 @@
1
+ """Service modules"""
2
+
3
+ from .identity import IdentityService
4
+ from .memory import MemoryService
5
+ from .collective import CollectiveService
6
+ from .budget import BudgetService
7
+ from .receipts import ReceiptsService
8
+ from .reputation import ReputationService
9
+ from .extraction import ExtractionService
10
+ from .facilitator import FacilitatorService
11
+ from .sessions import SessionService
12
+ from .royalty import RoyaltyService
13
+ from .workspaces import WorkspaceService
14
+ from .owner import OwnerService
15
+
16
+ __all__ = [
17
+ "IdentityService",
18
+ "MemoryService",
19
+ "CollectiveService",
20
+ "BudgetService",
21
+ "ReceiptsService",
22
+ "ReputationService",
23
+ "ExtractionService",
24
+ "FacilitatorService",
25
+ "SessionService",
26
+ "RoyaltyService",
27
+ "WorkspaceService",
28
+ "OwnerService",
29
+ ]
@@ -0,0 +1,285 @@
1
+ """Budget Service - Manage monthly spending budgets with alert support per HLD §2.2 Budget Guardian"""
2
+
3
+ from typing import List, Callable, Union
4
+ from datetime import datetime
5
+ from ..types import BudgetStatus, BudgetAlert, BudgetAlertLevel, BudgetAlertHandler
6
+
7
+
8
+ class BudgetService:
9
+ """Budget service for spending management with proactive alerts"""
10
+
11
+ def __init__(self, client):
12
+ self.client = client
13
+ self._alert_handlers: List[BudgetAlertHandler] = []
14
+ self._last_alerted_threshold = 0
15
+ self.DEFAULT_THRESHOLDS = [50, 80, 100] # Per HLD §2.2
16
+
17
+ async def get_status(self) -> BudgetStatus:
18
+ """
19
+ Get current budget status and check alert thresholds (free)
20
+
21
+ Example:
22
+ ```python
23
+ budget = await client.budget.get_status()
24
+ print(f"Limit: ${budget.limit_cents / 100}")
25
+ print(f"Spent: ${budget.spent_cents / 100}")
26
+ print(f"Usage: {budget.percentage_used:.1f}%")
27
+ ```
28
+ """
29
+ response = await self.client.request("GET", "/v1/budget")
30
+
31
+ if not response.success or not response.data:
32
+ raise Exception("Failed to get budget status")
33
+
34
+ data = response.data
35
+ status = BudgetStatus(
36
+ limit_cents=data["limitCents"],
37
+ spent_cents=data["spentCents"],
38
+ remaining_cents=data["remainingCents"],
39
+ percentage_used=data["percentageUsed"],
40
+ current_period=data["currentPeriod"],
41
+ )
42
+
43
+ # Check for budget alerts per HLD §2.2
44
+ await self._check_and_trigger_alerts(status)
45
+
46
+ return status
47
+
48
+ async def update_limit(self, limit_cents: int) -> dict:
49
+ """Update monthly budget limit (free)"""
50
+ self._validate_limit(limit_cents)
51
+
52
+ response = await self.client.request(
53
+ "PUT",
54
+ "/v1/budget",
55
+ {"limitCents": limit_cents},
56
+ )
57
+
58
+ if not response.success or not response.data:
59
+ raise Exception("Failed to update budget limit")
60
+
61
+ return response.data
62
+
63
+ async def can_afford(self, operation_cost_cents: int) -> bool:
64
+ """Check if operation is within budget"""
65
+ status = await self.get_status()
66
+ return status.remaining_cents >= operation_cost_cents
67
+
68
+ def on_alert(self, handler: BudgetAlertHandler):
69
+ """
70
+ Register an alert handler for budget threshold notifications
71
+ Per PRD FR-021: Usage Alerts at 50%, 80%, 100%
72
+
73
+ Args:
74
+ handler: Callback function to handle budget alerts
75
+
76
+ Example:
77
+ ```python
78
+ def handle_alert(alert: BudgetAlert):
79
+ print(f"Budget Alert: {alert.level.value}")
80
+ print(f" Message: {alert.message}")
81
+ print(f" Usage: {alert.percentage_used:.1f}%")
82
+ print(f" Remaining: ${alert.remaining_cents / 100}")
83
+
84
+ if alert.level == BudgetAlertLevel.CRITICAL_100:
85
+ # Take action - pause operations, notify admin, etc.
86
+ print("CRITICAL: Budget limit reached!")
87
+
88
+ client.budget.on_alert(handle_alert)
89
+ ```
90
+ """
91
+ self._alert_handlers.append(handler)
92
+
93
+ async def get_active_alerts(self) -> List[BudgetAlert]:
94
+ """
95
+ Get all currently active budget alerts
96
+
97
+ Returns:
98
+ List of active alerts based on current budget status
99
+
100
+ Example:
101
+ ```python
102
+ active_alerts = await client.budget.get_active_alerts()
103
+ if active_alerts:
104
+ print(f"{len(active_alerts)} active budget alerts")
105
+ for alert in active_alerts:
106
+ print(f"- {alert.level.value}: {alert.message}")
107
+ ```
108
+ """
109
+ status = await self.get_status()
110
+ return self._check_thresholds(status)
111
+
112
+ async def is_threshold_crossed(self, threshold: float) -> bool:
113
+ """
114
+ Check if a specific threshold has been crossed
115
+
116
+ Args:
117
+ threshold: Threshold percentage to check (50, 80, or 100)
118
+
119
+ Returns:
120
+ True if threshold has been crossed
121
+
122
+ Example:
123
+ ```python
124
+ if await client.budget.is_threshold_crossed(80):
125
+ print("80% budget threshold crossed!")
126
+ ```
127
+ """
128
+ status = await self.get_status()
129
+ return status.percentage_used >= threshold
130
+
131
+ def _check_thresholds(self, status: BudgetStatus) -> List[BudgetAlert]:
132
+ """Check budget thresholds and return active alerts"""
133
+ alerts = []
134
+
135
+ for threshold in self.DEFAULT_THRESHOLDS:
136
+ if status.percentage_used >= threshold:
137
+ alerts.append(self._create_alert(status, threshold))
138
+
139
+ return alerts
140
+
141
+ async def _check_and_trigger_alerts(self, status: BudgetStatus):
142
+ """Check thresholds and trigger alert handlers"""
143
+ # Find the highest crossed threshold
144
+ highest_crossed_threshold = 0
145
+ for threshold in self.DEFAULT_THRESHOLDS:
146
+ if status.percentage_used >= threshold:
147
+ highest_crossed_threshold = threshold
148
+
149
+ # Only trigger if we've crossed a new threshold
150
+ if highest_crossed_threshold > self._last_alerted_threshold:
151
+ alert = self._create_alert(status, highest_crossed_threshold)
152
+
153
+ # Trigger all registered handlers
154
+ for handler in self._alert_handlers:
155
+ try:
156
+ # Handle both sync and async handlers
157
+ import inspect
158
+ if inspect.iscoroutinefunction(handler):
159
+ await handler(alert)
160
+ else:
161
+ handler(alert)
162
+ except Exception as e:
163
+ # Log error but don't throw - allow other handlers to execute
164
+ if self.client.debug:
165
+ print(f"Budget alert handler error: {e}")
166
+
167
+ # Update last alerted threshold
168
+ self._last_alerted_threshold = highest_crossed_threshold
169
+
170
+ def _create_alert(self, status: BudgetStatus, threshold: float) -> BudgetAlert:
171
+ """Create a budget alert object"""
172
+ if threshold >= 100:
173
+ level = BudgetAlertLevel.CRITICAL_100
174
+ message = "CRITICAL: Monthly budget limit reached (100%). Operations may be throttled."
175
+ elif threshold >= 80:
176
+ level = BudgetAlertLevel.WARN_80
177
+ message = "WARNING: Approaching budget limit (80%). Consider reviewing spending or increasing limit."
178
+ else:
179
+ level = BudgetAlertLevel.WARN_50
180
+ message = "NOTICE: Half of monthly budget consumed (50%). Monitor spending closely."
181
+
182
+ return BudgetAlert(
183
+ level=level,
184
+ threshold=threshold,
185
+ percentage_used=status.percentage_used,
186
+ spent_cents=status.spent_cents,
187
+ limit_cents=status.limit_cents,
188
+ remaining_cents=status.remaining_cents,
189
+ message=message,
190
+ timestamp=datetime.utcnow().isoformat() + "Z",
191
+ )
192
+
193
+ def _validate_limit(self, limit_cents: int):
194
+ """Validate budget limit"""
195
+ if not isinstance(limit_cents, int):
196
+ raise ValueError("limit_cents must be an integer")
197
+ if limit_cents < 0:
198
+ raise ValueError("limit_cents must be non-negative")
199
+ if limit_cents > 1000000:
200
+ raise ValueError("limit_cents cannot exceed 1,000,000 ($10,000)")
201
+
202
+ # ========== Fleet Budget (Owner Only) ==========
203
+
204
+ async def get_fleet_status(self) -> dict:
205
+ """
206
+ Get fleet budget status for authenticated owner.
207
+ Returns aggregated spending across all owned agents.
208
+ Requires owner authentication (did:owner:...)
209
+
210
+ Returns:
211
+ Fleet budget status with per-agent breakdown
212
+
213
+ Example:
214
+ ```python
215
+ fleet = await client.budget.get_fleet_status()
216
+
217
+ print(f"Fleet Cap: ${fleet['fleet_budget_cap_usd']}")
218
+ print(f"Total Spent: ${fleet['total_spent_usd']}")
219
+ print(f"Usage: {fleet['percentage_used']:.1f}%")
220
+
221
+ for agent in fleet['agents']:
222
+ print(f" {agent['name']}: ${agent['spent_cents'] / 100}")
223
+ ```
224
+ """
225
+ response = await self.client.request("GET", "/v1/budget/fleet")
226
+
227
+ if not response.success or not response.data:
228
+ raise Exception(
229
+ response.error.get("message", "Failed to get fleet budget status")
230
+ if response.error
231
+ else "Failed to get fleet budget status"
232
+ )
233
+
234
+ data = response.data
235
+ return {
236
+ "owner_did": data["ownerDID"],
237
+ "fleet_budget_cap_cents": data.get("fleetBudgetCapCents"),
238
+ "fleet_budget_cap_usd": data.get("fleetBudgetCapUSD"),
239
+ "total_spent_cents": data["totalSpentCents"],
240
+ "total_spent_usd": data["totalSpentUSD"],
241
+ "remaining_cents": data.get("remainingCents"),
242
+ "remaining_usd": data.get("remainingUSD"),
243
+ "percentage_used": data.get("percentageUsed"),
244
+ "agent_count": data["agentCount"],
245
+ "total_agent_limits_cents": data["totalAgentLimitsCents"],
246
+ "agents": data.get("agents", []),
247
+ }
248
+
249
+ async def update_fleet_cap(self, fleet_cap_cents: int | None) -> dict:
250
+ """
251
+ Update fleet budget cap for authenticated owner.
252
+ Set to None for unlimited.
253
+ Requires owner authentication (did:owner:...)
254
+
255
+ Args:
256
+ fleet_cap_cents: Budget cap in cents, or None for unlimited
257
+
258
+ Returns:
259
+ Update confirmation
260
+
261
+ Example:
262
+ ```python
263
+ # Set fleet cap to $500
264
+ result = await client.budget.update_fleet_cap(50000)
265
+
266
+ # Remove fleet cap (unlimited)
267
+ result = await client.budget.update_fleet_cap(None)
268
+ ```
269
+ """
270
+ if fleet_cap_cents is not None:
271
+ if not isinstance(fleet_cap_cents, int) or fleet_cap_cents < 0:
272
+ raise ValueError("fleet_cap_cents must be a positive integer or None")
273
+
274
+ response = await self.client.request(
275
+ "PUT", "/v1/budget/fleet", {"fleetCapCents": fleet_cap_cents}
276
+ )
277
+
278
+ if not response.success or not response.data:
279
+ raise Exception(
280
+ response.error.get("message", "Failed to update fleet budget cap")
281
+ if response.error
282
+ else "Failed to update fleet budget cap"
283
+ )
284
+
285
+ return response.data