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.
- iwa/plugins/olas/service_manager/base.py +26 -7
- iwa/plugins/olas/service_manager/drain.py +12 -0
- iwa/plugins/olas/service_manager/lifecycle.py +11 -0
- iwa/plugins/olas/service_manager/staking.py +36 -2
- iwa/plugins/olas/tests/test_service_manager_errors.py +3 -3
- iwa/plugins/olas/tests/test_service_staking.py +1 -1
- iwa/web/cache.py +143 -0
- iwa/web/routers/accounts.py +55 -27
- iwa/web/routers/olas/services.py +109 -41
- iwa/web/routers/olas/staking.py +4 -0
- iwa/web/server.py +1 -0
- iwa/web/tests/test_response_cache.py +660 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/METADATA +1 -1
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/RECORD +18 -16
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/WHEEL +0 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/entry_points.txt +0 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.1.4.dist-info → iwa-0.1.6.dist-info}/top_level.txt +0 -0
|
@@ -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(
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
iwa/web/routers/accounts.py
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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}")
|