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.
Files changed (32) hide show
  1. iwa/core/chainlist.py +116 -0
  2. iwa/core/constants.py +1 -0
  3. iwa/core/contracts/cache.py +131 -0
  4. iwa/core/contracts/contract.py +7 -0
  5. iwa/core/rpc_monitor.py +60 -0
  6. iwa/plugins/olas/constants.py +41 -39
  7. iwa/plugins/olas/contracts/abis/mech_marketplace_v1.json +828 -0
  8. iwa/plugins/olas/contracts/activity_checker.py +63 -25
  9. iwa/plugins/olas/contracts/mech_marketplace_v1.py +68 -0
  10. iwa/plugins/olas/contracts/staking.py +115 -19
  11. iwa/plugins/olas/events.py +141 -0
  12. iwa/plugins/olas/scripts/test_full_mech_flow.py +1 -1
  13. iwa/plugins/olas/service_manager/base.py +7 -2
  14. iwa/plugins/olas/service_manager/lifecycle.py +30 -5
  15. iwa/plugins/olas/service_manager/mech.py +251 -42
  16. iwa/plugins/olas/service_manager/staking.py +6 -2
  17. iwa/plugins/olas/tests/test_olas_integration.py +38 -10
  18. iwa/plugins/olas/tests/test_service_manager.py +7 -1
  19. iwa/plugins/olas/tests/test_service_manager_errors.py +22 -11
  20. iwa/plugins/olas/tests/test_service_manager_flows.py +24 -8
  21. iwa/plugins/olas/tests/test_service_staking.py +59 -15
  22. iwa/plugins/olas/tests/test_staking_validation.py +8 -14
  23. iwa/tools/reset_tenderly.py +2 -2
  24. iwa/tools/test_chainlist.py +38 -0
  25. iwa/web/routers/olas/staking.py +9 -4
  26. {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/METADATA +1 -1
  27. {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/RECORD +32 -24
  28. tests/test_rpc_efficiency.py +103 -0
  29. {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/WHEEL +0 -0
  30. {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/entry_points.txt +0 -0
  31. {iwa-0.0.18.dist-info → iwa-0.0.20.dist-info}/licenses/LICENSE +0 -0
  32. {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
- # Check for marketplace-aware checker
47
- try:
48
- mech_mp_function = getattr(self.contract.functions, "mechMarketplace", None)
49
- self.mech_marketplace = mech_mp_function().call() if mech_mp_function else None
50
- except Exception:
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
- return self.contract.functions.isRatioPass(
105
- list(current_nonces), list(last_nonces), ts_diff
106
- ).call()
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
- # Get activity checker from the staking contract
83
- activity_checker_address = self.call("activityChecker")
84
- self.activity_checker = ActivityCheckerContract(
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=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
- return self.call("tsCheckpoint")
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("OLAS_MECH_MARKETPLACE")
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 = ServiceRegistryContract(registry_address, chain_name=chain_name)
69
- self.manager = ServiceManagerContract(manager_address, chain_name=chain_name)
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 = ServiceRegistryTokenUtilityContract(
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
- if not self._process_spin_up_state(current_state, agent_address, bond_amount_wei):
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}")