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.
@@ -0,0 +1,663 @@
1
+ """
2
+ ERC-8004 Trustless Agents client for x402 SDK.
3
+
4
+ This module provides integration with the ERC-8004 reputation system,
5
+ enabling agents to build verifiable reputation through on-chain feedback.
6
+
7
+ Features:
8
+ - Query agent identity from Identity Registry
9
+ - Query agent reputation from Reputation Registry
10
+ - Submit feedback with proof of payment
11
+ - Revoke feedback
12
+
13
+ Example:
14
+ >>> from uvd_x402_sdk.erc8004 import Erc8004Client
15
+ >>>
16
+ >>> client = Erc8004Client()
17
+ >>>
18
+ >>> # Get agent identity
19
+ >>> identity = await client.get_identity("ethereum", 42)
20
+ >>> print(f"Agent: {identity.agent_uri}")
21
+ >>>
22
+ >>> # Get agent reputation
23
+ >>> reputation = await client.get_reputation("ethereum", 42)
24
+ >>> print(f"Score: {reputation.summary.summary_value}")
25
+ >>>
26
+ >>> # Submit feedback after payment
27
+ >>> result = await client.submit_feedback(
28
+ ... network="ethereum",
29
+ ... agent_id=42,
30
+ ... value=95,
31
+ ... tag1="quality",
32
+ ... proof=settle_response.proof_of_payment,
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
+ # ERC-8004 extension identifier
43
+ ERC8004_EXTENSION_ID = "8004-reputation"
44
+
45
+ # Supported networks for ERC-8004
46
+ Erc8004Network = Literal["ethereum", "ethereum-sepolia", "base-mainnet"]
47
+
48
+
49
+ class Erc8004ContractAddresses(BaseModel):
50
+ """Contract addresses for ERC-8004 on a network."""
51
+
52
+ identity_registry: Optional[str] = None
53
+ reputation_registry: Optional[str] = None
54
+ validation_registry: Optional[str] = None
55
+
56
+
57
+ # Contract addresses per network
58
+ ERC8004_CONTRACTS: dict[str, Erc8004ContractAddresses] = {
59
+ "ethereum": Erc8004ContractAddresses(
60
+ identity_registry="0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
61
+ reputation_registry="0x8004BAa17C55a88189AE136b182e5fdA19dE9b63",
62
+ ),
63
+ "ethereum-sepolia": Erc8004ContractAddresses(
64
+ identity_registry="0x8004A818BFB912233c491871b3d84c89A494BD9e",
65
+ reputation_registry="0x8004B663056A597Dffe9eCcC1965A193B7388713",
66
+ validation_registry="0x8004Cb1BF31DAf7788923b405b754f57acEB4272",
67
+ ),
68
+ # Base Mainnet - Same addresses as Ethereum (CREATE2 deterministic deployment)
69
+ "base-mainnet": Erc8004ContractAddresses(
70
+ identity_registry="0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
71
+ reputation_registry="0x8004BAa17C55a88189AE136b182e5fdA19dE9b63",
72
+ ),
73
+ }
74
+
75
+
76
+ class ProofOfPayment(BaseModel):
77
+ """
78
+ Cryptographic proof of a settled payment for reputation submission.
79
+
80
+ This proof is returned when settling with the 8004-reputation extension
81
+ and is required for submitting authorized feedback.
82
+ """
83
+
84
+ transaction_hash: str = Field(..., alias="transactionHash")
85
+ block_number: int = Field(..., alias="blockNumber")
86
+ network: str
87
+ payer: str
88
+ payee: str
89
+ amount: str
90
+ token: str
91
+ timestamp: int
92
+ payment_hash: str = Field(..., alias="paymentHash")
93
+
94
+ class Config:
95
+ populate_by_name = True
96
+
97
+
98
+ class AgentService(BaseModel):
99
+ """Agent service entry."""
100
+
101
+ name: str
102
+ endpoint: str
103
+ version: Optional[str] = None
104
+
105
+
106
+ class AgentRegistration(BaseModel):
107
+ """Agent registration reference."""
108
+
109
+ agent_id: int = Field(..., alias="agentId")
110
+ agent_registry: str = Field(..., alias="agentRegistry")
111
+
112
+ class Config:
113
+ populate_by_name = True
114
+
115
+
116
+ class AgentIdentity(BaseModel):
117
+ """Agent identity information from the Identity Registry."""
118
+
119
+ agent_id: int = Field(..., alias="agentId")
120
+ owner: str
121
+ agent_uri: str = Field(..., alias="agentUri")
122
+ agent_wallet: Optional[str] = Field(None, alias="agentWallet")
123
+ network: str
124
+
125
+ class Config:
126
+ populate_by_name = True
127
+
128
+
129
+ class AgentRegistrationFile(BaseModel):
130
+ """Agent registration file structure (resolved from agentURI)."""
131
+
132
+ type_: str = Field("https://eips.ethereum.org/EIPS/eip-8004#agent-v1", alias="type")
133
+ name: str
134
+ description: str
135
+ image: Optional[str] = None
136
+ services: list[AgentService] = Field(default_factory=list)
137
+ x402_support: bool = Field(False, alias="x402Support")
138
+ active: bool = True
139
+ registrations: list[AgentRegistration] = Field(default_factory=list)
140
+ supported_trust: list[str] = Field(default_factory=list, alias="supportedTrust")
141
+
142
+ class Config:
143
+ populate_by_name = True
144
+
145
+
146
+ class ReputationSummary(BaseModel):
147
+ """Reputation summary for an agent."""
148
+
149
+ agent_id: int = Field(..., alias="agentId")
150
+ count: int
151
+ summary_value: int = Field(..., alias="summaryValue")
152
+ summary_value_decimals: int = Field(..., alias="summaryValueDecimals")
153
+ network: str
154
+
155
+ class Config:
156
+ populate_by_name = True
157
+
158
+
159
+ class FeedbackEntry(BaseModel):
160
+ """Individual feedback entry."""
161
+
162
+ client: str
163
+ feedback_index: int = Field(..., alias="feedbackIndex")
164
+ value: int
165
+ value_decimals: int = Field(..., alias="valueDecimals")
166
+ tag1: str
167
+ tag2: str
168
+ is_revoked: bool = Field(..., alias="isRevoked")
169
+
170
+ class Config:
171
+ populate_by_name = True
172
+
173
+
174
+ class ReputationResponse(BaseModel):
175
+ """Reputation query response."""
176
+
177
+ agent_id: int = Field(..., alias="agentId")
178
+ summary: ReputationSummary
179
+ feedback: Optional[list[FeedbackEntry]] = None
180
+ network: str
181
+
182
+ class Config:
183
+ populate_by_name = True
184
+
185
+
186
+ class FeedbackParams(BaseModel):
187
+ """Parameters for submitting reputation feedback."""
188
+
189
+ agent_id: int = Field(..., alias="agentId")
190
+ value: int
191
+ value_decimals: int = Field(0, alias="valueDecimals")
192
+ tag1: str = ""
193
+ tag2: str = ""
194
+ endpoint: str = ""
195
+ feedback_uri: str = Field("", alias="feedbackUri")
196
+ feedback_hash: Optional[str] = Field(None, alias="feedbackHash")
197
+ proof: Optional[ProofOfPayment] = None
198
+
199
+ class Config:
200
+ populate_by_name = True
201
+
202
+
203
+ class FeedbackRequest(BaseModel):
204
+ """Feedback request body for POST /feedback."""
205
+
206
+ x402_version: int = Field(1, alias="x402Version")
207
+ network: str
208
+ feedback: FeedbackParams
209
+
210
+ class Config:
211
+ populate_by_name = True
212
+
213
+
214
+ class FeedbackResponse(BaseModel):
215
+ """Feedback response from POST /feedback."""
216
+
217
+ success: bool
218
+ transaction: Optional[str] = None
219
+ feedback_index: Optional[int] = Field(None, alias="feedbackIndex")
220
+ error: Optional[str] = None
221
+ network: str
222
+
223
+ class Config:
224
+ populate_by_name = True
225
+
226
+
227
+ class SettleResponseWithProof(BaseModel):
228
+ """Extended settle response with ERC-8004 proof of payment."""
229
+
230
+ success: bool
231
+ transaction_hash: Optional[str] = Field(None, alias="transactionHash")
232
+ network: Optional[str] = None
233
+ error: Optional[str] = None
234
+ payer: Optional[str] = None
235
+ proof_of_payment: Optional[ProofOfPayment] = Field(None, alias="proofOfPayment")
236
+
237
+ class Config:
238
+ populate_by_name = True
239
+
240
+
241
+ class Erc8004Client:
242
+ """
243
+ Client for ERC-8004 Trustless Agents API.
244
+
245
+ Provides methods for:
246
+ - Querying agent identity
247
+ - Querying agent reputation
248
+ - Submitting reputation feedback
249
+ - Revoking feedback
250
+
251
+ Example:
252
+ >>> client = Erc8004Client()
253
+ >>>
254
+ >>> # Get agent identity
255
+ >>> identity = await client.get_identity("ethereum", 42)
256
+ >>> print(identity.agent_uri)
257
+ >>>
258
+ >>> # Get agent reputation
259
+ >>> reputation = await client.get_reputation("ethereum", 42)
260
+ >>> print(f"Score: {reputation.summary.summary_value}")
261
+ >>>
262
+ >>> # Submit feedback after payment
263
+ >>> result = await client.submit_feedback(
264
+ ... network="ethereum",
265
+ ... agent_id=42,
266
+ ... value=95,
267
+ ... tag1="quality",
268
+ ... proof=settle_response.proof_of_payment,
269
+ ... )
270
+ """
271
+
272
+ def __init__(
273
+ self,
274
+ base_url: str = "https://facilitator.ultravioletadao.xyz",
275
+ timeout: float = 30.0,
276
+ ):
277
+ """
278
+ Initialize the ERC-8004 client.
279
+
280
+ Args:
281
+ base_url: Base URL of the facilitator API
282
+ timeout: Request timeout in seconds
283
+ """
284
+ self.base_url = base_url.rstrip("/")
285
+ self.timeout = timeout
286
+ self._client = httpx.AsyncClient(timeout=timeout)
287
+
288
+ async def __aenter__(self) -> "Erc8004Client":
289
+ return self
290
+
291
+ async def __aexit__(self, *args: Any) -> None:
292
+ await self._client.aclose()
293
+
294
+ async def get_identity(
295
+ self,
296
+ network: Erc8004Network,
297
+ agent_id: int,
298
+ ) -> AgentIdentity:
299
+ """
300
+ Get agent identity from the Identity Registry.
301
+
302
+ Args:
303
+ network: Network where agent is registered
304
+ agent_id: Agent's tokenId
305
+
306
+ Returns:
307
+ Agent identity information
308
+
309
+ Raises:
310
+ httpx.HTTPStatusError: If the request fails
311
+ """
312
+ url = f"{self.base_url}/identity/{network}/{agent_id}"
313
+ response = await self._client.get(url)
314
+ response.raise_for_status()
315
+ return AgentIdentity.model_validate(response.json())
316
+
317
+ async def resolve_agent_uri(self, agent_uri: str) -> AgentRegistrationFile:
318
+ """
319
+ Resolve agent registration file from agentURI.
320
+
321
+ Args:
322
+ agent_uri: URI pointing to agent registration file
323
+
324
+ Returns:
325
+ Resolved agent registration file
326
+
327
+ Raises:
328
+ httpx.HTTPStatusError: If the request fails
329
+ """
330
+ # Handle IPFS URIs
331
+ url = agent_uri
332
+ if agent_uri.startswith("ipfs://"):
333
+ cid = agent_uri.replace("ipfs://", "")
334
+ url = f"https://ipfs.io/ipfs/{cid}"
335
+
336
+ response = await self._client.get(url)
337
+ response.raise_for_status()
338
+ return AgentRegistrationFile.model_validate(response.json())
339
+
340
+ async def get_reputation(
341
+ self,
342
+ network: Erc8004Network,
343
+ agent_id: int,
344
+ *,
345
+ tag1: Optional[str] = None,
346
+ tag2: Optional[str] = None,
347
+ include_feedback: bool = False,
348
+ ) -> ReputationResponse:
349
+ """
350
+ Get agent reputation from the Reputation Registry.
351
+
352
+ Args:
353
+ network: Network where agent is registered
354
+ agent_id: Agent's tokenId
355
+ tag1: Filter by primary tag
356
+ tag2: Filter by secondary tag
357
+ include_feedback: Include individual feedback entries
358
+
359
+ Returns:
360
+ Reputation summary and optionally individual feedback entries
361
+
362
+ Raises:
363
+ httpx.HTTPStatusError: If the request fails
364
+ """
365
+ params: dict[str, Any] = {}
366
+ if tag1:
367
+ params["tag1"] = tag1
368
+ if tag2:
369
+ params["tag2"] = tag2
370
+ if include_feedback:
371
+ params["includeFeedback"] = "true"
372
+
373
+ url = f"{self.base_url}/reputation/{network}/{agent_id}"
374
+ response = await self._client.get(url, params=params or None)
375
+ response.raise_for_status()
376
+ return ReputationResponse.model_validate(response.json())
377
+
378
+ async def submit_feedback(
379
+ self,
380
+ network: Erc8004Network,
381
+ agent_id: int,
382
+ value: int,
383
+ *,
384
+ value_decimals: int = 0,
385
+ tag1: str = "",
386
+ tag2: str = "",
387
+ endpoint: str = "",
388
+ feedback_uri: str = "",
389
+ feedback_hash: Optional[str] = None,
390
+ proof: Optional[ProofOfPayment] = None,
391
+ x402_version: int = 1,
392
+ ) -> FeedbackResponse:
393
+ """
394
+ Submit reputation feedback for an agent.
395
+
396
+ Requires proof of payment for authorized feedback submission.
397
+
398
+ Args:
399
+ network: Network where feedback will be submitted
400
+ agent_id: Agent's tokenId
401
+ value: Feedback value (e.g., 95 for 95/100)
402
+ value_decimals: Decimal places for value interpretation (0-18)
403
+ tag1: Primary categorization tag
404
+ tag2: Secondary categorization tag
405
+ endpoint: Service endpoint that was used
406
+ feedback_uri: URI to off-chain feedback file
407
+ feedback_hash: Keccak256 hash of feedback content
408
+ proof: Proof of payment (required for authorized feedback)
409
+ x402_version: x402 protocol version
410
+
411
+ Returns:
412
+ Feedback response with transaction hash
413
+
414
+ Example:
415
+ >>> # After settling a payment with ERC-8004 extension
416
+ >>> result = await client.submit_feedback(
417
+ ... network="ethereum",
418
+ ... agent_id=42,
419
+ ... value=95,
420
+ ... tag1="quality",
421
+ ... proof=settle_response.proof_of_payment,
422
+ ... )
423
+ """
424
+ request = FeedbackRequest(
425
+ x402_version=x402_version,
426
+ network=network,
427
+ feedback=FeedbackParams(
428
+ agent_id=agent_id,
429
+ value=value,
430
+ value_decimals=value_decimals,
431
+ tag1=tag1,
432
+ tag2=tag2,
433
+ endpoint=endpoint,
434
+ feedback_uri=feedback_uri,
435
+ feedback_hash=feedback_hash,
436
+ proof=proof,
437
+ ),
438
+ )
439
+
440
+ url = f"{self.base_url}/feedback"
441
+ try:
442
+ response = await self._client.post(
443
+ url,
444
+ json=request.model_dump(by_alias=True, exclude_none=True),
445
+ )
446
+ response.raise_for_status()
447
+ return FeedbackResponse.model_validate(response.json())
448
+ except httpx.HTTPStatusError as e:
449
+ return FeedbackResponse(
450
+ success=False,
451
+ error=f"Facilitator error: {e.response.status_code} - {e.response.text}",
452
+ network=network,
453
+ )
454
+ except Exception as e:
455
+ return FeedbackResponse(
456
+ success=False,
457
+ error=str(e),
458
+ network=network,
459
+ )
460
+
461
+ async def revoke_feedback(
462
+ self,
463
+ network: Erc8004Network,
464
+ agent_id: int,
465
+ feedback_index: int,
466
+ *,
467
+ x402_version: int = 1,
468
+ ) -> FeedbackResponse:
469
+ """
470
+ Revoke previously submitted feedback.
471
+
472
+ Only the original submitter can revoke their feedback.
473
+
474
+ Args:
475
+ network: Network where feedback was submitted
476
+ agent_id: Agent ID
477
+ feedback_index: Index of feedback to revoke
478
+ x402_version: x402 protocol version
479
+
480
+ Returns:
481
+ Revocation result
482
+ """
483
+ url = f"{self.base_url}/feedback/revoke"
484
+ try:
485
+ response = await self._client.post(
486
+ url,
487
+ json={
488
+ "x402Version": x402_version,
489
+ "network": network,
490
+ "agentId": agent_id,
491
+ "feedbackIndex": feedback_index,
492
+ },
493
+ )
494
+ response.raise_for_status()
495
+ return FeedbackResponse.model_validate(response.json())
496
+ except httpx.HTTPStatusError as e:
497
+ return FeedbackResponse(
498
+ success=False,
499
+ error=f"Facilitator error: {e.response.status_code} - {e.response.text}",
500
+ network=network,
501
+ )
502
+ except Exception as e:
503
+ return FeedbackResponse(
504
+ success=False,
505
+ error=str(e),
506
+ network=network,
507
+ )
508
+
509
+ def get_contracts(self, network: Erc8004Network) -> Optional[Erc8004ContractAddresses]:
510
+ """
511
+ Get ERC-8004 contract addresses for a network.
512
+
513
+ Args:
514
+ network: Network to get contracts for
515
+
516
+ Returns:
517
+ Contract addresses or None if not deployed
518
+ """
519
+ return ERC8004_CONTRACTS.get(network)
520
+
521
+ def is_available(self, network: str) -> bool:
522
+ """
523
+ Check if ERC-8004 is available on a network.
524
+
525
+ Args:
526
+ network: Network to check
527
+
528
+ Returns:
529
+ True if ERC-8004 contracts are deployed
530
+ """
531
+ return network in ERC8004_CONTRACTS
532
+
533
+ async def get_feedback_metadata(self) -> dict[str, Any]:
534
+ """
535
+ Get feedback endpoint metadata.
536
+
537
+ Returns:
538
+ Endpoint information for /feedback
539
+ """
540
+ url = f"{self.base_url}/feedback"
541
+ response = await self._client.get(url)
542
+ response.raise_for_status()
543
+ return response.json()
544
+
545
+ async def append_response(
546
+ self,
547
+ network: Erc8004Network,
548
+ agent_id: int,
549
+ feedback_index: int,
550
+ response_text: str,
551
+ *,
552
+ response_uri: Optional[str] = None,
553
+ x402_version: int = 1,
554
+ ) -> FeedbackResponse:
555
+ """
556
+ Append a response to existing feedback.
557
+
558
+ Allows agents to respond to feedback they received.
559
+ Only the agent (identity owner) can append responses.
560
+
561
+ Args:
562
+ network: Network where feedback was submitted
563
+ agent_id: Agent ID
564
+ feedback_index: Index of feedback to respond to
565
+ response_text: Response content
566
+ response_uri: Optional URI to off-chain response file
567
+ x402_version: x402 protocol version
568
+
569
+ Returns:
570
+ Response result
571
+
572
+ Example:
573
+ >>> # Agent responds to feedback
574
+ >>> result = await client.append_response(
575
+ ... network="ethereum",
576
+ ... agent_id=42,
577
+ ... feedback_index=1,
578
+ ... response_text="Thank you for your feedback!",
579
+ ... )
580
+ """
581
+ url = f"{self.base_url}/feedback/response"
582
+ payload: dict[str, Any] = {
583
+ "x402Version": x402_version,
584
+ "network": network,
585
+ "agentId": agent_id,
586
+ "feedbackIndex": feedback_index,
587
+ "response": response_text,
588
+ }
589
+ if response_uri:
590
+ payload["responseUri"] = response_uri
591
+
592
+ try:
593
+ response = await self._client.post(url, json=payload)
594
+ response.raise_for_status()
595
+ return FeedbackResponse.model_validate(response.json())
596
+ except httpx.HTTPStatusError as e:
597
+ return FeedbackResponse(
598
+ success=False,
599
+ error=f"Facilitator error: {e.response.status_code} - {e.response.text}",
600
+ network=network,
601
+ )
602
+ except Exception as e:
603
+ return FeedbackResponse(
604
+ success=False,
605
+ error=str(e),
606
+ network=network,
607
+ )
608
+
609
+
610
+ def build_erc8004_payment_requirements(
611
+ amount: str,
612
+ recipient: str,
613
+ resource: str,
614
+ *,
615
+ network: str = "base",
616
+ description: str = "Payment for resource access",
617
+ mime_type: str = "application/json",
618
+ timeout_seconds: int = 300,
619
+ ) -> dict[str, Any]:
620
+ """
621
+ Build payment requirements with ERC-8004 extension.
622
+
623
+ Adds the 8004-reputation extension to include proof of payment
624
+ in settlement responses for reputation submission.
625
+
626
+ Args:
627
+ amount: Amount in human-readable format (e.g., "1.00")
628
+ recipient: Recipient address
629
+ resource: Resource URL being protected
630
+ network: Chain name (e.g., "base", "ethereum")
631
+ description: Description of the resource
632
+ mime_type: MIME type of the resource
633
+ timeout_seconds: Maximum timeout in seconds
634
+
635
+ Returns:
636
+ Payment requirements with ERC-8004 extension
637
+
638
+ Example:
639
+ >>> requirements = build_erc8004_payment_requirements(
640
+ ... amount="1.00",
641
+ ... recipient="0x...",
642
+ ... resource="https://api.example.com/service",
643
+ ... network="ethereum",
644
+ ... )
645
+ >>> # Settlement will include proofOfPayment
646
+ >>> result = await facilitator.settle(payment, requirements)
647
+ >>> print(result.proof_of_payment)
648
+ """
649
+ return {
650
+ "scheme": "exact",
651
+ "network": network,
652
+ "maxAmountRequired": str(int(float(amount) * 1_000_000)), # 6 decimals
653
+ "resource": resource,
654
+ "description": description,
655
+ "mimeType": mime_type,
656
+ "payTo": recipient,
657
+ "maxTimeoutSeconds": timeout_seconds,
658
+ "extra": {
659
+ ERC8004_EXTENSION_ID: {
660
+ "includeProof": True,
661
+ },
662
+ },
663
+ }