iwa 0.0.27__py3-none-any.whl → 0.0.29__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
iwa/core/cli.py CHANGED
@@ -7,6 +7,7 @@ from web3 import Web3
7
7
 
8
8
  from iwa.core.chain import ChainInterfaces
9
9
  from iwa.core.constants import NATIVE_CURRENCY_ADDRESS
10
+ from iwa.core.contracts.decoder import ErrorDecoder
10
11
  from iwa.core.keys import KeyStorage
11
12
  from iwa.core.services import PluginService
12
13
  from iwa.core.tables import list_accounts
@@ -206,6 +207,23 @@ def web_server(
206
207
  run_server(host=host, port=server_port)
207
208
 
208
209
 
210
+ @iwa_cli.command("decode")
211
+ def decode_hex(
212
+ hex_data: str = typer.Argument(..., help="The hex-encoded error data (e.g., 0xa43d6ada...)"),
213
+ ):
214
+ """Decode a hex error identifier into a human-readable message."""
215
+ decoder = ErrorDecoder()
216
+ results = decoder.decode(hex_data)
217
+
218
+ if not results:
219
+ typer.echo(f"Could not decode error data: {hex_data}")
220
+ return
221
+
222
+ typer.echo(f"\nDecoding results for {hex_data[:10]}:")
223
+ for _name, msg, source in results:
224
+ typer.echo(f" [{source}] {msg}")
225
+
226
+
209
227
  @wallet_cli.command("drain")
210
228
  def drain_wallet(
211
229
  from_address_or_tag: str = typer.Option(..., "--from", "-f", help="From address or tag"),
@@ -11,6 +11,7 @@ from web3.contract import Contract
11
11
  from web3.exceptions import ContractCustomError
12
12
 
13
13
  from iwa.core.chain import ChainInterfaces
14
+ from iwa.core.contracts.decoder import ErrorDecoder
14
15
  from iwa.core.rpc_monitor import RPCMonitor
15
16
  from iwa.core.utils import configure_logger
16
17
 
@@ -111,7 +112,7 @@ class ContractInstance:
111
112
  )
112
113
  return selectors
113
114
 
114
- def decode_error(self, error_data: str) -> Optional[Tuple[str, str]]:
115
+ def decode_error(self, error_data: str) -> Optional[Tuple[str, str]]: # noqa: C901
115
116
  """Decode error data from a failed transaction or call.
116
117
 
117
118
  Handles:
@@ -172,6 +173,16 @@ class ContractInstance:
172
173
  logger.debug(f"Failed to decode Panic(uint256): {e}")
173
174
  return ("Panic", "Failed to decode panic code")
174
175
 
176
+ # 4. Global Fallback Decoder
177
+ try:
178
+ global_results = ErrorDecoder().decode(error_data)
179
+ if global_results:
180
+ # Use the first match
181
+ error_name, error_msg, _ = global_results[0]
182
+ return (error_name, error_msg)
183
+ except Exception as e:
184
+ logger.debug(f"Global decoder failed: {e}")
185
+
175
186
  return None
176
187
 
177
188
  def _extract_error_data(self, exception: Exception) -> Optional[str]:
@@ -0,0 +1,154 @@
1
+ """Global error decoder for Ethereum contracts."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Tuple
6
+
7
+ from eth_abi import decode
8
+ from loguru import logger
9
+ from web3 import Web3
10
+
11
+ # Standard error selectors (copied from contract.py for consistency)
12
+ ERROR_SELECTOR = "0x08c379a0" # Error(string)
13
+ PANIC_SELECTOR = "0x4e487b71" # Panic(uint256)
14
+
15
+ PANIC_CODES = {
16
+ 0x00: "Generic compiler inserted panic",
17
+ 0x01: "Assert failed",
18
+ 0x11: "Arithmetic overflow/underflow",
19
+ 0x12: "Division by zero",
20
+ 0x21: "Invalid enum value",
21
+ 0x22: "Storage byte array incorrectly encoded",
22
+ 0x31: "Pop on empty array",
23
+ 0x32: "Array index out of bounds",
24
+ 0x41: "Too much memory allocated",
25
+ 0x51: "Invalid internal function call",
26
+ }
27
+
28
+
29
+ class ErrorDecoder:
30
+ """Global registry of error selectors from all project ABIs."""
31
+
32
+ _instance = None
33
+ _selectors: Dict[str, List[Dict[str, Any]]] = {} # selector -> list of possible decodings
34
+ _initialized = False
35
+
36
+ def __new__(cls):
37
+ """Singleton pattern."""
38
+ if cls._instance is None:
39
+ cls._instance = super(ErrorDecoder, cls).__new__(cls)
40
+ return cls._instance
41
+
42
+ def __init__(self):
43
+ """Initialize and load all ABIs once."""
44
+ if self._initialized:
45
+ return
46
+ self.load_all_abis()
47
+ self._initialized = True
48
+
49
+ def load_all_abis(self):
50
+ """Find and load all ABI files in the project."""
51
+ # Find the root of the source tree
52
+ # Assuming we are in src/iwa/core/contracts/decoder.py
53
+ current_file = Path(__file__).resolve()
54
+ src_root = current_file.parents[3] # Go up to 'src'
55
+
56
+ abi_files = list(src_root.glob("**/contracts/abis/*.json"))
57
+
58
+ # Also check core ABIs if they are in a different place
59
+ core_abi_path = src_root / "iwa" / "core" / "contracts" / "abis"
60
+ if core_abi_path.exists() and core_abi_path not in [f.parent for f in abi_files]:
61
+ abi_files.extend(list(core_abi_path.glob("*.json")))
62
+
63
+ logger.debug(f"Found {len(abi_files)} ABI files for error decoding.")
64
+
65
+ for abi_path in abi_files:
66
+ try:
67
+ with open(abi_path, "r", encoding="utf-8") as f:
68
+ content = json.load(f)
69
+ abi = content.get("abi") if isinstance(content, dict) and "abi" in content else content
70
+ if isinstance(abi, list):
71
+ self._process_abi(abi, abi_path.name)
72
+ except Exception as e:
73
+ logger.warning(f"Failed to load ABI {abi_path}: {e}")
74
+
75
+ def _process_abi(self, abi: List[Dict], source_name: str):
76
+ """Extract error selectors from an ABI."""
77
+ for entry in abi:
78
+ if entry.get("type") == "error":
79
+ name = entry["name"]
80
+ inputs = entry.get("inputs", [])
81
+ types = [i["type"] for i in inputs]
82
+ names = [i["name"] for i in inputs]
83
+
84
+ # Signature: Name(type1,type2,...)
85
+ types_str = ",".join(types)
86
+ signature = f"{name}({types_str})"
87
+ selector = "0x" + Web3.keccak(text=signature)[:4].hex()
88
+
89
+ decoding = {
90
+ "name": name,
91
+ "types": types,
92
+ "arg_names": names,
93
+ "source": source_name,
94
+ "signature": signature
95
+ }
96
+
97
+ if selector not in self._selectors:
98
+ self._selectors[selector] = []
99
+
100
+ # Avoid duplicates
101
+ if decoding not in self._selectors[selector]:
102
+ self._selectors[selector].append(decoding)
103
+
104
+ def decode(self, error_data: str) -> List[Tuple[str, str, str]]: # noqa: C901
105
+ """Decode hex error data.
106
+
107
+ Returns:
108
+ List of (error_name, formatted_message, source_abi)
109
+
110
+ """
111
+ if not error_data:
112
+ return []
113
+
114
+ if not error_data.startswith("0x"):
115
+ error_data = "0x" + error_data
116
+
117
+ if len(error_data) < 10:
118
+ return []
119
+
120
+ selector = error_data[:10].lower()
121
+ encoded_args = error_data[10:]
122
+
123
+ results = []
124
+
125
+ # 1. Check Standard Error(string)
126
+ if selector == ERROR_SELECTOR:
127
+ try:
128
+ decoded = decode(["string"], bytes.fromhex(encoded_args))
129
+ results.append(("Error", f"Error: {decoded[0]}", "Built-in"))
130
+ except Exception:
131
+ pass
132
+
133
+ # 2. Check Panic(uint256)
134
+ if selector == PANIC_SELECTOR:
135
+ try:
136
+ decoded = decode(["uint256"], bytes.fromhex(encoded_args))
137
+ code = decoded[0]
138
+ msg = PANIC_CODES.get(code, f"Unknown panic code {code}")
139
+ results.append(("Panic", f"Panic: {msg}", "Built-in"))
140
+ except Exception:
141
+ pass
142
+
143
+ # 3. Check Custom Errors
144
+ if selector in self._selectors:
145
+ for d in self._selectors[selector]:
146
+ try:
147
+ decoded = decode(d["types"], bytes.fromhex(encoded_args))
148
+ args_str = ", ".join(f"{n}={v}" for n, v in zip(d["arg_names"], decoded, strict=False))
149
+ results.append((d["name"], f"{d['name']}({args_str})", d["source"]))
150
+ except Exception:
151
+ # Try next possible decoding for this selector
152
+ continue
153
+
154
+ return results
@@ -9,11 +9,14 @@ from web3 import exceptions as web3_exceptions
9
9
  from iwa.core.chain import ChainInterfaces
10
10
  from iwa.core.db import log_transaction
11
11
  from iwa.core.keys import KeyStorage
12
+ from iwa.core.models import StoredSafeAccount
12
13
  from iwa.core.services.account import AccountService
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from iwa.core.chain import ChainInterface
16
17
 
18
+ # Circular import during type checking
19
+
17
20
  # ERC20 Transfer event signature: Transfer(address indexed from, address indexed to, uint256 value)
18
21
  TRANSFER_EVENT_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
19
22
 
@@ -205,12 +208,13 @@ class TransferLogger:
205
208
  class TransactionService:
206
209
  """Manages transaction lifecycle: signing, sending, retrying."""
207
210
 
208
- def __init__(self, key_storage: KeyStorage, account_service: AccountService):
211
+ def __init__(self, key_storage: KeyStorage, account_service: AccountService, safe_service=None):
209
212
  """Initialize TransactionService."""
210
213
  self.key_storage = key_storage
211
214
  self.account_service = account_service
215
+ self.safe_service = safe_service
212
216
 
213
- def sign_and_send(
217
+ def sign_and_send( # noqa: C901
214
218
  self,
215
219
  transaction: dict,
216
220
  signer_address_or_tag: str,
@@ -228,6 +232,14 @@ class TransactionService:
228
232
  if not self._prepare_transaction(tx, signer_address_or_tag, chain_interface):
229
233
  return False, {}
230
234
 
235
+ # CHECK FOR SAFE TRANSACTION
236
+ signer_account = self.account_service.resolve_account(signer_address_or_tag)
237
+ if isinstance(signer_account, StoredSafeAccount):
238
+ if not self.safe_service:
239
+ logger.error("Attempted Safe transaction but SafeService is not initialized.")
240
+ return False, {}
241
+ return self._execute_via_safe(tx, signer_account, chain_interface, chain_name, tags)
242
+
231
243
  # Mutable state for retry attempts
232
244
  state = {"gas_retries": 0, "max_gas_retries": 5}
233
245
 
@@ -367,6 +379,55 @@ class TransactionService:
367
379
 
368
380
  return list(set(final_tags))
369
381
 
382
+ def _execute_via_safe(
383
+ self,
384
+ tx: dict,
385
+ signer_account: StoredSafeAccount,
386
+ chain_interface,
387
+ chain_name: str,
388
+ tags: List[str] = None
389
+ ) -> Tuple[bool, Dict]:
390
+ """Execute transaction via SafeService."""
391
+ logger.info(f"Routing transaction via Safe {signer_account.address}...")
392
+
393
+ try:
394
+ # Extract basic params
395
+ to_addr = tx.get("to")
396
+ value = tx.get("value", 0)
397
+ data = tx.get("data", "")
398
+ if isinstance(data, bytes):
399
+ data = "0x" + data.hex()
400
+
401
+ # Execute
402
+ tx_hash = self.safe_service.execute_safe_transaction(
403
+ safe_address_or_tag=signer_account.address,
404
+ to=to_addr,
405
+ value=value,
406
+ chain_name=chain_name,
407
+ data=data
408
+ )
409
+
410
+ # Wait for receipt
411
+ receipt = chain_interface.web3.eth.wait_for_transaction_receipt(tx_hash)
412
+
413
+ status = getattr(receipt, "status", None)
414
+ if status is None and isinstance(receipt, dict):
415
+ status = receipt.get("status")
416
+
417
+ if receipt and status == 1:
418
+ logger.info(f"Safe transaction executed successfully. Tx Hash: {tx_hash}")
419
+ self._log_successful_transaction(
420
+ receipt, tx, signer_account, chain_name, bytes.fromhex(tx_hash.replace("0x", "")), tags, chain_interface
421
+ )
422
+ return True, receipt
423
+ else:
424
+ logger.error("Safe transaction failed (status 0).")
425
+ return False, {}
426
+
427
+ except Exception as e:
428
+ logger.exception(f"Safe transaction failed: {e}")
429
+ return False, {}
430
+
370
431
  def _is_gas_too_low_error(self, err_text: str) -> bool:
371
432
  """Check if error is due to low gas."""
372
433
  low_gas_signals = [
iwa/core/wallet.py CHANGED
@@ -37,7 +37,7 @@ class Wallet:
37
37
  self.balance_service = BalanceService(self.key_storage, self.account_service)
38
38
  self.safe_service = SafeService(self.key_storage, self.account_service)
39
39
  # self.transaction_manager = TransactionManager(self.key_storage, self.account_service)
40
- self.transaction_service = TransactionService(self.key_storage, self.account_service)
40
+ self.transaction_service = TransactionService(self.key_storage, self.account_service, self.safe_service)
41
41
 
42
42
  self.transfer_service = TransferService(
43
43
  self.key_storage,
@@ -82,7 +82,13 @@ class DiscoveredService:
82
82
  service_name: Optional[str] = None
83
83
  # New fields for full service import
84
84
  staking_contract_address: Optional[str] = None
85
- service_owner_address: Optional[str] = None
85
+ service_owner_eoa_address: Optional[str] = None
86
+ service_owner_multisig_address: Optional[str] = None
87
+
88
+ @property
89
+ def service_owner_address(self) -> Optional[str]:
90
+ """Backward compatibility: effective owner address."""
91
+ return self.service_owner_multisig_address or self.service_owner_eoa_address
86
92
 
87
93
  @property
88
94
  def agent_key(self) -> Optional[DiscoveredKey]:
@@ -517,7 +523,12 @@ class OlasServiceImporter:
517
523
  return keys
518
524
 
519
525
  def _extract_owner_address(self, service: DiscoveredService, operate_folder: Path) -> None:
520
- """Extract owner address from wallets/ethereum.json."""
526
+ """Extract owner address from wallets/ethereum.json.
527
+
528
+ Handles two cases:
529
+ 1. EOA is the owner (legacy).
530
+ 2. Safe is the owner, and EOA is a signer (new staking programs).
531
+ """
521
532
  wallets_folder = operate_folder / "wallets"
522
533
  if not wallets_folder.exists():
523
534
  return
@@ -526,9 +537,35 @@ class OlasServiceImporter:
526
537
  if eth_json.exists():
527
538
  try:
528
539
  data = json.loads(eth_json.read_text())
529
- if "address" in data:
530
- service.service_owner_address = data["address"]
531
- logger.debug(f"Extracted owner address: {service.service_owner_address}")
540
+
541
+ # Check for "safes" entry which indicates the owner is a Safe
542
+ # 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
545
+
546
+ # Logic update:
547
+ # 1. Capture EOA address always (it's the signer)
548
+ eoa_address = data.get("address")
549
+
550
+ # 2. Check for Safe Owner for the current service chain
551
+ safe_owner_address = None
552
+ if "safes" in data and isinstance(data["safes"], dict):
553
+ # We try to match with service.chain_name if available, usually "gnosis"
554
+ chain = service.chain_name or "gnosis"
555
+ safe_owner_address = data["safes"].get(chain)
556
+
557
+ if safe_owner_address:
558
+ # CASE: Owner is Safe
559
+ service.service_owner_multisig_address = safe_owner_address
560
+ service.service_owner_eoa_address = eoa_address # The EOA is the signer/controller
561
+
562
+ logger.debug(f"Extracted Safe owner address: {safe_owner_address} (Signer: {eoa_address})")
563
+ elif eoa_address:
564
+ # CASE: Owner is EOA
565
+ service.service_owner_eoa_address = eoa_address
566
+ service.service_owner_multisig_address = None
567
+ logger.debug(f"Extracted EOA owner address: {eoa_address}")
568
+
532
569
  except (json.JSONDecodeError, IOError) as e:
533
570
  logger.warning(f"Failed to parse {eth_json}: {e}")
534
571
 
@@ -541,14 +578,14 @@ class OlasServiceImporter:
541
578
  existing_addrs.add(key.address.lower())
542
579
 
543
580
  def _infer_owner_address(self, service: DiscoveredService) -> None:
544
- """Infer service_owner_address from keys with role='owner' if not already set."""
545
- if service.service_owner_address:
581
+ """Infer service_owner_eoa_address from keys with role='owner' if not already set."""
582
+ if service.service_owner_eoa_address:
546
583
  return # Already set
547
584
 
548
585
  for key in service.keys:
549
586
  if key.role == "owner" and key.address:
550
- service.service_owner_address = key.address
551
- logger.debug(f"Inferred owner address from key: {key.address}")
587
+ service.service_owner_eoa_address = key.address
588
+ logger.debug(f"Inferred owner EOA address from key: {key.address}")
552
589
  return
553
590
 
554
591
  def _parse_keystore_file(
@@ -725,8 +762,14 @@ class OlasServiceImporter:
725
762
 
726
763
  def _import_discovered_safes(self, service: DiscoveredService, result: ImportResult) -> None:
727
764
  """Import Safe from the service if present."""
765
+ # 1. Import Agent Multisig (the one the agent controls)
728
766
  if service.safe_address:
729
- safe_result = self._import_safe(service)
767
+ safe_result = self._import_safe(
768
+ address=service.safe_address,
769
+ signers=self._get_agent_signers(service),
770
+ tag_suffix="safe", # e.g. trader_zeta_safe
771
+ service_name=service.service_name
772
+ )
730
773
  if safe_result[0]:
731
774
  result.imported_safes.append(service.safe_address)
732
775
  elif safe_result[1] == "duplicate":
@@ -734,6 +777,44 @@ class OlasServiceImporter:
734
777
  else:
735
778
  result.errors.append(f"Safe {service.safe_address}: {safe_result[1]}")
736
779
 
780
+ # 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
+ owner_signers = self._get_owner_signers(service)
784
+
785
+ safe_result = self._import_safe(
786
+ address=service.service_owner_multisig_address,
787
+ signers=owner_signers,
788
+ tag_suffix="owner_safe", # e.g. trader_zeta_owner_safe
789
+ service_name=service.service_name
790
+ )
791
+ 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}")
794
+
795
+ def _get_agent_signers(self, service: DiscoveredService) -> List[str]:
796
+ """Get list of signers for the agent safe."""
797
+ signers = []
798
+ for key in service.keys:
799
+ if key.role == "agent":
800
+ addr = key.address
801
+ if not addr.startswith("0x"):
802
+ addr = "0x" + addr
803
+ signers.append(addr)
804
+ return signers
805
+
806
+ def _get_owner_signers(self, service: DiscoveredService) -> List[str]:
807
+ """Get list of signers for the owner safe."""
808
+ signers = []
809
+ for key in service.keys:
810
+ # We look for keys marked as owner/operator
811
+ if key.role in ["owner", "operator"]:
812
+ addr = key.address
813
+ if not addr.startswith("0x"):
814
+ addr = "0x" + addr
815
+ signers.append(addr)
816
+ return signers
817
+
737
818
  def _import_discovered_service_config(
738
819
  self, service: DiscoveredService, result: ImportResult
739
820
  ) -> None:
@@ -833,27 +914,26 @@ class OlasServiceImporter:
833
914
  i += 1
834
915
  return f"{base_tag}_{i}"
835
916
 
836
- def _import_safe(self, service: DiscoveredService) -> Tuple[bool, str]:
837
- """Import a Safe from a discovered service."""
838
- if not service.safe_address:
917
+ def _import_safe(
918
+ self,
919
+ address: str,
920
+ signers: List[str] = None,
921
+ tag_suffix: str = "safe",
922
+ service_name: Optional[str] = None
923
+ ) -> Tuple[bool, str]:
924
+ """Import a generic Safe."""
925
+ if not address:
839
926
  return False, "no safe address"
840
927
 
841
928
  # Check for duplicate
842
- existing = self.key_storage.find_stored_account(service.safe_address)
929
+ existing = self.key_storage.find_stored_account(address)
843
930
  if existing:
844
931
  return False, "duplicate"
845
932
 
846
- # Get signers from agent keys
847
- signers = []
848
- for key in service.keys:
849
- if key.role == "agent":
850
- signers.append(key.address)
851
-
852
933
  # Generate tag
853
-
854
- prefix = service.service_name or "imported"
934
+ prefix = service_name or "imported"
855
935
  prefix = re.sub(r"[^a-z0-9]+", "_", prefix.lower()).strip("_")
856
- base_tag = f"{prefix}_safe"
936
+ base_tag = f"{prefix}_{tag_suffix}"
857
937
 
858
938
  existing_tags = {
859
939
  acc.tag for acc in self.key_storage.accounts.values() if hasattr(acc, "tag")
@@ -866,15 +946,15 @@ class OlasServiceImporter:
866
946
 
867
947
  safe_account = StoredSafeAccount(
868
948
  tag=tag,
869
- address=service.safe_address,
870
- chains=[service.chain_name],
949
+ address=address,
950
+ chains=["gnosis"], # TODO: detecting chain dynamically would be better
871
951
  threshold=1, # Default, accurate value requires on-chain query
872
- signers=signers,
952
+ signers=signers or [],
873
953
  )
874
954
 
875
- self.key_storage.accounts[service.safe_address] = safe_account
955
+ self.key_storage.accounts[address] = safe_account
876
956
  self.key_storage.save()
877
- logger.info(f"Imported Safe {service.safe_address} as '{tag}'")
957
+ logger.info(f"Imported Safe {address} as '{tag}'")
878
958
  return True, "ok"
879
959
 
880
960
  def _import_service_config(self, service: DiscoveredService) -> Tuple[bool, str]:
@@ -901,7 +981,8 @@ class OlasServiceImporter:
901
981
  service_id=service.service_id,
902
982
  agent_ids=[25], # Trader agents always use agent ID 25
903
983
  multisig_address=service.safe_address,
904
- service_owner_address=service.service_owner_address,
984
+ service_owner_eoa_address=service.service_owner_eoa_address,
985
+ service_owner_multisig_address=service.service_owner_multisig_address,
905
986
  staking_contract_address=service.staking_contract_address,
906
987
  token_address=str(OLAS_TOKEN_ADDRESS_GNOSIS),
907
988
  )
@@ -975,3 +1056,4 @@ class OlasServiceImporter:
975
1056
  key.signature_failed = True
976
1057
  logger.warning(f"Error verifying signature for key {key.address}: {e}")
977
1058
 
1059
+ FLAGS_OWNER_SAFE="deprecated"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Dict, List, Optional
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, Field, root_validator
6
6
 
7
7
  from iwa.core.models import EthereumAddress
8
8
 
@@ -14,12 +14,40 @@ class Service(BaseModel):
14
14
  chain_name: str
15
15
  service_id: int # Unique per chain
16
16
  agent_ids: List[int] = Field(default_factory=list) # List of agent type IDs
17
- service_owner_address: Optional[EthereumAddress] = None
17
+
18
+ # New explicit owner fields
19
+ service_owner_eoa_address: Optional[EthereumAddress] = None
20
+ service_owner_multisig_address: Optional[EthereumAddress] = None
21
+
22
+ # Deprecated fields (kept for migration, removed from physical model via aliasing/validation)
23
+ # Actually, we keep it optional but not used, or use migration logic.
24
+ # Let's remove it from fields and rely on before validator to map it to eoa.
25
+
18
26
  agent_address: Optional[EthereumAddress] = None
19
27
  multisig_address: Optional[EthereumAddress] = None
20
28
  staking_contract_address: Optional[EthereumAddress] = None
21
29
  token_address: Optional[EthereumAddress] = None
22
30
 
31
+ @root_validator(pre=True)
32
+ def migrate_owner_fields(cls, values): # noqa: N805
33
+ """Migrate legacy service_owner_address to service_owner_eoa_address."""
34
+ # Check for legacy 'service_owner_address'
35
+ if "service_owner_address" in values and values["service_owner_address"]:
36
+ legacy_addr = values["service_owner_address"]
37
+
38
+ # If service_owner_eoa_address is missing, use legacy
39
+ if "service_owner_eoa_address" not in values or not values["service_owner_eoa_address"]:
40
+ values["service_owner_eoa_address"] = legacy_addr
41
+
42
+ # Remove legacy field from values so it doesn't cause extra field errors if we removed it from model
43
+ # Or if strict.
44
+ return values
45
+
46
+ @property
47
+ def service_owner_address(self) -> Optional[EthereumAddress]:
48
+ """Backward compatibility property: Returns effective owner (Safe if present, else EOA)."""
49
+ return self.service_owner_multisig_address or self.service_owner_eoa_address
50
+
23
51
  @property
24
52
  def key(self) -> str:
25
53
  """Unique key for this service (chain_name:service_id)."""
@@ -106,5 +134,8 @@ class OlasConfig(BaseModel):
106
134
  target = multisig_address.lower()
107
135
  for service in self.services.values():
108
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))
109
140
  return service
110
141
  return None
@@ -141,6 +141,20 @@ class OlasPlugin(Plugin):
141
141
  safe_text = service.safe_address
142
142
  if safe_exists:
143
143
  safe_text += " [green]✓[/green]"
144
+
145
+ # Check if Agent is a signer
146
+ agent_key = next((k for k in service.keys if k.role == "agent"), None)
147
+ if agent_key and on_chain_signers:
148
+ key_addr = agent_key.address.lower()
149
+ if not key_addr.startswith("0x"):
150
+ key_addr = "0x" + key_addr
151
+
152
+ is_signer = key_addr in [s.lower() for s in on_chain_signers]
153
+ if not is_signer:
154
+ safe_text += f"\n[bold red]⚠ Agent {agent_key.address} - NOT A SIGNER![/bold red]"
155
+ else:
156
+ safe_text += f" (Signer: {agent_key.address[:6]}...)"
157
+
144
158
  elif safe_exists is False:
145
159
  safe_text = (
146
160
  f"[bold red]⚠ {service.safe_address} - DOES NOT EXIST ON-CHAIN![/bold red]"
@@ -163,26 +177,63 @@ class OlasPlugin(Plugin):
163
177
  table.add_row("Staking", "[red]Not detected[/red]")
164
178
  table.add_row("Staking Addr", "[red]Not detected[/red]")
165
179
 
166
- def _add_owner_info(self, table, service) -> None:
180
+ def _add_owner_info(self, table, service) -> None: # noqa: C901
167
181
  """Add owner information to the display table."""
182
+ # 1. Display Signer/EOA Owner
168
183
  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
+ 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}")
195
+ 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
+ else:
199
+ table.add_row("Owner (EOA)", "[yellow]N/A[/yellow]")
200
+
201
+ # 2. Display Safe Owner
202
+ if service.service_owner_multisig_address:
203
+ # Check on-chain existence if possible (using same helper as agent safe)
204
+ on_chain_signers, safe_exists = self._get_safe_signers(
205
+ service.service_owner_multisig_address, service.chain_name
206
+ )
207
+ val = service.service_owner_multisig_address
208
+ if safe_exists:
209
+ val += " [green]✓[/green]"
210
+
211
+ # Check if EOA owner is a signer
212
+ if owner_key and on_chain_signers:
213
+ key_addr = owner_key.address.lower()
214
+ if not key_addr.startswith("0x"):
215
+ key_addr = "0x" + key_addr
216
+
217
+ is_signer = key_addr in [s.lower() for s in on_chain_signers]
218
+ if not is_signer:
219
+ # Ensure display has 0x
220
+ disp_addr = owner_key.address
221
+ if not disp_addr.startswith("0x"):
222
+ disp_addr = "0x" + disp_addr
223
+ val += f"\n[bold red]⚠ {disp_addr} - NOT A SIGNER![/bold red]"
224
+ else:
225
+ # Ensure display has 0x
226
+ disp_addr = owner_key.address
227
+ if not disp_addr.startswith("0x"):
228
+ disp_addr = "0x" + disp_addr
229
+ val += f" (Signer: {disp_addr[:6]}...)"
230
+
231
+ elif safe_exists is False:
232
+ val += " [bold red]⚠ DOES NOT EXIST![/bold red]"
233
+
234
+ table.add_row("Owner (Safe)", val)
184
235
  else:
185
- table.add_row("Owner", "[red]Not detected[/red]")
236
+ table.add_row("Owner (Safe)", "[yellow]N/A[/yellow]")
186
237
 
187
238
  def _add_agent_info(self, table, service, on_chain_signers, safe_exists) -> None:
188
239
  """Add agent information to the display table."""
@@ -272,15 +272,11 @@ def test_generate_tag_collisions(importer):
272
272
 
273
273
  def test_import_safe_duplicate(importer):
274
274
  """Test importing a Safe that already exists."""
275
- from iwa.plugins.olas.importer import DiscoveredService
276
-
277
- service = DiscoveredService(
278
- service_id=1, chain_name="gnosis", safe_address="0xSafe", source_folder=Path("/tmp")
279
- )
275
+ safe_address = "0xSafe"
280
276
 
281
277
  importer.key_storage.find_stored_account.return_value = MagicMock()
282
278
 
283
- success, msg = importer._import_safe(service)
279
+ success, msg = importer._import_safe(safe_address)
284
280
  assert success is False
285
281
  assert msg == "duplicate"
286
282
 
@@ -310,22 +306,14 @@ def test_import_key_success(importer):
310
306
 
311
307
  def test_import_safe_success(importer):
312
308
  """Test successful Safe import with tag generation."""
313
- from iwa.plugins.olas.importer import DiscoveredService
314
-
315
- service = DiscoveredService(
316
- service_id=1,
317
- chain_name="gnosis",
318
- safe_address="0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB",
319
- source_folder=Path("/tmp"),
320
- service_name="my_service",
321
- )
309
+ safe_address = "0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB"
322
310
  importer.key_storage.find_stored_account.return_value = None
323
311
  importer.key_storage.accounts = {}
324
312
 
325
- success, msg = importer._import_safe(service)
313
+ success, msg = importer._import_safe(safe_address, service_name="my_service")
326
314
  assert success is True
327
315
  assert msg == "ok"
328
- assert "0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB" in importer.key_storage.accounts
316
+ assert safe_address in importer.key_storage.accounts
329
317
 
330
318
 
331
319
  def test_import_service_config_success(importer):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.27
3
+ Version: 0.0.29
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
@@ -2,7 +2,7 @@ iwa/__init__.py,sha256=vu12UytYNREtMRvIWp6AfV1GgUe53XWwCMhYyqKAPgo,19
2
2
  iwa/__main__.py,sha256=eJU5Uxeu9Y7shWg5dt5Mcq0pMC4wFVNWjeYGKSf4Apw,88
3
3
  iwa/core/__init__.py,sha256=GJv4LJOXeZ3hgGvbt5I6omkoFkP2A9qhHjpDlOep9ik,24
4
4
  iwa/core/chainlist.py,sha256=IOnlfRFaPlzdg91HbILynzvRfEJ9afikjFUS06K4rfU,4021
5
- iwa/core/cli.py,sha256=GCrHSUp5W1KdLHoWbk0aCLTphkKDLROoFh82uyUqdY8,7729
5
+ iwa/core/cli.py,sha256=7zKhPq5Yeb8yq3J3JpSGX5uHPrL6xkm79r2715ltzwo,8306
6
6
  iwa/core/constants.py,sha256=_CYUVQpR--dRPuxotsmbzQE-22y61tlnjUD7IhlvVVA,997
7
7
  iwa/core/db.py,sha256=WI-mP0tQAmwFPeEi9w7RCa_Mcf_zBfd_7JcbHJwU1aU,10377
8
8
  iwa/core/ipfs.py,sha256=aHjq_pflgwDVHl8g5EMQv0q2RAmMs-a0pOTVsj_L5xE,4980
@@ -19,7 +19,7 @@ iwa/core/test.py,sha256=gey0dql5eajo1itOhgkSrgfyGWue2eSfpr0xzX3vc38,643
19
19
  iwa/core/types.py,sha256=EfDfIwLajTNK-BP9K17QLOIsGCs8legplKI_bUD_NjM,1992
20
20
  iwa/core/ui.py,sha256=DglmrI7XhUmOpLn9Nog9Cej4r-VT0JGFkuSNBx-XorQ,3131
21
21
  iwa/core/utils.py,sha256=shJuANkXSWVO3NF49syPA9hCG7H5AzaMJOG8V4fo6IM,4279
22
- iwa/core/wallet.py,sha256=sNFK-_0y-EgeLpNHt9o5tCqTM0oVqJra-eAWjR7AgyU,13038
22
+ iwa/core/wallet.py,sha256=GI83BOFGTto6u8LPKepJDqWxxNsHqfNF-vCxy4pTk3U,13057
23
23
  iwa/core/chain/__init__.py,sha256=XJMmn0ed-_aVkY2iEMKpuTxPgIKBd41dexSVmEZTa-o,1604
24
24
  iwa/core/chain/errors.py,sha256=9SEbhxZ-qASPkzt-DoI51qq0GRJVqRgqgL720gO7a64,1275
25
25
  iwa/core/chain/interface.py,sha256=04eGlhonHAvxFnqLoHRWUaQBzys6jW6BppUuNNjlnSk,18809
@@ -28,7 +28,8 @@ iwa/core/chain/models.py,sha256=0OgBo08FZEQisOdd00YUMXSAV7BC0CcWpqJ2y-gs0cI,4863
28
28
  iwa/core/chain/rate_limiter.py,sha256=gU7TmWdH9D_wbXKT1X7mIgoIUCWVuebgvRhxiyLGAmI,6613
29
29
  iwa/core/contracts/__init__.py,sha256=P5GFY_pnuI02teqVY2U0t98bn1_SSPAbcAzRMpCdTi4,34
30
30
  iwa/core/contracts/cache.py,sha256=7wGQgyXAmofvx-irbxSnGgIDzQik6Pcpm7up6wMQcoo,4454
31
- iwa/core/contracts/contract.py,sha256=DCSQmlOom_SkGXElcr1VQyAt2nt0GjkYhLpiQsWDJPY,12810
31
+ iwa/core/contracts/contract.py,sha256=RJaKh05p1MdgsCd8v799LwW1apkDboRDCu8Kah02760,13254
32
+ iwa/core/contracts/decoder.py,sha256=HaPw43ZAGswaiLPasMNU2rXKSvozb5t-O46VzJ-YH-U,5488
32
33
  iwa/core/contracts/erc20.py,sha256=VqriOdUXej0ilTgpukpm1FUF_9sSrVMAPuEpIvyZ2SQ,2646
33
34
  iwa/core/contracts/multisend.py,sha256=tSdBCWe7LSdBoKZ7z2QebmRFK4M2ln7H3kmJBeEb4Ho,2431
34
35
  iwa/core/contracts/abis/erc20.json,sha256=vrdExMWcIogg_nO59j1Pmipmpa2Ulj3oCCdcdrrFVCE,16995
@@ -39,7 +40,7 @@ iwa/core/services/account.py,sha256=01MoEvl6FJlMnMB4fGwsPtnGa4kgA-d5hJeKu_ACg7Y,
39
40
  iwa/core/services/balance.py,sha256=mPE12CuOFfCaJXaQXWOcQM1O03ZF3ghpy_-oOjNk_GE,4104
40
41
  iwa/core/services/plugin.py,sha256=GNNlbtELyHl7MNVChrypF76GYphxXduxDog4kx1MLi8,3277
41
42
  iwa/core/services/safe.py,sha256=ZmgVwbQhYlH5r3qhlY5uP8nCPtkkvV3sNnYG7_UCWUQ,14831
42
- iwa/core/services/transaction.py,sha256=DiEVwE1L_UpCyC5UmknaRwRYRxsDlAkwMQRN64NiwIQ,15162
43
+ iwa/core/services/transaction.py,sha256=1O8FAmXZak2oVu15ooUQsc0ByIS2F-O0vWxcAV2ylqk,17525
43
44
  iwa/core/services/transfer/__init__.py,sha256=ZJfshFxJRsp8rkOqfVvd1cqEzIJ9tqBJh8pc0l90GLk,5576
44
45
  iwa/core/services/transfer/base.py,sha256=sohz-Ss2i-pGYGl4x9bD93cnYKcSvsXaXyvyRawvgQs,9043
45
46
  iwa/core/services/transfer/erc20.py,sha256=958ctXPWxq_KSQNoaG7RqWbC8SRb9NB3MzhtC2dp_NU,8960
@@ -61,10 +62,10 @@ iwa/plugins/gnosis/tests/test_safe.py,sha256=hQHVHBWQhGnuvzvx4U9fOWEwASJWwql42q6
61
62
  iwa/plugins/olas/__init__.py,sha256=_NhBczzM61fhGYwGhnWfEeL8Jywyy_730GASe2BxzeQ,106
62
63
  iwa/plugins/olas/constants.py,sha256=iTFoO2QW3KbhL5k5sKsJxxyDytl9wVIb_9hAih55KrE,7728
63
64
  iwa/plugins/olas/events.py,sha256=SWD3wYdQ-l6dLUJSkfh_WsLmedH4Vsw_EvYXg7QC3yc,5970
64
- iwa/plugins/olas/importer.py,sha256=A7trGY8yz-uB0MNDcC7W3U_FR_wSECoFnMkzCVw3rlc,38059
65
+ iwa/plugins/olas/importer.py,sha256=KQr8YXTL9SzoEBTAezh_zmJrIJqDQA_yqy8WoBX6wyM,41796
65
66
  iwa/plugins/olas/mech_reference.py,sha256=CaSCpQnQL4F7wOG6Ox6Zdoy-uNEQ78YBwVLILQZKL8Q,5782
66
- iwa/plugins/olas/models.py,sha256=xC5hYakX53pBT6zZteM9cyiC7t6XRLLpobjQmDYueOo,3520
67
- iwa/plugins/olas/plugin.py,sha256=7vTnr7OUZhzXKxyQjjp5oiRkMsB5c-1arDW4YYoV9RM,13156
67
+ iwa/plugins/olas/models.py,sha256=36N2Wuia7DVfpekJJ2pXPeS0lqhe5ED-Hjdox9YQG2c,5230
68
+ iwa/plugins/olas/plugin.py,sha256=zOPWyoVkSVh6guJ3TZj5enJFuiIbP3fRM8FkziPB-c0,15606
68
69
  iwa/plugins/olas/contracts/activity_checker.py,sha256=WXxuzbpXGVqIfEiMPiiqN3Z_UxIY-Lvx0raa1ErBfPA,5323
69
70
  iwa/plugins/olas/contracts/base.py,sha256=y73aQbDq6l4zUpz_eQAg4MsLkTAEqjjupXlcvxjfgCI,240
70
71
  iwa/plugins/olas/contracts/mech.py,sha256=dXYtyORc-oiu9ga5PtTquOFkoakb6BLGKvlUsteygIg,2767
@@ -92,7 +93,7 @@ iwa/plugins/olas/service_manager/mech.py,sha256=NVzVbEmyOe3wK92VEzCCOSuy3HDkEP1M
92
93
  iwa/plugins/olas/service_manager/staking.py,sha256=4WS1m1E1ddX5EbQuvNnNswZjT20nc7I9IKzjrBuUCFw,28701
93
94
  iwa/plugins/olas/tests/conftest.py,sha256=4vM7EI00SrTGyeP0hNzsGSQHEj2-iznVgzlNh2_OGfo,739
94
95
  iwa/plugins/olas/tests/test_importer.py,sha256=i9LKov7kNRECB3hmRnhKBwcfx3uxtjWe4BB77bOOpeo,4282
95
- iwa/plugins/olas/tests/test_importer_error_handling.py,sha256=O5yd7w_eURtkJb8_IwAGkz8fyHLTzYfI5c2JxWl3oOo,12081
96
+ iwa/plugins/olas/tests/test_importer_error_handling.py,sha256=yHpdxfN20gnZ1cHPJqk9xHC_26jyVN8kiziY-sl4FuA,11696
96
97
  iwa/plugins/olas/tests/test_mech_contracts.py,sha256=wvxuigPafF-ySIHVBdWVei3AO418iPh7cSVdAlUGm_s,3566
97
98
  iwa/plugins/olas/tests/test_olas_contracts.py,sha256=B8X-5l1KfYMoZOiM94_rcNzbILLl78rqt_jhyxzAOqE,10835
98
99
  iwa/plugins/olas/tests/test_olas_integration.py,sha256=vjL8-RNdxXu6RFR5F1Bn7xqnxnUVWTzl2--Pp7-0r5A,22973
@@ -157,7 +158,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=C264MH-CTyDW4GLUrTXBgLJKUk4-89pFAScBd
157
158
  iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
158
159
  iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
159
160
  iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
160
- iwa-0.0.27.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
161
+ iwa-0.0.29.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
161
162
  tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
162
163
  tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
163
164
  tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
@@ -210,8 +211,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
210
211
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
211
212
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
212
213
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
213
- iwa-0.0.27.dist-info/METADATA,sha256=vCUmE_RYSF6v1ahopD9eTY58p9kkWivsmwwv5dFMKO8,7295
214
- iwa-0.0.27.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
215
- iwa-0.0.27.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
216
- iwa-0.0.27.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
217
- iwa-0.0.27.dist-info/RECORD,,
214
+ iwa-0.0.29.dist-info/METADATA,sha256=zgMk6FzzMD7GUiUl9TBDKFuRvoaRgOYxnWOWt3s14AU,7295
215
+ iwa-0.0.29.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
216
+ iwa-0.0.29.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
217
+ iwa-0.0.29.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
218
+ iwa-0.0.29.dist-info/RECORD,,
File without changes