iwa 0.1.4__py3-none-any.whl → 0.1.6__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.
@@ -11,6 +11,7 @@ from iwa.core.wallet import Wallet
11
11
  from iwa.plugins.olas.constants import OLAS_CONTRACTS
12
12
  from iwa.plugins.olas.contracts.service import ServiceManagerContract, ServiceRegistryContract
13
13
  from iwa.plugins.olas.models import OlasConfig
14
+ from iwa.web.cache import CacheTTL, response_cache
14
15
 
15
16
 
16
17
  class ServiceManagerBase:
@@ -99,11 +100,14 @@ class ServiceManagerBase:
99
100
  return None
100
101
  return self.registry.get_service(self.service.service_id)
101
102
 
102
- def get_service_state(self, service_id: Optional[int] = None) -> str:
103
+ def get_service_state(
104
+ self, service_id: Optional[int] = None, force_refresh: bool = False
105
+ ) -> str:
103
106
  """Get the state of a service as a string.
104
107
 
105
108
  Args:
106
109
  service_id: Optional service ID. If not provided, uses the active service.
110
+ force_refresh: If True, bypass cache and fetch fresh data.
107
111
 
108
112
  Returns:
109
113
  The state name (e.g., 'DEPLOYED') or 'UNKNOWN' if not found.
@@ -114,9 +118,24 @@ class ServiceManagerBase:
114
118
  return "UNKNOWN"
115
119
  service_id = self.service.service_id
116
120
 
117
- try:
118
- info = self.registry.get_service(service_id)
119
- return info["state"].name
120
- except Exception as e:
121
- logger.debug(f"Failed to get service state for {service_id}: {e}")
122
- return "UNKNOWN"
121
+ # Build cache key using service_key if available, else chain:id
122
+ if self.service and self.service.key:
123
+ cache_key = f"service_state:{self.service.key}"
124
+ else:
125
+ cache_key = f"service_state:{self.chain_name}:{service_id}"
126
+
127
+ # Invalidate cache if force refresh requested
128
+ if force_refresh:
129
+ response_cache.invalidate(cache_key)
130
+
131
+ def fetch_state():
132
+ try:
133
+ info = self.registry.get_service(service_id)
134
+ return info["state"].name
135
+ except Exception as e:
136
+ logger.debug(f"Failed to get service state for {service_id}: {e}")
137
+ return "UNKNOWN"
138
+
139
+ return response_cache.get_or_compute(
140
+ cache_key, fetch_state, CacheTTL.SERVICE_STATE
141
+ )
@@ -7,6 +7,7 @@ from loguru import logger
7
7
  from iwa.core.contracts.erc20 import ERC20Contract
8
8
  from iwa.plugins.olas.constants import OLAS_TOKEN_ADDRESS_GNOSIS
9
9
  from iwa.plugins.olas.contracts.staking import StakingContract, StakingState
10
+ from iwa.web.cache import response_cache
10
11
 
11
12
 
12
13
  class DrainManagerMixin:
@@ -110,6 +111,11 @@ class DrainManagerMixin:
110
111
  logger.warning("RewardClaimed event not found, using estimated amount")
111
112
 
112
113
  logger.info(f"Successfully claimed {claimed_amount / 1e18:.4f} OLAS rewards")
114
+
115
+ # Invalidate caches - rewards and balances changed
116
+ response_cache.invalidate(f"staking_status:{self.service.key}")
117
+ response_cache.invalidate(f"balances:{self.service.key}")
118
+
113
119
  return True, claimed_amount
114
120
 
115
121
  def withdraw_rewards(self) -> Tuple[bool, float]:
@@ -179,6 +185,9 @@ class DrainManagerMixin:
179
185
  logger.error("Failed to transfer OLAS")
180
186
  return False, 0
181
187
 
188
+ # Invalidate balances cache - OLAS moved from Safe
189
+ response_cache.invalidate(f"balances:{self.service.key}")
190
+
182
191
  logger.info(f"Withdrew {olas_amount:.4f} OLAS to {withdrawal_tag}")
183
192
  return True, olas_amount
184
193
 
@@ -238,6 +247,9 @@ class DrainManagerMixin:
238
247
  logger.info("Drain returned empty but rewards were claimed. Reporting partial success.")
239
248
  drained["safe_rewards_only"] = {"olas": claimed_rewards / 1e18}
240
249
 
250
+ # Invalidate balances cache - multiple accounts drained
251
+ response_cache.invalidate(f"balances:{self.service.key}")
252
+
241
253
  logger.info(f"Drain complete. Accounts drained: {list(drained.keys())}")
242
254
  return drained
243
255
 
@@ -75,6 +75,7 @@ from iwa.plugins.olas.constants import (
75
75
  )
76
76
  from iwa.plugins.olas.contracts.service import ServiceState
77
77
  from iwa.plugins.olas.models import Service
78
+ from iwa.web.cache import response_cache
78
79
 
79
80
 
80
81
  class LifecycleManagerMixin:
@@ -899,6 +900,10 @@ class LifecycleManagerMixin:
899
900
  except Exception as e:
900
901
  logger.error(f"[DEPLOY] Failed to fund multisig: {e}")
901
902
 
903
+ # Invalidate service state cache
904
+ if self.service:
905
+ response_cache.invalidate(f"service_state:{self.service.key}")
906
+
902
907
  logger.info("[DEPLOY] Success - service is now DEPLOYED")
903
908
  return multisig_address
904
909
 
@@ -949,6 +954,9 @@ class LifecycleManagerMixin:
949
954
  logger.error("Terminate service event not found")
950
955
  return False
951
956
 
957
+ # Invalidate service state cache
958
+ response_cache.invalidate(f"service_state:{self.service.key}")
959
+
952
960
  logger.info("Service terminated successfully")
953
961
  return True
954
962
 
@@ -984,6 +992,9 @@ class LifecycleManagerMixin:
984
992
  logger.error("Unbond service event not found")
985
993
  return False
986
994
 
995
+ # Invalidate service state cache
996
+ response_cache.invalidate(f"service_state:{self.service.key}")
997
+
987
998
  logger.info("Service unbonded successfully")
988
999
  return True
989
1000
 
@@ -55,6 +55,7 @@ from iwa.core.types import EthereumAddress
55
55
  from iwa.core.utils import get_tx_hash
56
56
  from iwa.plugins.olas.contracts.staking import StakingContract, StakingState
57
57
  from iwa.plugins.olas.models import StakingStatus
58
+ from iwa.web.cache import CacheTTL, response_cache
58
59
 
59
60
 
60
61
  class StakingManagerMixin:
@@ -100,9 +101,12 @@ class StakingManagerMixin:
100
101
 
101
102
  return address
102
103
 
103
- def get_staking_status(self) -> Optional[StakingStatus]:
104
+ def get_staking_status(self, force_refresh: bool = False) -> Optional[StakingStatus]:
104
105
  """Get comprehensive staking status for the active service.
105
106
 
107
+ Args:
108
+ force_refresh: If True, bypass cache and fetch fresh data.
109
+
106
110
  Returns:
107
111
  StakingStatus with liveness check info, or None if no service loaded.
108
112
 
@@ -111,6 +115,22 @@ class StakingManagerMixin:
111
115
  logger.error("No active service")
112
116
  return None
113
117
 
118
+ # Build cache key
119
+ cache_key = f"staking_status:{self.service.key}"
120
+
121
+ # Invalidate cache if force refresh requested
122
+ if force_refresh:
123
+ response_cache.invalidate(cache_key)
124
+
125
+ def fetch_staking_status():
126
+ return self._fetch_staking_status_impl()
127
+
128
+ return response_cache.get_or_compute(
129
+ cache_key, fetch_staking_status, CacheTTL.STAKING_STATUS
130
+ )
131
+
132
+ def _fetch_staking_status_impl(self) -> Optional[StakingStatus]:
133
+ """Internal implementation for fetching staking status (uncached)."""
114
134
  service_id = self.service.service_id
115
135
  staking_address = self.service.staking_contract_address
116
136
 
@@ -534,6 +554,11 @@ class StakingManagerMixin:
534
554
  self.service.staking_contract_address = EthereumAddress(staking_contract.address)
535
555
  self._update_and_save_service_state()
536
556
 
557
+ # Invalidate caches - state and balances changed
558
+ response_cache.invalidate(f"service_state:{self.service.key}")
559
+ response_cache.invalidate(f"staking_status:{self.service.key}")
560
+ response_cache.invalidate(f"balances:{self.service.key}")
561
+
537
562
  logger.info(f"[STAKE] Service {self.service.service_id} is now STAKED")
538
563
  return True
539
564
 
@@ -620,10 +645,15 @@ class StakingManagerMixin:
620
645
  self.service.staking_contract_address = None
621
646
  self._update_and_save_service_state()
622
647
 
648
+ # Invalidate caches - state and balances changed
649
+ response_cache.invalidate(f"service_state:{self.service.key}")
650
+ response_cache.invalidate(f"staking_status:{self.service.key}")
651
+ response_cache.invalidate(f"balances:{self.service.key}")
652
+
623
653
  logger.info("Service unstaked successfully")
624
654
  return True
625
655
 
626
- def call_checkpoint(
656
+ def call_checkpoint( # noqa: C901
627
657
  self,
628
658
  staking_contract: Optional[StakingContract] = None,
629
659
  grace_period_seconds: int = 600,
@@ -720,4 +750,8 @@ class StakingManagerMixin:
720
750
  service_ids = [e["args"]["serviceId"] for e in inactivity_warnings]
721
751
  logger.warning(f"Services with inactivity warnings: {service_ids}")
722
752
 
753
+ # Invalidate staking status cache - epoch info changed
754
+ if self.service:
755
+ response_cache.invalidate(f"staking_status:{self.service.key}")
756
+
723
757
  return True
@@ -170,14 +170,14 @@ def test_service_manager_staking_status_failures(mock_manager):
170
170
  manager.service = Service(service_name="t", chain_name="gnosis", service_id=1, agent_ids=[1])
171
171
  # Staking contract missing
172
172
  manager.service.staking_contract_address = None
173
- status = manager.get_staking_status()
173
+ status = manager.get_staking_status(force_refresh=True)
174
174
  assert status.is_staked is False
175
175
 
176
176
  # Exception in contract loading
177
177
  manager.service.staking_contract_address = VALID_ADDR
178
178
  with patch("iwa.plugins.olas.service_manager.staking.StakingContract") as mock_staking_cls:
179
179
  mock_staking_cls.side_effect = Exception("fail")
180
- status = manager.get_staking_status()
180
+ status = manager.get_staking_status(force_refresh=True)
181
181
  assert status.staking_state == "ERROR"
182
182
 
183
183
  # Exception in get_service_info
@@ -185,7 +185,7 @@ def test_service_manager_staking_status_failures(mock_manager):
185
185
  mock_staking = mock_staking_cls.return_value
186
186
  mock_staking.get_staking_state.return_value = StakingState.STAKED
187
187
  mock_staking.get_service_info.side_effect = Exception("fail")
188
- status = manager.get_staking_status()
188
+ status = manager.get_staking_status(force_refresh=True)
189
189
  assert status.is_staked is True
190
190
  assert status.mech_requests_this_epoch == 0
191
191
 
@@ -194,7 +194,7 @@ def test_sm_get_staking_status_with_full_info(mock_wallet):
194
194
  patch("iwa.plugins.olas.service_manager.staking.Web3") as mock_web3,
195
195
  ):
196
196
  mock_web3.from_wei.return_value = 0.001
197
- status = sm.get_staking_status()
197
+ status = sm.get_staking_status(force_refresh=True)
198
198
  assert status.is_staked is True
199
199
  assert status.epoch_number == 5
200
200
 
iwa/web/cache.py ADDED
@@ -0,0 +1,143 @@
1
+ """Response cache for web API endpoints to reduce RPC calls."""
2
+
3
+ import os
4
+ import time
5
+ from threading import Lock
6
+ from typing import Any, Callable, Dict, Optional, TypeVar
7
+
8
+ from loguru import logger
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class ResponseCache:
14
+ """Singleton TTL cache for API response data.
15
+
16
+ Caches expensive query results (service status, balances, etc.)
17
+ to prevent redundant RPC calls when refreshing the web UI.
18
+ """
19
+
20
+ _instance: Optional["ResponseCache"] = None
21
+ _lock = Lock()
22
+
23
+ def __new__(cls) -> "ResponseCache":
24
+ """Ensure singleton instance."""
25
+ with cls._lock:
26
+ if cls._instance is None:
27
+ cls._instance = super().__new__(cls)
28
+ cls._instance._cache: Dict[str, Any] = {}
29
+ cls._instance._timestamps: Dict[str, float] = {}
30
+ cls._instance._enabled = os.environ.get("IWA_RESPONSE_CACHE", "1") != "0"
31
+ return cls._instance
32
+
33
+ def get(self, key: str, ttl_seconds: int = 60) -> Optional[Any]:
34
+ """Get a cached value if it exists and hasn't expired.
35
+
36
+ Args:
37
+ key: Cache key.
38
+ ttl_seconds: Time-to-live in seconds.
39
+
40
+ Returns:
41
+ Cached value or None if not found/expired.
42
+
43
+ """
44
+ if not self._enabled:
45
+ return None
46
+
47
+ with self._lock:
48
+ if key in self._cache:
49
+ created_at = self._timestamps.get(key, 0)
50
+ if time.time() - created_at < ttl_seconds:
51
+ logger.debug(f"Cache HIT: {key}")
52
+ return self._cache[key]
53
+ else:
54
+ # Expired
55
+ del self._cache[key]
56
+ del self._timestamps[key]
57
+ return None
58
+
59
+ def set(self, key: str, value: Any) -> None:
60
+ """Store a value in the cache.
61
+
62
+ Args:
63
+ key: Cache key.
64
+ value: Value to cache.
65
+
66
+ """
67
+ if not self._enabled:
68
+ return
69
+
70
+ with self._lock:
71
+ self._cache[key] = value
72
+ self._timestamps[key] = time.time()
73
+ logger.debug(f"Cache SET: {key}")
74
+
75
+ def invalidate(self, pattern: Optional[str] = None) -> None:
76
+ """Invalidate cache entries.
77
+
78
+ Args:
79
+ pattern: If provided, invalidate keys containing this pattern.
80
+ If None, clear entire cache.
81
+
82
+ """
83
+ with self._lock:
84
+ if pattern is None:
85
+ self._cache.clear()
86
+ self._timestamps.clear()
87
+ logger.debug("Cache cleared")
88
+ else:
89
+ keys_to_remove = [k for k in self._cache if pattern in k]
90
+ for key in keys_to_remove:
91
+ del self._cache[key]
92
+ del self._timestamps[key]
93
+ if keys_to_remove:
94
+ logger.debug(f"Cache invalidated {len(keys_to_remove)} entries matching '{pattern}'")
95
+
96
+ def get_or_compute(
97
+ self,
98
+ key: str,
99
+ compute_fn: Callable[[], T],
100
+ ttl_seconds: int = 60,
101
+ ) -> T:
102
+ """Get cached value or compute and cache it.
103
+
104
+ Args:
105
+ key: Cache key.
106
+ compute_fn: Function to compute the value if not cached.
107
+ ttl_seconds: Time-to-live in seconds.
108
+
109
+ Returns:
110
+ Cached or computed value.
111
+
112
+ """
113
+ cached = self.get(key, ttl_seconds)
114
+ if cached is not None:
115
+ return cached
116
+
117
+ value = compute_fn()
118
+ self.set(key, value)
119
+ return value
120
+
121
+
122
+ # Singleton accessor
123
+ response_cache = ResponseCache()
124
+
125
+
126
+ # TTL constants for different data types (in seconds)
127
+ class CacheTTL:
128
+ """Standard TTL values for different data types."""
129
+
130
+ # Service state changes infrequently (after user actions)
131
+ SERVICE_STATE = 120 # 2 minutes
132
+
133
+ # Staking status (epoch info, rewards) changes slowly
134
+ STAKING_STATUS = 60 # 1 minute
135
+
136
+ # Balances can change via external transactions
137
+ BALANCES = 30 # 30 seconds
138
+
139
+ # Account list rarely changes
140
+ ACCOUNTS = 300 # 5 minutes
141
+
142
+ # Basic service info (config-based) rarely changes
143
+ SERVICE_BASIC = 120 # 2 minutes
@@ -7,6 +7,7 @@ from loguru import logger
7
7
  from slowapi import Limiter
8
8
  from slowapi.util import get_remote_address
9
9
 
10
+ from iwa.web.cache import CacheTTL, response_cache
10
11
  from iwa.web.dependencies import verify_auth, wallet
11
12
  from iwa.web.models import AccountCreateRequest, SafeCreateRequest
12
13
 
@@ -24,39 +25,62 @@ limiter = Limiter(key_func=get_remote_address)
24
25
  def get_accounts(
25
26
  chain: str = "gnosis",
26
27
  tokens: str = None,
28
+ refresh: bool = False,
27
29
  auth: bool = Depends(verify_auth),
28
30
  ):
29
- """Get all accounts and their balances for a specific chain."""
31
+ """Get all accounts and their balances for a specific chain.
32
+
33
+ Args:
34
+ chain: Chain name to query balances on.
35
+ tokens: Comma-separated list of token names to fetch balances for.
36
+ refresh: If true, bypass cache and fetch fresh data.
37
+ auth: Authentication dependency (injected).
38
+
39
+ """
30
40
  if not chain.replace("-", "").isalnum():
31
41
  raise HTTPException(status_code=400, detail="Invalid chain name")
32
- try:
33
- # Parse tokens from query parameter or use defaults
34
- if tokens:
35
- token_names = [t.strip() for t in tokens.split(",") if t.strip()]
36
- else:
37
- token_names = ["native", "OLAS", "WXDAI", "USDC"]
38
-
39
- accounts_data, balances = wallet.get_accounts_balances(chain, token_names)
40
-
41
- # Merge data
42
- result = []
43
- for addr, data in accounts_data.items():
44
- account_balances = balances.get(addr, {})
45
- # Determine account type: if it has 'signers' attribute, it's a Safe
46
- account_type = "Safe" if hasattr(data, "signers") else "EOA"
47
- result.append(
48
- {
49
- "address": addr,
50
- "tag": data.tag,
51
- "type": account_type,
52
- "balances": account_balances,
53
- }
42
+
43
+ # Parse tokens from query parameter or use defaults
44
+ if tokens:
45
+ token_names = [t.strip() for t in tokens.split(",") if t.strip()]
46
+ else:
47
+ token_names = ["native", "OLAS", "WXDAI", "USDC"]
48
+
49
+ # Create cache key from chain and tokens
50
+ cache_key = f"accounts:{chain}:{','.join(sorted(token_names))}"
51
+
52
+ # Invalidate cache if refresh requested
53
+ if refresh:
54
+ response_cache.invalidate(cache_key)
55
+
56
+ def fetch_accounts():
57
+ try:
58
+ accounts_data, balances = wallet.get_accounts_balances(
59
+ chain, token_names
54
60
  )
55
61
 
56
- return result
57
- except Exception as e:
58
- logger.error(f"Error fetching accounts: {e}")
59
- raise HTTPException(status_code=500, detail=str(e)) from None
62
+ # Merge data
63
+ result = []
64
+ for addr, data in accounts_data.items():
65
+ account_balances = balances.get(addr, {})
66
+ # Determine account type: if it has 'signers' attribute, it's a Safe
67
+ account_type = "Safe" if hasattr(data, "signers") else "EOA"
68
+ result.append(
69
+ {
70
+ "address": addr,
71
+ "tag": data.tag,
72
+ "type": account_type,
73
+ "balances": account_balances,
74
+ }
75
+ )
76
+ return result
77
+ except Exception as e:
78
+ logger.error(f"Error fetching accounts: {e}")
79
+ raise HTTPException(status_code=500, detail=str(e)) from None
80
+
81
+ return response_cache.get_or_compute(
82
+ cache_key, fetch_accounts, CacheTTL.BALANCES
83
+ )
60
84
 
61
85
 
62
86
  @router.post(
@@ -69,6 +93,8 @@ def create_eoa(request: Request, req: AccountCreateRequest, auth: bool = Depends
69
93
  """Create a new EOA account with the given tag."""
70
94
  try:
71
95
  wallet.key_storage.generate_new_account(req.tag)
96
+ # Invalidate accounts cache (new account added)
97
+ response_cache.invalidate("accounts:")
72
98
  return {"status": "success"}
73
99
  except Exception as e:
74
100
  raise HTTPException(status_code=400, detail=str(e)) from None
@@ -108,6 +134,8 @@ def create_safe(request: Request, req: SafeCreateRequest, auth: bool = Depends(v
108
134
  req.tag,
109
135
  salt_nonce,
110
136
  )
137
+ # Invalidate accounts cache (new Safe added)
138
+ response_cache.invalidate("accounts:")
111
139
  return {"status": "success"}
112
140
  except Exception as e:
113
141
  logger.error(f"Error creating Safe: {e}")