iwa 0.0.18__py3-none-any.whl → 0.0.20__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/core/chainlist.py +116 -0
- iwa/core/constants.py +1 -0
- iwa/core/contracts/cache.py +131 -0
- iwa/core/contracts/contract.py +7 -0
- iwa/core/rpc_monitor.py +60 -0
- iwa/plugins/olas/constants.py +41 -39
- iwa/plugins/olas/contracts/abis/mech_marketplace_v1.json +828 -0
- iwa/plugins/olas/contracts/activity_checker.py +63 -25
- iwa/plugins/olas/contracts/mech_marketplace_v1.py +68 -0
- iwa/plugins/olas/contracts/staking.py +115 -19
- iwa/plugins/olas/events.py +141 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +1 -1
- iwa/plugins/olas/service_manager/base.py +7 -2
- iwa/plugins/olas/service_manager/lifecycle.py +30 -5
- iwa/plugins/olas/service_manager/mech.py +251 -42
- iwa/plugins/olas/service_manager/staking.py +6 -2
- iwa/plugins/olas/tests/test_olas_integration.py +38 -10
- iwa/plugins/olas/tests/test_service_manager.py +7 -1
- iwa/plugins/olas/tests/test_service_manager_errors.py +22 -11
- iwa/plugins/olas/tests/test_service_manager_flows.py +24 -8
- iwa/plugins/olas/tests/test_service_staking.py +59 -15
- iwa/plugins/olas/tests/test_staking_validation.py +8 -14
- iwa/tools/reset_tenderly.py +2 -2
- iwa/tools/test_chainlist.py +38 -0
- iwa/web/routers/olas/staking.py +9 -4
- {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/METADATA +1 -1
- {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/RECORD +32 -24
- tests/test_rpc_efficiency.py +103 -0
- {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/WHEEL +0 -0
- {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/top_level.txt +0 -0
|
@@ -8,7 +8,7 @@ The liveness check (isRatioPass) verifies that the service is making enough mech
|
|
|
8
8
|
requests relative to the time elapsed since the last checkpoint.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
from typing import Tuple
|
|
11
|
+
from typing import Optional, Tuple
|
|
12
12
|
|
|
13
13
|
from iwa.core.constants import DEFAULT_MECH_CONTRACT_ADDRESS
|
|
14
14
|
from iwa.core.types import EthereumAddress
|
|
@@ -43,27 +43,11 @@ class ActivityCheckerContract(ContractInstance):
|
|
|
43
43
|
"""
|
|
44
44
|
super().__init__(address, chain_name=chain_name)
|
|
45
45
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
self.mech_marketplace = None
|
|
52
|
-
|
|
53
|
-
# Get the mech address this checker tracks (legacy or priority mech)
|
|
54
|
-
try:
|
|
55
|
-
agent_mech_function = getattr(self.contract.functions, "agentMech", None)
|
|
56
|
-
self.agent_mech = (
|
|
57
|
-
agent_mech_function().call() if agent_mech_function else DEFAULT_MECH_CONTRACT_ADDRESS
|
|
58
|
-
)
|
|
59
|
-
except Exception:
|
|
60
|
-
self.agent_mech = DEFAULT_MECH_CONTRACT_ADDRESS
|
|
61
|
-
|
|
62
|
-
# Get liveness ratio (requests per second * 1e18)
|
|
63
|
-
try:
|
|
64
|
-
self.liveness_ratio = self.contract.functions.livenessRatio().call()
|
|
65
|
-
except Exception:
|
|
66
|
-
self.liveness_ratio = 0
|
|
46
|
+
# Cache for lazy loading
|
|
47
|
+
self._mech_marketplace: Optional[EthereumAddress] = None
|
|
48
|
+
self._agent_mech: Optional[EthereumAddress] = None
|
|
49
|
+
self._liveness_ratio: Optional[int] = None
|
|
50
|
+
|
|
67
51
|
|
|
68
52
|
def get_multisig_nonces(self, multisig: EthereumAddress) -> Tuple[int, int]:
|
|
69
53
|
"""Get the nonces for a multisig address.
|
|
@@ -80,6 +64,41 @@ class ActivityCheckerContract(ContractInstance):
|
|
|
80
64
|
nonces = self.contract.functions.getMultisigNonces(multisig).call()
|
|
81
65
|
return (nonces[0], nonces[1])
|
|
82
66
|
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def mech_marketplace(self) -> Optional[EthereumAddress]:
|
|
70
|
+
"""Get the mech marketplace address."""
|
|
71
|
+
if self._mech_marketplace is None:
|
|
72
|
+
try:
|
|
73
|
+
mech_mp_function = getattr(self.contract.functions, "mechMarketplace", None)
|
|
74
|
+
self._mech_marketplace = mech_mp_function().call() if mech_mp_function else None
|
|
75
|
+
except Exception:
|
|
76
|
+
self._mech_marketplace = None
|
|
77
|
+
return self._mech_marketplace
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def agent_mech(self) -> EthereumAddress:
|
|
81
|
+
"""Get the agent mech address."""
|
|
82
|
+
if self._agent_mech is None:
|
|
83
|
+
try:
|
|
84
|
+
agent_mech_function = getattr(self.contract.functions, "agentMech", None)
|
|
85
|
+
self._agent_mech = (
|
|
86
|
+
agent_mech_function().call() if agent_mech_function else DEFAULT_MECH_CONTRACT_ADDRESS
|
|
87
|
+
)
|
|
88
|
+
except Exception:
|
|
89
|
+
self._agent_mech = DEFAULT_MECH_CONTRACT_ADDRESS
|
|
90
|
+
return self._agent_mech
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def liveness_ratio(self) -> int:
|
|
94
|
+
"""Get the liveness ratio."""
|
|
95
|
+
if self._liveness_ratio is None:
|
|
96
|
+
try:
|
|
97
|
+
self._liveness_ratio = self.contract.functions.livenessRatio().call()
|
|
98
|
+
except Exception:
|
|
99
|
+
self._liveness_ratio = 0
|
|
100
|
+
return self._liveness_ratio
|
|
101
|
+
|
|
83
102
|
def is_ratio_pass(
|
|
84
103
|
self,
|
|
85
104
|
current_nonces: Tuple[int, int],
|
|
@@ -101,6 +120,25 @@ class ActivityCheckerContract(ContractInstance):
|
|
|
101
120
|
True if liveness requirements are met.
|
|
102
121
|
|
|
103
122
|
"""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
123
|
+
# Optimized implementation to avoid RPC call
|
|
124
|
+
current_safe, current_requests = current_nonces
|
|
125
|
+
last_safe, last_requests = last_nonces
|
|
126
|
+
|
|
127
|
+
diff_safe = current_safe - last_safe
|
|
128
|
+
diff_requests = current_requests - last_requests
|
|
129
|
+
|
|
130
|
+
# 1. Check if requests exceed transactions (impossible in valid operation)
|
|
131
|
+
# Also check for negative diffs (data corruption/stale data edge case)
|
|
132
|
+
if diff_requests > diff_safe or diff_requests < 0 or diff_safe < 0:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# 2. Check time difference validity
|
|
136
|
+
if ts_diff == 0:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
# 3. Check ratio
|
|
140
|
+
# ratio = (diffRequests * 1e18) / ts_diff >= livenessRatio
|
|
141
|
+
# We use integer arithmetic as per Solidity
|
|
142
|
+
ratio = (diff_requests * 10**18) // ts_diff
|
|
143
|
+
|
|
144
|
+
return ratio >= self.liveness_ratio
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Mech Marketplace V1 contract interaction (VERSION 1.0.0).
|
|
2
|
+
|
|
3
|
+
This contract version is used by older staking programs like Expert 17 MM.
|
|
4
|
+
It has a different request signature than v2, requiring staking instance
|
|
5
|
+
and service ID parameters for both the mech and the requester.
|
|
6
|
+
|
|
7
|
+
Key differences from v2:
|
|
8
|
+
- request() takes staking instance + service ID for both mech and requester
|
|
9
|
+
- No payment types or balance trackers
|
|
10
|
+
- No checkMech or mapPaymentTypeBalanceTrackers functions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Optional
|
|
16
|
+
|
|
17
|
+
from iwa.core.contracts.contract import ContractInstance
|
|
18
|
+
from iwa.core.types import EthereumAddress
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class V1RequestParams:
|
|
23
|
+
"""Parameters for v1 marketplace request."""
|
|
24
|
+
|
|
25
|
+
data: bytes
|
|
26
|
+
priority_mech: str
|
|
27
|
+
priority_mech_staking_instance: str
|
|
28
|
+
priority_mech_service_id: int
|
|
29
|
+
requester_staking_instance: str
|
|
30
|
+
requester_service_id: int
|
|
31
|
+
response_timeout: int = 300
|
|
32
|
+
value: int = 10_000_000_000_000_000 # 0.01 xDAI
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MechMarketplaceV1Contract(ContractInstance):
|
|
36
|
+
"""Class to interact with the Mech Marketplace v1 contract (VERSION 1.0.0).
|
|
37
|
+
|
|
38
|
+
This is the older marketplace used by staking contracts like Expert 17 MM.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name = "mech_marketplace_v1"
|
|
42
|
+
abi_path = Path(__file__).parent / "abis" / "mech_marketplace_v1.json"
|
|
43
|
+
|
|
44
|
+
def prepare_request_tx(
|
|
45
|
+
self,
|
|
46
|
+
from_address: EthereumAddress,
|
|
47
|
+
params: V1RequestParams,
|
|
48
|
+
) -> Optional[Dict]:
|
|
49
|
+
"""Prepare a v1 marketplace request transaction.
|
|
50
|
+
|
|
51
|
+
v1 ABI: request(bytes data, address priorityMech,
|
|
52
|
+
address priorityMechStakingInstance, uint256 priorityMechServiceId,
|
|
53
|
+
address requesterStakingInstance, uint256 requesterServiceId,
|
|
54
|
+
uint256 responseTimeout)
|
|
55
|
+
"""
|
|
56
|
+
return self.prepare_transaction(
|
|
57
|
+
method_name="request",
|
|
58
|
+
method_kwargs={
|
|
59
|
+
"data": params.data,
|
|
60
|
+
"priorityMech": params.priority_mech,
|
|
61
|
+
"priorityMechStakingInstance": params.priority_mech_staking_instance,
|
|
62
|
+
"priorityMechServiceId": params.priority_mech_service_id,
|
|
63
|
+
"requesterStakingInstance": params.requester_staking_instance,
|
|
64
|
+
"requesterServiceId": params.requester_service_id,
|
|
65
|
+
"responseTimeout": params.response_timeout,
|
|
66
|
+
},
|
|
67
|
+
tx_params={"from": from_address, "value": params.value},
|
|
68
|
+
)
|
|
@@ -79,22 +79,9 @@ class StakingContract(ContractInstance):
|
|
|
79
79
|
self.chain_name = chain_name
|
|
80
80
|
self._contract_params_cache: Dict[str, int] = {}
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
activity_checker_address, chain_name=chain_name
|
|
86
|
-
)
|
|
87
|
-
self.activity_checker_address = activity_checker_address
|
|
88
|
-
|
|
89
|
-
# Cache contract parameters
|
|
90
|
-
self.available_rewards = self.call("availableRewards")
|
|
91
|
-
self.balance = self.call("balance")
|
|
92
|
-
self.liveness_period = self.call("livenessPeriod")
|
|
93
|
-
self.rewards_per_second = self.call("rewardsPerSecond")
|
|
94
|
-
self.max_num_services = self.call("maxNumServices")
|
|
95
|
-
self.min_staking_deposit = self.call("minStakingDeposit")
|
|
96
|
-
self.min_staking_duration_hours = self.call("minStakingDuration") / 3600
|
|
97
|
-
self.staking_token_address = self.call("stakingToken")
|
|
82
|
+
self._activity_checker: Optional[ActivityCheckerContract] = None
|
|
83
|
+
self._activity_checker_address: Optional[EthereumAddress] = None
|
|
84
|
+
|
|
98
85
|
|
|
99
86
|
def get_requirements(self) -> Dict[str, Union[str, int]]:
|
|
100
87
|
"""Get the contract requirements for token and deposits.
|
|
@@ -193,10 +180,16 @@ class StakingContract(ContractInstance):
|
|
|
193
180
|
remaining_seconds = (epoch_end - datetime.now(timezone.utc)).total_seconds()
|
|
194
181
|
|
|
195
182
|
# Check liveness ratio using activity checker
|
|
183
|
+
# logic: use the latest of (service_start_time, global_checkpoint_time)
|
|
184
|
+
# If service started AFTER global checkpoint, use service_start_time.
|
|
185
|
+
# If service was already running, use global_checkpoint_time.
|
|
186
|
+
global_ts_checkpoint = self.ts_checkpoint()
|
|
187
|
+
effective_ts_start = max(ts_start, global_ts_checkpoint)
|
|
188
|
+
|
|
196
189
|
liveness_passed = self.is_liveness_ratio_passed(
|
|
197
190
|
current_nonces=current_nonces,
|
|
198
191
|
last_nonces=(last_safe_nonce, last_mech_requests),
|
|
199
|
-
ts_start=
|
|
192
|
+
ts_start=effective_ts_start,
|
|
200
193
|
)
|
|
201
194
|
|
|
202
195
|
return {
|
|
@@ -223,8 +216,36 @@ class StakingContract(ContractInstance):
|
|
|
223
216
|
return StakingState(self.call("getStakingState", service_id))
|
|
224
217
|
|
|
225
218
|
def ts_checkpoint(self) -> int:
|
|
226
|
-
"""Get the timestamp of the last checkpoint.
|
|
227
|
-
|
|
219
|
+
"""Get the timestamp of the last checkpoint.
|
|
220
|
+
|
|
221
|
+
Cached until the estimated end of the current epoch (ts_checkpoint + liveness_period).
|
|
222
|
+
"""
|
|
223
|
+
now = time.time()
|
|
224
|
+
cache_key = "ts_checkpoint"
|
|
225
|
+
|
|
226
|
+
# Check if we have a valid cached value
|
|
227
|
+
if cache_key in self._contract_params_cache:
|
|
228
|
+
ts = self._contract_params_cache[cache_key]
|
|
229
|
+
# Use liveness period to determine if we should re-check
|
|
230
|
+
if now < ts + self.liveness_period:
|
|
231
|
+
return ts
|
|
232
|
+
|
|
233
|
+
# If past expected epoch end, check at most once per minute
|
|
234
|
+
last_checked = self._contract_params_cache.get(f"{cache_key}_last_checked", 0)
|
|
235
|
+
if now - last_checked < 60:
|
|
236
|
+
return ts
|
|
237
|
+
|
|
238
|
+
# Fetch new value
|
|
239
|
+
ts = self.call("tsCheckpoint")
|
|
240
|
+
self._contract_params_cache[cache_key] = ts
|
|
241
|
+
self._contract_params_cache[f"{cache_key}_last_checked"] = now
|
|
242
|
+
return ts
|
|
243
|
+
|
|
244
|
+
def clear_epoch_cache(self) -> None:
|
|
245
|
+
"""Clear cache for epoch-dependent properties."""
|
|
246
|
+
self._contract_params_cache.pop("ts_checkpoint", None)
|
|
247
|
+
self._contract_params_cache.pop("ts_checkpoint_last_checked", None)
|
|
248
|
+
logger.debug(f"Cleared epoch cache for StakingContract {self.address}")
|
|
228
249
|
|
|
229
250
|
def get_required_requests(self, use_liveness_period: bool = True) -> int:
|
|
230
251
|
"""Calculate the required requests for the current epoch.
|
|
@@ -246,6 +267,81 @@ class StakingContract(ContractInstance):
|
|
|
246
267
|
(time_diff * self.activity_checker.liveness_ratio) / 1e18 + requests_safety_margin
|
|
247
268
|
)
|
|
248
269
|
|
|
270
|
+
@property
|
|
271
|
+
def activity_checker_address_value(self) -> EthereumAddress:
|
|
272
|
+
"""Get the activity checker address."""
|
|
273
|
+
if self._activity_checker_address is None:
|
|
274
|
+
self._activity_checker_address = self.call("activityChecker")
|
|
275
|
+
return self._activity_checker_address
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def activity_checker_address(self) -> EthereumAddress:
|
|
279
|
+
"""Backwards compatibility for activity_checker_address."""
|
|
280
|
+
return self.activity_checker_address_value
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def activity_checker(self) -> ActivityCheckerContract:
|
|
284
|
+
"""Get the activity checker contract."""
|
|
285
|
+
if self._activity_checker is None:
|
|
286
|
+
self._activity_checker = ActivityCheckerContract(
|
|
287
|
+
self.activity_checker_address_value, chain_name=self.chain_name
|
|
288
|
+
)
|
|
289
|
+
return self._activity_checker
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def available_rewards(self) -> int:
|
|
293
|
+
"""Get available rewards."""
|
|
294
|
+
if "availableRewards" not in self._contract_params_cache:
|
|
295
|
+
self._contract_params_cache["availableRewards"] = self.call("availableRewards")
|
|
296
|
+
return self._contract_params_cache["availableRewards"]
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def balance(self) -> int:
|
|
300
|
+
"""Get contract balance."""
|
|
301
|
+
if "balance" not in self._contract_params_cache:
|
|
302
|
+
self._contract_params_cache["balance"] = self.call("balance")
|
|
303
|
+
return self._contract_params_cache["balance"]
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def liveness_period(self) -> int:
|
|
307
|
+
"""Get liveness period."""
|
|
308
|
+
if "livenessPeriod" not in self._contract_params_cache:
|
|
309
|
+
self._contract_params_cache["livenessPeriod"] = self.call("livenessPeriod")
|
|
310
|
+
return self._contract_params_cache["livenessPeriod"]
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def rewards_per_second(self) -> int:
|
|
314
|
+
"""Get rewards per second."""
|
|
315
|
+
if "rewardsPerSecond" not in self._contract_params_cache:
|
|
316
|
+
self._contract_params_cache["rewardsPerSecond"] = self.call("rewardsPerSecond")
|
|
317
|
+
return self._contract_params_cache["rewardsPerSecond"]
|
|
318
|
+
|
|
319
|
+
@property
|
|
320
|
+
def max_num_services(self) -> int:
|
|
321
|
+
"""Get max number of services."""
|
|
322
|
+
if "maxNumServices" not in self._contract_params_cache:
|
|
323
|
+
self._contract_params_cache["maxNumServices"] = self.call("maxNumServices")
|
|
324
|
+
return self._contract_params_cache["maxNumServices"]
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def min_staking_deposit(self) -> int:
|
|
328
|
+
"""Get min staking deposit."""
|
|
329
|
+
if "minStakingDeposit" not in self._contract_params_cache:
|
|
330
|
+
self._contract_params_cache["minStakingDeposit"] = self.call("minStakingDeposit")
|
|
331
|
+
return self._contract_params_cache["minStakingDeposit"]
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def min_staking_duration_hours(self) -> float:
|
|
335
|
+
"""Get min staking duration in hours."""
|
|
336
|
+
return self.min_staking_duration / 3600
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def staking_token_address(self) -> EthereumAddress:
|
|
340
|
+
"""Get staking token address."""
|
|
341
|
+
if "stakingToken" not in self._contract_params_cache:
|
|
342
|
+
self._contract_params_cache["stakingToken"] = self.call("stakingToken")
|
|
343
|
+
return self._contract_params_cache["stakingToken"]
|
|
344
|
+
|
|
249
345
|
def is_liveness_ratio_passed(
|
|
250
346
|
self,
|
|
251
347
|
current_nonces: tuple,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Event-based cache invalidation for Olas contracts."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
from iwa.core.contracts.cache import ContractCache
|
|
7
|
+
from iwa.plugins.olas.contracts.staking import StakingContract
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OlasEventInvalidator:
|
|
11
|
+
"""Monitors OLAS events and invalidates caches."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, chain_name: str = "gnosis"):
|
|
14
|
+
"""Initialize the invalidator."""
|
|
15
|
+
self.chain_name = chain_name
|
|
16
|
+
self.contract_cache = ContractCache()
|
|
17
|
+
|
|
18
|
+
# We need to find active staking contracts to monitor
|
|
19
|
+
# For now, we'll use a dynamic approach or configuration
|
|
20
|
+
# Ideally, this service would know about all deployed staking contracts
|
|
21
|
+
# But efficiently, we might just want to monitor the ones in our cache?
|
|
22
|
+
# A simple approach for this MVP: Monitor contracts currently in the cache
|
|
23
|
+
# OR monitor a known set of staking contracts from constants.
|
|
24
|
+
|
|
25
|
+
# Since EventMonitor requires a list of addresses upfront, let's use the ones
|
|
26
|
+
# defined in constants if available, or just rely on what's active.
|
|
27
|
+
# However, EventMonitor checks transfers, not generic logs for specific events.
|
|
28
|
+
# The base EventMonitor in iwa.core.monitor is too specific (transfers).
|
|
29
|
+
# We should implement a specific loop here or extend EventMonitor.
|
|
30
|
+
# To avoid complex inheritance of a class not designed for extension (EventMonitor),
|
|
31
|
+
# we will implement a focused loop using the same pattern.
|
|
32
|
+
|
|
33
|
+
from iwa.core.chain import ChainInterfaces
|
|
34
|
+
from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
|
|
35
|
+
|
|
36
|
+
self.chain_interface = ChainInterfaces().get(chain_name)
|
|
37
|
+
self.web3 = self.chain_interface.web3
|
|
38
|
+
|
|
39
|
+
# Get addresses to monitor
|
|
40
|
+
contracts = OLAS_TRADER_STAKING_CONTRACTS.get(chain_name, {})
|
|
41
|
+
self.staking_addresses = [addr for _, addr in contracts.items()]
|
|
42
|
+
|
|
43
|
+
self.running = False
|
|
44
|
+
|
|
45
|
+
def start(self):
|
|
46
|
+
"""Start the event monitoring loop."""
|
|
47
|
+
import threading
|
|
48
|
+
|
|
49
|
+
self.running = True
|
|
50
|
+
thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
51
|
+
thread.start()
|
|
52
|
+
logger.info(f"Started OlasEventInvalidator for {len(self.staking_addresses)} contracts")
|
|
53
|
+
|
|
54
|
+
def stop(self):
|
|
55
|
+
"""Stop the monitoring loop."""
|
|
56
|
+
self.running = False
|
|
57
|
+
|
|
58
|
+
def _monitor_loop(self):
|
|
59
|
+
"""Main monitoring loop."""
|
|
60
|
+
import time
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
last_block = self.web3.eth.block_number
|
|
64
|
+
except Exception:
|
|
65
|
+
last_block = 0
|
|
66
|
+
|
|
67
|
+
while self.running:
|
|
68
|
+
try:
|
|
69
|
+
current_block = self.web3.eth.block_number
|
|
70
|
+
|
|
71
|
+
if current_block > last_block:
|
|
72
|
+
self._check_events(last_block + 1, current_block)
|
|
73
|
+
last_block = current_block
|
|
74
|
+
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"Error in OlasEventInvalidator: {e}")
|
|
77
|
+
|
|
78
|
+
time.sleep(10) # check every 10 seconds
|
|
79
|
+
|
|
80
|
+
def _check_events(self, from_block: int, to_block: int):
|
|
81
|
+
"""Check for relevant events in the block range."""
|
|
82
|
+
# Cap range
|
|
83
|
+
if to_block - from_block > 100:
|
|
84
|
+
from_block = to_block - 100
|
|
85
|
+
|
|
86
|
+
# We care about Checkpoint events on StakingContracts
|
|
87
|
+
# Event signature for Checkpoint: Checkpoint(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256)
|
|
88
|
+
# Actually easier to use the contract instance to get the topic or event object
|
|
89
|
+
|
|
90
|
+
# Need ABI for this. Let's assume we can get it from a dummy contract instance
|
|
91
|
+
if not self.staking_addresses:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# 1. Checkpoint events
|
|
95
|
+
# Topic 0 for Checkpoint event
|
|
96
|
+
# We can construct a filter for all staking addresses
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
# Ensure contract is cached for later use
|
|
100
|
+
self.contract_cache.get_contract(
|
|
101
|
+
StakingContract, self.staking_addresses[0], self.chain_name
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
logs = self.web3.eth.get_logs({
|
|
106
|
+
"fromBlock": from_block,
|
|
107
|
+
"toBlock": to_block,
|
|
108
|
+
"address": self.staking_addresses,
|
|
109
|
+
"topics": [self.web3.keccak(text="Checkpoint(uint256,uint256,uint256,uint256,uint256,uint256,uint256,uint256)").hex()]
|
|
110
|
+
# Note: signature might vary, safer to use the event object if ABI allows
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
# If we used the contract event object to filter, it handles the topic generation:
|
|
114
|
+
# logs = checkpoint_event_abi.get_logs(fromBlock=from_block, toBlock=to_block)
|
|
115
|
+
# But get_logs on the contract object filters by THAT contract address usually?
|
|
116
|
+
# web3.eth.get_logs is broader.
|
|
117
|
+
|
|
118
|
+
for log in logs:
|
|
119
|
+
addr = log["address"]
|
|
120
|
+
logger.info(f"Checkpoint detected on {addr} at block {log['blockNumber']}")
|
|
121
|
+
|
|
122
|
+
# Invalidate cache for this contract
|
|
123
|
+
# We want to call clear_epoch_cache on the EXISTING cached instance if present
|
|
124
|
+
# ContractCache().get_contract(...) might return it or create new.
|
|
125
|
+
# We need a way to 'get if exists' or assume get_contract is cheap enough.
|
|
126
|
+
# Specifically we want to clear the EPOCH cache, not just destroy the instance
|
|
127
|
+
# (though destroying it works too, it's just less efficient for the next call).
|
|
128
|
+
|
|
129
|
+
# Option A: Just invalidate the instance
|
|
130
|
+
# self.contract_cache.invalidate(StakingContract, addr, self.chain_name)
|
|
131
|
+
|
|
132
|
+
# Option B: Get instance and clear specific cache (safe public access)
|
|
133
|
+
instance = self.contract_cache.get_if_cached(
|
|
134
|
+
StakingContract, addr, self.chain_name
|
|
135
|
+
)
|
|
136
|
+
if instance:
|
|
137
|
+
instance.clear_epoch_cache()
|
|
138
|
+
logger.debug(f"Cleared epoch cache for {addr}")
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.warning(f"Failed to check logs in invalidator: {e}")
|
|
@@ -173,7 +173,7 @@ def main(): # noqa: C901
|
|
|
173
173
|
# Get contract addresses
|
|
174
174
|
protocol_contracts = OLAS_CONTRACTS.get("gnosis", {})
|
|
175
175
|
legacy_mech_address = protocol_contracts.get("OLAS_MECH")
|
|
176
|
-
marketplace_address = protocol_contracts.get("
|
|
176
|
+
marketplace_address = protocol_contracts.get("OLAS_MECH_MARKETPLACE_V2")
|
|
177
177
|
|
|
178
178
|
# Step 3: Send Legacy Mech Request
|
|
179
179
|
print_step("Step 3: Send Legacy Mech Request", "3️⃣")
|
|
@@ -5,6 +5,7 @@ from typing import Dict, Optional
|
|
|
5
5
|
from loguru import logger
|
|
6
6
|
|
|
7
7
|
from iwa.core.chain import ChainInterfaces
|
|
8
|
+
from iwa.core.contracts.cache import ContractCache
|
|
8
9
|
from iwa.core.models import Config
|
|
9
10
|
from iwa.core.wallet import Wallet
|
|
10
11
|
from iwa.plugins.olas.constants import OLAS_CONTRACTS
|
|
@@ -65,8 +66,12 @@ class ServiceManagerBase:
|
|
|
65
66
|
if not registry_address or not manager_address:
|
|
66
67
|
raise ValueError(f"OLAS contracts not found for chain: {chain_name}")
|
|
67
68
|
|
|
68
|
-
self.registry =
|
|
69
|
-
|
|
69
|
+
self.registry = ContractCache().get_contract(
|
|
70
|
+
ServiceRegistryContract, registry_address, chain_name=chain_name
|
|
71
|
+
)
|
|
72
|
+
self.manager = ContractCache().get_contract(
|
|
73
|
+
ServiceManagerContract, manager_address, chain_name=chain_name
|
|
74
|
+
)
|
|
70
75
|
logger.debug(f"[SM-INIT] ServiceManager initialized. Chain: {chain_name}")
|
|
71
76
|
logger.debug(f"[SM-INIT] Registry Address: {self.registry.address}")
|
|
72
77
|
logger.debug(f"[SM-INIT] Manager Address: {self.manager.address}")
|
|
@@ -65,6 +65,7 @@ from web3.types import Wei
|
|
|
65
65
|
|
|
66
66
|
from iwa.core.chain import ChainInterfaces
|
|
67
67
|
from iwa.core.constants import NATIVE_CURRENCY_ADDRESS, ZERO_ADDRESS
|
|
68
|
+
from iwa.core.contracts.cache import ContractCache
|
|
68
69
|
from iwa.core.types import EthereumAddress
|
|
69
70
|
from iwa.core.utils import get_tx_hash
|
|
70
71
|
from iwa.plugins.olas.constants import (
|
|
@@ -485,7 +486,8 @@ class LifecycleManagerMixin:
|
|
|
485
486
|
agent_id = agent_ids[0]
|
|
486
487
|
|
|
487
488
|
# Use the ServiceRegistryTokenUtilityContract with official ABI
|
|
488
|
-
token_utility =
|
|
489
|
+
token_utility = ContractCache().get_contract(
|
|
490
|
+
ServiceRegistryTokenUtilityContract,
|
|
489
491
|
address=str(utility_address),
|
|
490
492
|
chain_name=self.chain_name,
|
|
491
493
|
)
|
|
@@ -759,7 +761,7 @@ class LifecycleManagerMixin:
|
|
|
759
761
|
logger.info("[REGISTER] Success - service is now FINISHED_REGISTRATION")
|
|
760
762
|
return True
|
|
761
763
|
|
|
762
|
-
def deploy(self) -> Optional[str]: # noqa: C901
|
|
764
|
+
def deploy(self, fund_multisig: bool = False) -> Optional[str]: # noqa: C901
|
|
763
765
|
"""Deploy the service."""
|
|
764
766
|
logger.info(f"[DEPLOY] Starting deployment for service {self.service.service_id}")
|
|
765
767
|
|
|
@@ -841,6 +843,25 @@ class LifecycleManagerMixin:
|
|
|
841
843
|
except Exception as e:
|
|
842
844
|
logger.warning(f"[DEPLOY] Failed to register multisig in wallet: {e}")
|
|
843
845
|
|
|
846
|
+
# Fund the multisig with 1 xDAI from master if requested (for staking)
|
|
847
|
+
if fund_multisig:
|
|
848
|
+
try:
|
|
849
|
+
funding_amount = Web3.to_wei(1, "ether")
|
|
850
|
+
logger.info(f"[DEPLOY] Funding multisig {multisig_address} with 1 xDAI from master")
|
|
851
|
+
tx_hash = self.wallet.send(
|
|
852
|
+
from_address_or_tag=self.wallet.master_account.address,
|
|
853
|
+
to_address_or_tag=multisig_address,
|
|
854
|
+
token_address_or_name="native",
|
|
855
|
+
amount_wei=funding_amount,
|
|
856
|
+
chain_name=self.chain_name,
|
|
857
|
+
)
|
|
858
|
+
if tx_hash:
|
|
859
|
+
logger.info(f"[DEPLOY] Funded multisig: {tx_hash}")
|
|
860
|
+
else:
|
|
861
|
+
logger.error("[DEPLOY] Failed to fund multisig")
|
|
862
|
+
except Exception as e:
|
|
863
|
+
logger.error(f"[DEPLOY] Failed to fund multisig: {e}")
|
|
864
|
+
|
|
844
865
|
logger.info("[DEPLOY] Success - service is now DEPLOYED")
|
|
845
866
|
return multisig_address
|
|
846
867
|
|
|
@@ -976,7 +997,10 @@ class LifecycleManagerMixin:
|
|
|
976
997
|
previous_state = current_state
|
|
977
998
|
logger.info(f"[SPIN-UP] Step {step}: Processing {current_state.name}...")
|
|
978
999
|
|
|
979
|
-
|
|
1000
|
+
should_fund = staking_contract is not None
|
|
1001
|
+
if not self._process_spin_up_state(
|
|
1002
|
+
current_state, agent_address, bond_amount_wei, fund_multisig=should_fund
|
|
1003
|
+
):
|
|
980
1004
|
logger.error(f"[SPIN-UP] Step {step} FAILED at state {current_state.name}")
|
|
981
1005
|
return False
|
|
982
1006
|
|
|
@@ -1012,6 +1036,7 @@ class LifecycleManagerMixin:
|
|
|
1012
1036
|
current_state: ServiceState,
|
|
1013
1037
|
agent_address: Optional[str],
|
|
1014
1038
|
bond_amount_wei: Optional[Wei],
|
|
1039
|
+
fund_multisig: bool = False,
|
|
1015
1040
|
) -> bool:
|
|
1016
1041
|
"""Process a single state transition for spin up."""
|
|
1017
1042
|
if current_state == ServiceState.PRE_REGISTRATION:
|
|
@@ -1025,8 +1050,8 @@ class LifecycleManagerMixin:
|
|
|
1025
1050
|
):
|
|
1026
1051
|
return False
|
|
1027
1052
|
elif current_state == ServiceState.FINISHED_REGISTRATION:
|
|
1028
|
-
logger.info("[SPIN-UP] Action: deploy()")
|
|
1029
|
-
if not self.deploy():
|
|
1053
|
+
logger.info(f"[SPIN-UP] Action: deploy(fund_multisig={fund_multisig})")
|
|
1054
|
+
if not self.deploy(fund_multisig=fund_multisig):
|
|
1030
1055
|
return False
|
|
1031
1056
|
else:
|
|
1032
1057
|
logger.error(f"[SPIN-UP] Invalid state: {current_state.name}")
|