iwa 0.0.0__py3-none-any.whl → 0.0.1a2__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 (191) hide show
  1. conftest.py +22 -0
  2. iwa/__init__.py +1 -0
  3. iwa/__main__.py +6 -0
  4. iwa/core/__init__.py +1 -0
  5. iwa/core/chain/__init__.py +68 -0
  6. iwa/core/chain/errors.py +47 -0
  7. iwa/core/chain/interface.py +514 -0
  8. iwa/core/chain/manager.py +38 -0
  9. iwa/core/chain/models.py +128 -0
  10. iwa/core/chain/rate_limiter.py +193 -0
  11. iwa/core/cli.py +210 -0
  12. iwa/core/constants.py +28 -0
  13. iwa/core/contracts/__init__.py +1 -0
  14. iwa/core/contracts/contract.py +297 -0
  15. iwa/core/contracts/erc20.py +79 -0
  16. iwa/core/contracts/multisend.py +71 -0
  17. iwa/core/db.py +317 -0
  18. iwa/core/keys.py +361 -0
  19. iwa/core/mnemonic.py +385 -0
  20. iwa/core/models.py +344 -0
  21. iwa/core/monitor.py +209 -0
  22. iwa/core/plugins.py +45 -0
  23. iwa/core/pricing.py +91 -0
  24. iwa/core/services/__init__.py +17 -0
  25. iwa/core/services/account.py +57 -0
  26. iwa/core/services/balance.py +113 -0
  27. iwa/core/services/plugin.py +88 -0
  28. iwa/core/services/safe.py +392 -0
  29. iwa/core/services/transaction.py +172 -0
  30. iwa/core/services/transfer/__init__.py +166 -0
  31. iwa/core/services/transfer/base.py +260 -0
  32. iwa/core/services/transfer/erc20.py +247 -0
  33. iwa/core/services/transfer/multisend.py +386 -0
  34. iwa/core/services/transfer/native.py +262 -0
  35. iwa/core/services/transfer/swap.py +326 -0
  36. iwa/core/settings.py +95 -0
  37. iwa/core/tables.py +60 -0
  38. iwa/core/test.py +27 -0
  39. iwa/core/tests/test_wallet.py +255 -0
  40. iwa/core/types.py +59 -0
  41. iwa/core/ui.py +99 -0
  42. iwa/core/utils.py +59 -0
  43. iwa/core/wallet.py +380 -0
  44. iwa/plugins/__init__.py +1 -0
  45. iwa/plugins/gnosis/__init__.py +5 -0
  46. iwa/plugins/gnosis/cow/__init__.py +6 -0
  47. iwa/plugins/gnosis/cow/quotes.py +148 -0
  48. iwa/plugins/gnosis/cow/swap.py +403 -0
  49. iwa/plugins/gnosis/cow/types.py +20 -0
  50. iwa/plugins/gnosis/cow_utils.py +44 -0
  51. iwa/plugins/gnosis/plugin.py +68 -0
  52. iwa/plugins/gnosis/safe.py +157 -0
  53. iwa/plugins/gnosis/tests/test_cow.py +227 -0
  54. iwa/plugins/gnosis/tests/test_safe.py +100 -0
  55. iwa/plugins/olas/__init__.py +5 -0
  56. iwa/plugins/olas/constants.py +106 -0
  57. iwa/plugins/olas/contracts/activity_checker.py +93 -0
  58. iwa/plugins/olas/contracts/base.py +10 -0
  59. iwa/plugins/olas/contracts/mech.py +49 -0
  60. iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
  61. iwa/plugins/olas/contracts/service.py +215 -0
  62. iwa/plugins/olas/contracts/staking.py +403 -0
  63. iwa/plugins/olas/importer.py +736 -0
  64. iwa/plugins/olas/mech_reference.py +135 -0
  65. iwa/plugins/olas/models.py +110 -0
  66. iwa/plugins/olas/plugin.py +243 -0
  67. iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
  68. iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
  69. iwa/plugins/olas/service_manager/__init__.py +60 -0
  70. iwa/plugins/olas/service_manager/base.py +113 -0
  71. iwa/plugins/olas/service_manager/drain.py +336 -0
  72. iwa/plugins/olas/service_manager/lifecycle.py +839 -0
  73. iwa/plugins/olas/service_manager/mech.py +322 -0
  74. iwa/plugins/olas/service_manager/staking.py +530 -0
  75. iwa/plugins/olas/tests/conftest.py +30 -0
  76. iwa/plugins/olas/tests/test_importer.py +128 -0
  77. iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
  78. iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
  79. iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
  80. iwa/plugins/olas/tests/test_olas_integration.py +561 -0
  81. iwa/plugins/olas/tests/test_olas_models.py +144 -0
  82. iwa/plugins/olas/tests/test_olas_view.py +258 -0
  83. iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
  84. iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
  85. iwa/plugins/olas/tests/test_plugin.py +70 -0
  86. iwa/plugins/olas/tests/test_plugin_full.py +212 -0
  87. iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
  88. iwa/plugins/olas/tests/test_service_manager.py +1065 -0
  89. iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
  90. iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
  91. iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
  92. iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
  93. iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
  94. iwa/plugins/olas/tests/test_service_staking.py +342 -0
  95. iwa/plugins/olas/tests/test_staking_integration.py +269 -0
  96. iwa/plugins/olas/tests/test_staking_validation.py +109 -0
  97. iwa/plugins/olas/tui/__init__.py +1 -0
  98. iwa/plugins/olas/tui/olas_view.py +952 -0
  99. iwa/tools/check_profile.py +67 -0
  100. iwa/tools/release.py +111 -0
  101. iwa/tools/reset_env.py +111 -0
  102. iwa/tools/reset_tenderly.py +362 -0
  103. iwa/tools/restore_backup.py +82 -0
  104. iwa/tui/__init__.py +1 -0
  105. iwa/tui/app.py +174 -0
  106. iwa/tui/modals/__init__.py +5 -0
  107. iwa/tui/modals/base.py +406 -0
  108. iwa/tui/rpc.py +63 -0
  109. iwa/tui/screens/__init__.py +1 -0
  110. iwa/tui/screens/wallets.py +749 -0
  111. iwa/tui/tests/test_app.py +125 -0
  112. iwa/tui/tests/test_rpc.py +139 -0
  113. iwa/tui/tests/test_wallets_refactor.py +30 -0
  114. iwa/tui/tests/test_widgets.py +123 -0
  115. iwa/tui/widgets/__init__.py +5 -0
  116. iwa/tui/widgets/base.py +100 -0
  117. iwa/tui/workers.py +42 -0
  118. iwa/web/dependencies.py +76 -0
  119. iwa/web/models.py +76 -0
  120. iwa/web/routers/accounts.py +115 -0
  121. iwa/web/routers/olas/__init__.py +24 -0
  122. iwa/web/routers/olas/admin.py +169 -0
  123. iwa/web/routers/olas/funding.py +135 -0
  124. iwa/web/routers/olas/general.py +29 -0
  125. iwa/web/routers/olas/services.py +378 -0
  126. iwa/web/routers/olas/staking.py +341 -0
  127. iwa/web/routers/state.py +65 -0
  128. iwa/web/routers/swap.py +617 -0
  129. iwa/web/routers/transactions.py +153 -0
  130. iwa/web/server.py +155 -0
  131. iwa/web/tests/test_web_endpoints.py +713 -0
  132. iwa/web/tests/test_web_olas.py +430 -0
  133. iwa/web/tests/test_web_swap.py +103 -0
  134. iwa-0.0.1a2.dist-info/METADATA +234 -0
  135. iwa-0.0.1a2.dist-info/RECORD +186 -0
  136. iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
  137. iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
  138. iwa-0.0.1a2.dist-info/top_level.txt +4 -0
  139. tests/legacy_cow.py +248 -0
  140. tests/legacy_safe.py +93 -0
  141. tests/legacy_transaction_retry_logic.py +51 -0
  142. tests/legacy_tui.py +440 -0
  143. tests/legacy_wallets_screen.py +554 -0
  144. tests/legacy_web.py +243 -0
  145. tests/test_account_service.py +120 -0
  146. tests/test_balance_service.py +186 -0
  147. tests/test_chain.py +490 -0
  148. tests/test_chain_interface.py +210 -0
  149. tests/test_cli.py +139 -0
  150. tests/test_contract.py +195 -0
  151. tests/test_db.py +180 -0
  152. tests/test_drain_coverage.py +174 -0
  153. tests/test_erc20.py +95 -0
  154. tests/test_gnosis_plugin.py +111 -0
  155. tests/test_keys.py +449 -0
  156. tests/test_legacy_wallet.py +1285 -0
  157. tests/test_main.py +13 -0
  158. tests/test_mnemonic.py +217 -0
  159. tests/test_modals.py +109 -0
  160. tests/test_models.py +213 -0
  161. tests/test_monitor.py +202 -0
  162. tests/test_multisend.py +84 -0
  163. tests/test_plugin_service.py +119 -0
  164. tests/test_pricing.py +143 -0
  165. tests/test_rate_limiter.py +199 -0
  166. tests/test_reset_tenderly.py +202 -0
  167. tests/test_rpc_view.py +73 -0
  168. tests/test_safe_coverage.py +139 -0
  169. tests/test_safe_service.py +168 -0
  170. tests/test_service_manager_integration.py +61 -0
  171. tests/test_service_manager_structure.py +31 -0
  172. tests/test_service_transaction.py +176 -0
  173. tests/test_staking_router.py +71 -0
  174. tests/test_staking_simple.py +31 -0
  175. tests/test_tables.py +76 -0
  176. tests/test_transaction_service.py +161 -0
  177. tests/test_transfer_multisend.py +179 -0
  178. tests/test_transfer_native.py +220 -0
  179. tests/test_transfer_security.py +93 -0
  180. tests/test_transfer_structure.py +37 -0
  181. tests/test_transfer_swap_unit.py +155 -0
  182. tests/test_ui_coverage.py +66 -0
  183. tests/test_utils.py +53 -0
  184. tests/test_workers.py +91 -0
  185. tools/verify_drain.py +183 -0
  186. __init__.py +0 -2
  187. hello.py +0 -6
  188. iwa-0.0.0.dist-info/METADATA +0 -10
  189. iwa-0.0.0.dist-info/RECORD +0 -6
  190. iwa-0.0.0.dist-info/top_level.txt +0 -2
  191. {iwa-0.0.0.dist-info → iwa-0.0.1a2.dist-info}/WHEEL +0 -0
@@ -0,0 +1,135 @@
1
+ """Olas Mech Ecosystem - Contract Reference
2
+
3
+ This module documents all mech and marketplace contracts in the Olas ecosystem
4
+ and their relationship with activity checkers for staking rewards.
5
+
6
+ =============================================================================
7
+ MECH CONTRACTS
8
+ =============================================================================
9
+
10
+ 1. LEGACY MECH (Direct Requests)
11
+ Address: 0x77af31De935740567Cf4fF1986D04B2c964A786a
12
+ ABI: mech.json
13
+ Usage: Direct requests via request() -> deliver()
14
+ Functions:
15
+ - request(data) -> requestId
16
+ - deliver(requestId, data)
17
+ - getRequestsCount(multisig) -> count # Used by legacy activity checker
18
+
19
+ 2. MARKETPLACE MECHS (Via MechMarketplace)
20
+ Address: 0x601024E27f1C67B28209E24272CED8A31fc8151F (Priority Mech)
21
+ ABI: mech_new.json
22
+ Usage: Requests via MechMarketplace.request() -> mech.deliverToMarketplace()
23
+ Functions:
24
+ - requestFromMarketplace(...) -> called by marketplace
25
+ - deliverToMarketplace(...) -> delivers via marketplace
26
+ - mechMarketplace -> address of associated marketplace
27
+ - NO getRequestsCount() - tracking done by marketplace
28
+
29
+
30
+ =============================================================================
31
+ MARKETPLACE CONTRACTS
32
+ =============================================================================
33
+
34
+ 1. OLD MECH MARKETPLACE (v1 / Beta)
35
+ Address: 0x4554fE75c1f5576c1d7F765B2A036c199Adae329
36
+ Total Requests: ~1.87M
37
+ Used by: Pearl Beta Marketplace activity checker (0x7Ec96996Cd...)
38
+ Status: LEGACY / DEPRECATED
39
+
40
+ 2. NEW MECH MARKETPLACE (v2 / Current)
41
+ Address: 0x735FAAb1c4Ec41128c367AFb5c3baC73509f70bB
42
+ Total Requests: ~782K
43
+ ABI: mech_marketplace.json
44
+ Status: ACTIVE / CURRENT
45
+ Note: This is the one we use in ServiceManager.send_mech_request()
46
+
47
+
48
+ =============================================================================
49
+ ACTIVITY CHECKERS
50
+ =============================================================================
51
+
52
+ Activity checkers determine which requests count for staking liveness rewards.
53
+
54
+ 1. LEGACY ACTIVITY CHECKER (MechActivityChecker)
55
+ Address: 0x87E6a97bD97D41904B1125A014B16bec50C6A89D
56
+ Tracks: agentMech.getRequestsCount(multisig)
57
+ Mech: 0x77af31De... (Legacy Mech)
58
+ Used by: ALL TRADER staking contracts (Hobbyist, Expert 1-18)
59
+ Effect: ONLY Legacy mech requests count for rewards
60
+
61
+ 2. MARKETPLACE ACTIVITY CHECKER (MechMarketplaceActivityChecker)
62
+ Address: 0x7Ec96996Cd146B91779f01419db42E67463817a0
63
+ Tracks: mechMarketplace.getRequestsCount(multisig) (OLD marketplace)
64
+ Marketplace: 0x4554fE75... (OLD Marketplace)
65
+ Used by: Pearl Beta - Mech Marketplace staking contract
66
+ Effect: ONLY OLD marketplace requests count (NOT the new marketplace!)
67
+
68
+
69
+ =============================================================================
70
+ STAKING CONTRACT COMPATIBILITY
71
+ =============================================================================
72
+
73
+ | Staking Contract | Min OLAS | Activity Checker | Requests That Count |
74
+ |-----------------------|----------|------------------|---------------------|
75
+ | Hobbyist 1-2 | 50-250 | Legacy | Legacy mech only |
76
+ | Expert 1-18 | 100-500 | Legacy | Legacy mech only |
77
+ | Pearl Beta Marketplace| 20 | Marketplace | OLD marketplace only|
78
+
79
+
80
+ =============================================================================
81
+ STAKING DEPOSIT + BOND MECHANICS
82
+ =============================================================================
83
+
84
+ When staking a service, the TOTAL OLAS required is split 50/50:
85
+
86
+ minStakingDeposit: Goes to the staking contract as collateral
87
+ agentBond: Goes to Token Utility as operator bond
88
+
89
+ Example for Hobbyist 1 (100 OLAS total):
90
+ - minStakingDeposit: 50 OLAS (stored in staking contract)
91
+ - agentBond: 50 OLAS (stored in Token Utility for agent ID)
92
+ - Total: 100 OLAS
93
+
94
+ Both are stored in the Token Utility contract:
95
+ - mapServiceIdTokenDeposit(serviceId) -> (token, deposit)
96
+ - getAgentBond(serviceId, agentId) -> bond
97
+
98
+ | Contract Name | Min Deposit | Agent Bond | Total OLAS |
99
+ |-----------------------|-------------|------------|------------|
100
+ | Hobbyist 1 (100 OLAS) | 50 | 50 | 100 |
101
+ | Hobbyist 2 (500 OLAS) | 250 | 250 | 500 |
102
+ | Expert (1k OLAS) | 500 | 500 | 1000 |
103
+ | Expert 3 (2k OLAS) | 1000 | 1000 | 2000 |
104
+ | Expert 4+ (10k OLAS) | 5000 | 5000 | 10000 |
105
+
106
+
107
+ =============================================================================
108
+ IMPORTANT FINDINGS
109
+ =============================================================================
110
+
111
+ ⚠️ The NEW marketplace (0x735FAAb1...) that we use has NO STAKING CONTRACT
112
+ that tracks its requests for liveness rewards yet!
113
+
114
+ ⚠️ For TRADER staking rewards, you MUST use use_marketplace=False
115
+
116
+ ⚠️ Pearl Beta Mech Marketplace uses the OLD marketplace, not the new one
117
+
118
+ """
119
+
120
+ # Export contract addresses for easy reference
121
+ MECH_ECOSYSTEM = {
122
+ "gnosis": {
123
+ # Mech contracts
124
+ "legacy_mech": "0x77af31De935740567Cf4fF1986D04B2c964A786a",
125
+ "priority_mech": "0x601024E27f1C67B28209E24272CED8A31fc8151F",
126
+ # Marketplace contracts
127
+ "old_marketplace": "0x4554fE75c1f5576c1d7F765B2A036c199Adae329", # v1, deprecated
128
+ "new_marketplace": "0x735FAAb1c4Ec41128c367AFb5c3baC73509f70bB", # v2, current
129
+ # Activity checkers
130
+ "legacy_activity_checker": "0x87E6a97bD97D41904B1125A014B16bec50C6A89D",
131
+ "marketplace_activity_checker": "0x7Ec96996Cd146B91779f01419db42E67463817a0",
132
+ # Staking with marketplace support (uses OLD marketplace)
133
+ "pearl_beta_marketplace_staking": "0xDaF34eC46298b53a3d24CBCb431E84eBd23927dA",
134
+ }
135
+ }
@@ -0,0 +1,110 @@
1
+ """Olas models"""
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from iwa.core.models import EthereumAddress
8
+
9
+
10
+ class Service(BaseModel):
11
+ """Service model for OLAS services."""
12
+
13
+ service_name: str # Human-readable name
14
+ chain_name: str
15
+ service_id: int # Unique per chain
16
+ agent_ids: List[int] = Field(default_factory=list) # List of agent type IDs
17
+ service_owner_address: Optional[EthereumAddress] = None
18
+ agent_address: Optional[EthereumAddress] = None
19
+ multisig_address: Optional[EthereumAddress] = None
20
+ staking_contract_address: Optional[EthereumAddress] = None
21
+ token_address: Optional[EthereumAddress] = None
22
+
23
+ @property
24
+ def key(self) -> str:
25
+ """Unique key for this service (chain_name:service_id)."""
26
+ return f"{self.chain_name}:{self.service_id}"
27
+
28
+
29
+ class StakingStatus(BaseModel):
30
+ """Staking status for a service including liveness check info.
31
+
32
+ The activity checker tracks:
33
+ - safe_nonce: Total Safe multisig transactions
34
+ - mech_requests: Total mech requests made
35
+
36
+ Liveness is measured by mech_requests made since last checkpoint.
37
+ """
38
+
39
+ is_staked: bool
40
+ staking_contract_address: Optional[str] = None
41
+ staking_contract_name: Optional[str] = None
42
+ staking_state: str # "NOT_STAKED", "STAKED", "EVICTED"
43
+
44
+ # Mech request tracking (what determines liveness)
45
+ mech_requests_this_epoch: int = 0
46
+ required_mech_requests: int = 0
47
+ remaining_mech_requests: int = 0
48
+ has_enough_requests: bool = False
49
+ liveness_ratio_passed: bool = False
50
+
51
+ # Rewards
52
+ accrued_reward_wei: int = 0
53
+ accrued_reward_olas: float = 0
54
+
55
+ # Epoch timing
56
+ epoch_number: int = 0
57
+ epoch_end_utc: Optional[str] = None # ISO format string
58
+ remaining_epoch_seconds: float = 0
59
+
60
+ # Activity checker info
61
+ activity_checker_address: Optional[str] = None
62
+ liveness_ratio: int = 0 # Requests per second * 1e18
63
+
64
+ # Unstake timing
65
+ ts_start: int = 0
66
+ min_staking_duration: int = 0
67
+ unstake_available_at: Optional[str] = None # ISO format string
68
+
69
+
70
+ class OlasConfig(BaseModel):
71
+ """OlasConfig with multi-service support."""
72
+
73
+ # Dict keyed by service key (chain_name:service_id)
74
+ services: Dict[str, Service] = Field(default_factory=dict)
75
+
76
+ # Address to send claimed OLAS rewards to
77
+ withdrawal_address: Optional[EthereumAddress] = None
78
+
79
+ def add_service(self, service: Service) -> None:
80
+ """Add or update a service."""
81
+ self.services[service.key] = service
82
+
83
+ def remove_service(self, key: str) -> bool:
84
+ """Remove a service by key."""
85
+ if key in self.services:
86
+ del self.services[key]
87
+ return True
88
+ return False
89
+
90
+ def get_service(self, chain_name: str, service_id: int) -> Optional[Service]:
91
+ """Get a specific service by chain and ID."""
92
+ key = f"{chain_name}:{service_id}"
93
+ return self.services.get(key)
94
+
95
+ def get_service_by_multisig(self, multisig_address: str) -> Optional[Service]:
96
+ """Get a service by its multisig (Safe) address.
97
+
98
+ Args:
99
+ multisig_address: The Safe multisig address.
100
+
101
+ Returns:
102
+ Service if found, None otherwise.
103
+
104
+ """
105
+ # Normalize address for comparison
106
+ target = multisig_address.lower()
107
+ for service in self.services.values():
108
+ if service.multisig_address and str(service.multisig_address).lower() == target:
109
+ return service
110
+ return None
@@ -0,0 +1,243 @@
1
+ """Olas plugin."""
2
+
3
+ from pathlib import Path
4
+ from typing import Dict, Optional, Type
5
+
6
+ import typer
7
+ from pydantic import BaseModel
8
+
9
+ from iwa.core.plugins import Plugin
10
+ from iwa.core.wallet import Wallet
11
+ from iwa.plugins.olas.models import OlasConfig
12
+ from iwa.plugins.olas.service_manager import ServiceManager
13
+
14
+
15
+ class OlasPlugin(Plugin):
16
+ """Olas Plugin."""
17
+
18
+ @property
19
+ def name(self) -> str:
20
+ """Get plugin name."""
21
+ return "olas"
22
+
23
+ @property
24
+ def config_model(self) -> Type[BaseModel]:
25
+ """Get config model."""
26
+ return OlasConfig
27
+
28
+ def get_cli_commands(self) -> Dict[str, callable]:
29
+ """Get CLI commands."""
30
+ return {
31
+ "create": self.create_service,
32
+ "import": self.import_services,
33
+ }
34
+
35
+ def get_tui_view(self, wallet=None):
36
+ """Get TUI widget for this plugin."""
37
+ from iwa.plugins.olas.tui.olas_view import OlasView
38
+
39
+ return OlasView(wallet=wallet)
40
+
41
+ def create_service(
42
+ self,
43
+ chain_name: str = typer.Option("gnosis", "--chain", "-c"),
44
+ owner: Optional[str] = typer.Option(None, "--owner", "-o"),
45
+ token: Optional[str] = typer.Option(None, "--token"),
46
+ bond: int = typer.Option(1, "--bond", "-b"),
47
+ ):
48
+ """Create a new Olas service"""
49
+ wallet = Wallet()
50
+ manager = ServiceManager(wallet)
51
+ # Note: Manager logic currently depends on internal config state which might need setup
52
+ manager.create(chain_name, owner, token, bond)
53
+
54
+ def _get_safe_signers(self, safe_address: str, chain_name: str) -> tuple:
55
+ """Query Safe signers on-chain.
56
+
57
+ Returns:
58
+ Tuple of (signers_list, safe_exists):
59
+ - (list, True) if Safe exists and query succeeds
60
+ - ([], False) if Safe doesn't exist on-chain
61
+ - (None, None) if RPC not configured (skip verification)
62
+
63
+ """
64
+ try:
65
+ from safe_eth.eth import EthereumClient
66
+ from safe_eth.safe import Safe
67
+
68
+ from iwa.core.settings import settings
69
+
70
+ rpc_secret = getattr(settings, f"{chain_name}_rpc", None)
71
+ if not rpc_secret:
72
+ return None, None # Can't verify, skip
73
+
74
+ ethereum_client = EthereumClient(rpc_secret.get_secret_value())
75
+ safe = Safe(safe_address, ethereum_client)
76
+ owners = safe.retrieve_owners()
77
+ return owners, True
78
+ except Exception:
79
+ # Query failed - Safe likely doesn't exist
80
+ return [], False
81
+
82
+ def _display_service_table(self, console, service, index: int) -> None:
83
+ """Display a single discovered service as a Rich table."""
84
+ from rich.table import Table
85
+
86
+ table = Table(
87
+ title=f"Service {index}: {service.service_name or 'Unknown'}", show_header=False
88
+ )
89
+ table.add_column("Property", style="cyan")
90
+ table.add_column("Value")
91
+
92
+ table.add_row("Format", service.format)
93
+ table.add_row("Source", str(service.source_folder))
94
+ table.add_row("Service ID", str(service.service_id) if service.service_id else "N/A")
95
+ table.add_row("Chain", service.chain_name)
96
+
97
+ # Verify Safe and display
98
+ on_chain_signers, safe_exists = None, None
99
+ if service.safe_address:
100
+ on_chain_signers, safe_exists = self._get_safe_signers(
101
+ service.safe_address, service.chain_name
102
+ )
103
+ if safe_exists is None:
104
+ table.add_row("Safe", service.safe_address)
105
+ elif safe_exists:
106
+ table.add_row("Safe", f"{service.safe_address} [green]✓[/green]")
107
+ else:
108
+ table.add_row(
109
+ "Safe",
110
+ f"[bold red]⚠ {service.safe_address} - DOES NOT EXIST ON-CHAIN![/bold red]",
111
+ )
112
+ else:
113
+ table.add_row("Safe", "N/A")
114
+
115
+ # Display keys with signer verification
116
+ for key in service.keys:
117
+ status = "🔒 encrypted" if key.is_encrypted else "🔓 plaintext"
118
+ key_info = f"{key.address} {status}"
119
+
120
+ if key.role == "agent" and service.safe_address:
121
+ if not safe_exists:
122
+ key_info = f"[bold red]⚠ {key.address} - NOT A SIGNER OF THE SAFE![/bold red]"
123
+ elif on_chain_signers is not None:
124
+ is_signer = key.address.lower() in [s.lower() for s in on_chain_signers]
125
+ if not is_signer:
126
+ key_info = (
127
+ f"[bold red]⚠ {key.address} - NOT A SIGNER OF THE SAFE![/bold red]"
128
+ )
129
+
130
+ table.add_row(f"Key ({key.role})", key_info)
131
+
132
+ console.print(table)
133
+ console.print()
134
+
135
+ def _import_and_print_results(self, console, importer, discovered, password) -> tuple:
136
+ """Import all discovered services and print results."""
137
+ total_keys = 0
138
+ total_safes = 0
139
+ total_services = 0
140
+ all_skipped = []
141
+ all_errors = []
142
+
143
+ for service in discovered:
144
+ console.print(
145
+ f"\n[bold]Importing[/bold] {service.service_name or service.source_folder}..."
146
+ )
147
+ result = importer.import_service(service, password)
148
+
149
+ total_keys += len(result.imported_keys)
150
+ total_safes += len(result.imported_safes)
151
+ total_services += len(result.imported_services)
152
+ all_skipped.extend(result.skipped)
153
+ all_errors.extend(result.errors)
154
+
155
+ if result.imported_keys:
156
+ console.print(
157
+ f" [green]✓[/green] Imported keys: {', '.join(result.imported_keys)}"
158
+ )
159
+ if result.imported_safes:
160
+ console.print(
161
+ f" [green]✓[/green] Imported safes: {', '.join(result.imported_safes)}"
162
+ )
163
+ if result.imported_services:
164
+ console.print(
165
+ f" [green]✓[/green] Imported services: {', '.join(result.imported_services)}"
166
+ )
167
+ if result.skipped:
168
+ for item in result.skipped:
169
+ console.print(f" [yellow]⊘[/yellow] Skipped: {item}")
170
+ if result.errors:
171
+ for error in result.errors:
172
+ console.print(f" [red]✗[/red] Error: {error}")
173
+
174
+ return total_keys, total_safes, total_services, all_skipped, all_errors
175
+
176
+ def import_services(
177
+ self,
178
+ path: str = typer.Argument(..., help="Directory to scan for Olas services"),
179
+ dry_run: bool = typer.Option(
180
+ False, "--dry-run", "-n", help="Show what would be imported without making changes"
181
+ ),
182
+ password: Optional[str] = typer.Option(
183
+ None, "--password", "-p", help="Password for encrypted keys (will prompt if needed)"
184
+ ),
185
+ yes: bool = typer.Option(
186
+ False, "--yes", "-y", help="Import all without confirmation prompts"
187
+ ),
188
+ ):
189
+ """Import Olas services and keys from external directories."""
190
+ from rich.console import Console
191
+
192
+ from iwa.plugins.olas.importer import OlasServiceImporter
193
+
194
+ console = Console()
195
+
196
+ # Scan directory
197
+ console.print(f"\n[bold]Scanning[/bold] {path}...")
198
+ importer = OlasServiceImporter()
199
+ discovered = importer.scan_directory(Path(path))
200
+
201
+ if not discovered:
202
+ console.print("[yellow]No Olas services found.[/yellow]")
203
+ raise typer.Exit(code=0)
204
+
205
+ # Display discovered services
206
+ console.print(f"\n[bold green]Found {len(discovered)} service(s):[/bold green]\n")
207
+ for i, service in enumerate(discovered, 1):
208
+ self._display_service_table(console, service, i)
209
+
210
+ if dry_run:
211
+ console.print("[yellow]Dry run mode - no changes made.[/yellow]")
212
+ raise typer.Exit(code=0)
213
+
214
+ # Confirm import
215
+ if not yes:
216
+ confirm = typer.confirm("Import these services?")
217
+ if not confirm:
218
+ console.print("[yellow]Aborted.[/yellow]")
219
+ raise typer.Exit(code=0)
220
+
221
+ # Check if we need a password for encrypted keys
222
+ needs_password = any(key.is_encrypted for service in discovered for key in service.keys)
223
+ if needs_password and not password:
224
+ console.print(
225
+ "\n[yellow]Some keys are encrypted. Please enter the source password.[/yellow]"
226
+ )
227
+ password = typer.prompt("Password", hide_input=True)
228
+
229
+ # Import services
230
+ total_keys, total_safes, total_services, all_skipped, all_errors = (
231
+ self._import_and_print_results(console, importer, discovered, password)
232
+ )
233
+
234
+ # Summary
235
+ console.print("\n[bold]Summary:[/bold]")
236
+ console.print(f" Keys imported: {total_keys}")
237
+ console.print(f" Safes imported: {total_safes}")
238
+ console.print(f" Services imported: {total_services}")
239
+ if all_skipped:
240
+ console.print(f" Skipped: {len(all_skipped)}")
241
+ if all_errors:
242
+ console.print(f" [red]Errors: {len(all_errors)}[/red]")
243
+ raise typer.Exit(code=1)