iwa 0.0.33__py3-none-any.whl → 0.0.59__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 (83) hide show
  1. iwa/core/chain/interface.py +130 -11
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +48 -12
  4. iwa/core/chainlist.py +15 -10
  5. iwa/core/cli.py +4 -1
  6. iwa/core/contracts/cache.py +1 -1
  7. iwa/core/contracts/contract.py +1 -0
  8. iwa/core/contracts/decoder.py +10 -4
  9. iwa/core/http.py +31 -0
  10. iwa/core/ipfs.py +21 -7
  11. iwa/core/keys.py +65 -15
  12. iwa/core/models.py +58 -13
  13. iwa/core/pricing.py +10 -6
  14. iwa/core/rpc_monitor.py +1 -0
  15. iwa/core/secrets.py +27 -0
  16. iwa/core/services/account.py +1 -1
  17. iwa/core/services/balance.py +0 -23
  18. iwa/core/services/safe.py +72 -45
  19. iwa/core/services/safe_executor.py +350 -0
  20. iwa/core/services/transaction.py +43 -13
  21. iwa/core/services/transfer/erc20.py +14 -3
  22. iwa/core/services/transfer/native.py +14 -31
  23. iwa/core/services/transfer/swap.py +1 -0
  24. iwa/core/tests/test_gnosis_fee.py +91 -0
  25. iwa/core/tests/test_ipfs.py +85 -0
  26. iwa/core/tests/test_pricing.py +65 -0
  27. iwa/core/tests/test_regression_fixes.py +97 -0
  28. iwa/core/utils.py +2 -0
  29. iwa/core/wallet.py +6 -4
  30. iwa/plugins/gnosis/cow/quotes.py +2 -2
  31. iwa/plugins/gnosis/cow/swap.py +18 -32
  32. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  33. iwa/plugins/olas/constants.py +15 -5
  34. iwa/plugins/olas/contracts/activity_checker.py +3 -3
  35. iwa/plugins/olas/contracts/staking.py +0 -1
  36. iwa/plugins/olas/events.py +15 -13
  37. iwa/plugins/olas/importer.py +29 -25
  38. iwa/plugins/olas/models.py +0 -3
  39. iwa/plugins/olas/plugin.py +16 -14
  40. iwa/plugins/olas/service_manager/drain.py +16 -9
  41. iwa/plugins/olas/service_manager/lifecycle.py +23 -12
  42. iwa/plugins/olas/service_manager/staking.py +15 -10
  43. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  44. iwa/plugins/olas/tests/test_olas_archiving.py +83 -0
  45. iwa/plugins/olas/tests/test_olas_integration.py +49 -29
  46. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  47. iwa/plugins/olas/tests/test_service_manager.py +15 -17
  48. iwa/plugins/olas/tests/test_service_manager_errors.py +6 -5
  49. iwa/plugins/olas/tests/test_service_manager_flows.py +7 -6
  50. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  51. iwa/plugins/olas/tests/test_service_staking.py +64 -38
  52. iwa/tools/drain_accounts.py +61 -0
  53. iwa/tools/list_contracts.py +2 -0
  54. iwa/tools/reset_env.py +2 -1
  55. iwa/tools/test_chainlist.py +5 -1
  56. iwa/tui/screens/wallets.py +2 -4
  57. iwa/web/routers/accounts.py +1 -1
  58. iwa/web/routers/olas/services.py +10 -5
  59. iwa/web/static/app.js +21 -9
  60. iwa/web/static/style.css +4 -0
  61. iwa/web/tests/test_web_endpoints.py +2 -2
  62. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/METADATA +6 -3
  63. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/RECORD +82 -71
  64. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/WHEEL +1 -1
  65. tests/test_balance_service.py +0 -43
  66. tests/test_chain.py +13 -5
  67. tests/test_cli.py +2 -2
  68. tests/test_drain_coverage.py +12 -6
  69. tests/test_keys.py +23 -23
  70. tests/test_rate_limiter.py +2 -2
  71. tests/test_rate_limiter_retry.py +103 -0
  72. tests/test_rpc_efficiency.py +4 -1
  73. tests/test_rpc_rate_limit.py +34 -0
  74. tests/test_rpc_rotation.py +59 -11
  75. tests/test_safe_coverage.py +37 -23
  76. tests/test_safe_executor.py +361 -0
  77. tests/test_safe_integration.py +153 -0
  78. tests/test_safe_service.py +1 -1
  79. tests/test_transfer_swap_unit.py +5 -1
  80. tests/test_pricing.py +0 -160
  81. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/entry_points.txt +0 -0
  82. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/licenses/LICENSE +0 -0
  83. {iwa-0.0.33.dist-info → iwa-0.0.59.dist-info}/top_level.txt +0 -0
@@ -181,9 +181,7 @@ class OlasServiceImporter:
181
181
  if service.service_id:
182
182
  key = f"{service.chain_name}:{service.service_id}"
183
183
  if key in seen_keys:
184
- logger.debug(
185
- f"Skipping duplicate service {key} from {service.source_folder}"
186
- )
184
+ logger.debug(f"Skipping duplicate service {key} from {service.source_folder}")
187
185
  duplicates += 1
188
186
  continue
189
187
  seen_keys.add(key)
@@ -482,9 +480,7 @@ class OlasServiceImporter:
482
480
  staking_program_id, chain_name
483
481
  )
484
482
 
485
- def _resolve_staking_contract(
486
- self, staking_program_id: str, chain_name: str
487
- ) -> Optional[str]:
483
+ def _resolve_staking_contract(self, staking_program_id: str, chain_name: str) -> Optional[str]:
488
484
  """Resolve a staking program ID to a contract address."""
489
485
  address = STAKING_PROGRAM_MAP.get(staking_program_id)
490
486
  if address:
@@ -540,8 +536,10 @@ class OlasServiceImporter:
540
536
 
541
537
  # Check for "safes" entry which indicates the owner is a Safe
542
538
  # Structure: "safes": { "gnosis": "0x..." }
543
- if "safes" in data and FLAGS_OWNER_SAFE in data["safes"]: # Need to detect chain dynamically or iterate
544
- pass
539
+ if (
540
+ "safes" in data and FLAGS_OWNER_SAFE in data["safes"]
541
+ ): # Need to detect chain dynamically or iterate
542
+ pass
545
543
 
546
544
  # Logic update:
547
545
  # 1. Capture EOA address always (it's the signer)
@@ -557,9 +555,13 @@ class OlasServiceImporter:
557
555
  if safe_owner_address:
558
556
  # CASE: Owner is Safe
559
557
  service.service_owner_multisig_address = safe_owner_address
560
- service.service_owner_eoa_address = eoa_address # The EOA is the signer/controller
558
+ service.service_owner_eoa_address = (
559
+ eoa_address # The EOA is the signer/controller
560
+ )
561
561
 
562
- logger.debug(f"Extracted Safe owner address: {safe_owner_address} (Signer: {eoa_address})")
562
+ logger.debug(
563
+ f"Extracted Safe owner address: {safe_owner_address} (Signer: {eoa_address})"
564
+ )
563
565
  elif eoa_address:
564
566
  # CASE: Owner is EOA
565
567
  service.service_owner_eoa_address = eoa_address
@@ -767,8 +769,8 @@ class OlasServiceImporter:
767
769
  safe_result = self._import_safe(
768
770
  address=service.safe_address,
769
771
  signers=self._get_agent_signers(service),
770
- tag_suffix="safe", # e.g. trader_zeta_safe
771
- service_name=service.service_name
772
+ tag_suffix="multisig", # e.g. trader_zeta_safe
773
+ service_name=service.service_name,
772
774
  )
773
775
  if safe_result[0]:
774
776
  result.imported_safes.append(service.safe_address)
@@ -778,19 +780,22 @@ class OlasServiceImporter:
778
780
  result.errors.append(f"Safe {service.safe_address}: {safe_result[1]}")
779
781
 
780
782
  # 2. Import Owner Safe (if it exists and is different)
781
- if service.service_owner_multisig_address and service.service_owner_multisig_address != service.safe_address:
782
- # Signer for Owner Safe is the EOA owner key
783
+ if (
784
+ service.service_owner_multisig_address
785
+ and service.service_owner_multisig_address != service.safe_address
786
+ ):
787
+ # Signer for Owner Safe is the EOA owner key
783
788
  owner_signers = self._get_owner_signers(service)
784
789
 
785
790
  safe_result = self._import_safe(
786
791
  address=service.service_owner_multisig_address,
787
792
  signers=owner_signers,
788
- tag_suffix="owner_safe", # e.g. trader_zeta_owner_safe
789
- service_name=service.service_name
793
+ tag_suffix="owner_multisig", # e.g. trader_zeta_owner_safe
794
+ service_name=service.service_name,
790
795
  )
791
796
  if safe_result[0]:
792
- result.imported_safes.append(service.service_owner_multisig_address)
793
- logger.info(f"Imported Owner Safe {service.service_owner_multisig_address}")
797
+ result.imported_safes.append(service.service_owner_multisig_address)
798
+ logger.info(f"Imported Owner Safe {service.service_owner_multisig_address}")
794
799
 
795
800
  def _get_agent_signers(self, service: DiscoveredService) -> List[str]:
796
801
  """Get list of signers for the agent safe."""
@@ -878,8 +883,7 @@ class OlasServiceImporter:
878
883
  self.key_storage._password,
879
884
  tag,
880
885
  )
881
- self.key_storage.accounts[encrypted.address] = encrypted
882
- self.key_storage.save()
886
+ self.key_storage.register_account(encrypted)
883
887
  logger.info(f"Imported key {key.address} as '{tag}'")
884
888
  return True, "ok"
885
889
  except Exception as e:
@@ -926,8 +930,8 @@ class OlasServiceImporter:
926
930
  self,
927
931
  address: str,
928
932
  signers: List[str] = None,
929
- tag_suffix: str = "safe",
930
- service_name: Optional[str] = None
933
+ tag_suffix: str = "multisig",
934
+ service_name: Optional[str] = None,
931
935
  ) -> Tuple[bool, str]:
932
936
  """Import a generic Safe."""
933
937
  if not address:
@@ -960,8 +964,7 @@ class OlasServiceImporter:
960
964
  signers=signers or [],
961
965
  )
962
966
 
963
- self.key_storage.accounts[address] = safe_account
964
- self.key_storage.save()
967
+ self.key_storage.register_account(safe_account)
965
968
  logger.info(f"Imported Safe {address} as '{tag}'")
966
969
  return True, "ok"
967
970
 
@@ -1064,4 +1067,5 @@ class OlasServiceImporter:
1064
1067
  key.signature_failed = True
1065
1068
  logger.warning(f"Error verifying signature for key {key.address}: {e}")
1066
1069
 
1067
- FLAGS_OWNER_SAFE="deprecated"
1070
+
1071
+ FLAGS_OWNER_SAFE = "deprecated"
@@ -134,8 +134,5 @@ class OlasConfig(BaseModel):
134
134
  target = multisig_address.lower()
135
135
  for service in self.services.values():
136
136
  if service.multisig_address and str(service.multisig_address).lower() == target:
137
- # The following line is from the Code Edit, but it does not fit syntactically here.
138
- # It appears to be from a different file (decoder.py) as indicated by the instruction.
139
- # args_str = ", ".join(f"{n}={v}" for n, v in zip(d["arg_names"], decoded, strict=False))
140
137
  return service
141
138
  return None
@@ -151,7 +151,9 @@ class OlasPlugin(Plugin):
151
151
 
152
152
  is_signer = key_addr in [s.lower() for s in on_chain_signers]
153
153
  if not is_signer:
154
- safe_text += f"\n[bold red]⚠ Agent {agent_key.address} - NOT A SIGNER![/bold red]"
154
+ safe_text += (
155
+ f"\n[bold red]⚠ Agent {agent_key.address} - NOT A SIGNER![/bold red]"
156
+ )
155
157
  else:
156
158
  safe_text += f" (Signer: {agent_key.address[:6]}...)"
157
159
 
@@ -182,21 +184,21 @@ class OlasPlugin(Plugin):
182
184
  # 1. Display Signer/EOA Owner
183
185
  owner_key = next((k for k in service.keys if k.role == "owner"), None)
184
186
  if owner_key:
185
- val = owner_key.address
186
- if not val.startswith("0x"):
187
- val = "0x" + val
188
-
189
- if owner_key.signature_verified:
190
- val = f"[green]{val}[/green]"
191
- elif not owner_key.is_encrypted:
192
- val = f"[red]{val}[/red]"
193
- status = "🔒 encrypted" if owner_key.is_encrypted else "🔓 plaintext"
194
- table.add_row("Owner (EOA)", f"{val} {status}")
187
+ val = owner_key.address
188
+ if not val.startswith("0x"):
189
+ val = "0x" + val
190
+
191
+ if owner_key.signature_verified:
192
+ val = f"[green]{val}[/green]"
193
+ elif not owner_key.is_encrypted:
194
+ val = f"[red]{val}[/red]"
195
+ status = "🔒 encrypted" if owner_key.is_encrypted else "🔓 plaintext"
196
+ table.add_row("Owner (EOA)", f"{val} {status}")
195
197
  elif service.service_owner_eoa_address:
196
- # Fallback if we have an address but no key object
197
- table.add_row("Owner (EOA)", service.service_owner_eoa_address)
198
+ # Fallback if we have an address but no key object
199
+ table.add_row("Owner (EOA)", service.service_owner_eoa_address)
198
200
  else:
199
- table.add_row("Owner (EOA)", "[yellow]N/A[/yellow]")
201
+ table.add_row("Owner (EOA)", "[yellow]N/A[/yellow]")
200
202
 
201
203
  # 2. Display Safe Owner
202
204
  if service.service_owner_multisig_address:
@@ -107,12 +107,18 @@ class DrainManagerMixin:
107
107
  logger.error("Service has no multisig address")
108
108
  return False, 0
109
109
 
110
- if not self.olas_config.withdrawal_address:
111
- logger.error("No withdrawal address configured in OlasConfig")
112
- return False, 0
113
-
110
+ withdrawal_address = (
111
+ str(self.olas_config.withdrawal_address)
112
+ if self.olas_config.withdrawal_address
113
+ else str(self.wallet.master_account.address)
114
+ )
114
115
  multisig_address = str(self.service.multisig_address)
115
- withdrawal_address = str(self.olas_config.withdrawal_address)
116
+
117
+ if not self.olas_config.withdrawal_address:
118
+ logger.warning(
119
+ "No withdrawal address configured. Defaulting to master account: "
120
+ f"{withdrawal_address}"
121
+ )
116
122
 
117
123
  # Get OLAS balance of the Safe
118
124
  olas_token = ERC20Contract(
@@ -126,9 +132,10 @@ class DrainManagerMixin:
126
132
  return False, 0
127
133
 
128
134
  olas_amount = olas_balance / 1e18
129
- logger.info(
130
- f"Withdrawing {olas_amount:.4f} OLAS from {multisig_address} to {withdrawal_address}"
131
- )
135
+ withdrawal_tag = self.wallet.get_tag_by_address(withdrawal_address) or withdrawal_address
136
+ multisig_tag = self.wallet.get_tag_by_address(multisig_address) or multisig_address
137
+
138
+ logger.info(f"Withdrawing {olas_amount:.4f} OLAS from {multisig_tag} to {withdrawal_tag}")
132
139
 
133
140
  # Transfer from Safe to withdrawal address
134
141
  tx_hash = self.wallet.send(
@@ -143,7 +150,7 @@ class DrainManagerMixin:
143
150
  logger.error("Failed to transfer OLAS")
144
151
  return False, 0
145
152
 
146
- logger.info(f"Withdrew {olas_amount:.4f} OLAS to {withdrawal_address}")
153
+ logger.info(f"Withdrew {olas_amount:.4f} OLAS to {withdrawal_tag}")
147
154
  return True, olas_amount
148
155
 
149
156
  def drain_service(
@@ -429,9 +429,7 @@ class LifecycleManagerMixin:
429
429
  )
430
430
 
431
431
  if balance < bond_amount:
432
- logger.error(
433
- f"[ACTIVATE] FAIL: Owner balance {balance} < required {bond_amount}"
434
- )
432
+ logger.error(f"[ACTIVATE] FAIL: Owner balance {balance} < required {bond_amount}")
435
433
  return False
436
434
 
437
435
  protocol_contracts = OLAS_CONTRACTS.get(self.chain_name.lower(), {})
@@ -632,7 +630,7 @@ class LifecycleManagerMixin:
632
630
  # Use service_name for consistency with Safe naming
633
631
  agent_tag = f"{self.service.service_name}_agent"
634
632
  try:
635
- agent_account = self.wallet.key_storage.create_account(agent_tag)
633
+ agent_account = self.wallet.key_storage.generate_new_account(agent_tag)
636
634
  agent_account_address = agent_account.address
637
635
  logger.info(f"Created new agent account: {agent_account_address}")
638
636
 
@@ -682,7 +680,9 @@ class LifecycleManagerMixin:
682
680
  )
683
681
  return True
684
682
 
685
- logger.info(f"[REGISTER] Service Owner approving Token Utility for bond: {bond_amount_wei} wei")
683
+ logger.info(
684
+ f"[REGISTER] Service Owner approving Token Utility for bond: {bond_amount_wei} wei"
685
+ )
686
686
 
687
687
  utility_address = str(
688
688
  OLAS_CONTRACTS[self.chain_name]["OLAS_SERVICE_REGISTRY_TOKEN_UTILITY"]
@@ -697,9 +697,7 @@ class LifecycleManagerMixin:
697
697
  )
698
698
 
699
699
  if allowance >= bond_amount_wei:
700
- logger.debug(
701
- f"[REGISTER] Sufficient allowance ({allowance} >= {bond_amount_wei})"
702
- )
700
+ logger.debug(f"[REGISTER] Sufficient allowance ({allowance} >= {bond_amount_wei})")
703
701
  return True
704
702
 
705
703
  # Use service owner which holds the OLAS tokens (not necessarily master)
@@ -800,7 +798,9 @@ class LifecycleManagerMixin:
800
798
  )
801
799
  return False
802
800
 
803
- logger.debug(f"[DEPLOY] Preparing deploy tx for owner {self._get_label(self.service.service_owner_address)}")
801
+ logger.debug(
802
+ f"[DEPLOY] Preparing deploy tx for owner {self._get_label(self.service.service_owner_address)}"
803
+ )
804
804
  deploy_tx = self.manager.prepare_deploy_tx(
805
805
  from_address=self.service.service_owner_address,
806
806
  service_id=self.service.service_id,
@@ -855,16 +855,27 @@ class LifecycleManagerMixin:
855
855
  _, agent_instances = self.registry.call("getAgentInstances", self.service.service_id)
856
856
  service_info = self.registry.get_service(self.service.service_id)
857
857
  threshold = service_info["threshold"]
858
+ # Store the multisig in the wallet with tag
859
+ multisig_tag = f"{self.service.service_name}_multisig"
860
+
861
+ # ARCHIVING LOGIC: If tag is already taken by a different address, rename the old one
862
+ existing = self.wallet.key_storage.find_stored_account(multisig_tag)
863
+ if existing and existing.address != multisig_address:
864
+ archive_tag = f"{multisig_tag}_old_{existing.address[:6]}"
865
+ logger.info(f"[DEPLOY] Archiving old multisig: {multisig_tag} -> {archive_tag}")
866
+ try:
867
+ self.wallet.key_storage.rename_account(existing.address, archive_tag)
868
+ except Exception as ex:
869
+ logger.warning(f"[DEPLOY] Failed to rename old multisig (collision?): {ex}")
858
870
 
859
871
  safe_account = StoredSafeAccount(
860
- tag=f"{self.service.service_name}_multisig",
872
+ tag=multisig_tag,
861
873
  address=multisig_address,
862
874
  chains=[self.chain_name],
863
875
  threshold=threshold,
864
876
  signers=agent_instances,
865
877
  )
866
- self.wallet.key_storage.accounts[multisig_address] = safe_account
867
- self.wallet.key_storage.save()
878
+ self.wallet.key_storage.register_account(safe_account)
868
879
  logger.debug("[DEPLOY] Registered multisig in wallet")
869
880
  except Exception as e:
870
881
  logger.warning(f"[DEPLOY] Failed to register multisig in wallet: {e}")
@@ -90,6 +90,7 @@ class StakingManagerMixin:
90
90
  # Try token/contract names
91
91
  try:
92
92
  from iwa.core.chain import ChainInterfaces
93
+
93
94
  chain_interface = ChainInterfaces().get(self.chain_name)
94
95
  token_name = chain_interface.chain.get_token_name(address)
95
96
  if token_name:
@@ -211,14 +212,14 @@ class StakingManagerMixin:
211
212
  # Helper to safely get min_staking_duration
212
213
  try:
213
214
  min_duration = staking.min_staking_duration
214
- logger.info(f"min_staking_duration: {min_duration}")
215
+ logger.debug(f"min_staking_duration: {min_duration}")
215
216
  except Exception as e:
216
217
  logger.error(f"Failed to get min_staking_duration: {e}")
217
218
  min_duration = 0
218
219
 
219
220
  unstake_at = None
220
221
  ts_start = info.get("ts_start", 0)
221
- logger.info(f"ts_start: {ts_start}")
222
+ logger.debug(f"ts_start: {ts_start}")
222
223
 
223
224
  if ts_start > 0:
224
225
  try:
@@ -227,12 +228,12 @@ class StakingManagerMixin:
227
228
  unstake_ts,
228
229
  tz=timezone.utc,
229
230
  ).isoformat()
230
- logger.info(f"unstake_available_at: {unstake_at} (ts={unstake_ts})")
231
+ logger.debug(f"unstake_available_at: {unstake_at} (ts={unstake_ts})")
231
232
  except Exception as e:
232
233
  logger.error(f"calc error: {e}")
233
234
  pass
234
235
  else:
235
- logger.warning("ts_start is 0, cannot calculate unstake time")
236
+ logger.debug("ts_start is 0, cannot calculate unstake time")
236
237
 
237
238
  return unstake_at, ts_start, min_duration
238
239
 
@@ -407,7 +408,9 @@ class StakingManagerMixin:
407
408
  # NOTE: We don't check OLAS balance here because OLAS was already
408
409
  # deposited to the Token Utility during activation (min_staking_deposit)
409
410
  # and registration (agent_bond). The staking contract pulls from there.
410
- logger.debug("[STAKE] OLAS already deposited to Token Utility during activation/registration")
411
+ logger.debug(
412
+ "[STAKE] OLAS already deposited to Token Utility during activation/registration"
413
+ )
411
414
 
412
415
  return {"min_deposit": min_deposit, "staking_token": staking_token}
413
416
 
@@ -437,7 +440,9 @@ class StakingManagerMixin:
437
440
  owner_address = self.service.service_owner_address or self.wallet.master_account.address
438
441
 
439
442
  # Approve service NFT - this is an ERC-721 approval, not ERC-20
440
- logger.debug(f"[STAKE] Approving service NFT for staking contract from {self._get_label(owner_address)}...")
443
+ logger.debug(
444
+ f"[STAKE] Approving service NFT for staking contract from {self._get_label(owner_address)}..."
445
+ )
441
446
  approve_tx = self.registry.prepare_approve_tx(
442
447
  from_address=owner_address,
443
448
  spender=staking_contract.address,
@@ -483,7 +488,9 @@ class StakingManagerMixin:
483
488
  # Use service owner which holds the NFT (not necessarily master)
484
489
  owner_address = self.service.service_owner_address or self.wallet.master_account.address
485
490
 
486
- logger.debug(f"[STAKE] Preparing stake transaction from {self._get_label(owner_address)}...")
491
+ logger.debug(
492
+ f"[STAKE] Preparing stake transaction from {self._get_label(owner_address)}..."
493
+ )
487
494
  stake_tx = staking_contract.prepare_stake_tx(
488
495
  from_address=owner_address,
489
496
  service_id=self.service.service_id,
@@ -601,9 +608,7 @@ class StakingManagerMixin:
601
608
  return False
602
609
 
603
610
  tx_hash = get_tx_hash(receipt)
604
- logger.info(
605
- f"Unstake transaction sent: {tx_hash if receipt else 'No Receipt'}"
606
- )
611
+ logger.info(f"Unstake transaction sent: {tx_hash if receipt else 'No Receipt'}")
607
612
 
608
613
  events = staking_contract.extract_events(receipt)
609
614
 
@@ -296,6 +296,12 @@ def test_import_key_success(importer):
296
296
  importer.key_storage.find_stored_account.return_value = None
297
297
  importer.key_storage.accounts = {}
298
298
 
299
+ # Define side effect to update accounts dict when register_account is called
300
+ def mock_register(acc):
301
+ importer.key_storage.accounts[acc.address] = acc
302
+
303
+ importer.key_storage.register_account.side_effect = mock_register
304
+
299
305
  with patch("iwa.core.keys.EncryptedAccount.encrypt_private_key") as mock_enc:
300
306
  mock_enc.return_value = MagicMock(address=addr)
301
307
  success, msg = importer._import_key(key, "my_service")
@@ -310,10 +316,17 @@ def test_import_safe_success(importer):
310
316
  importer.key_storage.find_stored_account.return_value = None
311
317
  importer.key_storage.accounts = {}
312
318
 
319
+ # Define side effect to update accounts dict when register_account is called
320
+ def mock_register(acc):
321
+ importer.key_storage.accounts[acc.address] = acc
322
+
323
+ importer.key_storage.register_account.side_effect = mock_register
324
+
313
325
  success, msg = importer._import_safe(safe_address, service_name="my_service")
314
326
  assert success is True
315
327
  assert msg == "ok"
316
328
  assert safe_address in importer.key_storage.accounts
329
+ assert importer.key_storage.accounts[safe_address].tag == "my_service_multisig"
317
330
 
318
331
 
319
332
  def test_import_service_config_success(importer):
@@ -0,0 +1,83 @@
1
+ """Tests for Olas multisig archiving logic."""
2
+
3
+ import unittest
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from iwa.core.models import StoredAccount
7
+ from iwa.plugins.olas.contracts.service import ServiceState
8
+ from iwa.plugins.olas.models import Service
9
+ from iwa.plugins.olas.service_manager import ServiceManager
10
+
11
+
12
+ class TestOlasArchiving(unittest.TestCase):
13
+ """Test multisig archiving logic during deployment."""
14
+
15
+ def setUp(self):
16
+ """Set up test fixtures."""
17
+ self.mock_wallet = MagicMock()
18
+ # Mock KeyStorage
19
+ self.mock_key_storage = MagicMock()
20
+ self.mock_wallet.key_storage = self.mock_key_storage
21
+
22
+ # Initialize ServiceManager
23
+ with (
24
+ patch("iwa.core.models.Config"),
25
+ patch("iwa.plugins.olas.service_manager.base.ChainInterfaces"),
26
+ patch("iwa.plugins.olas.service_manager.base.ContractCache"),
27
+ patch("iwa.plugins.olas.service_manager.base.ServiceRegistryContract"),
28
+ ):
29
+ self.sm = ServiceManager(self.mock_wallet)
30
+ self.sm.service = Service(service_name="trader_psi", chain_name="gnosis", service_id=1)
31
+ self.sm.chain_name = "gnosis"
32
+
33
+ def test_multisig_archiving_logic(self):
34
+ """Test that old multisig is archived if tag is taken by different address."""
35
+ multisig_tag = "trader_psi_multisig"
36
+ old_address = "0x1111111111111111111111111111111111111111"
37
+ new_address = "0x2222222222222222222222222222222222222222"
38
+
39
+ # 1. Simulate existing multisig in wallet with same tag
40
+ existing_acc = StoredAccount(address=old_address, tag=multisig_tag)
41
+ self.mock_key_storage.find_stored_account.return_value = existing_acc
42
+
43
+ # 2. Mock required contract calls for deploy()
44
+ self.sm.registry.get_service.return_value = {
45
+ "state": ServiceState.FINISHED_REGISTRATION, # DEPLOYED
46
+ "security_deposit": 0,
47
+ "multisig": new_address,
48
+ "threshold": 1,
49
+ "configHash": b"\x00" * 32,
50
+ }
51
+ self.sm.registry.call.return_value = (None, []) # getAgentInstances
52
+
53
+ # 3. Trigger deploy (archiving happens here)
54
+ with (
55
+ patch.object(
56
+ self.mock_wallet, "sign_and_send_transaction", return_value=(True, {"status": 1})
57
+ ),
58
+ patch.object(
59
+ self.sm.registry,
60
+ "extract_events",
61
+ return_value=[
62
+ {"name": "DeployService", "args": {}},
63
+ {"name": "CreateMultisigWithAgents", "args": {"multisig": new_address}},
64
+ ],
65
+ ),
66
+ patch("iwa.plugins.olas.service_manager.lifecycle.get_tx_hash", return_value="0xhash"),
67
+ patch("iwa.core.models.StoredSafeAccount") as mock_safe_cls,
68
+ ):
69
+ self.sm.deploy(fund_multisig=False)
70
+
71
+ # 4. Verify rename_account was called for the old address
72
+ archive_tag = f"{multisig_tag}_old_{old_address[:6]}"
73
+ self.mock_key_storage.rename_account.assert_called_with(old_address, archive_tag)
74
+
75
+ # 5. Verify new account constructor was called with correct params
76
+ mock_safe_cls.assert_called()
77
+ _, kwargs = mock_safe_cls.call_args
78
+ self.assertEqual(kwargs["address"], new_address)
79
+ self.assertEqual(kwargs["tag"], multisig_tag)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()
@@ -309,9 +309,11 @@ 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"), \
313
- patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache, \
314
- patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache):
312
+ with (
313
+ patch("iwa.core.models.Config"),
314
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
315
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
316
+ ):
315
317
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
316
318
  sm = ServiceManager(mock_wallet)
317
319
 
@@ -323,9 +325,11 @@ def test_sm_create_token_utility_missing(mock_wallet):
323
325
 
324
326
  def test_sm_get_staking_status_staked_info_fail(mock_wallet):
325
327
  """Cover get_staking_status with STAKED but get_service_info fails (lines 843-854)."""
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):
328
+ with (
329
+ patch("iwa.core.models.Config"),
330
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
331
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
332
+ ):
329
333
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
330
334
  sm = ServiceManager(mock_wallet)
331
335
  sm.service = Service(
@@ -347,9 +351,11 @@ def test_sm_get_staking_status_staked_info_fail(mock_wallet):
347
351
 
348
352
  def test_sm_call_checkpoint_prepare_fail(mock_wallet):
349
353
  """Cover call_checkpoint prepare failure (lines 1100-1102)."""
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):
354
+ with (
355
+ patch("iwa.core.models.Config"),
356
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
357
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
358
+ ):
353
359
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
354
360
  sm = ServiceManager(mock_wallet)
355
361
  sm.service = Service(
@@ -369,9 +375,11 @@ def test_sm_call_checkpoint_prepare_fail(mock_wallet):
369
375
 
370
376
  def test_sm_spin_up_no_service(mock_wallet):
371
377
  """Cover spin_up with no service (lines 1167-1170)."""
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):
378
+ with (
379
+ patch("iwa.core.models.Config"),
380
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
381
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
382
+ ):
375
383
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
376
384
  sm = ServiceManager(mock_wallet)
377
385
  sm.service = None
@@ -382,9 +390,11 @@ def test_sm_spin_up_no_service(mock_wallet):
382
390
 
383
391
  def test_sm_spin_up_activation_fail(mock_wallet):
384
392
  """Cover spin_up activation failure (lines 1181-1183)."""
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):
393
+ with (
394
+ patch("iwa.core.models.Config"),
395
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
396
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
397
+ ):
388
398
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
389
399
  sm = ServiceManager(mock_wallet)
390
400
  sm.service = Service(service_name="t", chain_name="gnosis", service_id=1)
@@ -402,9 +412,11 @@ def test_sm_spin_up_activation_fail(mock_wallet):
402
412
 
403
413
  def test_sm_wind_down_no_service(mock_wallet):
404
414
  """Cover wind_down with no service (lines 1264-1266)."""
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):
415
+ with (
416
+ patch("iwa.core.models.Config"),
417
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
418
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
419
+ ):
408
420
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
409
421
  sm = ServiceManager(mock_wallet)
410
422
  sm.service = None
@@ -415,9 +427,11 @@ def test_sm_wind_down_no_service(mock_wallet):
415
427
 
416
428
  def test_sm_wind_down_nonexistent(mock_wallet):
417
429
  """Cover wind_down with non-existent service (lines 1274-1276)."""
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):
430
+ with (
431
+ patch("iwa.core.models.Config"),
432
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
433
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
434
+ ):
421
435
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
422
436
  sm = ServiceManager(mock_wallet)
423
437
  sm.service = Service(service_name="t", chain_name="gnosis", service_id=1)
@@ -432,9 +446,11 @@ def test_sm_wind_down_nonexistent(mock_wallet):
432
446
 
433
447
  def test_sm_mech_request_no_service(mock_wallet):
434
448
  """Cover _send_legacy_mech_request with no service (lines 1502-1504)."""
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):
449
+ with (
450
+ patch("iwa.core.models.Config"),
451
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
452
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
453
+ ):
438
454
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
439
455
  sm = ServiceManager(mock_wallet)
440
456
  sm.service = None
@@ -445,9 +461,11 @@ def test_sm_mech_request_no_service(mock_wallet):
445
461
 
446
462
  def test_sm_mech_request_no_address(mock_wallet):
447
463
  """Cover _send_legacy_mech_request missing mech address (lines 1510-1512)."""
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):
464
+ with (
465
+ patch("iwa.core.models.Config"),
466
+ patch("iwa.plugins.olas.service_manager.base.ContractCache") as mock_cache,
467
+ patch("iwa.plugins.olas.service_manager.staking.ContractCache", mock_cache),
468
+ ):
451
469
  mock_cache.return_value.get_contract.side_effect = lambda cls, *a, **k: cls(*a, **k)
452
470
  sm = ServiceManager(mock_wallet)
453
471
  sm.service = Service(service_name="t", chain_name="unknown", service_id=1)
@@ -458,8 +476,10 @@ def test_sm_mech_request_no_address(mock_wallet):
458
476
 
459
477
  def test_sm_marketplace_mech_no_service(mock_wallet):
460
478
  """Cover _send_marketplace_mech_request with no service (lines 1549-1551)."""
461
- with patch("iwa.core.models.Config"), \
462
- patch("iwa.plugins.olas.service_manager.base.ContractCache"):
479
+ with (
480
+ patch("iwa.core.models.Config"),
481
+ patch("iwa.plugins.olas.service_manager.base.ContractCache"),
482
+ ):
463
483
  sm = ServiceManager(mock_wallet)
464
484
  sm.service = None
465
485