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.
- conftest.py +22 -0
- iwa/__init__.py +1 -0
- iwa/__main__.py +6 -0
- iwa/core/__init__.py +1 -0
- iwa/core/chain/__init__.py +68 -0
- iwa/core/chain/errors.py +47 -0
- iwa/core/chain/interface.py +514 -0
- iwa/core/chain/manager.py +38 -0
- iwa/core/chain/models.py +128 -0
- iwa/core/chain/rate_limiter.py +193 -0
- iwa/core/cli.py +210 -0
- iwa/core/constants.py +28 -0
- iwa/core/contracts/__init__.py +1 -0
- iwa/core/contracts/contract.py +297 -0
- iwa/core/contracts/erc20.py +79 -0
- iwa/core/contracts/multisend.py +71 -0
- iwa/core/db.py +317 -0
- iwa/core/keys.py +361 -0
- iwa/core/mnemonic.py +385 -0
- iwa/core/models.py +344 -0
- iwa/core/monitor.py +209 -0
- iwa/core/plugins.py +45 -0
- iwa/core/pricing.py +91 -0
- iwa/core/services/__init__.py +17 -0
- iwa/core/services/account.py +57 -0
- iwa/core/services/balance.py +113 -0
- iwa/core/services/plugin.py +88 -0
- iwa/core/services/safe.py +392 -0
- iwa/core/services/transaction.py +172 -0
- iwa/core/services/transfer/__init__.py +166 -0
- iwa/core/services/transfer/base.py +260 -0
- iwa/core/services/transfer/erc20.py +247 -0
- iwa/core/services/transfer/multisend.py +386 -0
- iwa/core/services/transfer/native.py +262 -0
- iwa/core/services/transfer/swap.py +326 -0
- iwa/core/settings.py +95 -0
- iwa/core/tables.py +60 -0
- iwa/core/test.py +27 -0
- iwa/core/tests/test_wallet.py +255 -0
- iwa/core/types.py +59 -0
- iwa/core/ui.py +99 -0
- iwa/core/utils.py +59 -0
- iwa/core/wallet.py +380 -0
- iwa/plugins/__init__.py +1 -0
- iwa/plugins/gnosis/__init__.py +5 -0
- iwa/plugins/gnosis/cow/__init__.py +6 -0
- iwa/plugins/gnosis/cow/quotes.py +148 -0
- iwa/plugins/gnosis/cow/swap.py +403 -0
- iwa/plugins/gnosis/cow/types.py +20 -0
- iwa/plugins/gnosis/cow_utils.py +44 -0
- iwa/plugins/gnosis/plugin.py +68 -0
- iwa/plugins/gnosis/safe.py +157 -0
- iwa/plugins/gnosis/tests/test_cow.py +227 -0
- iwa/plugins/gnosis/tests/test_safe.py +100 -0
- iwa/plugins/olas/__init__.py +5 -0
- iwa/plugins/olas/constants.py +106 -0
- iwa/plugins/olas/contracts/activity_checker.py +93 -0
- iwa/plugins/olas/contracts/base.py +10 -0
- iwa/plugins/olas/contracts/mech.py +49 -0
- iwa/plugins/olas/contracts/mech_marketplace.py +43 -0
- iwa/plugins/olas/contracts/service.py +215 -0
- iwa/plugins/olas/contracts/staking.py +403 -0
- iwa/plugins/olas/importer.py +736 -0
- iwa/plugins/olas/mech_reference.py +135 -0
- iwa/plugins/olas/models.py +110 -0
- iwa/plugins/olas/plugin.py +243 -0
- iwa/plugins/olas/scripts/test_full_mech_flow.py +259 -0
- iwa/plugins/olas/scripts/test_simple_lifecycle.py +74 -0
- iwa/plugins/olas/service_manager/__init__.py +60 -0
- iwa/plugins/olas/service_manager/base.py +113 -0
- iwa/plugins/olas/service_manager/drain.py +336 -0
- iwa/plugins/olas/service_manager/lifecycle.py +839 -0
- iwa/plugins/olas/service_manager/mech.py +322 -0
- iwa/plugins/olas/service_manager/staking.py +530 -0
- iwa/plugins/olas/tests/conftest.py +30 -0
- iwa/plugins/olas/tests/test_importer.py +128 -0
- iwa/plugins/olas/tests/test_importer_error_handling.py +349 -0
- iwa/plugins/olas/tests/test_mech_contracts.py +85 -0
- iwa/plugins/olas/tests/test_olas_contracts.py +249 -0
- iwa/plugins/olas/tests/test_olas_integration.py +561 -0
- iwa/plugins/olas/tests/test_olas_models.py +144 -0
- iwa/plugins/olas/tests/test_olas_view.py +258 -0
- iwa/plugins/olas/tests/test_olas_view_actions.py +137 -0
- iwa/plugins/olas/tests/test_olas_view_modals.py +120 -0
- iwa/plugins/olas/tests/test_plugin.py +70 -0
- iwa/plugins/olas/tests/test_plugin_full.py +212 -0
- iwa/plugins/olas/tests/test_service_lifecycle.py +150 -0
- iwa/plugins/olas/tests/test_service_manager.py +1065 -0
- iwa/plugins/olas/tests/test_service_manager_errors.py +208 -0
- iwa/plugins/olas/tests/test_service_manager_flows.py +497 -0
- iwa/plugins/olas/tests/test_service_manager_mech.py +135 -0
- iwa/plugins/olas/tests/test_service_manager_rewards.py +360 -0
- iwa/plugins/olas/tests/test_service_manager_validation.py +145 -0
- iwa/plugins/olas/tests/test_service_staking.py +342 -0
- iwa/plugins/olas/tests/test_staking_integration.py +269 -0
- iwa/plugins/olas/tests/test_staking_validation.py +109 -0
- iwa/plugins/olas/tui/__init__.py +1 -0
- iwa/plugins/olas/tui/olas_view.py +952 -0
- iwa/tools/check_profile.py +67 -0
- iwa/tools/release.py +111 -0
- iwa/tools/reset_env.py +111 -0
- iwa/tools/reset_tenderly.py +362 -0
- iwa/tools/restore_backup.py +82 -0
- iwa/tui/__init__.py +1 -0
- iwa/tui/app.py +174 -0
- iwa/tui/modals/__init__.py +5 -0
- iwa/tui/modals/base.py +406 -0
- iwa/tui/rpc.py +63 -0
- iwa/tui/screens/__init__.py +1 -0
- iwa/tui/screens/wallets.py +749 -0
- iwa/tui/tests/test_app.py +125 -0
- iwa/tui/tests/test_rpc.py +139 -0
- iwa/tui/tests/test_wallets_refactor.py +30 -0
- iwa/tui/tests/test_widgets.py +123 -0
- iwa/tui/widgets/__init__.py +5 -0
- iwa/tui/widgets/base.py +100 -0
- iwa/tui/workers.py +42 -0
- iwa/web/dependencies.py +76 -0
- iwa/web/models.py +76 -0
- iwa/web/routers/accounts.py +115 -0
- iwa/web/routers/olas/__init__.py +24 -0
- iwa/web/routers/olas/admin.py +169 -0
- iwa/web/routers/olas/funding.py +135 -0
- iwa/web/routers/olas/general.py +29 -0
- iwa/web/routers/olas/services.py +378 -0
- iwa/web/routers/olas/staking.py +341 -0
- iwa/web/routers/state.py +65 -0
- iwa/web/routers/swap.py +617 -0
- iwa/web/routers/transactions.py +153 -0
- iwa/web/server.py +155 -0
- iwa/web/tests/test_web_endpoints.py +713 -0
- iwa/web/tests/test_web_olas.py +430 -0
- iwa/web/tests/test_web_swap.py +103 -0
- iwa-0.0.1a2.dist-info/METADATA +234 -0
- iwa-0.0.1a2.dist-info/RECORD +186 -0
- iwa-0.0.1a2.dist-info/entry_points.txt +2 -0
- iwa-0.0.1a2.dist-info/licenses/LICENSE +21 -0
- iwa-0.0.1a2.dist-info/top_level.txt +4 -0
- tests/legacy_cow.py +248 -0
- tests/legacy_safe.py +93 -0
- tests/legacy_transaction_retry_logic.py +51 -0
- tests/legacy_tui.py +440 -0
- tests/legacy_wallets_screen.py +554 -0
- tests/legacy_web.py +243 -0
- tests/test_account_service.py +120 -0
- tests/test_balance_service.py +186 -0
- tests/test_chain.py +490 -0
- tests/test_chain_interface.py +210 -0
- tests/test_cli.py +139 -0
- tests/test_contract.py +195 -0
- tests/test_db.py +180 -0
- tests/test_drain_coverage.py +174 -0
- tests/test_erc20.py +95 -0
- tests/test_gnosis_plugin.py +111 -0
- tests/test_keys.py +449 -0
- tests/test_legacy_wallet.py +1285 -0
- tests/test_main.py +13 -0
- tests/test_mnemonic.py +217 -0
- tests/test_modals.py +109 -0
- tests/test_models.py +213 -0
- tests/test_monitor.py +202 -0
- tests/test_multisend.py +84 -0
- tests/test_plugin_service.py +119 -0
- tests/test_pricing.py +143 -0
- tests/test_rate_limiter.py +199 -0
- tests/test_reset_tenderly.py +202 -0
- tests/test_rpc_view.py +73 -0
- tests/test_safe_coverage.py +139 -0
- tests/test_safe_service.py +168 -0
- tests/test_service_manager_integration.py +61 -0
- tests/test_service_manager_structure.py +31 -0
- tests/test_service_transaction.py +176 -0
- tests/test_staking_router.py +71 -0
- tests/test_staking_simple.py +31 -0
- tests/test_tables.py +76 -0
- tests/test_transaction_service.py +161 -0
- tests/test_transfer_multisend.py +179 -0
- tests/test_transfer_native.py +220 -0
- tests/test_transfer_security.py +93 -0
- tests/test_transfer_structure.py +37 -0
- tests/test_transfer_swap_unit.py +155 -0
- tests/test_ui_coverage.py +66 -0
- tests/test_utils.py +53 -0
- tests/test_workers.py +91 -0
- tools/verify_drain.py +183 -0
- __init__.py +0 -2
- hello.py +0 -6
- iwa-0.0.0.dist-info/METADATA +0 -10
- iwa-0.0.0.dist-info/RECORD +0 -6
- iwa-0.0.0.dist-info/top_level.txt +0 -2
- {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"
|