iwa 0.0.21__py3-none-any.whl → 0.0.24__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.
@@ -7,6 +7,7 @@ Supports two formats:
7
7
  """
8
8
 
9
9
  import json
10
+ import re
10
11
  from dataclasses import dataclass, field
11
12
  from pathlib import Path
12
13
  from typing import List, Optional, Tuple
@@ -17,6 +18,28 @@ from loguru import logger
17
18
  from iwa.core.keys import EncryptedAccount, KeyStorage
18
19
  from iwa.core.models import Config, StoredSafeAccount
19
20
 
21
+ # Known mappings from olas-operate-middleware staking programs
22
+ # See: https://github.com/valory-xyz/olas-operate-middleware/blob/main/operate/ledger/profiles.py
23
+ STAKING_PROGRAM_MAP = {
24
+ # Pearl staking programs (gnosis) - operate format
25
+ "pearl_alpha": "0x5344B7DD311e5d3DdDd46A4f71481Bd7b05AAA3e", # Expert Legacy
26
+ "pearl_beta": "0x389B46C259631Acd6a69Bde8B6cEe218230bAE8C", # Hobbyist 1 Legacy
27
+ "pearl_beta_2": "0xE56dF1E563De1B10715cB313D514af350D207212", # Expert 5 Legacy
28
+ "pearl_beta_3": "0xD7A3C8b975f71030135f1a66E9e23164d54fF455", # Expert 7 Legacy
29
+ "pearl_beta_4": "0x17dBAe44BC5618Cc254055B386A29576b4F87015", # Expert 9 Legacy
30
+ "pearl_beta_5": "0xB0ef657b8302bd2c74B6E6D9B2b4b39145b19c6f", # Expert 10 Legacy
31
+ "pearl_beta_mm_v2_1": "0x75eeca6207be98cac3fde8a20ecd7b01e50b3472", # Expert 3 MM v2
32
+ "pearl_beta_mm_v2_2": "0x9c7f6103e3a72e4d1805b9c683ea5b370ec1a99f", # Expert 4 MM v2
33
+ "pearl_beta_mm_v2_3": "0xcdC603e0Ee55Aae92519f9770f214b2Be4967f7d", # Expert 5 MM v2
34
+ # Quickstart staking programs (gnosis) - quickstart format
35
+ "quickstart_beta_expert_4": "0xaD9d891134443B443D7F30013c7e14Fe27F2E029", # Expert 4 Legacy
36
+ "quickstart_beta_expert_7": "0xD7A3C8b975f71030135f1a66E9e23164d54fF455", # Expert 7 Legacy
37
+ "quickstart_beta_expert_9": "0x17dBAe44BC5618Cc254055B386A29576b4F87015", # Expert 9 Legacy
38
+ "quickstart_beta_expert_11": "0x3112c1613eAC3dBAE3D4E38CeF023eb9E2C91CF7", # Expert 11 Legacy
39
+ "quickstart_beta_expert_16_mech_marketplace": "0x6c65430515c70a3f5E62107CC301685B7D46f991", # Expert 16 MM v1
40
+ "quickstart_beta_expert_18_mech_marketplace": "0x041e679d04Fc0D4f75Eb937Dea729Df09a58e454", # Expert 18 MM v1
41
+ }
42
+
20
43
 
21
44
  @dataclass
22
45
  class DiscoveredKey:
@@ -26,8 +49,10 @@ class DiscoveredKey:
26
49
  private_key: Optional[str] = None # Plaintext hex (None if still encrypted)
27
50
  encrypted_keystore: Optional[dict] = None # Web3 v3 keystore format
28
51
  source_file: Path = field(default_factory=Path)
29
- role: str = "unknown" # "agent", "operator", "owner"
52
+ role: str = "unknown" # "agent", "owner"
30
53
  is_encrypted: bool = False
54
+ signature_verified: bool = False
55
+ signature_failed: bool = False
31
56
 
32
57
  @property
33
58
  def is_decrypted(self) -> bool:
@@ -55,6 +80,9 @@ class DiscoveredService:
55
80
  source_folder: Path = field(default_factory=Path)
56
81
  format: str = "unknown" # "trader_runner" or "operate"
57
82
  service_name: Optional[str] = None
83
+ # New fields for full service import
84
+ staking_contract_address: Optional[str] = None
85
+ service_owner_address: Optional[str] = None
58
86
 
59
87
  @property
60
88
  def agent_key(self) -> Optional[DiscoveredKey]:
@@ -66,9 +94,14 @@ class DiscoveredService:
66
94
 
67
95
  @property
68
96
  def operator_key(self) -> Optional[DiscoveredKey]:
69
- """Get the operator key if present."""
97
+ """Get the operator (owner) key. Alias for compatibility."""
98
+ return self.owner_key
99
+
100
+ @property
101
+ def owner_key(self) -> Optional[DiscoveredKey]:
102
+ """Get the owner key if present (matches 'owner' or 'operator' roles)."""
70
103
  for key in self.keys:
71
- if key.role in ("operator", "owner"):
104
+ if key.role in ["owner", "operator"]:
72
105
  return key
73
106
  return None
74
107
 
@@ -89,15 +122,17 @@ class ImportResult:
89
122
  class OlasServiceImporter:
90
123
  """Discover and import Olas services from external directories."""
91
124
 
92
- def __init__(self, key_storage: Optional[KeyStorage] = None):
125
+ def __init__(self, key_storage: Optional[KeyStorage] = None, password: Optional[str] = None):
93
126
  """Initialize the importer.
94
127
 
95
128
  Args:
96
129
  key_storage: KeyStorage instance. If None, will create one.
130
+ password: Optional password to decrypt discovered keystores.
97
131
 
98
132
  """
99
133
  self.key_storage = key_storage or KeyStorage()
100
134
  self.config = Config()
135
+ self.password = password
101
136
 
102
137
  def scan_directory(self, path: Path) -> List[DiscoveredService]:
103
138
  """Recursively scan a directory for Olas services.
@@ -106,7 +141,7 @@ class OlasServiceImporter:
106
141
  path: Directory to scan.
107
142
 
108
143
  Returns:
109
- List of discovered services.
144
+ List of discovered services (deduplicated by chain:service_id).
110
145
 
111
146
  """
112
147
  path = Path(path)
@@ -129,8 +164,51 @@ class OlasServiceImporter:
129
164
  services = self._parse_operate_format(operate)
130
165
  discovered.extend(services)
131
166
 
132
- logger.info(f"Discovered {len(discovered)} Olas service(s)")
133
- return discovered
167
+ return self._deduplicate_services(discovered)
168
+
169
+ def _deduplicate_services(self, services: List[DiscoveredService]) -> List[DiscoveredService]:
170
+ """Deduplicate discovered services by chain:service_id."""
171
+ seen_keys: set = set()
172
+ unique_services = []
173
+ duplicates = 0
174
+ for service in services:
175
+ if service.service_id:
176
+ key = f"{service.chain_name}:{service.service_id}"
177
+ if key in seen_keys:
178
+ logger.debug(
179
+ f"Skipping duplicate service {key} from {service.source_folder}"
180
+ )
181
+ duplicates += 1
182
+ continue
183
+ seen_keys.add(key)
184
+ unique_services.append(service)
185
+
186
+ if duplicates:
187
+ logger.info(f"Skipped {duplicates} duplicate service(s)")
188
+ logger.info(f"Discovered {len(unique_services)} unique Olas service(s)")
189
+ return unique_services
190
+
191
+ def _find_trader_name(self, folder: Path) -> str:
192
+ """Find the trader name by traversing up the directory tree.
193
+
194
+ Handles quickstart format where the .operate folder is nested inside
195
+ a quickstart folder, e.g.: trader_altair/quickstart/.operate/
196
+
197
+ Returns the first folder name starting with 'trader_' or the
198
+ immediate folder name if none found.
199
+ """
200
+ current = folder
201
+ fallback = folder.name
202
+
203
+ # Traverse up looking for trader_* folder
204
+ for _ in range(5): # Max 5 levels up
205
+ if current.name.startswith("trader_"):
206
+ return current.name
207
+ current = current.parent
208
+ if current == current.parent: # Reached root
209
+ break
210
+
211
+ return fallback
134
212
 
135
213
  def _parse_trader_runner_format(self, folder: Path) -> Optional[DiscoveredService]:
136
214
  """Parse a .trader_runner folder.
@@ -153,6 +231,9 @@ class OlasServiceImporter:
153
231
  service.safe_address = self._extract_safe_address(folder)
154
232
  service.keys = self._extract_trader_keys(folder)
155
233
 
234
+ # Extract staking program from .env
235
+ self._extract_staking_from_env(service, folder)
236
+
156
237
  if not service.keys and not service.service_id:
157
238
  logger.debug(f"No valid data found in {folder}")
158
239
  return None
@@ -187,10 +268,10 @@ class OlasServiceImporter:
187
268
  if key:
188
269
  keys.append(key)
189
270
 
190
- # Parse operator_pkey.txt
271
+ # Parse operator_pkey.txt (contains owner key)
191
272
  operator_file = folder / "operator_pkey.txt"
192
273
  if operator_file.exists():
193
- key = self._parse_keystore_file(operator_file, role="operator")
274
+ key = self._parse_keystore_file(operator_file, role="owner")
194
275
  if key:
195
276
  keys.append(key)
196
277
 
@@ -205,6 +286,31 @@ class OlasServiceImporter:
205
286
  keys.append(key)
206
287
  return keys
207
288
 
289
+ def _extract_staking_from_env(self, service: DiscoveredService, folder: Path) -> None:
290
+ """Extract STAKING_PROGRAM from .env file in trader_runner folder."""
291
+ # Check parent folder for .env (usually alongside .trader_runner)
292
+ env_file = folder.parent / ".env"
293
+ if not env_file.exists():
294
+ # Also check inside the folder itself
295
+ env_file = folder / ".env"
296
+ if not env_file.exists():
297
+ return
298
+
299
+ try:
300
+ content = env_file.read_text()
301
+ for line in content.splitlines():
302
+ line = line.strip()
303
+ if line.startswith("STAKING_PROGRAM="):
304
+ program_id = line.split("=", 1)[1].strip().strip('"').strip("'")
305
+ if program_id:
306
+ service.staking_contract_address = self._resolve_staking_contract(
307
+ program_id, service.chain_name
308
+ )
309
+ logger.debug(f"Found STAKING_PROGRAM={program_id} in {env_file}")
310
+ break
311
+ except IOError as e:
312
+ logger.warning(f"Failed to read {env_file}: {e}")
313
+
208
314
  def _parse_operate_format(self, folder: Path) -> List[DiscoveredService]:
209
315
  """Parse a .operate folder.
210
316
 
@@ -288,12 +394,15 @@ class OlasServiceImporter:
288
394
 
289
395
  # Use the folder name containing .operate (e.g., "trader_xi")
290
396
  operate_folder = config_file.parent.parent.parent # services/<uuid> -> .operate
291
- parent_folder = operate_folder.parent # .operate -> trader_xi
397
+ parent_folder = operate_folder.parent # .operate -> trader_xi or quickstart
398
+
399
+ # Handle quickstart format: traverse up to find trader_* folder
400
+ service_name = self._find_trader_name(parent_folder)
292
401
 
293
402
  service = DiscoveredService(
294
403
  source_folder=config_file.parent,
295
404
  format="operate",
296
- service_name=parent_folder.name,
405
+ service_name=service_name,
297
406
  )
298
407
 
299
408
  # 1. Extract keys from config
@@ -311,6 +420,9 @@ class OlasServiceImporter:
311
420
  external_keys = self._extract_external_keys_folder(operate_folder)
312
421
  self._merge_unique_keys(service, external_keys)
313
422
 
423
+ # 5. Extract owner address from wallets folder
424
+ self._extract_owner_address(service, operate_folder)
425
+
314
426
  return service
315
427
 
316
428
  def _extract_keys_from_operate_config(
@@ -325,19 +437,19 @@ class OlasServiceImporter:
325
437
  # Remove 0x prefix if present
326
438
  if private_key.startswith("0x"):
327
439
  private_key = private_key[2:]
328
- keys.append(
329
- DiscoveredKey(
330
- address=key_data["address"],
331
- private_key=private_key,
332
- role="agent",
333
- source_file=config_file,
334
- is_encrypted=False,
335
- )
440
+ key = DiscoveredKey(
441
+ address=key_data["address"],
442
+ private_key=private_key,
443
+ role="agent",
444
+ source_file=config_file,
445
+ is_encrypted=False,
336
446
  )
447
+ self._verify_key_signature(key)
448
+ keys.append(key)
337
449
  return keys
338
450
 
339
451
  def _enrich_service_with_chain_info(self, service: DiscoveredService, data: dict) -> None:
340
- """Extract service ID and Safe address from chain configs."""
452
+ """Extract service ID, Safe address, and staking contract from chain configs."""
341
453
  chain_configs = data.get("chain_configs", {})
342
454
  for chain_name, chain_config in chain_configs.items():
343
455
  chain_data = chain_config.get("chain_data", {})
@@ -350,6 +462,25 @@ class OlasServiceImporter:
350
462
  if "multisig" in chain_data:
351
463
  service.safe_address = chain_data["multisig"]
352
464
 
465
+ # Extract staking contract from user_params
466
+ user_params = chain_data.get("user_params", {})
467
+ staking_program_id = user_params.get("staking_program_id")
468
+ if staking_program_id:
469
+ service.staking_contract_address = self._resolve_staking_contract(
470
+ staking_program_id, chain_name
471
+ )
472
+
473
+ def _resolve_staking_contract(
474
+ self, staking_program_id: str, chain_name: str
475
+ ) -> Optional[str]:
476
+ """Resolve a staking program ID to a contract address."""
477
+ address = STAKING_PROGRAM_MAP.get(staking_program_id)
478
+ if address:
479
+ logger.debug(f"Resolved staking program '{staking_program_id}' -> {address}")
480
+ else:
481
+ logger.warning(f"Unknown staking program ID: {staking_program_id}")
482
+ return address
483
+
353
484
  def _extract_parent_wallet_keys(self, operate_folder: Path) -> List[DiscoveredKey]:
354
485
  """Extract owner keys from parent wallets folder."""
355
486
  keys = []
@@ -357,7 +488,12 @@ class OlasServiceImporter:
357
488
  if wallets_folder.exists():
358
489
  eth_txt = wallets_folder / "ethereum.txt"
359
490
  if eth_txt.exists():
491
+ # Try plaintext first
360
492
  key = self._parse_plaintext_key_file(eth_txt, role="owner")
493
+ if not key:
494
+ # Fallback to keystore
495
+ key = self._parse_keystore_file(eth_txt, role="owner")
496
+
361
497
  if key:
362
498
  keys.append(key)
363
499
  return keys
@@ -374,6 +510,22 @@ class OlasServiceImporter:
374
510
  keys.append(key)
375
511
  return keys
376
512
 
513
+ def _extract_owner_address(self, service: DiscoveredService, operate_folder: Path) -> None:
514
+ """Extract owner address from wallets/ethereum.json."""
515
+ wallets_folder = operate_folder / "wallets"
516
+ if not wallets_folder.exists():
517
+ return
518
+
519
+ eth_json = wallets_folder / "ethereum.json"
520
+ if eth_json.exists():
521
+ try:
522
+ data = json.loads(eth_json.read_text())
523
+ if "address" in data:
524
+ service.service_owner_address = data["address"]
525
+ logger.debug(f"Extracted owner address: {service.service_owner_address}")
526
+ except (json.JSONDecodeError, IOError) as e:
527
+ logger.warning(f"Failed to parse {eth_json}: {e}")
528
+
377
529
  def _merge_unique_keys(self, service: DiscoveredService, new_keys: List[DiscoveredKey]):
378
530
  """Merge new keys into service avoiding duplicates by address."""
379
531
  existing_addrs = {k.address.lower() for k in service.keys}
@@ -398,13 +550,21 @@ class OlasServiceImporter:
398
550
  if not address.startswith("0x"):
399
551
  address = "0x" + address
400
552
 
401
- return DiscoveredKey(
553
+ key = DiscoveredKey(
402
554
  address=address,
403
555
  encrypted_keystore=keystore,
404
556
  role=role,
405
557
  source_file=file_path,
406
558
  is_encrypted=True,
407
559
  )
560
+
561
+ # Attempt decryption if password provided
562
+ if self.password:
563
+ self._attempt_decryption(key)
564
+ if key.private_key:
565
+ self._verify_key_signature(key)
566
+
567
+ return key
408
568
  except (json.JSONDecodeError, IOError) as e:
409
569
  logger.warning(f"Failed to parse keystore {file_path}: {e}")
410
570
  return None
@@ -422,15 +582,19 @@ class OlasServiceImporter:
422
582
  address = keystore.get("address", "")
423
583
  if not address.startswith("0x"):
424
584
  address = "0x" + address
425
- keys.append(
426
- DiscoveredKey(
427
- address=address,
428
- encrypted_keystore=keystore,
429
- role="agent",
430
- source_file=file_path,
431
- is_encrypted=True,
432
- )
585
+ key = DiscoveredKey(
586
+ address=address,
587
+ encrypted_keystore=keystore,
588
+ role="agent",
589
+ source_file=file_path,
590
+ is_encrypted=True,
433
591
  )
592
+ # Attempt decryption if password provided
593
+ if self.password:
594
+ self._attempt_decryption(key)
595
+ if key.private_key:
596
+ self._verify_key_signature(key)
597
+ keys.append(key)
434
598
  return keys
435
599
  except (json.JSONDecodeError, IOError):
436
600
  return []
@@ -446,13 +610,15 @@ class OlasServiceImporter:
446
610
  try:
447
611
  data = json.loads(content)
448
612
  if isinstance(data, dict) and "private_key" in data and "address" in data:
449
- return DiscoveredKey(
613
+ key = DiscoveredKey(
450
614
  address=data["address"],
451
615
  private_key=data["private_key"],
452
616
  role=role,
453
617
  source_file=file_path,
454
618
  is_encrypted=False,
455
619
  )
620
+ self._verify_key_signature(key)
621
+ return key
456
622
  except json.JSONDecodeError:
457
623
  pass
458
624
 
@@ -460,13 +626,15 @@ class OlasServiceImporter:
460
626
  if len(content) == 64 or (len(content) == 66 and content.startswith("0x")):
461
627
  private_key = content[2:] if content.startswith("0x") else content
462
628
  account = Account.from_key(bytes.fromhex(private_key))
463
- return DiscoveredKey(
629
+ key = DiscoveredKey(
464
630
  address=account.address,
465
631
  private_key=private_key,
466
632
  role=role,
467
633
  source_file=file_path,
468
634
  is_encrypted=False,
469
635
  )
636
+ self._verify_key_signature(key)
637
+ return key
470
638
 
471
639
  return None
472
640
  except Exception as e:
@@ -625,8 +793,6 @@ class OlasServiceImporter:
625
793
  Tags follow the pattern: {service_name}_{role}
626
794
  Example: trader_alpha_agent, trader_alpha_operator
627
795
  """
628
- import re
629
-
630
796
  # Use service name as prefix, or 'imported' as fallback
631
797
  prefix = service_name or "imported"
632
798
 
@@ -667,7 +833,6 @@ class OlasServiceImporter:
667
833
  signers.append(key.address)
668
834
 
669
835
  # Generate tag
670
- import re
671
836
 
672
837
  prefix = service.service_name or "imported"
673
838
  prefix = re.sub(r"[^a-z0-9]+", "_", prefix.lower()).strip("_")
@@ -698,6 +863,7 @@ class OlasServiceImporter:
698
863
  def _import_service_config(self, service: DiscoveredService) -> Tuple[bool, str]:
699
864
  """Import service config to OlasConfig."""
700
865
  try:
866
+ from iwa.plugins.olas.constants import OLAS_TOKEN_ADDRESS_GNOSIS
701
867
  from iwa.plugins.olas.models import OlasConfig, Service
702
868
 
703
869
  # Get or create OlasConfig
@@ -711,13 +877,16 @@ class OlasServiceImporter:
711
877
  if key in olas_config.services:
712
878
  return False, "duplicate"
713
879
 
714
- # Create service model
880
+ # Create service model with all fields
715
881
  olas_service = Service(
716
882
  service_name=service.service_name or f"service_{service.service_id}",
717
883
  chain_name=service.chain_name,
718
884
  service_id=service.service_id,
719
- agent_ids=[], # Would need on-chain query
885
+ agent_ids=[25], # Trader agents always use agent ID 25
720
886
  multisig_address=service.safe_address,
887
+ service_owner_address=service.service_owner_address,
888
+ staking_contract_address=service.staking_contract_address,
889
+ token_address=str(OLAS_TOKEN_ADDRESS_GNOSIS),
721
890
  )
722
891
 
723
892
  # Set agent address if we have one
@@ -726,7 +895,7 @@ class OlasServiceImporter:
726
895
  olas_service.agent_address = agent_key.address
727
896
 
728
897
  olas_config.add_service(olas_service)
729
- self.config.save()
898
+ self.config.save_config()
730
899
  logger.info(f"Imported service {key}")
731
900
  return True, "ok"
732
901
 
@@ -734,3 +903,58 @@ class OlasServiceImporter:
734
903
  return False, "Olas plugin not available"
735
904
  except Exception as e:
736
905
  return False, str(e)
906
+
907
+ def _attempt_decryption(self, key: DiscoveredKey) -> None:
908
+ """Attempt to decrypt an encrypted keystore using the provided password."""
909
+ if not self.password or not key.encrypted_keystore:
910
+ return
911
+
912
+ try:
913
+ logger.debug(f"Attempting decryption for {key.address}")
914
+
915
+ # Use Account.decrypt to handle standard web3 keystores
916
+ private_key_bytes = Account.decrypt(key.encrypted_keystore, self.password)
917
+ key.private_key = private_key_bytes.hex()
918
+ key.is_encrypted = False
919
+ # If we successfully decrypted, it's no longer "encrypted" for verification purposes
920
+ logger.debug(f"Successfully decrypted key for {key.address}")
921
+ except ValueError as e:
922
+ # Password incorrect
923
+ logger.warning(f"Decryption failed (ValueError) for {key.address}: {e}")
924
+ except Exception as e:
925
+ logger.warning(f"Error decrypting key {key.address}: {type(e).__name__} - {e}")
926
+
927
+ def _verify_key_signature(self, key: DiscoveredKey) -> None:
928
+ """Verify that the plaintext private key can sign a message and recover the address."""
929
+ if not key.private_key or not key.address:
930
+ return
931
+
932
+ try:
933
+ from eth_account.messages import encode_defunct
934
+
935
+ message = "Hello, world!"
936
+ encoded_message = encode_defunct(text=message)
937
+ signed_message = Account.sign_message(encoded_message, private_key=key.private_key)
938
+ recovered_address = Account.recover_message(
939
+ encoded_message, signature=signed_message.signature
940
+ )
941
+
942
+ # Normalize address to lowercase with 0x prefix
943
+ key_addr = key.address.lower()
944
+ if not key_addr.startswith("0x"):
945
+ key_addr = "0x" + key_addr
946
+ recovered_addr = recovered_address.lower()
947
+
948
+ if recovered_addr == key_addr:
949
+ key.signature_verified = True
950
+ logger.debug(f"Signature verified for key {key.address}")
951
+ else:
952
+ key.signature_failed = True
953
+ logger.warning(
954
+ f"Signature verification FAILED for key {key.address}. "
955
+ f"Recovered: {recovered_address}"
956
+ )
957
+ except Exception as e:
958
+ key.signature_failed = True
959
+ logger.warning(f"Error verifying signature for key {key.address}: {e}")
960
+
@@ -1,10 +1,11 @@
1
1
  """Olas plugin."""
2
2
 
3
3
  from pathlib import Path
4
- from typing import Dict, Optional, Type
4
+ from typing import Dict, List, Optional, Tuple, Type
5
5
 
6
6
  import typer
7
7
  from pydantic import BaseModel
8
+ from rich.console import Console
8
9
 
9
10
  from iwa.core.plugins import Plugin
10
11
  from iwa.core.wallet import Wallet
@@ -86,7 +87,18 @@ class OlasPlugin(Plugin):
86
87
  # Query failed - Safe likely doesn't exist
87
88
  return [], False
88
89
 
89
- def _display_service_table(self, console, service, index: int) -> None:
90
+ def _resolve_staking_name(self, address: str, chain_name: str) -> str | None:
91
+ """Resolve staking contract address to human-readable name."""
92
+ from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
93
+
94
+ chain_contracts = OLAS_TRADER_STAKING_CONTRACTS.get(chain_name, {})
95
+ addr_lower = address.lower()
96
+ for name, contract_addr in chain_contracts.items():
97
+ if str(contract_addr).lower() == addr_lower:
98
+ return name
99
+ return None
100
+
101
+ def _display_service_table(self, console: Console, service, index: int) -> None:
90
102
  """Display a single discovered service as a Rich table."""
91
103
  from rich.table import Table
92
104
 
@@ -98,46 +110,102 @@ class OlasPlugin(Plugin):
98
110
 
99
111
  table.add_row("Format", service.format)
100
112
  table.add_row("Source", str(service.source_folder))
101
- table.add_row("Service ID", str(service.service_id) if service.service_id else "N/A")
113
+ table.add_row(
114
+ "Service ID",
115
+ str(service.service_id) if service.service_id else "[red]Not detected[/red]",
116
+ )
102
117
  table.add_row("Chain", service.chain_name)
103
118
 
104
119
  # Verify Safe and display
120
+ on_chain_signers, safe_exists = self._add_safe_info(table, service)
121
+
122
+ # Display staking contract info
123
+ self._add_staking_info(table, service)
124
+
125
+ # Display owner
126
+ self._add_owner_info(table, service)
127
+
128
+ # Display agent key
129
+ self._add_agent_info(table, service, on_chain_signers, safe_exists)
130
+
131
+ console.print(table)
132
+ console.print()
133
+
134
+ def _add_safe_info(self, table, service) -> Tuple[Optional[List[str]], Optional[bool]]:
135
+ """Add Safe information to the display table."""
105
136
  on_chain_signers, safe_exists = None, None
106
137
  if service.safe_address:
107
138
  on_chain_signers, safe_exists = self._get_safe_signers(
108
139
  service.safe_address, service.chain_name
109
140
  )
110
- if safe_exists is None:
111
- table.add_row("Safe", service.safe_address)
112
- elif safe_exists:
113
- table.add_row("Safe", f"{service.safe_address} [green]✓[/green]")
114
- else:
115
- table.add_row(
116
- "Safe",
117
- f"[bold red]⚠ {service.safe_address} - DOES NOT EXIST ON-CHAIN![/bold red]",
141
+ safe_text = service.safe_address
142
+ if safe_exists:
143
+ safe_text += " [green]✓[/green]"
144
+ elif safe_exists is False:
145
+ safe_text = (
146
+ f"[bold red]⚠ {service.safe_address} - DOES NOT EXIST ON-CHAIN![/bold red]"
118
147
  )
148
+ table.add_row("Multisig", safe_text)
119
149
  else:
120
- table.add_row("Safe", "N/A")
121
-
122
- # Display keys with signer verification
123
- for key in service.keys:
124
- status = "🔒 encrypted" if key.is_encrypted else "🔓 plaintext"
125
- key_info = f"{key.address} {status}"
126
-
127
- if key.role == "agent" and service.safe_address:
128
- if not safe_exists:
129
- key_info = f"[bold red]⚠ {key.address} - NOT A SIGNER OF THE SAFE![/bold red]"
150
+ table.add_row("Multisig", "[red]Not detected[/red]")
151
+ return on_chain_signers, safe_exists
152
+
153
+ def _add_staking_info(self, table, service) -> None:
154
+ """Add staking information to the display table."""
155
+ if service.staking_contract_address:
156
+ staking_name = self._resolve_staking_name(
157
+ service.staking_contract_address, service.chain_name
158
+ )
159
+ val = staking_name if staking_name else "[red]Unknown[/red]"
160
+ table.add_row("Staking", val)
161
+ table.add_row("Staking Addr", service.staking_contract_address)
162
+ else:
163
+ table.add_row("Staking", "[red]Not detected[/red]")
164
+ table.add_row("Staking Addr", "[red]Not detected[/red]")
165
+
166
+ def _add_owner_info(self, table, service) -> None:
167
+ """Add owner information to the display table."""
168
+ owner_key = next((k for k in service.keys if k.role == "owner"), None)
169
+ owner_addr = service.service_owner_address
170
+ if not owner_addr and owner_key:
171
+ owner_addr = owner_key.address
172
+
173
+ if owner_addr:
174
+ val = owner_addr
175
+ if owner_key:
176
+ if owner_key.signature_verified:
177
+ val = f"[green]{owner_addr}[/green]"
178
+ elif not owner_key.is_encrypted:
179
+ val = f"[red]{owner_addr}[/red]"
180
+ status = "🔒 encrypted" if owner_key.is_encrypted else "🔓 plaintext"
181
+ table.add_row("Owner", f"{val} {status}")
182
+ else:
183
+ table.add_row("Owner", val)
184
+ else:
185
+ table.add_row("Owner", "[red]Not detected[/red]")
186
+
187
+ def _add_agent_info(self, table, service, on_chain_signers, safe_exists) -> None:
188
+ """Add agent information to the display table."""
189
+ agent_key = next((k for k in service.keys if k.role == "agent"), None)
190
+ if agent_key:
191
+ status = "🔒 encrypted" if agent_key.is_encrypted else "🔓 plaintext"
192
+ addr_val = agent_key.address
193
+ if agent_key.signature_verified:
194
+ addr_val = f"[green]{agent_key.address}[/green]"
195
+ elif not agent_key.is_encrypted:
196
+ addr_val = f"[red]{agent_key.address}[/red]"
197
+
198
+ key_info = f"{addr_val} {status}"
199
+ if service.safe_address:
200
+ if safe_exists is False:
201
+ key_info = f"[bold red]⚠ {agent_key.address} - NOT A SIGNER![/bold red]"
130
202
  elif on_chain_signers is not None:
131
- is_signer = key.address.lower() in [s.lower() for s in on_chain_signers]
203
+ is_signer = agent_key.address.lower() in [s.lower() for s in on_chain_signers]
132
204
  if not is_signer:
133
- key_info = (
134
- f"[bold red]⚠ {key.address} - NOT A SIGNER OF THE SAFE![/bold red]"
135
- )
136
-
137
- table.add_row(f"Key ({key.role})", key_info)
138
-
139
- console.print(table)
140
- console.print()
205
+ key_info = f"[bold red]⚠ {agent_key.address} - NOT A SIGNER![/bold red]"
206
+ table.add_row("Agent", key_info)
207
+ else:
208
+ table.add_row("Agent", "[red]Not detected[/red]")
141
209
 
142
210
  def _import_and_print_results(self, console, importer, discovered, password) -> tuple:
143
211
  """Import all discovered services and print results."""
@@ -194,15 +262,18 @@ class OlasPlugin(Plugin):
194
262
  ),
195
263
  ):
196
264
  """Import Olas services and keys from external directories."""
197
- from rich.console import Console
198
-
199
265
  from iwa.plugins.olas.importer import OlasServiceImporter
200
266
 
201
267
  console = Console()
202
268
 
203
269
  # Scan directory
204
270
  console.print(f"\n[bold]Scanning[/bold] {path}...")
205
- importer = OlasServiceImporter()
271
+
272
+ # Ask for password in dry-run to allow signature verification of encrypted keys
273
+ if dry_run and not password:
274
+ password = self._prompt_dry_run_password()
275
+
276
+ importer = OlasServiceImporter(password=password)
206
277
  discovered = importer.scan_directory(Path(path))
207
278
 
208
279
  if not discovered:
@@ -219,11 +290,9 @@ class OlasPlugin(Plugin):
219
290
  raise typer.Exit(code=0)
220
291
 
221
292
  # Confirm import
222
- if not yes:
223
- confirm = typer.confirm("Import these services?")
224
- if not confirm:
225
- console.print("[yellow]Aborted.[/yellow]")
226
- raise typer.Exit(code=0)
293
+ if not yes and not typer.confirm("Import these services?"):
294
+ console.print("[yellow]Aborted.[/yellow]")
295
+ raise typer.Exit(code=0)
227
296
 
228
297
  # Check if we need a password for encrypted keys
229
298
  needs_password = any(key.is_encrypted for service in discovered for key in service.keys)
@@ -234,11 +303,28 @@ class OlasPlugin(Plugin):
234
303
  password = typer.prompt("Password", hide_input=True)
235
304
 
236
305
  # Import services
237
- total_keys, total_safes, total_services, all_skipped, all_errors = (
238
- self._import_and_print_results(console, importer, discovered, password)
306
+ results = self._import_and_print_results(console, importer, discovered, password)
307
+ self._print_import_summary(console, *results)
308
+
309
+ def _prompt_dry_run_password(self) -> Optional[str]:
310
+ """Prompt for password during dry-run."""
311
+ pwd = typer.prompt(
312
+ "Enter wallet password to verify encrypted keys (optional, press Enter to skip)",
313
+ hide_input=True,
314
+ default="",
239
315
  )
316
+ return pwd if pwd else None
240
317
 
241
- # Summary
318
+ def _print_import_summary(
319
+ self,
320
+ console: Console,
321
+ total_keys: int,
322
+ total_safes: int,
323
+ total_services: int,
324
+ all_skipped: List[str],
325
+ all_errors: List[str],
326
+ ) -> None:
327
+ """Print import summary."""
242
328
  console.print("\n[bold]Summary:[/bold]")
243
329
  console.print(f" Keys imported: {total_keys}")
244
330
  console.print(f" Safes imported: {total_safes}")
@@ -161,7 +161,7 @@ def test_parse_trader_runner_keys(importer, tmp_path):
161
161
  assert service.safe_address == "0xSafeAddress"
162
162
  assert len(service.keys) == 2
163
163
  assert any(k.role == "agent" for k in service.keys)
164
- assert any(k.role == "operator" for k in service.keys)
164
+ assert any(k.role == "owner" for k in service.keys)
165
165
 
166
166
 
167
167
  def test_parse_trader_runner_invalid_id(importer, tmp_path):
@@ -68,7 +68,7 @@ def test_import_services_cli_scan_only(plugin, runner):
68
68
  ]
69
69
 
70
70
  # Test dry-run
71
- result = runner.invoke(app, ["/tmp/test", "--dry-run"])
71
+ result = runner.invoke(app, ["/tmp/test", "--dry-run"], input="\n")
72
72
  assert result.exit_code == 0
73
73
  assert "Found 1 service(s)" in result.output
74
74
  assert "Dry run mode" in result.output
@@ -185,15 +185,15 @@ def test_import_services_cli_complex_display(plugin, runner):
185
185
  # Mock Safe exists with Agent as signer
186
186
  mock_get_signers.return_value = (["0xAgent"], True)
187
187
 
188
- result = runner.invoke(app, ["/tmp/test", "--dry-run"])
188
+ result = runner.invoke(app, ["/tmp/test", "--dry-run"], input="\n")
189
189
  assert "0xSafe" in result.output
190
190
  assert "✓" in result.output
191
191
  assert "0xAgent 🔓 plaintext" in result.output # Not a warning
192
192
 
193
193
  # 2. Service where agent is NOT a signer
194
194
  mock_get_signers.return_value = (["0xOther"], True)
195
- result = runner.invoke(app, ["/tmp/test", "--dry-run"])
196
- assert "NOT A SIGNER OF THE SAFE" in result.output
195
+ result = runner.invoke(app, ["/tmp/test", "--dry-run"], input="\n")
196
+ assert "NOT A SIGNER!" in result.output
197
197
 
198
198
 
199
199
  def test_import_services_cli_password_prompt(plugin, runner):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.21
3
+ Version: 0.0.24
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -61,10 +61,10 @@ iwa/plugins/gnosis/tests/test_safe.py,sha256=hQHVHBWQhGnuvzvx4U9fOWEwASJWwql42q6
61
61
  iwa/plugins/olas/__init__.py,sha256=_NhBczzM61fhGYwGhnWfEeL8Jywyy_730GASe2BxzeQ,106
62
62
  iwa/plugins/olas/constants.py,sha256=iTFoO2QW3KbhL5k5sKsJxxyDytl9wVIb_9hAih55KrE,7728
63
63
  iwa/plugins/olas/events.py,sha256=SWD3wYdQ-l6dLUJSkfh_WsLmedH4Vsw_EvYXg7QC3yc,5970
64
- iwa/plugins/olas/importer.py,sha256=f8KlZ9dGcNbpg8uoTYbO9sDvbluZoslhpWFLqPjPnLA,26717
64
+ iwa/plugins/olas/importer.py,sha256=-zI0Fqmf6E7w3OpqEuTR8vuQOaAGNykGWzHZgadrHmE,37362
65
65
  iwa/plugins/olas/mech_reference.py,sha256=CaSCpQnQL4F7wOG6Ox6Zdoy-uNEQ78YBwVLILQZKL8Q,5782
66
66
  iwa/plugins/olas/models.py,sha256=xC5hYakX53pBT6zZteM9cyiC7t6XRLLpobjQmDYueOo,3520
67
- iwa/plugins/olas/plugin.py,sha256=h7Wjw-ozalu9ocZbFcQGEw_TM8fgW6Qyh_DY1VOGeLY,9393
67
+ iwa/plugins/olas/plugin.py,sha256=sv1Hx4-wHZOwpdjhrpvxrFaA44wiGkcCXjbI_7NLH2Y,13127
68
68
  iwa/plugins/olas/contracts/activity_checker.py,sha256=WXxuzbpXGVqIfEiMPiiqN3Z_UxIY-Lvx0raa1ErBfPA,5323
69
69
  iwa/plugins/olas/contracts/base.py,sha256=y73aQbDq6l4zUpz_eQAg4MsLkTAEqjjupXlcvxjfgCI,240
70
70
  iwa/plugins/olas/contracts/mech.py,sha256=dXYtyORc-oiu9ga5PtTquOFkoakb6BLGKvlUsteygIg,2767
@@ -92,7 +92,7 @@ iwa/plugins/olas/service_manager/mech.py,sha256=NVzVbEmyOe3wK92VEzCCOSuy3HDkEP1M
92
92
  iwa/plugins/olas/service_manager/staking.py,sha256=7REp_HziKtqF9uSvbcq01C9XiaxgVT3gCimuLAAdNnM,28219
93
93
  iwa/plugins/olas/tests/conftest.py,sha256=4vM7EI00SrTGyeP0hNzsGSQHEj2-iznVgzlNh2_OGfo,739
94
94
  iwa/plugins/olas/tests/test_importer.py,sha256=i9LKov7kNRECB3hmRnhKBwcfx3uxtjWe4BB77bOOpeo,4282
95
- iwa/plugins/olas/tests/test_importer_error_handling.py,sha256=X37TrvJ6-3-NuJ2megm0Cnx3KJdA1wke563pRf_EPJk,12084
95
+ iwa/plugins/olas/tests/test_importer_error_handling.py,sha256=O5yd7w_eURtkJb8_IwAGkz8fyHLTzYfI5c2JxWl3oOo,12081
96
96
  iwa/plugins/olas/tests/test_mech_contracts.py,sha256=wvxuigPafF-ySIHVBdWVei3AO418iPh7cSVdAlUGm_s,3566
97
97
  iwa/plugins/olas/tests/test_olas_contracts.py,sha256=B8X-5l1KfYMoZOiM94_rcNzbILLl78rqt_jhyxzAOqE,10835
98
98
  iwa/plugins/olas/tests/test_olas_integration.py,sha256=vjL8-RNdxXu6RFR5F1Bn7xqnxnUVWTzl2--Pp7-0r5A,22973
@@ -101,7 +101,7 @@ iwa/plugins/olas/tests/test_olas_view.py,sha256=kh3crsriyoRiZC6l8vzGllocvQnYmqzi
101
101
  iwa/plugins/olas/tests/test_olas_view_actions.py,sha256=jAxr9bjFNAaxGf1btIrxdMaHgJ0PWX9aDwVU-oPGMpk,5109
102
102
  iwa/plugins/olas/tests/test_olas_view_modals.py,sha256=8j0PNFjKqFC5V1kBdVFWNLMvqGt49H6fLSYGxn02c8o,5562
103
103
  iwa/plugins/olas/tests/test_plugin.py,sha256=RVgU-Cq6t_3mOh90xFAGwlJOV7ZIgp0VNaK5ZAxisAQ,2565
104
- iwa/plugins/olas/tests/test_plugin_full.py,sha256=HdEJf_beqH18TaA-PFFMsKFA4ycXY-bz_kbaHQknaic,8390
104
+ iwa/plugins/olas/tests/test_plugin_full.py,sha256=GBZ-TL5t8NXy6HVQozCI1hMEn2EY4lXjGFC89QpoPxQ,8415
105
105
  iwa/plugins/olas/tests/test_service_lifecycle.py,sha256=sOCtpz8T9s55AZe9AoqP1h3XrXw5NDSjDqwLgYThvU4,5559
106
106
  iwa/plugins/olas/tests/test_service_manager.py,sha256=rS2m0A26apc-o4HsfP5oXmVcmZSR5e874bjhQKZRaSg,40650
107
107
  iwa/plugins/olas/tests/test_service_manager_errors.py,sha256=udlAsQj_t1F5TwVQuWhroF6jDJ4RmGEXaxPh87tMsuA,8538
@@ -157,7 +157,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=C264MH-CTyDW4GLUrTXBgLJKUk4-89pFAScBd
157
157
  iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
158
158
  iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
159
159
  iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
160
- iwa-0.0.21.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
160
+ iwa-0.0.24.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
161
161
  tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
162
162
  tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
163
163
  tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
@@ -210,8 +210,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
210
210
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
211
211
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
212
212
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
213
- iwa-0.0.21.dist-info/METADATA,sha256=G_17iq72W9SNwyz3rr7rrvVgRA_iKLlVgxUnbrXdLNk,7295
214
- iwa-0.0.21.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
215
- iwa-0.0.21.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
216
- iwa-0.0.21.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
217
- iwa-0.0.21.dist-info/RECORD,,
213
+ iwa-0.0.24.dist-info/METADATA,sha256=N-qgeL8ZAmzJTvBthIa65x8zoZpsmqVuFG4YZ_K7-kA,7295
214
+ iwa-0.0.24.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
215
+ iwa-0.0.24.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
216
+ iwa-0.0.24.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
217
+ iwa-0.0.24.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5