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,215 @@
1
+ """Service contract interaction."""
2
+
3
+ import time
4
+ from enum import Enum
5
+ from typing import Dict, Optional
6
+
7
+ from iwa.core.contracts.contract import ContractInstance
8
+ from iwa.core.types import EthereumAddress
9
+ from iwa.plugins.olas.constants import (
10
+ DEFAULT_DEPLOY_PAYLOAD,
11
+ )
12
+ from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
13
+
14
+
15
+ def get_deployment_payload(fallback_handler: str) -> str:
16
+ """Calculates deployment payload."""
17
+ return (
18
+ DEFAULT_DEPLOY_PAYLOAD.format(fallback_handler=fallback_handler[2:])
19
+ + int(time.time()).to_bytes(32, "big").hex()
20
+ )
21
+
22
+
23
+ class ServiceState(Enum):
24
+ """Enumeration of possible service states."""
25
+
26
+ NON_EXISTENT = 0
27
+ PRE_REGISTRATION = 1
28
+ ACTIVE_REGISTRATION = 2
29
+ FINISHED_REGISTRATION = 3
30
+ DEPLOYED = 4
31
+ TERMINATED_BONDED = 5
32
+
33
+
34
+ class ServiceRegistryContract(ContractInstance):
35
+ """Class to interact with the service registry contract."""
36
+
37
+ name = "service_registry"
38
+ abi_path = OLAS_ABI_PATH / "service_registry.json"
39
+
40
+ def get_service(self, service_id: int) -> Dict:
41
+ """Get the IDs of all registered services."""
42
+ (
43
+ security_deposit,
44
+ multisig,
45
+ config_hash,
46
+ threshold,
47
+ max_num_agent_instances,
48
+ num_agent_instances,
49
+ state,
50
+ agent_ids,
51
+ ) = self.call("getService", service_id)
52
+ return {
53
+ "security_deposit": security_deposit,
54
+ "multisig": multisig,
55
+ "config_hash": config_hash.hex(),
56
+ "threshold": threshold,
57
+ "max_num_agent_instances": max_num_agent_instances,
58
+ "num_agent_instances": num_agent_instances,
59
+ "state": ServiceState(state),
60
+ "agent_ids": agent_ids,
61
+ }
62
+
63
+ def get_token(self, service_id: int) -> str:
64
+ """Get the token address for a service."""
65
+ return self.call("token", service_id)
66
+
67
+ def get_agent_params(self, service_id: int, agent_id: int) -> Dict:
68
+ """Get agent params (slots, bond) for a service."""
69
+ (slots, bond) = self.call("getAgentParams", service_id, agent_id)
70
+ return {"slots": slots, "bond": bond}
71
+
72
+ def prepare_approve_tx(
73
+ self,
74
+ from_address: EthereumAddress,
75
+ spender: EthereumAddress,
76
+ id_: int,
77
+ ) -> Optional[Dict]:
78
+ """Approve."""
79
+ return self.prepare_transaction(
80
+ method_name="approve",
81
+ method_kwargs={"spender": spender, "id": id_},
82
+ tx_params={"from": from_address},
83
+ )
84
+
85
+
86
+ class ServiceManagerContract(ContractInstance):
87
+ """Class to interact with the service manager contract."""
88
+
89
+ name = "service_manager"
90
+ abi_path = OLAS_ABI_PATH / "service_manager.json"
91
+
92
+ def prepare_create_tx(
93
+ self,
94
+ from_address: EthereumAddress,
95
+ service_owner: EthereumAddress,
96
+ token_address: EthereumAddress,
97
+ config_hash: str,
98
+ agent_ids: list,
99
+ agent_params: list,
100
+ threshold: int,
101
+ ) -> Optional[Dict]:
102
+ """Create a new service."""
103
+ return self.prepare_transaction(
104
+ method_name="create",
105
+ method_kwargs={
106
+ "serviceOwner": service_owner,
107
+ "tokenAddress": token_address,
108
+ "configHash": config_hash,
109
+ "agentIds": agent_ids,
110
+ "agentParams": agent_params,
111
+ "threshold": threshold,
112
+ },
113
+ tx_params={"from": from_address},
114
+ )
115
+
116
+ def prepare_activate_registration_tx(
117
+ self,
118
+ from_address: EthereumAddress,
119
+ service_id: int,
120
+ value: int = 1,
121
+ ) -> Optional[Dict]:
122
+ """Activate registration for a service."""
123
+ tx = self.prepare_transaction(
124
+ method_name="activateRegistration",
125
+ method_kwargs={
126
+ "serviceId": service_id,
127
+ },
128
+ tx_params={"from": from_address, "value": value},
129
+ )
130
+ return tx
131
+
132
+ def prepare_register_agents_tx(
133
+ self,
134
+ from_address: EthereumAddress,
135
+ service_id: int,
136
+ agent_instances: list,
137
+ agent_ids: list,
138
+ value: int = 1,
139
+ ) -> Optional[Dict]:
140
+ """Register agents for a service."""
141
+ tx = self.prepare_transaction(
142
+ method_name="registerAgents",
143
+ method_kwargs={
144
+ "serviceId": service_id,
145
+ "agentInstances": agent_instances,
146
+ "agentIds": agent_ids,
147
+ },
148
+ tx_params={"from": from_address, "value": value},
149
+ )
150
+ return tx
151
+
152
+ def prepare_deploy_tx(
153
+ self,
154
+ from_address: EthereumAddress,
155
+ service_id: int,
156
+ multisig_implementation_address: Optional[str] = None,
157
+ fallback_handler: Optional[str] = None,
158
+ data: Optional[str] = None,
159
+ ) -> Optional[Dict]:
160
+ """Deploy a service."""
161
+ # Get addresses from chain if not provided
162
+ if not multisig_implementation_address:
163
+ multisig_implementation_address = self.chain_interface.get_contract_address(
164
+ "GNOSIS_SAFE_MULTISIG_IMPLEMENTATION"
165
+ )
166
+ if not fallback_handler:
167
+ fallback_handler = self.chain_interface.get_contract_address(
168
+ "GNOSIS_SAFE_FALLBACK_HANDLER"
169
+ )
170
+
171
+ if not multisig_implementation_address or not fallback_handler:
172
+ raise ValueError(
173
+ "Multisig implementation or fallback handler address not found for chain"
174
+ )
175
+
176
+ tx = self.prepare_transaction(
177
+ method_name="deploy",
178
+ method_kwargs={
179
+ "serviceId": service_id,
180
+ "multisigImplementationAddress": multisig_implementation_address,
181
+ "data": data or get_deployment_payload(fallback_handler),
182
+ },
183
+ tx_params={"from": from_address},
184
+ )
185
+ return tx
186
+
187
+ def prepare_terminate_tx(
188
+ self,
189
+ from_address: EthereumAddress,
190
+ service_id: int,
191
+ ) -> Optional[Dict]:
192
+ """Terminate a service."""
193
+ tx = self.prepare_transaction(
194
+ method_name="terminate",
195
+ method_kwargs={
196
+ "serviceId": service_id,
197
+ },
198
+ tx_params={"from": from_address},
199
+ )
200
+ return tx
201
+
202
+ def prepare_unbond_tx(
203
+ self,
204
+ from_address: EthereumAddress,
205
+ service_id: int,
206
+ ) -> Optional[Dict]:
207
+ """Terminate a service."""
208
+ tx = self.prepare_transaction(
209
+ method_name="unbond",
210
+ method_kwargs={
211
+ "serviceId": service_id,
212
+ },
213
+ tx_params={"from": from_address},
214
+ )
215
+ return tx
@@ -0,0 +1,403 @@
1
+ """Staking contract interaction.
2
+
3
+ =============================================================================
4
+ OLAS STAKING TOKEN MECHANICS
5
+ =============================================================================
6
+
7
+ When staking a service, the TOTAL OLAS required is split 50/50:
8
+
9
+ 1. minStakingDeposit: Collateral for the staking contract
10
+ - Checked by stakingContract.minStakingDeposit()
11
+ - Goes to the staking contract when stake() is called
12
+
13
+ 2. agentBond: Operator bond for the agent instance
14
+ - Must be deposited BEFORE staking during service creation
15
+ - Stored in Token Utility: getAgentBond(serviceId, agentId)
16
+
17
+ Both deposits are stored in the Token Utility contract:
18
+ - mapServiceIdTokenDeposit(serviceId) -> (token, deposit)
19
+ - getAgentBond(serviceId, agentId) -> bond
20
+
21
+ Example for Hobbyist 1 (100 OLAS total):
22
+ - minStakingDeposit: 50 OLAS
23
+ - agentBond: 50 OLAS (set during service creation)
24
+ - Total: 100 OLAS
25
+
26
+ The staking contract checks that:
27
+ 1. Service is in DEPLOYED state
28
+ 2. Service was created with the correct token (OLAS)
29
+ 3. minStakingDeposit is met
30
+ 4. Agent bond was deposited during service registration
31
+ """
32
+
33
+ import logging
34
+ import math
35
+ import time
36
+ from datetime import datetime, timezone
37
+ from enum import Enum
38
+ from typing import Dict, List, Optional, Union
39
+
40
+ from iwa.core.contracts.contract import ContractInstance
41
+ from iwa.core.types import EthereumAddress
42
+ from iwa.plugins.olas.contracts.activity_checker import ActivityCheckerContract
43
+ from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ class StakingState(Enum):
49
+ """Enum representing the staking state of a service."""
50
+
51
+ NOT_STAKED = 0
52
+ STAKED = 1
53
+ EVICTED = 2
54
+
55
+
56
+ class StakingContract(ContractInstance):
57
+ """Class to interact with the staking contract.
58
+
59
+ Manages staking operations for OLAS services and tracks activity/liveness
60
+ requirements through the associated activity checker.
61
+ """
62
+
63
+ name = "staking"
64
+ abi_path = OLAS_ABI_PATH / "staking.json"
65
+
66
+ def __init__(self, address: EthereumAddress, chain_name: str = "gnosis"):
67
+ """Initialize StakingContract.
68
+
69
+ Args:
70
+ address: The staking contract address.
71
+ chain_name: The chain name (default: gnosis).
72
+
73
+ Note:
74
+ minStakingDeposit is 50% of the total OLAS required.
75
+ The other 50% is the agentBond, deposited during service creation.
76
+ Example: Hobbyist 1 (100 OLAS) = 50 deposit + 50 bond
77
+
78
+ """
79
+ super().__init__(address, chain_name=chain_name)
80
+ self.chain_name = chain_name
81
+ self._contract_params_cache: Dict[str, int] = {}
82
+
83
+ # Get activity checker from the staking contract
84
+ activity_checker_address = self.call("activityChecker")
85
+ self.activity_checker = ActivityCheckerContract(
86
+ activity_checker_address, chain_name=chain_name
87
+ )
88
+ self.activity_checker_address = activity_checker_address
89
+
90
+ # Cache contract parameters
91
+ self.available_rewards = self.call("availableRewards")
92
+ self.balance = self.call("balance")
93
+ self.liveness_period = self.call("livenessPeriod")
94
+ self.rewards_per_second = self.call("rewardsPerSecond")
95
+ self.max_num_services = self.call("maxNumServices")
96
+ self.min_staking_deposit = self.call("minStakingDeposit")
97
+ self.min_staking_duration_hours = self.call("minStakingDuration") / 3600
98
+ self.staking_token_address = self.call("stakingToken")
99
+
100
+ def get_requirements(self) -> Dict[str, Union[str, int]]:
101
+ """Get the contract requirements for token and deposits.
102
+
103
+ Returns:
104
+ Dict containing:
105
+ - staking_token: address of the token required
106
+ - min_staking_deposit: amount required to be paid during stake()
107
+ - required_agent_bond: amount required to be set during service creation
108
+
109
+ """
110
+ # For Olas Trader contracts (Hobbyist, Alpha, Beta, etc.),
111
+ # the total OLAS is split 50/50:
112
+ # 50% as agent bond (in registry) and 50% as staking deposit (passed to stake()).
113
+ return {
114
+ "staking_token": self.staking_token_address,
115
+ "min_staking_deposit": self.min_staking_deposit,
116
+ "required_agent_bond": self.min_staking_deposit,
117
+ }
118
+
119
+ def calculate_accrued_staking_reward(self, service_id: int) -> int:
120
+ """Calculate the accrued staking reward for a given service ID."""
121
+ return self.call("calculateStakingLastReward", service_id)
122
+
123
+ def calculate_staking_reward(self, service_id: int) -> int:
124
+ """Calculate the current staking reward for a given service ID."""
125
+ return self.call("calculateStakingReward", service_id)
126
+
127
+ def get_epoch_counter(self) -> int:
128
+ """Get the current epoch counter from the staking contract."""
129
+ return self.call("epochCounter")
130
+
131
+ def get_next_epoch_start(self) -> datetime:
132
+ """Calculate the start time of the next epoch."""
133
+ return datetime.fromtimestamp(
134
+ self.call("getNextRewardCheckpointTimestamp"),
135
+ tz=timezone.utc,
136
+ )
137
+
138
+ def get_service_ids(self) -> List[int]:
139
+ """Get the current staked services."""
140
+ return self.call("getServiceIds")
141
+
142
+ def get_service_info(self, service_id: int) -> Dict:
143
+ """Get comprehensive staking information for a service.
144
+
145
+ Args:
146
+ service_id: The service ID to query.
147
+
148
+ Returns:
149
+ Dict with staking info including nonces, rewards, and liveness status.
150
+
151
+ Note:
152
+ Activity nonces from the checker are: (safe_nonce, mech_requests_count).
153
+ For liveness tracking, we use mech_requests_count (index 1).
154
+
155
+ """
156
+ result = self.call("getServiceInfo", service_id)
157
+ # Handle potential nested tuple if web3 returns [(struct)]
158
+ if (
159
+ isinstance(result, (list, tuple))
160
+ and len(result) == 1
161
+ and isinstance(result[0], (list, tuple))
162
+ ):
163
+ result = result[0]
164
+
165
+ try:
166
+ (
167
+ multisig_address,
168
+ owner_address,
169
+ nonces_on_last_checkpoint,
170
+ ts_start,
171
+ accrued_reward,
172
+ inactivity,
173
+ ) = result
174
+ except ValueError as e:
175
+ # Try to log useful info if unpacking fails
176
+ logger.error(
177
+ f"[Staking] Unpacking failed. Result type: {type(result)}, Result: {result}"
178
+ )
179
+ raise e
180
+
181
+ # Get current nonces from activity checker: (safe_nonce, mech_requests)
182
+ current_nonces = self.activity_checker.get_multisig_nonces(multisig_address)
183
+ current_safe_nonce, current_mech_requests = current_nonces
184
+
185
+ # Last checkpoint nonces are also (safe_nonce, mech_requests)
186
+ last_safe_nonce = nonces_on_last_checkpoint[0]
187
+ last_mech_requests = nonces_on_last_checkpoint[1]
188
+
189
+ # Mech requests this epoch (what matters for liveness)
190
+ mech_requests_this_epoch = current_mech_requests - last_mech_requests
191
+
192
+ required_requests = self.get_required_requests()
193
+ epoch_end = self.get_next_epoch_start()
194
+ remaining_seconds = (epoch_end - datetime.now(timezone.utc)).total_seconds()
195
+
196
+ # Check liveness ratio using activity checker
197
+ liveness_passed = self.is_liveness_ratio_passed(
198
+ current_nonces=current_nonces,
199
+ last_nonces=(last_safe_nonce, last_mech_requests),
200
+ ts_start=ts_start,
201
+ )
202
+
203
+ return {
204
+ "multisig_address": multisig_address,
205
+ "owner_address": owner_address,
206
+ "current_safe_nonce": current_safe_nonce,
207
+ "current_mech_requests": current_mech_requests,
208
+ "last_checkpoint_safe_nonce": last_safe_nonce,
209
+ "last_checkpoint_mech_requests": last_mech_requests,
210
+ "mech_requests_this_epoch": mech_requests_this_epoch,
211
+ "required_mech_requests": required_requests,
212
+ "remaining_mech_requests": max(0, required_requests - mech_requests_this_epoch),
213
+ "has_enough_requests": mech_requests_this_epoch >= required_requests,
214
+ "accrued_reward_wei": accrued_reward,
215
+ "epoch_end_utc": epoch_end,
216
+ "remaining_epoch_seconds": remaining_seconds,
217
+ "liveness_ratio_passed": liveness_passed,
218
+ "ts_start": ts_start,
219
+ "inactivity_count": inactivity,
220
+ }
221
+
222
+ def get_staking_state(self, service_id: int) -> StakingState:
223
+ """Get the staking state for a given service ID."""
224
+ return StakingState(self.call("getStakingState", service_id))
225
+
226
+ def ts_checkpoint(self) -> int:
227
+ """Get the timestamp of the last checkpoint."""
228
+ return self.call("tsCheckpoint")
229
+
230
+ def get_required_requests(self, use_liveness_period: bool = True) -> int:
231
+ """Calculate the required requests for the current epoch.
232
+
233
+ Includes a safety margin of 1 extra request.
234
+ """
235
+ requests_safety_margin = 1
236
+ now_ts = time.time()
237
+
238
+ # If use_liveness_period is True, we show the requirement for a standard epoch
239
+ # instead of the potentially very long period since the last global checkpoint.
240
+ time_diff = (
241
+ self.liveness_period
242
+ if use_liveness_period
243
+ else max(self.liveness_period, now_ts - self.ts_checkpoint())
244
+ )
245
+
246
+ return math.ceil(
247
+ (time_diff * self.activity_checker.liveness_ratio) / 1e18 + requests_safety_margin
248
+ )
249
+
250
+ def is_liveness_ratio_passed(
251
+ self,
252
+ current_nonces: tuple,
253
+ last_nonces: tuple,
254
+ ts_start: int,
255
+ ) -> bool:
256
+ """Check if the liveness ratio requirement is passed.
257
+
258
+ Uses the activity checker's isRatioPass function to determine
259
+ if the service meets liveness requirements for staking rewards.
260
+
261
+ Args:
262
+ current_nonces: Current (safe_nonce, mech_requests_count).
263
+ last_nonces: Nonces at the last checkpoint (safe_nonce, mech_requests_count).
264
+ ts_start: Timestamp when staking started or last checkpoint.
265
+
266
+ Returns:
267
+ True if liveness requirements are met.
268
+
269
+ """
270
+ # Calculate time difference since last checkpoint
271
+ ts_diff = int(time.time()) - ts_start
272
+ if ts_diff <= 0:
273
+ return False
274
+
275
+ return self.activity_checker.is_ratio_pass(
276
+ current_nonces=current_nonces,
277
+ last_nonces=last_nonces,
278
+ ts_diff=ts_diff,
279
+ )
280
+
281
+ @property
282
+ def min_staking_duration(self) -> int:
283
+ """Get the minimum duration a service must be staked before it can be unstaked."""
284
+ if "minStakingDuration" not in self._contract_params_cache:
285
+ self._contract_params_cache["minStakingDuration"] = self.call("minStakingDuration")
286
+ return self._contract_params_cache["minStakingDuration"]
287
+
288
+ def prepare_stake_tx(
289
+ self,
290
+ from_address: EthereumAddress,
291
+ service_id: int,
292
+ ) -> Optional[Dict]:
293
+ """Prepare a stake transaction."""
294
+ tx = self.prepare_transaction(
295
+ method_name="stake",
296
+ method_kwargs={
297
+ "serviceId": service_id,
298
+ },
299
+ tx_params={"from": from_address},
300
+ )
301
+ return tx
302
+
303
+ def prepare_unstake_tx(
304
+ self,
305
+ from_address: EthereumAddress,
306
+ service_id: int,
307
+ ) -> Optional[Dict]:
308
+ """Prepare an unstake transaction."""
309
+ tx = self.prepare_transaction(
310
+ method_name="unstake",
311
+ method_kwargs={
312
+ "serviceId": service_id,
313
+ },
314
+ tx_params={"from": from_address},
315
+ )
316
+ return tx
317
+
318
+ def prepare_claim_tx(
319
+ self,
320
+ from_address: EthereumAddress,
321
+ service_id: int,
322
+ ) -> Optional[Dict]:
323
+ """Prepare a claim transaction to claim staking rewards.
324
+
325
+ Args:
326
+ from_address: The address sending the transaction (service owner).
327
+ service_id: The service ID to claim rewards for.
328
+
329
+ Returns:
330
+ Transaction dict ready to be signed and sent.
331
+
332
+ """
333
+ tx = self.prepare_transaction(
334
+ method_name="claim",
335
+ method_kwargs={
336
+ "serviceId": service_id,
337
+ },
338
+ tx_params={"from": from_address},
339
+ )
340
+ return tx
341
+
342
+ def get_accrued_rewards(self, service_id: int) -> int:
343
+ """Get accrued rewards for a service.
344
+
345
+ Args:
346
+ service_id: The service ID to query.
347
+
348
+ Returns:
349
+ Accrued rewards in wei (from mapServiceInfo[3]).
350
+
351
+ """
352
+ service_info = self.call("mapServiceInfo", service_id)
353
+ # mapServiceInfo returns (multisig, owner, nonces, tsStart, reward, inactivity)
354
+ # reward is at index 4 (0-indexed)
355
+ return service_info[4] if len(service_info) > 4 else 0
356
+
357
+ def is_checkpoint_needed(self, grace_period_seconds: int = 600) -> bool:
358
+ """Check if the checkpoint needs to be called.
359
+
360
+ The checkpoint should be called when:
361
+ 1. The current epoch has ended (current time > epoch_end)
362
+ 2. A grace period has passed (to allow someone else to call it first)
363
+
364
+ Args:
365
+ grace_period_seconds: Seconds to wait after epoch ends before calling.
366
+ Defaults to 600 (10 minutes).
367
+
368
+ Returns:
369
+ True if checkpoint should be called, False otherwise.
370
+
371
+ """
372
+ epoch_end = self.get_next_epoch_start()
373
+ now = datetime.now(timezone.utc)
374
+
375
+ # If the epoch has not finished, no need to call
376
+ if now < epoch_end:
377
+ return False
378
+
379
+ # If less than grace_period has passed since epoch ended, wait
380
+ if (now - epoch_end).total_seconds() < grace_period_seconds:
381
+ return False
382
+
383
+ return True
384
+
385
+ def prepare_checkpoint_tx(self, from_address: str) -> Optional[Dict]:
386
+ """Prepare a checkpoint transaction.
387
+
388
+ The checkpoint closes the current epoch and starts a new one.
389
+ Anyone can call this once the epoch has ended.
390
+
391
+ Args:
392
+ from_address: The address sending the transaction.
393
+
394
+ Returns:
395
+ Transaction dict ready to be signed and sent.
396
+
397
+ """
398
+ tx = self.prepare_transaction(
399
+ method_name="checkpoint",
400
+ method_kwargs={},
401
+ tx_params={"from": from_address},
402
+ )
403
+ return tx