chipi-stack 2.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.
chipi_sdk/sessions.py ADDED
@@ -0,0 +1,836 @@
1
+ """Session key management for CHIPI wallets (SNIP-9 compatible)."""
2
+
3
+ import time
4
+ import secrets
5
+ from typing import Optional
6
+ from starknet_py.net.full_node_client import FullNodeClient
7
+ from starknet_py.net.signer.stark_curve_signer import KeyPair
8
+
9
+ from .models.session import (
10
+ SessionKeyData,
11
+ CreateSessionKeyParams,
12
+ AddSessionKeyParams,
13
+ RevokeSessionKeyParams,
14
+ GetSessionDataParams,
15
+ SessionDataResponse,
16
+ ExecuteWithSessionParams,
17
+ SetSpendingPolicyParams,
18
+ GetSpendingPolicyParams,
19
+ RemoveSpendingPolicyParams,
20
+ SpendingPolicyData,
21
+ )
22
+ from .models.wallet import WalletType
23
+ from .encryption import encrypt_private_key
24
+ from .errors import ChipiSessionError
25
+ from .constants import (
26
+ WALLET_CLASS_HASHES,
27
+ WALLET_RPC_ENDPOINTS,
28
+ SESSION_DEFAULTS,
29
+ SESSION_ERRORS,
30
+ SESSION_ENTRYPOINTS,
31
+ )
32
+ from .client import ChipiClient
33
+ from .execute_paymaster import (
34
+ execute_paymaster_transaction,
35
+ execute_paymaster_transaction_sync,
36
+ execute_paymaster_transaction_with_session,
37
+ execute_paymaster_transaction_with_session_sync,
38
+ )
39
+
40
+
41
+ class ChipiSessions:
42
+ """Session key management for CHIPI wallets."""
43
+
44
+ def __init__(self, client: ChipiClient):
45
+ """
46
+ Initialize session manager.
47
+
48
+ Args:
49
+ client: Chipi HTTP client
50
+ """
51
+ self.client = client
52
+
53
+ def _validate_chipi_wallet(
54
+ self,
55
+ wallet_type: Optional[WalletType],
56
+ operation: str,
57
+ wallet_public_key: Optional[str] = None,
58
+ ) -> None:
59
+ """
60
+ Validate that the wallet is a CHIPI wallet.
61
+
62
+ Args:
63
+ wallet_type: Wallet type
64
+ operation: Operation name for error messages
65
+ wallet_public_key: Optional wallet address for logging
66
+
67
+ Raises:
68
+ ChipiSessionError: If wallet type is not CHIPI
69
+ """
70
+ if wallet_type != WalletType.CHIPI:
71
+ print(
72
+ f"[ChipiSDK:Session:{operation}] Invalid wallet type",
73
+ {
74
+ "provided": wallet_type,
75
+ "required": "CHIPI",
76
+ "expectedClassHash": WALLET_CLASS_HASHES[WalletType.CHIPI],
77
+ "walletAddress": (
78
+ wallet_public_key[:15] + "..." if wallet_public_key else "(not provided)"
79
+ ),
80
+ "hint": "Session keys only work with CHIPI wallets (SNIP-9 compatible)",
81
+ },
82
+ )
83
+ raise ChipiSessionError(
84
+ f"Session keys require CHIPI wallet type. Got: \"{wallet_type or 'undefined'}\". "
85
+ f"Session keys are only supported for CHIPI wallets with class hash {WALLET_CLASS_HASHES[WalletType.CHIPI]}",
86
+ SESSION_ERRORS["INVALID_WALLET_TYPE_FOR_SESSION"],
87
+ )
88
+
89
+ def _to_hex(self, data: bytes) -> str:
90
+ """
91
+ Convert bytes to hex string with 0x prefix.
92
+
93
+ Args:
94
+ data: Bytes to convert
95
+
96
+ Returns:
97
+ Hex string with 0x prefix
98
+ """
99
+ return "0x" + data.hex()
100
+
101
+ def create_session_key(self, params: CreateSessionKeyParams) -> SessionKeyData:
102
+ """
103
+ Generate a new session keypair locally.
104
+
105
+ This method generates the session keys but does NOT register them on-chain.
106
+ The returned SessionKeyData should be stored externally by the developer.
107
+
108
+ After generating, call `add_session_key_to_contract()` to register on-chain.
109
+
110
+ Args:
111
+ params: Session creation parameters
112
+
113
+ Returns:
114
+ Session key data for external storage
115
+
116
+ Raises:
117
+ ChipiSessionError: If session creation fails
118
+ """
119
+ encrypt_key = params.encrypt_key
120
+ duration_seconds = params.duration_seconds or SESSION_DEFAULTS["DURATION_SECONDS"]
121
+
122
+ try:
123
+ # Generate random private key (32 bytes)
124
+ raw_private_key = secrets.token_bytes(32)
125
+ private_key_hex = self._to_hex(raw_private_key)
126
+
127
+ # Derive public key using starknet.py
128
+ key_pair = KeyPair.from_private_key(int(private_key_hex, 16))
129
+ public_key = hex(key_pair.public_key)
130
+
131
+ # Calculate expiration timestamp
132
+ valid_until = int(time.time()) + duration_seconds
133
+
134
+ # Encrypt the private key for storage
135
+ encrypted_private_key = encrypt_private_key(private_key_hex, encrypt_key)
136
+
137
+ return SessionKeyData(
138
+ public_key=public_key,
139
+ encrypted_private_key=encrypted_private_key,
140
+ valid_until=valid_until,
141
+ )
142
+ except Exception as error:
143
+ raise ChipiSessionError(
144
+ f"Failed to create session key: {str(error)}",
145
+ SESSION_ERRORS["SESSION_CREATION_FAILED"],
146
+ )
147
+
148
+ async def aadd_session_key_to_contract(
149
+ self, params: AddSessionKeyParams, bearer_token: str
150
+ ) -> str:
151
+ """
152
+ Register a session key on the smart contract (async).
153
+
154
+ Executes a sponsored transaction to call `add_or_update_session_key`.
155
+ The session must be registered before it can be used for transactions.
156
+
157
+ Args:
158
+ params: Session registration parameters
159
+ bearer_token: Authentication token
160
+
161
+ Returns:
162
+ Transaction hash
163
+
164
+ Raises:
165
+ ChipiSessionError: If registration fails
166
+ """
167
+ encrypt_key = params.encrypt_key
168
+ wallet = params.wallet
169
+ session_config = params.session_config
170
+
171
+ # Validate CHIPI wallet
172
+ self._validate_chipi_wallet(
173
+ wallet.wallet_type, "AddToContract", wallet.public_key
174
+ )
175
+
176
+ try:
177
+ print(
178
+ "[ChipiSDK:Session:AddToContract] Registering session on-chain",
179
+ {
180
+ "walletAddress": wallet.public_key[:15] + "...",
181
+ "sessionPubKey": session_config.session_public_key[:15] + "...",
182
+ "validUntil": time.strftime(
183
+ "%Y-%m-%d %H:%M:%S", time.gmtime(session_config.valid_until)
184
+ ),
185
+ "maxCalls": session_config.max_calls,
186
+ "allowedEntrypoints": len(session_config.allowed_entrypoints),
187
+ },
188
+ )
189
+
190
+ # Build calldata for add_or_update_session_key
191
+ calldata = [
192
+ session_config.session_public_key,
193
+ hex(session_config.valid_until),
194
+ hex(session_config.max_calls),
195
+ hex(len(session_config.allowed_entrypoints)),
196
+ *session_config.allowed_entrypoints,
197
+ ]
198
+
199
+ # Execute via paymaster (owner signature)
200
+ from .models.transaction import Call
201
+
202
+ tx_hash = await execute_paymaster_transaction(
203
+ params={
204
+ "encryptKey": encrypt_key,
205
+ "wallet": {**wallet.model_dump(), "walletType": "CHIPI"},
206
+ "calls": [
207
+ Call(
208
+ contractAddress=wallet.public_key,
209
+ entrypoint=SESSION_ENTRYPOINTS["ADD_OR_UPDATE"],
210
+ calldata=calldata,
211
+ ).model_dump()
212
+ ],
213
+ "saveToDatabase": False, # Don't record session management txs
214
+ },
215
+ bearer_token=bearer_token,
216
+ client=self.client,
217
+ )
218
+
219
+ print(
220
+ "[ChipiSDK:Session:AddToContract] Session registered successfully",
221
+ {
222
+ "txHash": tx_hash,
223
+ "sessionPubKey": session_config.session_public_key[:15] + "...",
224
+ },
225
+ )
226
+
227
+ return tx_hash
228
+ except Exception as error:
229
+ print(
230
+ "[ChipiSDK:Session:AddToContract] Registration failed",
231
+ {"error": str(error)},
232
+ )
233
+ raise
234
+
235
+ def add_session_key_to_contract(
236
+ self, params: AddSessionKeyParams, bearer_token: str
237
+ ) -> str:
238
+ """
239
+ Register a session key on the smart contract (sync).
240
+
241
+ Args:
242
+ params: Session registration parameters
243
+ bearer_token: Authentication token
244
+
245
+ Returns:
246
+ Transaction hash
247
+ """
248
+ import asyncio
249
+ return asyncio.run(self.aadd_session_key_to_contract(params, bearer_token))
250
+
251
+ async def arevoke_session_key(
252
+ self, params: RevokeSessionKeyParams, bearer_token: str
253
+ ) -> str:
254
+ """
255
+ Revoke a session key from the smart contract (async).
256
+
257
+ After revocation, the session key can no longer be used for transactions.
258
+
259
+ Args:
260
+ params: Session revocation parameters
261
+ bearer_token: Authentication token
262
+
263
+ Returns:
264
+ Transaction hash
265
+ """
266
+ encrypt_key = params.encrypt_key
267
+ wallet = params.wallet
268
+ session_public_key = params.session_public_key
269
+
270
+ # Validate CHIPI wallet
271
+ self._validate_chipi_wallet(wallet.wallet_type, "Revoke", wallet.public_key)
272
+
273
+ try:
274
+ print(
275
+ "[ChipiSDK:Session:Revoke] Revoking session from contract",
276
+ {
277
+ "walletAddress": wallet.public_key[:15] + "...",
278
+ "sessionToRevoke": session_public_key[:15] + "...",
279
+ },
280
+ )
281
+
282
+ # Execute via paymaster (owner signature)
283
+ from .models.transaction import Call
284
+
285
+ tx_hash = await execute_paymaster_transaction(
286
+ params={
287
+ "encryptKey": encrypt_key,
288
+ "wallet": {**wallet.model_dump(), "walletType": "CHIPI"},
289
+ "calls": [
290
+ Call(
291
+ contractAddress=wallet.public_key,
292
+ entrypoint=SESSION_ENTRYPOINTS["REVOKE"],
293
+ calldata=[session_public_key],
294
+ ).model_dump()
295
+ ],
296
+ "saveToDatabase": False,
297
+ },
298
+ bearer_token=bearer_token,
299
+ client=self.client,
300
+ )
301
+
302
+ print(
303
+ "[ChipiSDK:Session:Revoke] Session revoked successfully",
304
+ {
305
+ "txHash": tx_hash,
306
+ "sessionRevoked": session_public_key[:15] + "...",
307
+ },
308
+ )
309
+
310
+ return tx_hash
311
+ except Exception as error:
312
+ print("[ChipiSDK:Session:Revoke] Revocation failed", {"error": str(error)})
313
+ raise
314
+
315
+ def revoke_session_key(
316
+ self, params: RevokeSessionKeyParams, bearer_token: str
317
+ ) -> str:
318
+ """
319
+ Revoke a session key from the smart contract (sync).
320
+
321
+ Args:
322
+ params: Session revocation parameters
323
+ bearer_token: Authentication token
324
+
325
+ Returns:
326
+ Transaction hash
327
+ """
328
+ import asyncio
329
+ return asyncio.run(self.arevoke_session_key(params, bearer_token))
330
+
331
+ async def aget_session_data(
332
+ self, params: GetSessionDataParams
333
+ ) -> SessionDataResponse:
334
+ """
335
+ Query session data from the smart contract (async).
336
+
337
+ This is a read-only call that does not require signing or gas.
338
+
339
+ Args:
340
+ params: Query parameters
341
+
342
+ Returns:
343
+ Session data including status and remaining calls
344
+ """
345
+ wallet_address = params.wallet_address
346
+ session_public_key = params.session_public_key
347
+
348
+ try:
349
+ provider = FullNodeClient(node_url=WALLET_RPC_ENDPOINTS[WalletType.CHIPI])
350
+
351
+ print(
352
+ "[ChipiSDK:Session:GetData] Querying session data",
353
+ {
354
+ "walletAddress": wallet_address[:15] + "...",
355
+ "sessionPubKey": session_public_key[:15] + "...",
356
+ },
357
+ )
358
+
359
+ result = await provider.call_contract(
360
+ call={
361
+ "contract_address": int(wallet_address, 16),
362
+ "entry_point_selector": get_selector_from_name(
363
+ SESSION_ENTRYPOINTS["GET_DATA"]
364
+ ),
365
+ "calldata": [int(session_public_key, 16)],
366
+ }
367
+ )
368
+
369
+ # Parse the response
370
+ if len(result) < 4:
371
+ print(
372
+ "[ChipiSDK:Session:GetData] Unexpected response format",
373
+ {"resultLength": len(result)},
374
+ )
375
+ return SessionDataResponse(
376
+ is_active=False,
377
+ valid_until=0,
378
+ remaining_calls=0,
379
+ allowed_entrypoints=[],
380
+ )
381
+
382
+ is_active = result[0] == 1
383
+ valid_until = int(result[1])
384
+ remaining_calls = int(result[2])
385
+ entrypoints_len = int(result[3])
386
+ allowed_entrypoints = [hex(val) for val in result[4 : 4 + entrypoints_len]]
387
+
388
+ session_data = SessionDataResponse(
389
+ is_active=is_active,
390
+ valid_until=valid_until,
391
+ remaining_calls=remaining_calls,
392
+ allowed_entrypoints=allowed_entrypoints,
393
+ )
394
+
395
+ print(
396
+ "[ChipiSDK:Session:GetData] Session data retrieved",
397
+ {
398
+ "isActive": is_active,
399
+ "validUntil": time.strftime(
400
+ "%Y-%m-%d %H:%M:%S", time.gmtime(valid_until)
401
+ ),
402
+ "remainingCalls": remaining_calls,
403
+ "allowedEntrypointsCount": len(allowed_entrypoints),
404
+ },
405
+ )
406
+
407
+ return session_data
408
+ except Exception as error:
409
+ print(
410
+ "[ChipiSDK:Session:GetData] Query failed", {"error": str(error)}
411
+ )
412
+ # Return inactive session on error
413
+ return SessionDataResponse(
414
+ is_active=False,
415
+ valid_until=0,
416
+ remaining_calls=0,
417
+ allowed_entrypoints=[],
418
+ )
419
+
420
+ def get_session_data(self, params: GetSessionDataParams) -> SessionDataResponse:
421
+ """
422
+ Query session data from the smart contract (sync).
423
+
424
+ Args:
425
+ params: Query parameters
426
+
427
+ Returns:
428
+ Session data
429
+ """
430
+ import asyncio
431
+ return asyncio.run(self.aget_session_data(params))
432
+
433
+ async def aexecute_transaction_with_session(
434
+ self, params: ExecuteWithSessionParams, bearer_token: str
435
+ ) -> str:
436
+ """
437
+ Execute a gasless transaction using a session key (async).
438
+
439
+ The session must be registered on-chain before use.
440
+ CHIPI wallets only.
441
+
442
+ Args:
443
+ params: Session execution parameters
444
+ bearer_token: Authentication token
445
+
446
+ Returns:
447
+ Transaction hash
448
+ """
449
+ # Validate wallet type if provided
450
+ if params.wallet.wallet_type and params.wallet.wallet_type != WalletType.CHIPI:
451
+ raise ChipiSessionError(
452
+ f"Session execution only supports CHIPI wallets. Got: {params.wallet.wallet_type}",
453
+ SESSION_ERRORS["INVALID_WALLET_TYPE_FOR_SESSION"],
454
+ )
455
+
456
+ return await execute_paymaster_transaction_with_session(
457
+ params={
458
+ "encryptKey": params.encrypt_key,
459
+ "wallet": {**params.wallet.model_dump(), "walletType": "CHIPI"},
460
+ "session": params.session.model_dump(),
461
+ "calls": [call.model_dump() for call in params.calls],
462
+ "saveToDatabase": True,
463
+ },
464
+ bearer_token=bearer_token,
465
+ client=self.client,
466
+ )
467
+
468
+ def execute_transaction_with_session(
469
+ self, params: ExecuteWithSessionParams, bearer_token: str
470
+ ) -> str:
471
+ """
472
+ Execute a gasless transaction using a session key (sync).
473
+
474
+ Args:
475
+ params: Session execution parameters
476
+ bearer_token: Authentication token
477
+
478
+ Returns:
479
+ Transaction hash
480
+ """
481
+ # Validate wallet type if provided
482
+ if params.wallet.wallet_type and params.wallet.wallet_type != WalletType.CHIPI:
483
+ raise ChipiSessionError(
484
+ f"Session execution only supports CHIPI wallets. Got: {params.wallet.wallet_type}",
485
+ SESSION_ERRORS["INVALID_WALLET_TYPE_FOR_SESSION"],
486
+ )
487
+
488
+ return execute_paymaster_transaction_with_session_sync(
489
+ params={
490
+ "encryptKey": params.encrypt_key,
491
+ "wallet": {**params.wallet.model_dump(), "walletType": "CHIPI"},
492
+ "session": params.session.model_dump(),
493
+ "calls": [call.model_dump() for call in params.calls],
494
+ "saveToDatabase": True,
495
+ },
496
+ bearer_token=bearer_token,
497
+ client=self.client,
498
+ )
499
+
500
+
501
+ # --- Spending Policy Methods ---
502
+
503
+ def _split_u256(self, value: int) -> tuple[str, str]:
504
+ """Split an integer into Cairo u256 (low u128, high u128) hex strings."""
505
+ mask = (1 << 128) - 1
506
+ return (hex(value & mask), hex(value >> 128))
507
+
508
+ def _join_u256(self, low: int, high: int) -> int:
509
+ """Reconstruct an integer from Cairo u256 (low, high) parts."""
510
+ return low + (high << 128)
511
+
512
+ async def aset_spending_policy(
513
+ self, params: SetSpendingPolicyParams, bearer_token: str
514
+ ) -> str:
515
+ """
516
+ Set a spending policy for a session key + token pair (async).
517
+
518
+ Args:
519
+ params: Spending policy parameters
520
+ bearer_token: Authentication token
521
+
522
+ Returns:
523
+ Transaction hash
524
+ """
525
+ encrypt_key = params.encrypt_key
526
+ wallet = params.wallet
527
+ config = params.spending_policy_config
528
+
529
+ # Validate wallet type if provided (backward compat: omitted = assume CHIPI)
530
+ if wallet.wallet_type and wallet.wallet_type != WalletType.CHIPI:
531
+ raise ChipiSessionError(
532
+ f"Spending policies only support CHIPI wallets. Got: {wallet.wallet_type}",
533
+ SESSION_ERRORS["INVALID_WALLET_TYPE_FOR_SESSION"],
534
+ )
535
+
536
+ # Validate config
537
+ u256_max = (1 << 256) - 1
538
+ u64_max = (1 << 64) - 1
539
+
540
+ if not config.token or config.token.strip() == "":
541
+ raise ChipiSessionError(
542
+ f"Invalid token address for spending policy: \"{config.token}\"",
543
+ SESSION_ERRORS["INVALID_SPENDING_POLICY"],
544
+ )
545
+ if not isinstance(config.window_seconds, int) or config.window_seconds <= 0 or config.window_seconds > u64_max:
546
+ raise ChipiSessionError(
547
+ f"Invalid window duration: {config.window_seconds}. Must be a positive integer within u64 range.",
548
+ SESSION_ERRORS["INVALID_SPENDING_POLICY"],
549
+ )
550
+ if config.max_per_call < 0 or config.max_per_call > u256_max:
551
+ raise ChipiSessionError(
552
+ "Invalid max_per_call: must be >= 0 and <= 2^256 - 1",
553
+ SESSION_ERRORS["INVALID_SPENDING_POLICY"],
554
+ )
555
+ if config.max_per_window < 0 or config.max_per_window > u256_max:
556
+ raise ChipiSessionError(
557
+ "Invalid max_per_window: must be >= 0 and <= 2^256 - 1",
558
+ SESSION_ERRORS["INVALID_SPENDING_POLICY"],
559
+ )
560
+ if config.max_per_window > 0 and config.max_per_call > config.max_per_window:
561
+ raise ChipiSessionError(
562
+ "Invalid spending policy: max_per_call cannot exceed max_per_window",
563
+ SESSION_ERRORS["INVALID_SPENDING_POLICY"],
564
+ )
565
+
566
+ try:
567
+ print(
568
+ "[ChipiSDK:Session:SetSpendingPolicy] Setting spending policy",
569
+ {
570
+ "walletAddress": wallet.public_key[:15] + "...",
571
+ "sessionPubKey": config.session_public_key[:15] + "...",
572
+ "token": config.token[:15] + "...",
573
+ "maxPerCall": str(config.max_per_call),
574
+ "maxPerWindow": str(config.max_per_window),
575
+ "windowSeconds": config.window_seconds,
576
+ },
577
+ )
578
+
579
+ max_per_call_low, max_per_call_high = self._split_u256(config.max_per_call)
580
+ max_per_window_low, max_per_window_high = self._split_u256(config.max_per_window)
581
+
582
+ calldata = [
583
+ config.session_public_key,
584
+ config.token,
585
+ max_per_call_low,
586
+ max_per_call_high,
587
+ max_per_window_low,
588
+ max_per_window_high,
589
+ hex(config.window_seconds),
590
+ ]
591
+
592
+ from .models.transaction import Call
593
+
594
+ tx_hash = await execute_paymaster_transaction(
595
+ params={
596
+ "encryptKey": encrypt_key,
597
+ "wallet": {**wallet.model_dump(), "walletType": "CHIPI"},
598
+ "calls": [
599
+ Call(
600
+ contractAddress=wallet.public_key,
601
+ entrypoint=SESSION_ENTRYPOINTS["SET_SPENDING_POLICY"],
602
+ calldata=calldata,
603
+ ).model_dump()
604
+ ],
605
+ "saveToDatabase": False,
606
+ },
607
+ bearer_token=bearer_token,
608
+ client=self.client,
609
+ )
610
+
611
+ print(
612
+ "[ChipiSDK:Session:SetSpendingPolicy] Spending policy set successfully",
613
+ {"txHash": tx_hash},
614
+ )
615
+
616
+ return tx_hash
617
+ except Exception as error:
618
+ print(
619
+ "[ChipiSDK:Session:SetSpendingPolicy] Failed",
620
+ {"error": str(error)},
621
+ )
622
+ raise
623
+
624
+ def set_spending_policy(
625
+ self, params: SetSpendingPolicyParams, bearer_token: str
626
+ ) -> str:
627
+ """Set a spending policy for a session key + token pair (sync)."""
628
+ import asyncio
629
+ return asyncio.run(self.aset_spending_policy(params, bearer_token))
630
+
631
+ async def aget_spending_policy(
632
+ self, params: GetSpendingPolicyParams
633
+ ) -> SpendingPolicyData:
634
+ """
635
+ Query a spending policy from the smart contract (async).
636
+
637
+ Read-only — no signature or gas required.
638
+
639
+ Args:
640
+ params: Query parameters
641
+
642
+ Returns:
643
+ Spending policy data
644
+ """
645
+ wallet_address = params.wallet_address
646
+ session_public_key = params.session_public_key
647
+ token = params.token
648
+
649
+ if not token or token.strip() == "":
650
+ raise ChipiSessionError(
651
+ f"Invalid token address for spending policy query: \"{token}\"",
652
+ SESSION_ERRORS["INVALID_SPENDING_POLICY"],
653
+ )
654
+
655
+ try:
656
+ provider = FullNodeClient(node_url=WALLET_RPC_ENDPOINTS[WalletType.CHIPI])
657
+
658
+ print(
659
+ "[ChipiSDK:Session:GetSpendingPolicy] Querying spending policy",
660
+ {
661
+ "walletAddress": wallet_address[:15] + "...",
662
+ "sessionPubKey": session_public_key[:15] + "...",
663
+ "token": token[:15] + "...",
664
+ },
665
+ )
666
+
667
+ result = await provider.call_contract(
668
+ call={
669
+ "contract_address": int(wallet_address, 16),
670
+ "entry_point_selector": get_selector_from_name(
671
+ SESSION_ENTRYPOINTS["GET_SPENDING_POLICY"]
672
+ ),
673
+ "calldata": [int(session_public_key, 16), int(token, 16)],
674
+ }
675
+ )
676
+
677
+ if len(result) < 8:
678
+ print(
679
+ "[ChipiSDK:Session:GetSpendingPolicy] Unexpected response format",
680
+ {"resultLength": len(result)},
681
+ )
682
+ return SpendingPolicyData(
683
+ max_per_call=0,
684
+ max_per_window=0,
685
+ window_seconds=0,
686
+ spent_in_window=0,
687
+ window_start=0,
688
+ )
689
+
690
+ policy_data = SpendingPolicyData(
691
+ max_per_call=self._join_u256(int(result[0]), int(result[1])),
692
+ max_per_window=self._join_u256(int(result[2]), int(result[3])),
693
+ window_seconds=int(result[4]),
694
+ spent_in_window=self._join_u256(int(result[5]), int(result[6])),
695
+ window_start=int(result[7]),
696
+ )
697
+
698
+ print(
699
+ "[ChipiSDK:Session:GetSpendingPolicy] Spending policy retrieved",
700
+ {
701
+ "maxPerCall": str(policy_data.max_per_call),
702
+ "maxPerWindow": str(policy_data.max_per_window),
703
+ "windowSeconds": policy_data.window_seconds,
704
+ },
705
+ )
706
+
707
+ return policy_data
708
+ except Exception as error:
709
+ import re
710
+ error_msg = str(error)
711
+ not_found_patterns = [
712
+ r"entry point.*not found",
713
+ r"requested entrypoint was not found",
714
+ r"invalid message selector",
715
+ r"contract not found",
716
+ r"is not deployed",
717
+ ]
718
+ is_not_found = any(re.search(p, error_msg, re.IGNORECASE) for p in not_found_patterns)
719
+
720
+ if is_not_found:
721
+ print(
722
+ "[ChipiSDK:Session:GetSpendingPolicy] No policy found",
723
+ {"walletAddress": wallet_address[:15] + "..."},
724
+ )
725
+ return SpendingPolicyData(
726
+ max_per_call=0,
727
+ max_per_window=0,
728
+ window_seconds=0,
729
+ spent_in_window=0,
730
+ window_start=0,
731
+ )
732
+
733
+ print(
734
+ "[ChipiSDK:Session:GetSpendingPolicy] RPC/network error",
735
+ {"error": error_msg},
736
+ )
737
+ raise
738
+
739
+ def get_spending_policy(self, params: GetSpendingPolicyParams) -> SpendingPolicyData:
740
+ """Query a spending policy from the smart contract (sync)."""
741
+ import asyncio
742
+ return asyncio.run(self.aget_spending_policy(params))
743
+
744
+ async def aremove_spending_policy(
745
+ self, params: RemoveSpendingPolicyParams, bearer_token: str
746
+ ) -> str:
747
+ """
748
+ Remove a spending policy for a session key + token pair (async).
749
+
750
+ Args:
751
+ params: Policy removal parameters
752
+ bearer_token: Authentication token
753
+
754
+ Returns:
755
+ Transaction hash
756
+ """
757
+ encrypt_key = params.encrypt_key
758
+ wallet = params.wallet
759
+ session_public_key = params.session_public_key
760
+ token = params.token
761
+
762
+ # Validate wallet type if provided (backward compat: omitted = assume CHIPI)
763
+ if wallet.wallet_type and wallet.wallet_type != WalletType.CHIPI:
764
+ raise ChipiSessionError(
765
+ f"Spending policies only support CHIPI wallets. Got: {wallet.wallet_type}",
766
+ SESSION_ERRORS["INVALID_WALLET_TYPE_FOR_SESSION"],
767
+ )
768
+
769
+ if not token or token.strip() == "":
770
+ raise ChipiSessionError(
771
+ f"Invalid token address for spending policy removal: \"{token}\"",
772
+ SESSION_ERRORS["INVALID_SPENDING_POLICY"],
773
+ )
774
+
775
+ try:
776
+ print(
777
+ "[ChipiSDK:Session:RemoveSpendingPolicy] Removing spending policy",
778
+ {
779
+ "walletAddress": wallet.public_key[:15] + "...",
780
+ "sessionPubKey": session_public_key[:15] + "...",
781
+ "token": token[:15] + "...",
782
+ },
783
+ )
784
+
785
+ from .models.transaction import Call
786
+
787
+ tx_hash = await execute_paymaster_transaction(
788
+ params={
789
+ "encryptKey": encrypt_key,
790
+ "wallet": {**wallet.model_dump(), "walletType": "CHIPI"},
791
+ "calls": [
792
+ Call(
793
+ contractAddress=wallet.public_key,
794
+ entrypoint=SESSION_ENTRYPOINTS["REMOVE_SPENDING_POLICY"],
795
+ calldata=[session_public_key, token],
796
+ ).model_dump()
797
+ ],
798
+ "saveToDatabase": False,
799
+ },
800
+ bearer_token=bearer_token,
801
+ client=self.client,
802
+ )
803
+
804
+ print(
805
+ "[ChipiSDK:Session:RemoveSpendingPolicy] Spending policy removed",
806
+ {"txHash": tx_hash},
807
+ )
808
+
809
+ return tx_hash
810
+ except Exception as error:
811
+ print(
812
+ "[ChipiSDK:Session:RemoveSpendingPolicy] Failed",
813
+ {"error": str(error)},
814
+ )
815
+ raise
816
+
817
+ def remove_spending_policy(
818
+ self, params: RemoveSpendingPolicyParams, bearer_token: str
819
+ ) -> str:
820
+ """Remove a spending policy for a session key + token pair (sync)."""
821
+ import asyncio
822
+ return asyncio.run(self.aremove_spending_policy(params, bearer_token))
823
+
824
+
825
+ def get_selector_from_name(name: str) -> int:
826
+ """
827
+ Get selector from function name.
828
+
829
+ Args:
830
+ name: Function name
831
+
832
+ Returns:
833
+ Selector as integer
834
+ """
835
+ from starknet_py.hash.selector import get_selector_from_name as _get_selector
836
+ return _get_selector(name)