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
+ Request signing utilities per LLD §2.1
3
+ Signature format: METHOD\nPATH\nSHA256(body)\nX-Ts\nX-Agent-DID
4
+ """
5
+
6
+ import hashlib
7
+ import re
8
+ import time
9
+ from typing import Dict
10
+
11
+ from eth_account import Account
12
+ from eth_account.messages import encode_defunct
13
+ from solders.keypair import Keypair # type: ignore
14
+ from solders.pubkey import Pubkey # type: ignore
15
+
16
+
17
+ def sign_request(
18
+ method: str,
19
+ path: str,
20
+ body: str,
21
+ timestamp: int,
22
+ did: str,
23
+ private_key: str,
24
+ ) -> str:
25
+ """
26
+ Sign a request per LLD §2.1
27
+
28
+ Args:
29
+ method: HTTP method (GET, POST, etc.)
30
+ path: URL path (e.g., /v1/memory/store)
31
+ body: Request body (empty string for GET)
32
+ timestamp: Unix timestamp in milliseconds
33
+ did: Agent DID
34
+ private_key: Private key (hex string without 0x prefix)
35
+
36
+ Returns:
37
+ Signature (hex string)
38
+ """
39
+ # Create signature message per LLD §2.1
40
+ message = create_signature_message(method, path, body, timestamp, did)
41
+
42
+ # Sign message with private key
43
+ signature = sign_message(message, private_key, did)
44
+
45
+ return signature
46
+
47
+
48
+ def create_signature_message(
49
+ method: str,
50
+ path: str,
51
+ body: str,
52
+ timestamp: int,
53
+ did: str,
54
+ ) -> str:
55
+ """
56
+ Create signature message per LLD §2.1
57
+ Format: METHOD\nPATH\nSHA256(body)\nX-Ts\nX-Agent-DID
58
+ """
59
+ # Calculate SHA256 of body
60
+ body_hash = hashlib.sha256(body.encode('utf-8')).hexdigest()
61
+
62
+ # Build message per LLD §2.1
63
+ message = f"{method}\n{path}\n{body_hash}\n{timestamp}\n{did}"
64
+
65
+ return message
66
+
67
+
68
+ def sign_message(message: str, private_key: str, did: str) -> str:
69
+ """
70
+ Sign a message with private key
71
+ Supports both EVM (secp256k1) and Solana (ed25519) signing
72
+
73
+ Args:
74
+ message: Message to sign
75
+ private_key: Private key (hex string without 0x prefix)
76
+ did: Agent DID (used to determine signing algorithm)
77
+
78
+ Returns:
79
+ Signature (hex string without 0x prefix)
80
+ """
81
+ # Remove 0x prefix if present
82
+ clean_key = private_key[2:] if private_key.startswith('0x') else private_key
83
+
84
+ # Determine signing method based on DID
85
+ if ':evm:' in did:
86
+ # EVM signing using eth_account (secp256k1)
87
+ return sign_message_evm(message, clean_key)
88
+ elif ':sol:' in did:
89
+ # Solana signing using ed25519
90
+ return sign_message_solana(message, clean_key)
91
+ else:
92
+ raise ValueError(f"Unsupported DID type in: {did}")
93
+
94
+
95
+ def sign_message_evm(message: str, private_key: str) -> str:
96
+ """
97
+ Sign message using EVM secp256k1 (eth_account)
98
+ """
99
+ try:
100
+ # Create account from private key
101
+ account = Account.from_key(f"0x{private_key}")
102
+
103
+ # Encode message for Ethereum signing
104
+ message_hash = encode_defunct(text=message)
105
+
106
+ # Sign the message
107
+ signed = account.sign_message(message_hash)
108
+
109
+ # Return signature as hex without 0x prefix
110
+ return signed.signature.hex()[2:]
111
+ except Exception as e:
112
+ raise ValueError(f"EVM signing failed: {str(e)}")
113
+
114
+
115
+ def sign_message_solana(message: str, private_key: str) -> str:
116
+ """
117
+ Sign message using Solana ed25519
118
+ """
119
+ try:
120
+ # Convert hex private key to bytes
121
+ private_key_bytes = bytes.fromhex(private_key)
122
+
123
+ # Create keypair from secret key
124
+ # Solana keypairs are 64 bytes (32-byte secret + 32-byte public)
125
+ if len(private_key_bytes) == 32:
126
+ # If only 32 bytes, treat as seed
127
+ keypair = Keypair.from_seed(private_key_bytes)
128
+ elif len(private_key_bytes) == 64:
129
+ # Full keypair
130
+ keypair = Keypair.from_bytes(private_key_bytes)
131
+ else:
132
+ raise ValueError(f"Invalid Solana private key length: {len(private_key_bytes)} bytes")
133
+
134
+ # Sign the message
135
+ message_bytes = message.encode('utf-8')
136
+ signature = keypair.sign_message(message_bytes)
137
+
138
+ # Return signature as hex
139
+ return bytes(signature).hex()
140
+ except Exception as e:
141
+ raise ValueError(f"Solana signing failed: {str(e)}")
142
+
143
+
144
+ def validate_did(did: str) -> bool:
145
+ """
146
+ Validate DID format per LLD §2.1
147
+ Format: did:agent:<evm|sol>:<address>
148
+ - EVM: did:agent:evm:0x[40 hex chars]
149
+ - Solana: did:agent:sol:[32-44 base58 chars]
150
+ """
151
+ # Check basic structure
152
+ if not did.startswith('did:agent:'):
153
+ return False
154
+
155
+ parts = did.split(':')
156
+ if len(parts) != 4:
157
+ return False
158
+
159
+ _, _, chain, address = parts
160
+
161
+ if chain == 'evm':
162
+ # EVM addresses: 0x followed by 40 hex characters
163
+ return bool(re.match(r'^0x[a-fA-F0-9]{40}$', address))
164
+ elif chain == 'sol':
165
+ # Solana addresses: base58 string (32-44 characters typical)
166
+ return bool(re.match(r'^[1-9A-HJ-NP-Za-km-z]{32,44}$', address))
167
+
168
+ return False
169
+
170
+
171
+ def validate_timestamp(timestamp: int) -> bool:
172
+ """
173
+ Validate timestamp is within acceptable window (±300s per LLD §2.1)
174
+ """
175
+ now = int(time.time() * 1000)
176
+ diff = abs(now - timestamp)
177
+ return diff <= 300000 # 5 minutes = 300 seconds = 300,000ms
178
+
179
+
180
+ def generate_auth_headers(
181
+ method: str,
182
+ path: str,
183
+ body: str,
184
+ did: str,
185
+ private_key: str,
186
+ ) -> Dict[str, str]:
187
+ """
188
+ Generate authentication headers per LLD §2.1
189
+
190
+ Args:
191
+ method: HTTP method
192
+ path: URL path
193
+ body: Request body (empty string for GET)
194
+ did: Agent DID
195
+ private_key: Private key (hex string)
196
+
197
+ Returns:
198
+ Authentication headers
199
+ """
200
+ # Validate DID format
201
+ if not validate_did(did):
202
+ raise ValueError(f"Invalid DID format: {did}")
203
+
204
+ # Generate timestamp
205
+ timestamp = int(time.time() * 1000)
206
+
207
+ # Validate timestamp
208
+ if not validate_timestamp(timestamp):
209
+ raise ValueError("Generated timestamp is outside acceptable window")
210
+
211
+ # Sign request
212
+ signature = sign_request(method, path, body, timestamp, did, private_key)
213
+
214
+ # Return headers per LLD §2.1
215
+ return {
216
+ "X-Agent-DID": did,
217
+ "X-Sig": signature,
218
+ "X-Ts": str(timestamp),
219
+ }
220
+
221
+
222
+ def derive_evm_address(private_key: str) -> str:
223
+ """
224
+ Derive Ethereum address from private key
225
+ """
226
+ clean_key = private_key[2:] if private_key.startswith('0x') else private_key
227
+ account = Account.from_key(f"0x{clean_key}")
228
+ return account.address
229
+
230
+
231
+ def derive_solana_address(private_key: str) -> str:
232
+ """
233
+ Derive Solana address from private key
234
+ """
235
+ private_key_bytes = bytes.fromhex(private_key)
236
+
237
+ if len(private_key_bytes) == 32:
238
+ keypair = Keypair.from_seed(private_key_bytes)
239
+ elif len(private_key_bytes) == 64:
240
+ keypair = Keypair.from_bytes(private_key_bytes)
241
+ else:
242
+ raise ValueError(f"Invalid Solana private key length: {len(private_key_bytes)} bytes")
243
+
244
+ return str(keypair.pubkey())
xache/crypto/wallet.py ADDED
@@ -0,0 +1,240 @@
1
+ """
2
+ Wallet Generation Utilities
3
+ Production-ready BIP-39 compliant wallet generation for EVM and Solana
4
+ """
5
+
6
+ from typing import Optional, Literal
7
+ from dataclasses import dataclass
8
+ from mnemonic import Mnemonic
9
+ from eth_account import Account
10
+ from bip_utils import (
11
+ Bip39MnemonicGenerator,
12
+ Bip39SeedGenerator,
13
+ Bip39WordsNum,
14
+ Bip44,
15
+ Bip44Coins,
16
+ Bip44Changes
17
+ )
18
+ import nacl.signing
19
+ import base58
20
+
21
+ KeyType = Literal['evm', 'solana']
22
+
23
+
24
+ @dataclass
25
+ class WalletGenerationResult:
26
+ """Wallet generation result"""
27
+ did: str # DID format: did:agent:<evm|sol>:<address>
28
+ address: str # Wallet address
29
+ private_key: str # Private key (hex string with 0x prefix)
30
+ mnemonic: str # BIP-39 mnemonic (24 words)
31
+ key_type: KeyType # Key type (evm or solana)
32
+
33
+
34
+ @dataclass
35
+ class WalletGenerationOptions:
36
+ """Wallet generation options"""
37
+ label: Optional[str] = None
38
+ index: int = 0
39
+
40
+
41
+ class WalletGenerator:
42
+ """
43
+ Wallet Generator class
44
+ Provides secure, deterministic wallet generation for AI agents
45
+ """
46
+
47
+ @staticmethod
48
+ def generate(
49
+ key_type: KeyType,
50
+ options: Optional[WalletGenerationOptions] = None
51
+ ) -> WalletGenerationResult:
52
+ """
53
+ Generate a new wallet with random mnemonic
54
+
55
+ Args:
56
+ key_type: 'evm' for Ethereum/Base or 'solana' for Solana
57
+ options: Optional generation options
58
+
59
+ Returns:
60
+ Wallet generation result with DID, keys, and mnemonic
61
+
62
+ Example:
63
+ >>> wallet = WalletGenerator.generate('evm')
64
+ >>> print(wallet.did)
65
+ did:agent:evm:0x1234...
66
+ """
67
+ if options is None:
68
+ options = WalletGenerationOptions()
69
+
70
+ # Generate 24-word mnemonic (256-bit entropy)
71
+ mnemonic_generator = Bip39MnemonicGenerator()
72
+ mnemonic = mnemonic_generator.FromWordsNumber(Bip39WordsNum.WORDS_NUM_24)
73
+
74
+ return WalletGenerator.from_mnemonic(str(mnemonic), key_type, options)
75
+
76
+ @staticmethod
77
+ def from_mnemonic(
78
+ mnemonic: str,
79
+ key_type: KeyType,
80
+ options: Optional[WalletGenerationOptions] = None
81
+ ) -> WalletGenerationResult:
82
+ """
83
+ Generate wallet from existing mnemonic
84
+
85
+ Args:
86
+ mnemonic: BIP-39 mnemonic phrase (12 or 24 words)
87
+ key_type: 'evm' for Ethereum/Base or 'solana' for Solana
88
+ options: Optional generation options
89
+
90
+ Returns:
91
+ Wallet generation result with DID and keys
92
+
93
+ Raises:
94
+ ValueError: If mnemonic is invalid
95
+ """
96
+ if options is None:
97
+ options = WalletGenerationOptions()
98
+
99
+ # Validate mnemonic
100
+ mnemo = Mnemonic("english")
101
+ if not mnemo.check(mnemonic):
102
+ raise ValueError("Invalid mnemonic phrase")
103
+
104
+ if key_type == 'evm':
105
+ return WalletGenerator._generate_evm_wallet(mnemonic, options)
106
+ elif key_type == 'solana':
107
+ return WalletGenerator._generate_solana_wallet(mnemonic, options)
108
+ else:
109
+ raise ValueError(f"Unsupported key type: {key_type}")
110
+
111
+ @staticmethod
112
+ def _generate_evm_wallet(
113
+ mnemonic: str,
114
+ options: WalletGenerationOptions
115
+ ) -> WalletGenerationResult:
116
+ """Generate EVM wallet (Ethereum, Base) using BIP-44 path"""
117
+ # BIP-44 path: m/44'/60'/0'/0/{index}
118
+ seed = Bip39SeedGenerator(mnemonic).Generate()
119
+ bip44_ctx = Bip44.FromSeed(seed, Bip44Coins.ETHEREUM)
120
+
121
+ # Derive account
122
+ bip44_acc = bip44_ctx.Purpose().Coin().Account(0)
123
+ bip44_chg = bip44_acc.Change(Bip44Changes.CHAIN_EXT)
124
+ bip44_addr = bip44_chg.AddressIndex(options.index)
125
+
126
+ # Get private key
127
+ private_key_bytes = bip44_addr.PrivateKey().Raw().ToBytes()
128
+ private_key_hex = '0x' + private_key_bytes.hex()
129
+
130
+ # Create account from private key
131
+ account = Account.from_key(private_key_bytes)
132
+ address = account.address
133
+
134
+ did = f"did:agent:evm:{address}"
135
+
136
+ return WalletGenerationResult(
137
+ did=did,
138
+ address=address,
139
+ private_key=private_key_hex,
140
+ mnemonic=mnemonic,
141
+ key_type='evm'
142
+ )
143
+
144
+ @staticmethod
145
+ def _generate_solana_wallet(
146
+ mnemonic: str,
147
+ options: WalletGenerationOptions
148
+ ) -> WalletGenerationResult:
149
+ """Generate Solana wallet using BIP-44 path"""
150
+ # BIP-44 path: m/44'/501'/{index}'/0'
151
+ seed = Bip39SeedGenerator(mnemonic).Generate()
152
+ bip44_ctx = Bip44.FromSeed(seed, Bip44Coins.SOLANA)
153
+
154
+ # Derive account
155
+ bip44_acc = bip44_ctx.Purpose().Coin().Account(options.index)
156
+ bip44_chg = bip44_acc.Change(Bip44Changes.CHAIN_EXT)
157
+
158
+ # Get private key (first 32 bytes for ed25519)
159
+ private_key_bytes = bip44_chg.PrivateKey().Raw().ToBytes()[:32]
160
+ private_key_hex = '0x' + private_key_bytes.hex()
161
+
162
+ # Create ed25519 key pair
163
+ signing_key = nacl.signing.SigningKey(private_key_bytes)
164
+ verify_key = signing_key.verify_key
165
+
166
+ # Encode public key as base58
167
+ address = base58.b58encode(bytes(verify_key)).decode('ascii')
168
+
169
+ did = f"did:agent:sol:{address}"
170
+
171
+ return WalletGenerationResult(
172
+ did=did,
173
+ address=address,
174
+ private_key=private_key_hex,
175
+ mnemonic=mnemonic,
176
+ key_type='solana'
177
+ )
178
+
179
+ @staticmethod
180
+ def validate_did(did: str) -> bool:
181
+ """
182
+ Validate DID format
183
+
184
+ Args:
185
+ did: DID string to validate
186
+
187
+ Returns:
188
+ True if DID format is valid
189
+ """
190
+ import re
191
+
192
+ evm_pattern = r'^did:agent:evm:0x[a-fA-F0-9]{40}$'
193
+ sol_pattern = r'^did:agent:sol:[1-9A-HJ-NP-Za-km-z]{32,44}$'
194
+
195
+ return bool(re.match(evm_pattern, did) or re.match(sol_pattern, did))
196
+
197
+ @staticmethod
198
+ def did_to_address(did: str) -> str:
199
+ """
200
+ Extract address from DID
201
+
202
+ Args:
203
+ did: DID string
204
+
205
+ Returns:
206
+ Address extracted from DID
207
+
208
+ Raises:
209
+ ValueError: If DID format is invalid
210
+ """
211
+ parts = did.split(':')
212
+ if len(parts) != 4 or parts[0] != 'did' or parts[1] != 'agent':
213
+ raise ValueError('Invalid DID format')
214
+ return parts[3]
215
+
216
+ @staticmethod
217
+ def did_to_key_type(did: str) -> KeyType:
218
+ """
219
+ Extract key type from DID
220
+
221
+ Args:
222
+ did: DID string
223
+
224
+ Returns:
225
+ Key type extracted from DID
226
+
227
+ Raises:
228
+ ValueError: If DID format is invalid
229
+ """
230
+ parts = did.split(':')
231
+ if len(parts) != 4 or parts[0] != 'did' or parts[1] != 'agent':
232
+ raise ValueError('Invalid DID format')
233
+
234
+ key_type = parts[2]
235
+ if key_type == 'evm':
236
+ return 'evm'
237
+ if key_type == 'sol':
238
+ return 'solana'
239
+
240
+ raise ValueError(f'Invalid key type in DID: {key_type}')
xache/errors.py ADDED
@@ -0,0 +1,184 @@
1
+ """
2
+ Custom exception classes for Xache SDK
3
+ Maps to API error codes per LLD §2.1
4
+ """
5
+
6
+ from typing import Optional, Dict, Any
7
+
8
+
9
+ class XacheError(Exception):
10
+ """Base Xache error class"""
11
+
12
+ def __init__(
13
+ self,
14
+ code: str,
15
+ message: str,
16
+ status_code: int,
17
+ details: Optional[Dict[str, Any]] = None,
18
+ request_id: Optional[str] = None,
19
+ ):
20
+ super().__init__(message)
21
+ self.code = code
22
+ self.message = message
23
+ self.status_code = status_code
24
+ self.details = details or {}
25
+ self.request_id = request_id
26
+
27
+ def __str__(self) -> str:
28
+ msg = f"[{self.code}] {self.message}"
29
+ if self.request_id:
30
+ msg += f" (request_id: {self.request_id})"
31
+ return msg
32
+
33
+
34
+ class UnauthenticatedError(XacheError):
35
+ """Authentication error (401)"""
36
+
37
+ def __init__(
38
+ self,
39
+ message: str,
40
+ details: Optional[Dict[str, Any]] = None,
41
+ request_id: Optional[str] = None,
42
+ ):
43
+ super().__init__("UNAUTHENTICATED", message, 401, details, request_id)
44
+
45
+
46
+ class PaymentRequiredError(XacheError):
47
+ """Payment required error (402)"""
48
+
49
+ def __init__(
50
+ self,
51
+ message: str,
52
+ challenge_id: str,
53
+ amount: str,
54
+ chain_hint: str,
55
+ pay_to: str,
56
+ description: str,
57
+ request_id: Optional[str] = None,
58
+ ):
59
+ details = {
60
+ "challenge_id": challenge_id,
61
+ "amount": amount,
62
+ "chain_hint": chain_hint,
63
+ "pay_to": pay_to,
64
+ "description": description,
65
+ }
66
+ super().__init__("PAYMENT_REQUIRED", message, 402, details, request_id)
67
+ self.challenge_id = challenge_id
68
+ self.amount = amount
69
+ self.chain_hint = chain_hint
70
+ self.pay_to = pay_to
71
+ self.description = description
72
+
73
+
74
+ class RateLimitedError(XacheError):
75
+ """Rate limited error (429)"""
76
+
77
+ def __init__(
78
+ self,
79
+ message: str,
80
+ reset_at: Optional[str] = None,
81
+ details: Optional[Dict[str, Any]] = None,
82
+ request_id: Optional[str] = None,
83
+ ):
84
+ error_details = details or {}
85
+ if reset_at:
86
+ error_details["reset_at"] = reset_at
87
+ super().__init__("RATE_LIMITED", message, 429, error_details, request_id)
88
+ self.reset_at = reset_at
89
+
90
+
91
+ class BudgetExceededError(XacheError):
92
+ """Budget exceeded error (400)"""
93
+
94
+ def __init__(
95
+ self,
96
+ message: str,
97
+ details: Optional[Dict[str, Any]] = None,
98
+ request_id: Optional[str] = None,
99
+ ):
100
+ super().__init__("BUDGET_EXCEEDED", message, 400, details, request_id)
101
+
102
+
103
+ class InvalidInputError(XacheError):
104
+ """Invalid input error (400)"""
105
+
106
+ def __init__(
107
+ self,
108
+ message: str,
109
+ details: Optional[Dict[str, Any]] = None,
110
+ request_id: Optional[str] = None,
111
+ ):
112
+ super().__init__("INVALID_INPUT", message, 400, details, request_id)
113
+
114
+
115
+ class ConflictError(XacheError):
116
+ """Conflict error (409)"""
117
+
118
+ def __init__(
119
+ self,
120
+ message: str,
121
+ details: Optional[Dict[str, Any]] = None,
122
+ request_id: Optional[str] = None,
123
+ ):
124
+ super().__init__("CONFLICT", message, 409, details, request_id)
125
+
126
+
127
+ class RetryLaterError(XacheError):
128
+ """Retry later error (503)"""
129
+
130
+ def __init__(
131
+ self,
132
+ message: str,
133
+ details: Optional[Dict[str, Any]] = None,
134
+ request_id: Optional[str] = None,
135
+ ):
136
+ super().__init__("RETRY_LATER", message, 503, details, request_id)
137
+
138
+
139
+ class InternalError(XacheError):
140
+ """Internal server error (500)"""
141
+
142
+ def __init__(
143
+ self,
144
+ message: str,
145
+ details: Optional[Dict[str, Any]] = None,
146
+ request_id: Optional[str] = None,
147
+ ):
148
+ super().__init__("INTERNAL", message, 500, details, request_id)
149
+
150
+
151
+ class NetworkError(Exception):
152
+ """Network error (not from API)"""
153
+
154
+ def __init__(self, message: str, original_error: Optional[Exception] = None):
155
+ super().__init__(message)
156
+ self.original_error = original_error
157
+
158
+
159
+ def create_error_from_response(
160
+ code: str,
161
+ message: str,
162
+ status_code: int,
163
+ details: Optional[Dict[str, Any]] = None,
164
+ request_id: Optional[str] = None,
165
+ ) -> XacheError:
166
+ """Create appropriate error from API response"""
167
+ error_map = {
168
+ "UNAUTHENTICATED": UnauthenticatedError,
169
+ "RATE_LIMITED": RateLimitedError,
170
+ "BUDGET_EXCEEDED": BudgetExceededError,
171
+ "INVALID_INPUT": InvalidInputError,
172
+ "CONFLICT": ConflictError,
173
+ "RETRY_LATER": RetryLaterError,
174
+ "INTERNAL": InternalError,
175
+ }
176
+
177
+ error_class = error_map.get(code)
178
+ if error_class:
179
+ if code == "RATE_LIMITED":
180
+ reset_at = details.get("reset_at") if details else None
181
+ return error_class(message, reset_at, details, request_id)
182
+ return error_class(message, details, request_id)
183
+
184
+ return XacheError(code, message, status_code, details, request_id)
@@ -0,0 +1,5 @@
1
+ """Payment handling"""
2
+
3
+ from .handler import PaymentHandler
4
+
5
+ __all__ = ["PaymentHandler"]