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
@@ -1,28 +1,57 @@
1
1
  """Mech manager mixin.
2
2
 
3
- This module handles sending mech requests for OLAS services. There are TWO
4
- distinct flows for mech requests, and the correct one MUST be used based on
5
- the service's staking contract:
6
-
7
- 1. **Legacy Mech Flow** (use_marketplace=False):
8
- - Sends requests directly to the legacy mech contract (0x77af31De...)
9
- - Used by NON-MM staking contracts (e.g., "Expert X (Yk OLAS)")
10
- - Activity checker calls `agentMech.getRequestsCount(multisig)` to count
11
-
12
- 2. **Marketplace Flow** (use_marketplace=True):
13
- - Sends requests via MechMarketplace contract (0x735FAAb1c...)
14
- - Used by MM staking contracts (e.g., "Expert X MM (Yk OLAS)")
15
- - Activity checker calls `mechMarketplace.mapRequestCounts(multisig)` to count
16
- - Requires a priority_mech that is registered on the marketplace
17
- - Default priority_mech from olas-operate-middleware: 0xC05e7412...
18
-
19
- **IMPORTANT**: If a service is staked in an MM contract but sends legacy mech
20
- requests, those requests will NOT be counted by the activity checker, and the
21
- service will not receive staking rewards.
22
-
23
- The `get_marketplace_config()` method automatically detects which flow to use
24
- by checking if the staking contract's activity checker has a `mechMarketplace`
25
- field set to a non-zero address.
3
+ This module handles sending mech requests for OLAS services. There are THREE
4
+ distinct flows for mech requests, and the correct one is automatically selected
5
+ based on the service's staking contract configuration:
6
+
7
+ Flow Selection Logic:
8
+ 1. `get_marketplace_config()` checks if staking contract's activity checker
9
+ has a non-zero `mechMarketplace` address
10
+ 2. If yes marketplace request (v1 or v2 depending on address)
11
+ 3. If no → legacy mech request
12
+
13
+ ┌─────────────────────────────────────────────────────────────────────────────┐
14
+ FLOW 1: Legacy Mech (use_marketplace=False)
15
+ ├─────────────────────────────────────────────────────────────────────────────┤
16
+ Contract: Legacy Mech (0x77af31De...) │
17
+ │ Used by: NON-MM staking contracts (e.g., "Expert X (Yk OLAS)") │
18
+ │ Counting: agentMech.getRequestsCount(multisig) │
19
+ Method: _send_legacy_mech_request() │
20
+ └─────────────────────────────────────────────────────────────────────────────┘
21
+
22
+ ┌─────────────────────────────────────────────────────────────────────────────┐
23
+ FLOW 2: Marketplace v2 (use_marketplace=True, marketplace=0x735F...) │
24
+ ├─────────────────────────────────────────────────────────────────────────────┤
25
+ Contract: MechMarketplace v2 (0x735FAAb1c...) │
26
+ │ Used by: Newer MM staking contracts │
27
+ │ Counting: mechMarketplace.mapRequestCounts(multisig) │
28
+ │ Method: _send_marketplace_mech_request() → MechMarketplaceContract │
29
+ │ Signature: request(bytes,uint256,bytes32,address,uint256,bytes) │
30
+ │ Note: Uses payment types (PAYMENT_TYPE_NATIVE) │
31
+ └─────────────────────────────────────────────────────────────────────────────┘
32
+
33
+ ┌─────────────────────────────────────────────────────────────────────────────┐
34
+ │ FLOW 3: Marketplace v1 (use_marketplace=True, marketplace=0x4554...) │
35
+ ├─────────────────────────────────────────────────────────────────────────────┤
36
+ │ Contract: MechMarketplace v1 (0x4554fE75...) [VERSION 1.0.0] │
37
+ │ Used by: Older MM contracts (e.g., "Expert 17 MM", trader_ant) │
38
+ │ Counting: mechMarketplace.mapRequestCounts(multisig) │
39
+ │ Method: _send_v1_marketplace_request() → MechMarketplaceV1Contract │
40
+ │ Signature: request(bytes,address,address,uint256,address,uint256,uint256) │
41
+ │ Note: Requires staking instance + service ID for mech AND requester │
42
+ │ No payment types - simpler but different parameter set │
43
+ └─────────────────────────────────────────────────────────────────────────────┘
44
+
45
+ Important:
46
+ If a service is staked in an MM contract but sends requests to the wrong
47
+ marketplace (or uses legacy flow), those requests will NOT be counted by
48
+ the activity checker, and the service will not receive staking rewards.
49
+
50
+ The dispatch logic:
51
+ 1. _send_marketplace_mech_request() checks if marketplace ∈ V1_MARKETPLACES
52
+ 2. If v1 → dispatches to _send_v1_marketplace_request()
53
+ 3. If v2 → continues with MechMarketplaceContract (v2 ABI)
54
+
26
55
  """
27
56
 
28
57
  from typing import Optional
@@ -37,6 +66,31 @@ from iwa.plugins.olas.constants import (
37
66
  )
38
67
  from iwa.plugins.olas.contracts.mech import MechContract
39
68
  from iwa.plugins.olas.contracts.mech_marketplace import MechMarketplaceContract
69
+ from iwa.plugins.olas.contracts.mech_marketplace_v1 import (
70
+ MechMarketplaceV1Contract,
71
+ V1RequestParams,
72
+ )
73
+
74
+ # Maps marketplace address to (priority_mech_address, priority_mech_service_id, mech_staking_instance)
75
+ # Source: olas-operate-middleware profiles.py and manage.py
76
+ # The 3rd element (staking instance) is only needed for v1 marketplaces
77
+ DEFAULT_PRIORITY_MECH = {
78
+ "0x4554fE75c1f5576c1d7F765B2A036c199Adae329": (
79
+ "0x552cEA7Bc33CbBEb9f1D90c1D11D2C6daefFd053",
80
+ 975,
81
+ "0x998dEFafD094817EF329f6dc79c703f1CF18bC90", # Mech staking instance for v1
82
+ ),
83
+ "0x735FAAb1c4Ec41128c367AFb5c3baC73509f70bB": (
84
+ "0xC05e7412439bD7e91730a6880E18d5D5873F632C",
85
+ 2182,
86
+ None, # v2 doesn't need staking instance
87
+ ),
88
+ }
89
+
90
+ # Marketplace v1 addresses (use different request signature)
91
+ V1_MARKETPLACES = {
92
+ "0x4554fE75c1f5576c1d7F765B2A036c199Adae329", # VERSION 1.0.0
93
+ }
40
94
 
41
95
 
42
96
  class MechManagerMixin:
@@ -72,17 +126,26 @@ class MechManagerMixin:
72
126
  checker = staking.activity_checker
73
127
 
74
128
  if checker.mech_marketplace and checker.mech_marketplace != ZERO_ADDRESS:
75
- # Use the default marketplace priority mech from constants
76
- from iwa.plugins.olas.constants import OLAS_CONTRACTS
77
-
78
- protocol_contracts = OLAS_CONTRACTS.get(self.chain_name, {})
79
- priority_mech = protocol_contracts.get("OLAS_MECH_MARKETPLACE_PRIORITY")
129
+ # Get priority mech from mapping based on marketplace address
130
+ marketplace_addr = Web3.to_checksum_address(checker.mech_marketplace)
131
+ priority_mech_info = DEFAULT_PRIORITY_MECH.get(marketplace_addr)
132
+
133
+ if priority_mech_info:
134
+ priority_mech = priority_mech_info[0] # First element is mech address
135
+ else:
136
+ # Fallback to constants if marketplace not in mapping
137
+ protocol_contracts = OLAS_CONTRACTS.get(self.chain_name, {})
138
+ priority_mech = protocol_contracts.get("OLAS_MECH_MARKETPLACE_PRIORITY")
139
+ logger.warning(
140
+ f"[MECH] Marketplace {marketplace_addr} not in DEFAULT_PRIORITY_MECH, "
141
+ f"using fallback priority_mech: {priority_mech}"
142
+ )
80
143
 
81
144
  logger.info(
82
145
  f"[MECH] Service {self.service.service_id} requires marketplace requests "
83
- f"(marketplace={checker.mech_marketplace}, priority_mech={priority_mech})"
146
+ f"(marketplace={marketplace_addr}, priority_mech={priority_mech})"
84
147
  )
85
- return (True, checker.mech_marketplace, priority_mech)
148
+ return (True, marketplace_addr, priority_mech)
86
149
 
87
150
  return (False, None, None)
88
151
 
@@ -134,6 +197,7 @@ class MechManagerMixin:
134
197
  return None
135
198
 
136
199
  # Auto-detect marketplace requirement if not explicitly specified
200
+ detected_marketplace = None
137
201
  if use_marketplace is None:
138
202
  use_marketplace, detected_marketplace, detected_priority_mech = (
139
203
  self.get_marketplace_config()
@@ -143,10 +207,12 @@ class MechManagerMixin:
143
207
  mech_address = mech_address or detected_marketplace
144
208
 
145
209
  if use_marketplace:
210
+ # Use detected marketplace if available, otherwise _send_marketplace_mech_request
211
+ # will fall back to constant
146
212
  return self._send_marketplace_mech_request(
147
213
  data=data,
148
214
  value=value,
149
- marketplace_address=mech_address,
215
+ marketplace_address=detected_marketplace,
150
216
  priority_mech=priority_mech,
151
217
  max_delivery_rate=max_delivery_rate,
152
218
  payment_type=payment_type,
@@ -206,7 +272,12 @@ class MechManagerMixin:
206
272
  )
207
273
 
208
274
  def _validate_priority_mech(self, marketplace, priority_mech: str) -> bool:
209
- """Validate priority mech is registered on marketplace."""
275
+ """Validate priority mech is registered on marketplace.
276
+
277
+ Note: OLD marketplace v1 (0x4554...) doesn't have checkMech function.
278
+ In that case, we skip validation and proceed - v1 doesn't require
279
+ mech registration.
280
+ """
210
281
  try:
211
282
  mech_multisig = marketplace.call("checkMech", priority_mech)
212
283
  if mech_multisig == ZERO_ADDRESS:
@@ -214,8 +285,19 @@ class MechManagerMixin:
214
285
  return False
215
286
  logger.debug(f"Priority mech {priority_mech} -> multisig {mech_multisig}")
216
287
  except Exception as e:
217
- logger.error(f"Failed to verify priority mech registration: {e}")
218
- return False
288
+ # Check if this is a revert (v1 doesn't have checkMech) vs a network error
289
+ error_str = str(e).lower()
290
+ if "reverted" in error_str or "execution reverted" in error_str:
291
+ # v1 marketplaces don't have checkMech - skip validation
292
+ logger.warning(
293
+ f"Could not validate priority mech (marketplace may be v1): {e}. "
294
+ "Proceeding without validation."
295
+ )
296
+ return True
297
+ else:
298
+ # Real error (network, timeout, etc.) - fail validation
299
+ logger.error(f"Failed to validate priority mech (network error?): {e}")
300
+ return False
219
301
 
220
302
  # Log mech factory info (optional validation)
221
303
  try:
@@ -234,7 +316,11 @@ class MechManagerMixin:
234
316
  def _validate_marketplace_params(
235
317
  self, marketplace, response_timeout: int, payment_type: bytes
236
318
  ) -> bool:
237
- """Validate marketplace parameters."""
319
+ """Validate marketplace parameters.
320
+
321
+ Note: v1 marketplaces may not have all validation functions.
322
+ We proceed with warnings when validation functions are unavailable.
323
+ """
238
324
  # Validate response_timeout bounds
239
325
  try:
240
326
  min_timeout = marketplace.call("minResponseTimeout")
@@ -250,15 +336,31 @@ class MechManagerMixin:
250
336
  except Exception as e:
251
337
  logger.warning(f"Could not validate response_timeout bounds: {e}")
252
338
 
253
- # Validate payment type has balance tracker
339
+ # Validate payment type has balance tracker (v2 only)
254
340
  try:
255
341
  balance_tracker = marketplace.call("mapPaymentTypeBalanceTrackers", payment_type)
256
342
  if balance_tracker == ZERO_ADDRESS:
257
- logger.error(f"No balance tracker for payment type 0x{payment_type.hex()}")
343
+ # This is a validation failure for v2 - return False
344
+ logger.error(
345
+ f"No balance tracker for payment type 0x{payment_type.hex()}. "
346
+ "This is required for v2 marketplace requests."
347
+ )
258
348
  return False
259
- logger.debug(f"Payment type balance tracker: {balance_tracker}")
349
+ else:
350
+ logger.debug(f"Payment type balance tracker: {balance_tracker}")
260
351
  except Exception as e:
261
- logger.warning(f"Could not validate payment type: {e}")
352
+ # Check if this is a revert (v1 doesn't have this function) vs a network error
353
+ error_str = str(e).lower()
354
+ if "reverted" in error_str or "execution reverted" in error_str:
355
+ # v1 marketplaces don't have mapPaymentTypeBalanceTrackers - skip
356
+ logger.warning(
357
+ f"Could not validate payment type (marketplace may be v1): {e}. "
358
+ "Proceeding without validation."
359
+ )
360
+ else:
361
+ # Real error - fail validation
362
+ logger.error(f"Failed to validate payment type (network error?): {e}")
363
+ return False
262
364
 
263
365
  return True
264
366
 
@@ -269,7 +371,7 @@ class MechManagerMixin:
269
371
  chain_name = self.chain_name if self.service else getattr(self, "chain_name", "gnosis")
270
372
  protocol_contracts = OLAS_CONTRACTS.get(chain_name, {})
271
373
 
272
- resolved_mp = marketplace_addr or protocol_contracts.get("OLAS_MECH_MARKETPLACE")
374
+ resolved_mp = marketplace_addr or protocol_contracts.get("OLAS_MECH_MARKETPLACE_V2")
273
375
  if not resolved_mp:
274
376
  raise ValueError(f"Mech Marketplace address not found for chain {chain_name}")
275
377
 
@@ -294,14 +396,30 @@ class MechManagerMixin:
294
396
  self,
295
397
  data: bytes,
296
398
  value: Optional[int] = None,
297
- marketplace_address: Optional[str] = None,
298
399
  priority_mech: Optional[str] = None,
400
+ marketplace_address: Optional[str] = None,
299
401
  max_delivery_rate: Optional[int] = None,
300
402
  payment_type: Optional[bytes] = None,
301
403
  payment_data: bytes = b"",
302
404
  response_timeout: int = 300,
303
405
  ) -> Optional[str]:
304
- """Send a marketplace mech request with validation."""
406
+ """Send a marketplace mech request with validation.
407
+
408
+ Args:
409
+ data: Request data payload (bytes).
410
+ value: Native currency value to send with request (wei).
411
+ priority_mech: Priority mech address for request processing.
412
+ marketplace_address: The marketplace contract address from activity checker.
413
+ If None, falls back to OLAS_MECH_MARKETPLACE_V2 constant.
414
+ max_delivery_rate: Maximum delivery rate for the mech.
415
+ payment_type: Payment type bytes32 (defaults to PAYMENT_TYPE_NATIVE).
416
+ payment_data: Additional payment data.
417
+ response_timeout: Timeout for response in seconds.
418
+
419
+ Returns:
420
+ Transaction hash if successful, None otherwise.
421
+
422
+ """
305
423
  if not self.service:
306
424
  logger.error("No active service")
307
425
  return None
@@ -314,6 +432,17 @@ class MechManagerMixin:
314
432
  logger.error(e)
315
433
  return None
316
434
 
435
+ # Dispatch to v1 handler if marketplace is v1
436
+ if marketplace_address in V1_MARKETPLACES:
437
+ return self._send_v1_marketplace_request(
438
+ data=data,
439
+ marketplace_address=marketplace_address,
440
+ priority_mech=priority_mech,
441
+ response_timeout=response_timeout,
442
+ value=value,
443
+ )
444
+
445
+ # v2 flow
317
446
  marketplace = MechMarketplaceContract(marketplace_address, chain_name=self.chain_name)
318
447
 
319
448
  if not self._validate_priority_mech(marketplace, priority_mech):
@@ -350,6 +479,77 @@ class MechManagerMixin:
350
479
  expected_event="MarketplaceRequest",
351
480
  )
352
481
 
482
+ def _send_v1_marketplace_request(
483
+ self,
484
+ data: bytes,
485
+ marketplace_address: str,
486
+ priority_mech: str,
487
+ response_timeout: int = 300,
488
+ value: Optional[int] = None,
489
+ ) -> Optional[str]:
490
+ """Send a v1 marketplace mech request.
491
+
492
+ v1 marketplace (VERSION 1.0.0) requires staking instance and service ID
493
+ for both the mech and the requester, unlike v2 which uses payment types.
494
+ """
495
+ if not self.service:
496
+ logger.error("No active service")
497
+ return None
498
+
499
+ # Get mech info from DEFAULT_PRIORITY_MECH (now a 3-tuple)
500
+ mech_info = DEFAULT_PRIORITY_MECH.get(marketplace_address)
501
+ if not mech_info or len(mech_info) < 3:
502
+ logger.error(f"No priority mech info for v1 marketplace {marketplace_address}")
503
+ return None
504
+
505
+ priority_mech_address, priority_mech_service_id, priority_mech_staking = mech_info
506
+
507
+ if not priority_mech_staking:
508
+ logger.error(f"No mech staking instance for v1 marketplace {marketplace_address}")
509
+ return None
510
+
511
+ # Get requester staking info from current service
512
+ requester_staking_instance = self.service.staking_contract_address
513
+ requester_service_id = self.service.service_id
514
+
515
+ if not requester_staking_instance:
516
+ logger.error("No staking contract for current service (required for v1)")
517
+ return None
518
+
519
+ # Build v1 request params
520
+ params = V1RequestParams(
521
+ data=data,
522
+ priority_mech=priority_mech_address,
523
+ priority_mech_staking_instance=priority_mech_staking,
524
+ priority_mech_service_id=priority_mech_service_id,
525
+ requester_staking_instance=requester_staking_instance,
526
+ requester_service_id=requester_service_id,
527
+ response_timeout=response_timeout,
528
+ value=value or 10_000_000_000_000_000, # 0.01 xDAI default
529
+ )
530
+
531
+ logger.info(
532
+ f"[MECH-V1] Sending v1 marketplace request to {marketplace_address} "
533
+ f"(mech={priority_mech_address}, mech_svc={priority_mech_service_id})"
534
+ )
535
+
536
+ marketplace = MechMarketplaceV1Contract(marketplace_address, chain_name=self.chain_name)
537
+ tx_data = marketplace.prepare_request_tx(
538
+ from_address=self.service.multisig_address,
539
+ params=params,
540
+ )
541
+
542
+ if not tx_data:
543
+ logger.error("Failed to prepare v1 marketplace request transaction")
544
+ return None
545
+
546
+ return self._execute_mech_tx(
547
+ tx_data=tx_data,
548
+ to_address=str(marketplace_address),
549
+ contract_instance=marketplace,
550
+ expected_event="MarketplaceRequest",
551
+ )
552
+
353
553
  def _execute_mech_tx(
354
554
  self,
355
555
  tx_data: dict,
@@ -412,6 +612,15 @@ class MechManagerMixin:
412
612
 
413
613
  if event_found:
414
614
  logger.info(f"Event '{expected_event}' verified successfully")
615
+
616
+ # Log transfer events from receipt
617
+ from iwa.core.services.transaction import TransferLogger
618
+
619
+ transfer_logger = TransferLogger(
620
+ self.wallet.account_service, self.registry.chain_interface
621
+ )
622
+ transfer_logger.log_transfers(receipt)
623
+
415
624
  return tx_hash
416
625
  else:
417
626
  logger.error(f"Event '{expected_event}' NOT found in transaction logs")
@@ -50,6 +50,7 @@ from typing import Optional
50
50
  from loguru import logger
51
51
  from web3 import Web3
52
52
 
53
+ from iwa.core.contracts.cache import ContractCache
53
54
  from iwa.core.types import EthereumAddress
54
55
  from iwa.core.utils import get_tx_hash
55
56
  from iwa.plugins.olas.contracts.staking import StakingContract, StakingState
@@ -96,7 +97,9 @@ class StakingManagerMixin:
96
97
 
97
98
  # Load the staking contract
98
99
  try:
99
- staking = StakingContract(str(staking_address), chain_name=self.chain_name)
100
+ staking = ContractCache().get_contract(
101
+ StakingContract, str(staking_address), chain_name=self.chain_name
102
+ )
100
103
  except Exception as e:
101
104
  logger.error(f"Failed to load staking contract: {e}")
102
105
  return StakingStatus(
@@ -613,7 +616,8 @@ class StakingManagerMixin:
613
616
  # Load staking contract if not provided
614
617
  if not staking_contract:
615
618
  try:
616
- staking_contract = StakingContract(
619
+ staking_contract = ContractCache().get_contract(
620
+ StakingContract,
617
621
  str(self.service.staking_contract_address),
618
622
  chain_name=self.service.chain_name,
619
623
  )
@@ -309,7 +309,10 @@ def test_importer_import_service_config_duplicate(mock_wallet):
309
309
 
310
310
  def test_sm_create_token_utility_missing(mock_wallet):
311
311
  """Cover create() with missing token utility (lines 204-206)."""
312
- with patch("iwa.core.models.Config"):
312
+ with patch("iwa.core.models.Config"), \
313
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
314
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
315
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
313
316
  sm = ServiceManager(mock_wallet)
314
317
 
315
318
  with patch.dict("iwa.plugins.olas.service_manager.base.OLAS_CONTRACTS", {"unknown": {}}):
@@ -320,7 +323,10 @@ def test_sm_create_token_utility_missing(mock_wallet):
320
323
 
321
324
  def test_sm_get_staking_status_staked_info_fail(mock_wallet):
322
325
  """Cover get_staking_status with STAKED but get_service_info fails (lines 843-854)."""
323
- with patch("iwa.core.models.Config"):
326
+ with patch("iwa.core.models.Config"), \
327
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
328
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
329
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
324
330
  sm = ServiceManager(mock_wallet)
325
331
  sm.service = Service(
326
332
  service_name="t", chain_name="gnosis", service_id=1, staking_contract_address=VALID_ADDR
@@ -341,7 +347,10 @@ def test_sm_get_staking_status_staked_info_fail(mock_wallet):
341
347
 
342
348
  def test_sm_call_checkpoint_prepare_fail(mock_wallet):
343
349
  """Cover call_checkpoint prepare failure (lines 1100-1102)."""
344
- with patch("iwa.core.models.Config"):
350
+ with patch("iwa.core.models.Config"), \
351
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
352
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
353
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
345
354
  sm = ServiceManager(mock_wallet)
346
355
  sm.service = Service(
347
356
  service_name="t", chain_name="gnosis", service_id=1, staking_contract_address=VALID_ADDR
@@ -360,7 +369,10 @@ def test_sm_call_checkpoint_prepare_fail(mock_wallet):
360
369
 
361
370
  def test_sm_spin_up_no_service(mock_wallet):
362
371
  """Cover spin_up with no service (lines 1167-1170)."""
363
- with patch("iwa.core.models.Config"):
372
+ with patch("iwa.core.models.Config"), \
373
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
374
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
375
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
364
376
  sm = ServiceManager(mock_wallet)
365
377
  sm.service = None
366
378
 
@@ -370,7 +382,10 @@ def test_sm_spin_up_no_service(mock_wallet):
370
382
 
371
383
  def test_sm_spin_up_activation_fail(mock_wallet):
372
384
  """Cover spin_up activation failure (lines 1181-1183)."""
373
- with patch("iwa.core.models.Config"):
385
+ with patch("iwa.core.models.Config"), \
386
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
387
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
388
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
374
389
  sm = ServiceManager(mock_wallet)
375
390
  sm.service = Service(service_name="t", chain_name="gnosis", service_id=1)
376
391
 
@@ -387,7 +402,10 @@ def test_sm_spin_up_activation_fail(mock_wallet):
387
402
 
388
403
  def test_sm_wind_down_no_service(mock_wallet):
389
404
  """Cover wind_down with no service (lines 1264-1266)."""
390
- with patch("iwa.core.models.Config"):
405
+ with patch("iwa.core.models.Config"), \
406
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
407
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
408
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
391
409
  sm = ServiceManager(mock_wallet)
392
410
  sm.service = None
393
411
 
@@ -397,7 +415,10 @@ def test_sm_wind_down_no_service(mock_wallet):
397
415
 
398
416
  def test_sm_wind_down_nonexistent(mock_wallet):
399
417
  """Cover wind_down with non-existent service (lines 1274-1276)."""
400
- with patch("iwa.core.models.Config"):
418
+ with patch("iwa.core.models.Config"), \
419
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
420
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
421
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
401
422
  sm = ServiceManager(mock_wallet)
402
423
  sm.service = Service(service_name="t", chain_name="gnosis", service_id=1)
403
424
 
@@ -411,7 +432,10 @@ def test_sm_wind_down_nonexistent(mock_wallet):
411
432
 
412
433
  def test_sm_mech_request_no_service(mock_wallet):
413
434
  """Cover _send_legacy_mech_request with no service (lines 1502-1504)."""
414
- with patch("iwa.core.models.Config"):
435
+ with patch("iwa.core.models.Config"), \
436
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
437
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
438
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
415
439
  sm = ServiceManager(mock_wallet)
416
440
  sm.service = None
417
441
 
@@ -421,7 +445,10 @@ def test_sm_mech_request_no_service(mock_wallet):
421
445
 
422
446
  def test_sm_mech_request_no_address(mock_wallet):
423
447
  """Cover _send_legacy_mech_request missing mech address (lines 1510-1512)."""
424
- with patch("iwa.core.models.Config"):
448
+ with patch("iwa.core.models.Config"), \
449
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
450
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
451
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
425
452
  sm = ServiceManager(mock_wallet)
426
453
  sm.service = Service(service_name="t", chain_name="unknown", service_id=1)
427
454
 
@@ -431,7 +458,8 @@ def test_sm_mech_request_no_address(mock_wallet):
431
458
 
432
459
  def test_sm_marketplace_mech_no_service(mock_wallet):
433
460
  """Cover _send_marketplace_mech_request with no service (lines 1549-1551)."""
434
- with patch("iwa.core.models.Config"):
461
+ with patch("iwa.core.models.Config"), \
462
+ patch("iwa.plugins.olas.service_manager.base.ContractCache"):
435
463
  sm = ServiceManager(mock_wallet)
436
464
  sm.service = None
437
465
 
@@ -114,11 +114,16 @@ def service_manager(
114
114
  mock_service,
115
115
  ):
116
116
  """ServiceManager fixture with mocked dependencies."""
117
- with patch("iwa.plugins.olas.service_manager.base.Config") as local_mock_config:
117
+ with patch("iwa.plugins.olas.service_manager.base.Config") as local_mock_config, \
118
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache:
118
119
  instance = local_mock_config.return_value
119
120
  instance.plugins = {"olas": mock_olas_config}
120
121
  instance.save_config = MagicMock()
121
122
 
123
+ # Mock ContractCache to return MagicMock contracts
124
+ mock_cache_instance = mock_cache.return_value
125
+ mock_cache_instance.get_contract.return_value = MagicMock()
126
+
122
127
  sm = ServiceManager(mock_wallet)
123
128
  # Ensure service is properly set
124
129
  sm.service = mock_service
@@ -127,6 +132,7 @@ def service_manager(
127
132
  yield sm
128
133
 
129
134
 
135
+
130
136
  def test_init(service_manager):
131
137
  """Test initialization."""
132
138
  assert service_manager.registry is not None
@@ -23,12 +23,19 @@ def mock_wallet():
23
23
  return wallet
24
24
 
25
25
 
26
- def setup_manager(mock_wallet):
26
+ @pytest.fixture
27
+ def mock_manager(mock_wallet):
27
28
  """Setup a ServiceManager with mocked dependencies."""
28
- with patch("iwa.plugins.olas.service_manager.base.Config") as mock_cfg_cls:
29
+ with patch("iwa.plugins.olas.service_manager.base.Config") as mock_cfg_cls, \
30
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
31
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
32
+
33
+ mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
34
+
29
35
  mock_cfg = mock_cfg_cls.return_value
30
36
  mock_cfg.plugins = {"olas": MagicMock()}
31
37
  mock_cfg.plugins["olas"].get_service.return_value = None
38
+
32
39
  with patch(
33
40
  "iwa.plugins.olas.service_manager.OLAS_CONTRACTS",
34
41
  {
@@ -43,18 +50,22 @@ def setup_manager(mock_wallet):
43
50
  mock_if = mock_if_cls.return_value
44
51
  mock_if.get.return_value.chain.name.lower.return_value = "gnosis"
45
52
  mock_if.get.return_value.get_contract_address.return_value = VALID_ADDR
53
+
46
54
  manager = ServiceManager(mock_wallet)
47
55
  manager.registry = MagicMock()
48
56
  manager.manager_contract = MagicMock()
49
57
  manager.olas_config = mock_cfg.plugins["olas"]
50
58
  manager.chain_name = "gnosis"
59
+ # Fix recursive mock issue by setting explicit return value
60
+ manager.registry.chain_interface = MagicMock()
51
61
  manager.registry.chain_interface.get_contract_address.return_value = VALID_ADDR
52
- return manager
62
+
63
+ yield manager
53
64
 
54
65
 
55
- def test_service_manager_mech_requests_failures(mock_wallet):
66
+ def test_service_manager_mech_requests_failures(mock_manager):
56
67
  """Test failure paths in mech requests."""
57
- manager = setup_manager(mock_wallet)
68
+ manager = mock_manager
58
69
 
59
70
  # Service missing
60
71
  manager.service = None
@@ -99,9 +110,9 @@ def test_service_manager_mech_requests_failures(mock_wallet):
99
110
  assert manager.send_mech_request(b"data", use_marketplace=False) is None
100
111
 
101
112
 
102
- def test_service_manager_lifecycle_failures(mock_wallet):
113
+ def test_service_manager_lifecycle_failures(mock_manager, mock_wallet):
103
114
  """Test failure paths in lifecycle methods."""
104
- manager = setup_manager(mock_wallet)
115
+ manager = mock_manager
105
116
  manager.service = Service(service_name="t", chain_name="gnosis", service_id=1, agent_ids=[1])
106
117
 
107
118
  # register_agent failures
@@ -147,9 +158,9 @@ def test_service_manager_lifecycle_failures(mock_wallet):
147
158
  assert manager.wind_down() is True
148
159
 
149
160
 
150
- def test_service_manager_staking_status_failures(mock_wallet):
161
+ def test_service_manager_staking_status_failures(mock_manager):
151
162
  """Test failure paths in get_staking_status."""
152
- manager = setup_manager(mock_wallet)
163
+ manager = mock_manager
153
164
 
154
165
  # Service missing
155
166
  manager.service = None
@@ -178,9 +189,9 @@ def test_service_manager_staking_status_failures(mock_wallet):
178
189
  assert status.mech_requests_this_epoch == 0
179
190
 
180
191
 
181
- def test_service_manager_verify_event_exception(mock_wallet):
192
+ def test_service_manager_verify_event_exception(mock_manager):
182
193
  """Test exception path in _execute_mech_tx."""
183
- manager = setup_manager(mock_wallet)
194
+ manager = mock_manager
184
195
  manager.service = Service(
185
196
  service_name="t",
186
197
  chain_name="gnosis",