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,530 @@
1
+ """Staking manager mixin."""
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Optional
5
+
6
+ from loguru import logger
7
+ from web3 import Web3
8
+
9
+ from iwa.core.contracts.erc20 import ERC20Contract
10
+ from iwa.core.types import EthereumAddress
11
+ from iwa.plugins.olas.contracts.staking import StakingContract, StakingState
12
+ from iwa.plugins.olas.models import StakingStatus
13
+
14
+
15
+ class StakingManagerMixin:
16
+ """Mixin for staking operations."""
17
+
18
+ def get_staking_status(self) -> Optional[StakingStatus]:
19
+ """Get comprehensive staking status for the active service.
20
+
21
+ Returns:
22
+ StakingStatus with liveness check info, or None if no service loaded.
23
+
24
+ """
25
+ if not self.service:
26
+ logger.error("No active service")
27
+ return None
28
+
29
+ service_id = self.service.service_id
30
+ staking_address = self.service.staking_contract_address
31
+
32
+ # Check if service is staked
33
+ if not staking_address:
34
+ return StakingStatus(
35
+ is_staked=False,
36
+ staking_state="NOT_STAKED",
37
+ )
38
+
39
+ # Load the staking contract
40
+ try:
41
+ staking = StakingContract(str(staking_address), chain_name=self.chain_name)
42
+ except Exception as e:
43
+ logger.error(f"Failed to load staking contract: {e}")
44
+ return StakingStatus(
45
+ is_staked=False,
46
+ staking_state="ERROR",
47
+ staking_contract_address=str(staking_address),
48
+ )
49
+
50
+ # Get staking state
51
+ staking_state = staking.get_staking_state(service_id)
52
+ is_staked = staking_state == StakingState.STAKED
53
+
54
+ if not is_staked:
55
+ return StakingStatus(
56
+ is_staked=False,
57
+ staking_state=staking_state.name,
58
+ staking_contract_address=str(staking_address),
59
+ activity_checker_address=staking.activity_checker_address,
60
+ liveness_ratio=staking.activity_checker.liveness_ratio,
61
+ )
62
+
63
+ # Get detailed service info
64
+ try:
65
+ info = staking.get_service_info(service_id)
66
+ # Get current epoch number
67
+ epoch_number = staking.get_epoch_counter()
68
+ # Identify contract name
69
+ staking_name = self._identify_staking_contract_name(staking_address)
70
+ except Exception as e:
71
+ logger.error(f"Failed to get service info for service {service_id}: {str(e)}")
72
+ import traceback
73
+
74
+ logger.error(traceback.format_exc())
75
+ return StakingStatus(
76
+ is_staked=True,
77
+ staking_state=staking_state.name,
78
+ staking_contract_address=str(staking_address),
79
+ )
80
+
81
+ # Calculate unstake timing
82
+ unstake_at, ts_start, min_duration = self._calculate_unstake_time(staking, info)
83
+
84
+ return StakingStatus(
85
+ is_staked=True,
86
+ staking_state=staking_state.name,
87
+ staking_contract_address=str(staking_address),
88
+ staking_contract_name=staking_name,
89
+ mech_requests_this_epoch=info["mech_requests_this_epoch"],
90
+ required_mech_requests=info["required_mech_requests"],
91
+ remaining_mech_requests=info["remaining_mech_requests"],
92
+ has_enough_requests=info["has_enough_requests"],
93
+ liveness_ratio_passed=info["liveness_ratio_passed"],
94
+ accrued_reward_wei=info["accrued_reward_wei"],
95
+ accrued_reward_olas=float(Web3.from_wei(info["accrued_reward_wei"], "ether")),
96
+ epoch_number=epoch_number,
97
+ epoch_end_utc=info["epoch_end_utc"].isoformat() if info["epoch_end_utc"] else None,
98
+ remaining_epoch_seconds=info["remaining_epoch_seconds"],
99
+ activity_checker_address=staking.activity_checker_address,
100
+ liveness_ratio=staking.activity_checker.liveness_ratio,
101
+ ts_start=ts_start,
102
+ min_staking_duration=min_duration,
103
+ unstake_available_at=unstake_at,
104
+ )
105
+
106
+ def _identify_staking_contract_name(self, staking_address: str) -> Optional[str]:
107
+ """Identify the name of the staking contract from constants."""
108
+ from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
109
+
110
+ for chain_cts in OLAS_TRADER_STAKING_CONTRACTS.values():
111
+ for name, addr in chain_cts.items():
112
+ if str(addr).lower() == str(staking_address).lower():
113
+ return name
114
+ return None
115
+
116
+ def _calculate_unstake_time(
117
+ self, staking: StakingContract, info: dict
118
+ ) -> tuple[Optional[str], int, int]:
119
+ """Calculate unstake availability time.
120
+
121
+ Returns:
122
+ Tuple of (unstake_at_iso, ts_start, min_duration)
123
+
124
+ """
125
+ # Helper to safely get min_staking_duration
126
+ try:
127
+ min_duration = staking.min_staking_duration
128
+ logger.info(f"min_staking_duration: {min_duration}")
129
+ except Exception as e:
130
+ logger.error(f"Failed to get min_staking_duration: {e}")
131
+ min_duration = 0
132
+
133
+ unstake_at = None
134
+ ts_start = info.get("ts_start", 0)
135
+ logger.info(f"ts_start: {ts_start}")
136
+
137
+ if ts_start > 0:
138
+ try:
139
+ unstake_ts = ts_start + min_duration
140
+ unstake_at = datetime.fromtimestamp(
141
+ unstake_ts,
142
+ tz=timezone.utc,
143
+ ).isoformat()
144
+ logger.info(f"unstake_available_at: {unstake_at} (ts={unstake_ts})")
145
+ except Exception as e:
146
+ logger.error(f"calc error: {e}")
147
+ pass
148
+ else:
149
+ logger.warning("ts_start is 0, cannot calculate unstake time")
150
+
151
+ return unstake_at, ts_start, min_duration
152
+
153
+ def stake(self, staking_contract) -> bool:
154
+ """Stake the service in a staking contract.
155
+
156
+ Token Flow:
157
+ The total OLAS required is split 50/50 between deposit and bond:
158
+ - minStakingDeposit: Transferred to staking contract during this call
159
+ - agentBond: Already in Token Utility from service registration
160
+
161
+ Example for Hobbyist 1 (100 OLAS total):
162
+ - minStakingDeposit: 50 OLAS (from master account -> staking contract)
163
+ - agentBond: 50 OLAS (already in Token Utility)
164
+
165
+ Requirements:
166
+ - Service must be in DEPLOYED state
167
+ - Service must be created with OLAS token (not native currency)
168
+ - Master account must have >= minStakingDeposit OLAS tokens
169
+ - Staking contract must have available slots
170
+
171
+ Args:
172
+ staking_contract: StakingContract instance to stake in.
173
+
174
+ Returns:
175
+ True if staking succeeded, False otherwise.
176
+
177
+ """
178
+ # 1. Validation
179
+ requirements = self._check_stake_requirements(staking_contract)
180
+ if not requirements:
181
+ return False
182
+
183
+ min_deposit = requirements["min_deposit"]
184
+
185
+ # 2. Approve Tokens
186
+ if not self._approve_staking_tokens(staking_contract, min_deposit):
187
+ return False
188
+
189
+ # 3. Execute Stake Transaction
190
+ return self._execute_stake_transaction(staking_contract)
191
+
192
+ def _check_stake_requirements(self, staking_contract) -> Optional[dict]:
193
+ """Validate all conditions required for staking."""
194
+ from iwa.plugins.olas.contracts.service import ServiceState
195
+
196
+ # Check centralized staking requirements
197
+ reqs = staking_contract.get_requirements()
198
+ min_deposit = reqs["min_staking_deposit"]
199
+ required_bond = reqs["required_agent_bond"]
200
+ staking_token = Web3.to_checksum_address(reqs["staking_token"])
201
+ staking_token_lower = staking_token.lower()
202
+
203
+ logger.info(f"Checking stake requirements for service {self.service.service_id}")
204
+
205
+ # Check service state
206
+ service_info = self.registry.get_service(self.service.service_id)
207
+ service_state = service_info["state"]
208
+ logger.info(f"Service state: {service_state.name}")
209
+
210
+ if service_state != ServiceState.DEPLOYED:
211
+ logger.error("Service is not deployed, cannot stake")
212
+ return None
213
+
214
+ # Check token compatibility
215
+ service_token = (self.service.token_address or "").lower()
216
+ if service_token != staking_token_lower:
217
+ logger.error(
218
+ f"Token mismatch: service was created with {service_token or 'native'}, "
219
+ f"but staking contract requires {staking_token_lower}"
220
+ )
221
+ return None
222
+
223
+ # Check agent bond
224
+ try:
225
+ agent_ids = service_info["agent_ids"]
226
+ if not agent_ids:
227
+ logger.error("No agent IDs found for service")
228
+ return None
229
+
230
+ agent_id = agent_ids[0]
231
+ agent_params = self.registry.get_agent_params(self.service.service_id, agent_id)
232
+ current_bond = agent_params["bond"]
233
+
234
+ if current_bond < required_bond:
235
+ logger.error(
236
+ f"Service agent bond is too low ({current_bond} < {required_bond}). "
237
+ "Service must be created with the correct bond amount to be stakeable."
238
+ )
239
+ return None
240
+ except Exception as e:
241
+ logger.warning(f"Could not verify agent bond: {e}")
242
+
243
+ # Check free slots
244
+ staked_count = len(staking_contract.get_service_ids())
245
+ max_services = staking_contract.max_num_services
246
+ if staked_count >= max_services:
247
+ logger.error("Staking contract is full, no free slots available")
248
+ return None
249
+
250
+ # Check OLAS balance
251
+ erc20_contract = ERC20Contract(staking_token)
252
+ master_balance = erc20_contract.balance_of_wei(self.wallet.master_account.address)
253
+ if master_balance < min_deposit:
254
+ logger.error(
255
+ f"Not enough tokens to stake service (have {master_balance}, need {min_deposit})"
256
+ )
257
+ return None
258
+
259
+ return {"min_deposit": min_deposit, "staking_token": staking_token}
260
+
261
+ def _approve_staking_tokens(self, staking_contract, min_deposit: int) -> bool:
262
+ """Approve both the service NFT and OLAS tokens for staking."""
263
+ # Approve service NFT
264
+ approve_tx = self.registry.prepare_approve_tx(
265
+ from_address=self.wallet.master_account.address,
266
+ spender=staking_contract.address,
267
+ id_=self.service.service_id,
268
+ )
269
+
270
+ success, receipt = self.wallet.sign_and_send_transaction(
271
+ transaction=approve_tx,
272
+ signer_address_or_tag=self.wallet.master_account.address,
273
+ chain_name=self.chain_name,
274
+ tags=["olas_approve_service_nft"],
275
+ )
276
+
277
+ if not success:
278
+ logger.error("Failed to approve staking contract [Service Registry]")
279
+ return False
280
+
281
+ logger.info("Service token approved for staking contract")
282
+
283
+ # Approve OLAS tokens
284
+ # We need to get the token address from the contract requirements again or pass it
285
+ # Retching for simplicity and safety
286
+ reqs = staking_contract.get_requirements()
287
+ staking_token = Web3.to_checksum_address(reqs["staking_token"])
288
+ erc20_contract = ERC20Contract(staking_token)
289
+
290
+ olas_approve_tx = erc20_contract.prepare_approve_tx(
291
+ from_address=self.wallet.master_account.address,
292
+ spender=staking_contract.address,
293
+ amount_wei=min_deposit,
294
+ )
295
+
296
+ success, receipt = self.wallet.sign_and_send_transaction(
297
+ transaction=olas_approve_tx,
298
+ signer_address_or_tag=self.wallet.master_account.address,
299
+ chain_name=self.chain_name,
300
+ tags=["olas_approve_olas_token"],
301
+ )
302
+
303
+ if not success:
304
+ logger.error("Failed to approve OLAS tokens for staking contract")
305
+ return False
306
+
307
+ logger.info("OLAS tokens approved for staking contract")
308
+ return True
309
+
310
+ def _execute_stake_transaction(self, staking_contract) -> bool:
311
+ """Send the stake transaction and verify the result."""
312
+ stake_tx = staking_contract.prepare_stake_tx(
313
+ from_address=self.wallet.master_account.address,
314
+ service_id=self.service.service_id,
315
+ )
316
+
317
+ success, receipt = self.wallet.sign_and_send_transaction(
318
+ transaction=stake_tx,
319
+ signer_address_or_tag=self.wallet.master_account.address,
320
+ chain_name=self.chain_name,
321
+ tags=["olas_stake_service"],
322
+ )
323
+
324
+ if not success:
325
+ if receipt and "status" in receipt and receipt["status"] == 0:
326
+ logger.error(f"Stake transaction reverted. Receipt: {receipt}")
327
+ logger.error("Failed to stake service")
328
+ return False
329
+
330
+ logger.info("Service stake transaction sent successfully")
331
+
332
+ events = staking_contract.extract_events(receipt)
333
+ event_names = [event["name"] for event in events]
334
+
335
+ if "ServiceStaked" not in event_names:
336
+ logger.error("Stake service event not found")
337
+ return False
338
+
339
+ # Verify state
340
+ staking_state = staking_contract.get_staking_state(self.service.service_id)
341
+ if staking_state != StakingState.STAKED:
342
+ logger.error("Service is not staked after transaction")
343
+ return False
344
+
345
+ # Update local state
346
+ self.service.staking_contract_address = EthereumAddress(staking_contract.address)
347
+ self._update_and_save_service_state()
348
+
349
+ logger.info("Service staked successfully")
350
+ return True
351
+
352
+ def unstake(self, staking_contract) -> bool:
353
+ """Unstake the service from the staking contract."""
354
+ if not self.service:
355
+ logger.error("No active service")
356
+ return False
357
+
358
+ logger.info(
359
+ f"Preparing to unstake service {self.service.service_id} from {staking_contract.address}"
360
+ )
361
+
362
+ # Check that the service is staked
363
+ try:
364
+ staking_state = staking_contract.get_staking_state(self.service.service_id)
365
+ logger.info(f"Current staking state: {staking_state}")
366
+
367
+ if staking_state != StakingState.STAKED:
368
+ logger.error(
369
+ f"Service {self.service.service_id} is not staked (state={staking_state}), cannot unstake"
370
+ )
371
+ return False
372
+ except Exception as e:
373
+ logger.error(f"Failed to get staking state: {e}")
374
+ return False
375
+
376
+ # Check that enough time has passed since staking
377
+ try:
378
+ service_info = staking_contract.get_service_info(self.service.service_id)
379
+ ts_start = service_info.get("ts_start", 0)
380
+ if ts_start > 0:
381
+ min_duration = staking_contract.min_staking_duration
382
+ unlock_ts = ts_start + min_duration
383
+ now_ts = datetime.now(timezone.utc).timestamp()
384
+
385
+ if now_ts < unlock_ts:
386
+ diff = int(unlock_ts - now_ts)
387
+ logger.error(
388
+ f"Cannot unstake yet. Minimum staking duration not met. Unlocks in {diff} seconds."
389
+ )
390
+ return False
391
+ except Exception as e:
392
+ logger.warning(f"Could not verify staking duration: {e}. Proceeding with caution.")
393
+
394
+ # Unstake the service
395
+ try:
396
+ logger.info(f"Preparing unstake transaction for service {self.service.service_id}")
397
+ unstake_tx = staking_contract.prepare_unstake_tx(
398
+ from_address=self.wallet.master_account.address,
399
+ service_id=self.service.service_id,
400
+ )
401
+ logger.info("Unstake transaction prepared successfully")
402
+
403
+ except Exception as e:
404
+ logger.exception(f"Failed to prepare unstake tx: {e}")
405
+ return False
406
+
407
+ success, receipt = self.wallet.sign_and_send_transaction(
408
+ transaction=unstake_tx,
409
+ signer_address_or_tag=self.wallet.master_account.address,
410
+ chain_name=self.chain_name,
411
+ tags=["olas_unstake_service"],
412
+ )
413
+
414
+ if not success:
415
+ logger.error(f"Failed to unstake service {self.service.service_id}: Transaction failed")
416
+ return False
417
+
418
+ logger.info(
419
+ f"Unstake transaction sent: {receipt.get('transactionHash', '').hex() if receipt else 'No Receipt'}"
420
+ )
421
+
422
+ events = staking_contract.extract_events(receipt)
423
+
424
+ if "ServiceUnstaked" not in [event["name"] for event in events]:
425
+ logger.error("Unstake service event not found")
426
+ return False
427
+
428
+ self.service.staking_contract_address = None
429
+ self._update_and_save_service_state()
430
+
431
+ logger.info("Service unstaked successfully")
432
+ return True
433
+
434
+ def call_checkpoint(
435
+ self,
436
+ staking_contract: Optional[StakingContract] = None,
437
+ grace_period_seconds: int = 600,
438
+ ) -> bool:
439
+ """Call the checkpoint on the staking contract to close the current epoch.
440
+
441
+ The checkpoint closes the current epoch, calculates rewards for all staked
442
+ services, and starts a new epoch. Anyone can call this once the epoch has ended.
443
+
444
+ This method will:
445
+ 1. Check if the checkpoint is needed (epoch ended)
446
+ 2. Send the checkpoint transaction
447
+
448
+ Args:
449
+ staking_contract: Optional pre-loaded StakingContract. If not provided,
450
+ it will be loaded from the service's staking_contract_address.
451
+ grace_period_seconds: Seconds to wait after epoch ends before calling.
452
+ Defaults to 600 (10 minutes) to allow others to call first.
453
+
454
+ Returns:
455
+ True if checkpoint was called successfully, False otherwise.
456
+
457
+ """
458
+ if not self.service:
459
+ logger.error("No active service")
460
+ return False
461
+
462
+ if not self.service.staking_contract_address:
463
+ logger.error("Service is not staked")
464
+ return False
465
+
466
+ # Load staking contract if not provided
467
+ if not staking_contract:
468
+ try:
469
+ staking_contract = StakingContract(
470
+ str(self.service.staking_contract_address),
471
+ chain_name=self.service.chain_name,
472
+ )
473
+ except Exception as e:
474
+ logger.error(f"Failed to load staking contract: {e}")
475
+ return False
476
+
477
+ # Check if checkpoint is needed
478
+ if not staking_contract.is_checkpoint_needed(grace_period_seconds):
479
+ epoch_end = staking_contract.get_next_epoch_start()
480
+ logger.info(f"Checkpoint not needed yet. Epoch ends at {epoch_end.isoformat()}")
481
+ return False
482
+
483
+ logger.info("Calling checkpoint to close the current epoch")
484
+
485
+ # Prepare and send checkpoint transaction
486
+ checkpoint_tx = staking_contract.prepare_checkpoint_tx(
487
+ from_address=self.wallet.master_account.address,
488
+ )
489
+
490
+ if not checkpoint_tx:
491
+ logger.error("Failed to prepare checkpoint transaction")
492
+ return False
493
+
494
+ success, receipt = self.wallet.sign_and_send_transaction(
495
+ checkpoint_tx,
496
+ signer_address_or_tag=self.wallet.master_account.address,
497
+ chain_name=self.service.chain_name,
498
+ tags=["olas_call_checkpoint"],
499
+ )
500
+ if not success:
501
+ logger.error("Failed to send checkpoint transaction")
502
+ return False
503
+
504
+ # Verify the Checkpoint event was emitted
505
+ events = staking_contract.extract_events(receipt)
506
+ checkpoint_events = [e for e in events if e["name"] == "Checkpoint"]
507
+
508
+ if not checkpoint_events:
509
+ logger.error("Checkpoint event not found - transaction may have failed")
510
+ return False
511
+
512
+ # Log checkpoint details from the event
513
+ checkpoint_event = checkpoint_events[0]
514
+ args = checkpoint_event.get("args", {})
515
+ new_epoch = args.get("epoch", "unknown")
516
+ available_rewards = args.get("availableRewards", 0)
517
+ rewards_olas = available_rewards / 1e18 if available_rewards else 0
518
+
519
+ logger.info(
520
+ f"Checkpoint successful - New epoch: {new_epoch}, "
521
+ f"Available rewards: {rewards_olas:.2f} OLAS"
522
+ )
523
+
524
+ # Log any inactivity warnings
525
+ inactivity_warnings = [e for e in events if e["name"] == "ServiceInactivityWarning"]
526
+ if inactivity_warnings:
527
+ service_ids = [e["args"]["serviceId"] for e in inactivity_warnings]
528
+ logger.warning(f"Services with inactivity warnings: {service_ids}")
529
+
530
+ return True
@@ -0,0 +1,30 @@
1
+ """Shared fixtures for Olas tests."""
2
+
3
+ from unittest.mock import MagicMock
4
+
5
+ import pytest
6
+
7
+ from iwa.plugins.olas.models import OlasConfig, Service
8
+
9
+
10
+ @pytest.fixture
11
+ def mock_wallet():
12
+ """Mock wallet."""
13
+ wallet = MagicMock()
14
+ wallet.key_storage = MagicMock()
15
+ wallet.address = "0xWalletAddress"
16
+ return wallet
17
+
18
+
19
+ @pytest.fixture
20
+ def mock_olas_config():
21
+ """Mock Olas config."""
22
+ service = Service(
23
+ service_name="Test Service",
24
+ chain_name="gnosis",
25
+ service_id=1,
26
+ agent_ids=[],
27
+ multisig_address="0x1111111111111111111111111111111111111111",
28
+ staking_contract_address="0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB",
29
+ )
30
+ return OlasConfig(services={"gnosis:1": service})
@@ -0,0 +1,128 @@
1
+ """Tests for Olas Service Importer."""
2
+
3
+ import json
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ from eth_account import Account
8
+
9
+ from iwa.plugins.olas.importer import DiscoveredKey, DiscoveredService, OlasServiceImporter
10
+
11
+
12
+ @pytest.fixture
13
+ def temp_dirs(tmp_path):
14
+ """Create temporary directories for testing formats."""
15
+ # .trader_runner format
16
+ tr_path = tmp_path / "tr_service" / ".trader_runner"
17
+ tr_path.mkdir(parents=True)
18
+ (tr_path / "service_id.txt").write_text("123")
19
+ (tr_path / "service_safe_address.txt").write_text("0xSafeAddress")
20
+
21
+ # Mock encrypted keystore
22
+ keystore = {
23
+ "address": "78731d3ca6b7e34ac0f824c42a7cc18a495cabab",
24
+ "crypto": {"cipher": "aes-128-ctr"},
25
+ "id": "1",
26
+ "version": 3,
27
+ }
28
+ (tr_path / "agent_pkey.txt").write_text(json.dumps(keystore))
29
+
30
+ # .operate format
31
+ op_path = tmp_path / "op_service" / ".operate"
32
+ op_path.mkdir(parents=True)
33
+ services_path = op_path / "services" / "uuid"
34
+ services_path.mkdir(parents=True)
35
+
36
+ op_config = {
37
+ "keys": [{"address": "0xAgent", "private_key": "0x123"}],
38
+ "chain_configs": {"gnosis": {"chain_data": {"token": 456, "multisig": "0xOpSafe"}}},
39
+ }
40
+ (services_path / "config.json").write_text(json.dumps(op_config))
41
+
42
+ return tmp_path
43
+
44
+
45
+ @pytest.fixture
46
+ def importer():
47
+ """Create OlasServiceImporter with mocked KeyStorage."""
48
+ with patch("iwa.plugins.olas.importer.KeyStorage") as mock_ks_cls:
49
+ ks = mock_ks_cls.return_value
50
+ ks.accounts = {}
51
+ ks._password = "test_password"
52
+ # MockConfig is also needed since importer init creates one
53
+ with patch("iwa.plugins.olas.importer.Config") as mock_cfg_cls:
54
+ cfg = mock_cfg_cls.return_value
55
+ cfg.plugins = {}
56
+ return OlasServiceImporter(ks)
57
+
58
+
59
+ def test_scan_directory(importer, temp_dirs):
60
+ """Test scanning directory for services."""
61
+ services = importer.scan_directory(temp_dirs)
62
+ assert len(services) == 2
63
+
64
+ # Verify trader_runner service
65
+ tr_svc = next(s for s in services if s.format == "trader_runner")
66
+ assert tr_svc.service_id == 123
67
+ assert tr_svc.safe_address == "0xSafeAddress"
68
+ assert len(tr_svc.keys) == 1
69
+ assert tr_svc.keys[0].role == "agent"
70
+
71
+ # Verify operate service
72
+ op_svc = next(s for s in services if s.format == "operate")
73
+ assert op_svc.service_id == 456
74
+ assert op_svc.safe_address == "0xOpSafe"
75
+ assert op_svc.keys[0].address == "0xAgent"
76
+
77
+
78
+ def test_decrypt_key(importer):
79
+ """Test key decryption."""
80
+ # Create mock encrypted key
81
+ keystore = Account.encrypt("0x" + "1" * 64, "password")
82
+ key = DiscoveredKey(address="0xAddr", encrypted_keystore=keystore, is_encrypted=True)
83
+
84
+ success = importer.decrypt_key(key, "password")
85
+ assert success
86
+ assert key.private_key == "1" * 64
87
+ assert not key.is_encrypted
88
+
89
+
90
+ def test_import_service_success(importer):
91
+ """Test importing a discovered service."""
92
+ service = DiscoveredService(
93
+ service_id=789,
94
+ service_name="TestImport",
95
+ chain_name="gnosis",
96
+ safe_address="0xSafe",
97
+ keys=[
98
+ DiscoveredKey(address="0xAgent", private_key="1" * 64, role="agent", is_encrypted=False)
99
+ ],
100
+ )
101
+
102
+ with (
103
+ patch.object(importer, "_import_key", return_value=(True, "ok")),
104
+ patch.object(importer, "_import_safe", return_value=(True, "ok")),
105
+ patch.object(importer, "_import_service_config", return_value=(True, "ok")),
106
+ ):
107
+ result = importer.import_service(service)
108
+ assert result.success
109
+ assert len(result.imported_keys) == 1
110
+ assert "gnosis:789" in result.imported_services
111
+
112
+
113
+ def test_parse_plaintext_key_file(importer, tmp_path):
114
+ """Test parsing plaintext key file."""
115
+ # Test hex format
116
+ key_file = tmp_path / "key.txt"
117
+ key_hex = "1" * 64
118
+ key_file.write_text(key_hex)
119
+
120
+ key = importer._parse_plaintext_key_file(key_file, role="owner")
121
+ assert key is not None
122
+ assert key.private_key == key_hex
123
+
124
+ # Test JSON format
125
+ json_key = {"address": "0xAddr", "private_key": "0x2" * 32}
126
+ key_file.write_text(json.dumps(json_key))
127
+ key = importer._parse_plaintext_key_file(key_file, role="agent")
128
+ assert key.address == "0xAddr"