uvd-x402-sdk 0.5.6__py3-none-any.whl → 0.7.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.
uvd_x402_sdk/escrow.py ADDED
@@ -0,0 +1,637 @@
1
+ """
2
+ Escrow and Refund client for x402 SDK.
3
+
4
+ This module provides escrow payment functionality with refund and dispute
5
+ resolution capabilities. Payments can be held in escrow until service delivery
6
+ is confirmed, with options for refunds and dispute arbitration.
7
+
8
+ Features:
9
+ - Create escrow payments
10
+ - Release funds to recipients
11
+ - Request and process refunds
12
+ - Open and resolve disputes
13
+
14
+ Example:
15
+ >>> from uvd_x402_sdk.escrow import EscrowClient
16
+ >>>
17
+ >>> client = EscrowClient()
18
+ >>>
19
+ >>> # Create escrow payment
20
+ >>> escrow = await client.create_escrow(
21
+ ... payment_header="...",
22
+ ... requirements={...},
23
+ ... escrow_duration=86400, # 24 hours
24
+ ... )
25
+ >>>
26
+ >>> # After service delivery, release funds
27
+ >>> await client.release(escrow.id)
28
+ >>>
29
+ >>> # Or if service failed, request refund
30
+ >>> await client.request_refund(
31
+ ... escrow_id=escrow.id,
32
+ ... reason="Service not delivered",
33
+ ... )
34
+ """
35
+
36
+ from enum import Enum
37
+ from typing import Any, Literal, Optional
38
+
39
+ import httpx
40
+ from pydantic import BaseModel, Field
41
+
42
+
43
+ class EscrowStatus(str, Enum):
44
+ """Escrow payment status."""
45
+
46
+ PENDING = "pending" # Payment initiated, awaiting confirmation
47
+ HELD = "held" # Funds held in escrow
48
+ RELEASED = "released" # Funds released to recipient
49
+ REFUNDED = "refunded" # Funds returned to payer
50
+ DISPUTED = "disputed" # Dispute in progress
51
+ EXPIRED = "expired" # Escrow expired without resolution
52
+
53
+
54
+ class RefundStatus(str, Enum):
55
+ """Refund request status."""
56
+
57
+ PENDING = "pending" # Refund requested, awaiting processing
58
+ APPROVED = "approved" # Refund approved
59
+ REJECTED = "rejected" # Refund rejected
60
+ PROCESSED = "processed" # Refund completed on-chain
61
+ DISPUTED = "disputed" # Under dispute review
62
+
63
+
64
+ class DisputeOutcome(str, Enum):
65
+ """Dispute resolution outcome."""
66
+
67
+ PENDING = "pending" # Dispute under review
68
+ PAYER_WINS = "payer_wins" # Payer gets refund
69
+ RECIPIENT_WINS = "recipient_wins" # Recipient keeps funds
70
+ SPLIT = "split" # Funds split between parties
71
+
72
+
73
+ class ReleaseConditions(BaseModel):
74
+ """Conditions for releasing escrow funds."""
75
+
76
+ min_hold_time: Optional[int] = Field(None, alias="minHoldTime")
77
+ confirmations: Optional[int] = None
78
+ custom: Optional[Any] = None
79
+
80
+ class Config:
81
+ populate_by_name = True
82
+
83
+
84
+ class EscrowPayment(BaseModel):
85
+ """Escrow payment record."""
86
+
87
+ id: str
88
+ payment_header: str = Field(..., alias="paymentHeader")
89
+ status: EscrowStatus
90
+ network: str
91
+ payer: str
92
+ recipient: str
93
+ amount: str
94
+ asset: str
95
+ resource: str
96
+ expires_at: str = Field(..., alias="expiresAt")
97
+ release_conditions: Optional[ReleaseConditions] = Field(None, alias="releaseConditions")
98
+ transaction_hash: Optional[str] = Field(None, alias="transactionHash")
99
+ created_at: str = Field(..., alias="createdAt")
100
+ updated_at: str = Field(..., alias="updatedAt")
101
+
102
+ class Config:
103
+ populate_by_name = True
104
+
105
+
106
+ class RefundResponse(BaseModel):
107
+ """Refund response from recipient/facilitator."""
108
+
109
+ status: Literal["approved", "rejected"]
110
+ reason: Optional[str] = None
111
+ responded_at: str = Field(..., alias="respondedAt")
112
+
113
+ class Config:
114
+ populate_by_name = True
115
+
116
+
117
+ class RefundRequest(BaseModel):
118
+ """Refund request record."""
119
+
120
+ id: str
121
+ escrow_id: str = Field(..., alias="escrowId")
122
+ status: RefundStatus
123
+ reason: str
124
+ evidence: Optional[str] = None
125
+ amount_requested: str = Field(..., alias="amountRequested")
126
+ amount_approved: Optional[str] = Field(None, alias="amountApproved")
127
+ requester: str
128
+ transaction_hash: Optional[str] = Field(None, alias="transactionHash")
129
+ response: Optional[RefundResponse] = None
130
+ created_at: str = Field(..., alias="createdAt")
131
+ updated_at: str = Field(..., alias="updatedAt")
132
+
133
+ class Config:
134
+ populate_by_name = True
135
+
136
+
137
+ class Dispute(BaseModel):
138
+ """Dispute record."""
139
+
140
+ id: str
141
+ escrow_id: str = Field(..., alias="escrowId")
142
+ refund_request_id: Optional[str] = Field(None, alias="refundRequestId")
143
+ outcome: DisputeOutcome
144
+ initiator: Literal["payer", "recipient"]
145
+ reason: str
146
+ payer_evidence: Optional[str] = Field(None, alias="payerEvidence")
147
+ recipient_evidence: Optional[str] = Field(None, alias="recipientEvidence")
148
+ arbitration_notes: Optional[str] = Field(None, alias="arbitrationNotes")
149
+ payer_amount: Optional[str] = Field(None, alias="payerAmount")
150
+ recipient_amount: Optional[str] = Field(None, alias="recipientAmount")
151
+ transaction_hashes: Optional[list[str]] = Field(None, alias="transactionHashes")
152
+ created_at: str = Field(..., alias="createdAt")
153
+ resolved_at: Optional[str] = Field(None, alias="resolvedAt")
154
+
155
+ class Config:
156
+ populate_by_name = True
157
+
158
+
159
+ class EscrowListResponse(BaseModel):
160
+ """Paginated list of escrow payments."""
161
+
162
+ escrows: list[EscrowPayment]
163
+ total: int
164
+ page: int
165
+ limit: int
166
+ has_more: bool = Field(..., alias="hasMore")
167
+
168
+ class Config:
169
+ populate_by_name = True
170
+
171
+
172
+ class EscrowClient:
173
+ """
174
+ Client for x402 Escrow & Refund operations.
175
+
176
+ The Escrow system holds payments until service is verified,
177
+ enabling refunds and dispute resolution.
178
+
179
+ Example:
180
+ >>> client = EscrowClient()
181
+ >>>
182
+ >>> # Create escrow payment (backend)
183
+ >>> escrow = await client.create_escrow(
184
+ ... payment_header=request.headers["x-payment"],
185
+ ... requirements=payment_requirements,
186
+ ... escrow_duration=86400, # 24 hours
187
+ ... )
188
+ >>>
189
+ >>> # After service is provided, release the escrow
190
+ >>> await client.release(escrow.id)
191
+ >>>
192
+ >>> # If service not provided, payer can request refund
193
+ >>> await client.request_refund(
194
+ ... escrow_id=escrow.id,
195
+ ... reason="Service not delivered within expected timeframe",
196
+ ... )
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ base_url: str = "https://escrow.ultravioletadao.xyz",
202
+ api_key: Optional[str] = None,
203
+ timeout: float = 30.0,
204
+ ):
205
+ """
206
+ Initialize the Escrow client.
207
+
208
+ Args:
209
+ base_url: Base URL of the Escrow API
210
+ api_key: API key for authenticated operations
211
+ timeout: Request timeout in seconds
212
+ """
213
+ self.base_url = base_url.rstrip("/")
214
+ self.api_key = api_key
215
+ self.timeout = timeout
216
+ self._client = httpx.AsyncClient(timeout=timeout)
217
+
218
+ async def __aenter__(self) -> "EscrowClient":
219
+ return self
220
+
221
+ async def __aexit__(self, *args: Any) -> None:
222
+ await self._client.aclose()
223
+
224
+ def _get_headers(self, authenticated: bool = False) -> dict[str, str]:
225
+ """Get request headers."""
226
+ headers = {
227
+ "Content-Type": "application/json",
228
+ "Accept": "application/json",
229
+ }
230
+ if authenticated and self.api_key:
231
+ headers["Authorization"] = f"Bearer {self.api_key}"
232
+ return headers
233
+
234
+ async def create_escrow(
235
+ self,
236
+ payment_header: str,
237
+ requirements: dict[str, Any],
238
+ *,
239
+ escrow_duration: int = 86400,
240
+ release_conditions: Optional[dict[str, Any]] = None,
241
+ ) -> EscrowPayment:
242
+ """
243
+ Create an escrow payment.
244
+
245
+ Holds the payment in escrow until released or refunded.
246
+
247
+ Args:
248
+ payment_header: Base64-encoded X-PAYMENT header
249
+ requirements: Payment requirements dict
250
+ escrow_duration: Escrow duration in seconds (default: 24h)
251
+ release_conditions: Optional release conditions
252
+
253
+ Returns:
254
+ Created escrow payment
255
+
256
+ Raises:
257
+ httpx.HTTPStatusError: If the request fails
258
+ """
259
+ url = f"{self.base_url}/escrow"
260
+ payload = {
261
+ "paymentHeader": payment_header,
262
+ "paymentRequirements": requirements,
263
+ "escrowDuration": escrow_duration,
264
+ }
265
+ if release_conditions:
266
+ payload["releaseConditions"] = release_conditions
267
+
268
+ response = await self._client.post(
269
+ url,
270
+ json=payload,
271
+ headers=self._get_headers(authenticated=True),
272
+ )
273
+ response.raise_for_status()
274
+ return EscrowPayment.model_validate(response.json())
275
+
276
+ async def get_escrow(self, escrow_id: str) -> EscrowPayment:
277
+ """
278
+ Get escrow payment by ID.
279
+
280
+ Args:
281
+ escrow_id: Escrow payment ID
282
+
283
+ Returns:
284
+ Escrow payment details
285
+
286
+ Raises:
287
+ httpx.HTTPStatusError: If the request fails
288
+ """
289
+ url = f"{self.base_url}/escrow/{escrow_id}"
290
+ response = await self._client.get(url, headers=self._get_headers())
291
+ response.raise_for_status()
292
+ return EscrowPayment.model_validate(response.json())
293
+
294
+ async def release(self, escrow_id: str) -> EscrowPayment:
295
+ """
296
+ Release escrow funds to recipient.
297
+
298
+ Call this after service has been successfully provided.
299
+
300
+ Args:
301
+ escrow_id: Escrow payment ID
302
+
303
+ Returns:
304
+ Updated escrow payment with transaction hash
305
+
306
+ Raises:
307
+ httpx.HTTPStatusError: If the request fails
308
+ """
309
+ url = f"{self.base_url}/escrow/{escrow_id}/release"
310
+ response = await self._client.post(
311
+ url,
312
+ headers=self._get_headers(authenticated=True),
313
+ )
314
+ response.raise_for_status()
315
+ return EscrowPayment.model_validate(response.json())
316
+
317
+ async def request_refund(
318
+ self,
319
+ escrow_id: str,
320
+ reason: str,
321
+ *,
322
+ amount: Optional[str] = None,
323
+ evidence: Optional[str] = None,
324
+ ) -> RefundRequest:
325
+ """
326
+ Request a refund for an escrow payment.
327
+
328
+ Initiates a refund request that must be approved.
329
+
330
+ Args:
331
+ escrow_id: Escrow payment ID
332
+ reason: Reason for refund request
333
+ amount: Amount to refund (full amount if not specified)
334
+ evidence: Supporting evidence
335
+
336
+ Returns:
337
+ Created refund request
338
+
339
+ Raises:
340
+ httpx.HTTPStatusError: If the request fails
341
+ """
342
+ url = f"{self.base_url}/escrow/{escrow_id}/refund"
343
+ payload: dict[str, Any] = {"reason": reason}
344
+ if amount:
345
+ payload["amount"] = amount
346
+ if evidence:
347
+ payload["evidence"] = evidence
348
+
349
+ response = await self._client.post(
350
+ url,
351
+ json=payload,
352
+ headers=self._get_headers(authenticated=True),
353
+ )
354
+ response.raise_for_status()
355
+ return RefundRequest.model_validate(response.json())
356
+
357
+ async def approve_refund(
358
+ self,
359
+ refund_id: str,
360
+ amount: Optional[str] = None,
361
+ ) -> RefundRequest:
362
+ """
363
+ Approve a refund request (for recipients).
364
+
365
+ Args:
366
+ refund_id: Refund request ID
367
+ amount: Amount to approve (may be less than requested)
368
+
369
+ Returns:
370
+ Updated refund request
371
+
372
+ Raises:
373
+ httpx.HTTPStatusError: If the request fails
374
+ """
375
+ url = f"{self.base_url}/refund/{refund_id}/approve"
376
+ payload: dict[str, Any] = {}
377
+ if amount:
378
+ payload["amount"] = amount
379
+
380
+ response = await self._client.post(
381
+ url,
382
+ json=payload,
383
+ headers=self._get_headers(authenticated=True),
384
+ )
385
+ response.raise_for_status()
386
+ return RefundRequest.model_validate(response.json())
387
+
388
+ async def reject_refund(self, refund_id: str, reason: str) -> RefundRequest:
389
+ """
390
+ Reject a refund request (for recipients).
391
+
392
+ Args:
393
+ refund_id: Refund request ID
394
+ reason: Reason for rejection
395
+
396
+ Returns:
397
+ Updated refund request
398
+
399
+ Raises:
400
+ httpx.HTTPStatusError: If the request fails
401
+ """
402
+ url = f"{self.base_url}/refund/{refund_id}/reject"
403
+ response = await self._client.post(
404
+ url,
405
+ json={"reason": reason},
406
+ headers=self._get_headers(authenticated=True),
407
+ )
408
+ response.raise_for_status()
409
+ return RefundRequest.model_validate(response.json())
410
+
411
+ async def get_refund(self, refund_id: str) -> RefundRequest:
412
+ """
413
+ Get refund request by ID.
414
+
415
+ Args:
416
+ refund_id: Refund request ID
417
+
418
+ Returns:
419
+ Refund request details
420
+
421
+ Raises:
422
+ httpx.HTTPStatusError: If the request fails
423
+ """
424
+ url = f"{self.base_url}/refund/{refund_id}"
425
+ response = await self._client.get(url, headers=self._get_headers())
426
+ response.raise_for_status()
427
+ return RefundRequest.model_validate(response.json())
428
+
429
+ async def open_dispute(
430
+ self,
431
+ escrow_id: str,
432
+ reason: str,
433
+ evidence: Optional[str] = None,
434
+ ) -> Dispute:
435
+ """
436
+ Open a dispute for an escrow payment.
437
+
438
+ Initiates arbitration when payer and recipient disagree.
439
+
440
+ Args:
441
+ escrow_id: Escrow payment ID
442
+ reason: Reason for dispute
443
+ evidence: Supporting evidence
444
+
445
+ Returns:
446
+ Created dispute
447
+
448
+ Raises:
449
+ httpx.HTTPStatusError: If the request fails
450
+ """
451
+ url = f"{self.base_url}/escrow/{escrow_id}/dispute"
452
+ payload: dict[str, Any] = {"reason": reason}
453
+ if evidence:
454
+ payload["evidence"] = evidence
455
+
456
+ response = await self._client.post(
457
+ url,
458
+ json=payload,
459
+ headers=self._get_headers(authenticated=True),
460
+ )
461
+ response.raise_for_status()
462
+ return Dispute.model_validate(response.json())
463
+
464
+ async def submit_evidence(self, dispute_id: str, evidence: str) -> Dispute:
465
+ """
466
+ Submit evidence to a dispute.
467
+
468
+ Args:
469
+ dispute_id: Dispute ID
470
+ evidence: Evidence to submit
471
+
472
+ Returns:
473
+ Updated dispute
474
+
475
+ Raises:
476
+ httpx.HTTPStatusError: If the request fails
477
+ """
478
+ url = f"{self.base_url}/dispute/{dispute_id}/evidence"
479
+ response = await self._client.post(
480
+ url,
481
+ json={"evidence": evidence},
482
+ headers=self._get_headers(authenticated=True),
483
+ )
484
+ response.raise_for_status()
485
+ return Dispute.model_validate(response.json())
486
+
487
+ async def get_dispute(self, dispute_id: str) -> Dispute:
488
+ """
489
+ Get dispute by ID.
490
+
491
+ Args:
492
+ dispute_id: Dispute ID
493
+
494
+ Returns:
495
+ Dispute details
496
+
497
+ Raises:
498
+ httpx.HTTPStatusError: If the request fails
499
+ """
500
+ url = f"{self.base_url}/dispute/{dispute_id}"
501
+ response = await self._client.get(url, headers=self._get_headers())
502
+ response.raise_for_status()
503
+ return Dispute.model_validate(response.json())
504
+
505
+ async def list_escrows(
506
+ self,
507
+ *,
508
+ status: Optional[EscrowStatus] = None,
509
+ payer: Optional[str] = None,
510
+ recipient: Optional[str] = None,
511
+ page: int = 1,
512
+ limit: int = 20,
513
+ ) -> EscrowListResponse:
514
+ """
515
+ List escrow payments with filters.
516
+
517
+ Args:
518
+ status: Filter by status
519
+ payer: Filter by payer address
520
+ recipient: Filter by recipient address
521
+ page: Page number (1-indexed)
522
+ limit: Results per page
523
+
524
+ Returns:
525
+ Paginated list of escrow payments
526
+
527
+ Raises:
528
+ httpx.HTTPStatusError: If the request fails
529
+ """
530
+ params: dict[str, Any] = {"page": page, "limit": limit}
531
+ if status:
532
+ params["status"] = status.value
533
+ if payer:
534
+ params["payer"] = payer
535
+ if recipient:
536
+ params["recipient"] = recipient
537
+
538
+ url = f"{self.base_url}/escrow"
539
+ response = await self._client.get(
540
+ url,
541
+ params=params,
542
+ headers=self._get_headers(authenticated=True),
543
+ )
544
+ response.raise_for_status()
545
+ return EscrowListResponse.model_validate(response.json())
546
+
547
+ async def health_check(self) -> bool:
548
+ """
549
+ Check Escrow API health.
550
+
551
+ Returns:
552
+ True if healthy
553
+ """
554
+ try:
555
+ url = f"{self.base_url}/health"
556
+ response = await self._client.get(url)
557
+ return response.is_success
558
+ except Exception:
559
+ return False
560
+
561
+
562
+ # Helper functions
563
+
564
+
565
+ def can_release_escrow(escrow: EscrowPayment) -> bool:
566
+ """
567
+ Check if an escrow can be released.
568
+
569
+ Args:
570
+ escrow: Escrow payment to check
571
+
572
+ Returns:
573
+ True if the escrow can be released
574
+ """
575
+ from datetime import datetime
576
+
577
+ if escrow.status != EscrowStatus.HELD:
578
+ return False
579
+
580
+ # Check expiration
581
+ expires_at = datetime.fromisoformat(escrow.expires_at.replace("Z", "+00:00"))
582
+ if expires_at < datetime.now(expires_at.tzinfo):
583
+ return False
584
+
585
+ # Check minimum hold time if specified
586
+ if escrow.release_conditions and escrow.release_conditions.min_hold_time:
587
+ created_at = datetime.fromisoformat(escrow.created_at.replace("Z", "+00:00"))
588
+ min_release_time = created_at.timestamp() + escrow.release_conditions.min_hold_time
589
+ if datetime.now(created_at.tzinfo).timestamp() < min_release_time:
590
+ return False
591
+
592
+ return True
593
+
594
+
595
+ def can_refund_escrow(escrow: EscrowPayment) -> bool:
596
+ """
597
+ Check if an escrow can be refunded.
598
+
599
+ Args:
600
+ escrow: Escrow payment to check
601
+
602
+ Returns:
603
+ True if the escrow can be refunded
604
+ """
605
+ return escrow.status in (EscrowStatus.HELD, EscrowStatus.PENDING)
606
+
607
+
608
+ def is_escrow_expired(escrow: EscrowPayment) -> bool:
609
+ """
610
+ Check if an escrow is expired.
611
+
612
+ Args:
613
+ escrow: Escrow payment to check
614
+
615
+ Returns:
616
+ True if the escrow is expired
617
+ """
618
+ from datetime import datetime
619
+
620
+ expires_at = datetime.fromisoformat(escrow.expires_at.replace("Z", "+00:00"))
621
+ return expires_at < datetime.now(expires_at.tzinfo)
622
+
623
+
624
+ def escrow_time_remaining(escrow: EscrowPayment) -> float:
625
+ """
626
+ Calculate time remaining until escrow expires.
627
+
628
+ Args:
629
+ escrow: Escrow payment to check
630
+
631
+ Returns:
632
+ Seconds until expiration (negative if expired)
633
+ """
634
+ from datetime import datetime
635
+
636
+ expires_at = datetime.fromisoformat(escrow.expires_at.replace("Z", "+00:00"))
637
+ return (expires_at - datetime.now(expires_at.tzinfo)).total_seconds()
@@ -4,16 +4,16 @@ Network configurations for x402 payments.
4
4
  This module provides configuration for all supported blockchain networks,
5
5
  including USDC contract addresses, RPC URLs, and network-specific parameters.
6
6
 
7
- The SDK supports 17 mainnet networks across 6 blockchain families:
8
- - 10 EVM chains: Base, Ethereum, Polygon, Arbitrum, Optimism, Avalanche,
9
- Celo, HyperEVM, Unichain, Monad
10
- - 2 SVM chains: Solana, Fogo
11
- - 1 NEAR: NEAR Protocol
12
- - 1 Stellar: Stellar
13
- - 1 Algorand: Algorand
14
- - 1 Sui: Sui (sponsored transactions)
7
+ The SDK supports 21 blockchain networks across 6 network families:
8
+ - 13 EVM networks: Base, Ethereum, Polygon, Arbitrum, Optimism, Avalanche,
9
+ Celo, HyperEVM, Unichain, Monad, Scroll, SKALE, SKALE Testnet
10
+ - 2 SVM networks: Solana, Fogo
11
+ - 1 NEAR network: NEAR Protocol
12
+ - 1 Stellar network: Stellar
13
+ - 2 Algorand networks: Algorand mainnet, Algorand testnet
14
+ - 2 Sui networks: Sui mainnet, Sui testnet
15
15
 
16
- + 15 testnets for development and testing.
16
+ (12 EVM mainnets + 1 EVM testnet, 17 total mainnets)
17
17
 
18
18
  Multi-token support:
19
19
  - USDC: All chains
@@ -395,6 +395,9 @@ _NETWORK_TO_CAIP2 = {
395
395
  "hyperevm": "eip155:999",
396
396
  "unichain": "eip155:130",
397
397
  "monad": "eip155:143",
398
+ "scroll": "eip155:534352",
399
+ "skale": "eip155:1187947933",
400
+ "skale-testnet": "eip155:324705682",
398
401
  # SVM chains (solana:genesisHash first 32 chars)
399
402
  "solana": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
400
403
  "fogo": "solana:fogo", # Placeholder - update when known