iwa 0.0.62__py3-none-any.whl → 0.0.64__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iwa/plugins/olas/models.py +5 -22
- iwa/plugins/olas/service_manager/drain.py +34 -12
- iwa/plugins/olas/service_manager/lifecycle.py +3 -3
- iwa/plugins/olas/tests/test_olas_models.py +5 -5
- iwa/plugins/olas/tests/test_olas_view_actions.py +1 -1
- iwa/plugins/olas/tests/test_service_manager_rewards.py +6 -1
- iwa/plugins/olas/tests/test_service_staking.py +1 -0
- iwa/web/tests/test_web_olas.py +1 -1
- {iwa-0.0.62.dist-info → iwa-0.0.64.dist-info}/METADATA +1 -1
- {iwa-0.0.62.dist-info → iwa-0.0.64.dist-info}/RECORD +18 -17
- tests/test_chainlist_enrichment.py +233 -0
- tests/test_contract_cache.py +253 -0
- tests/test_drain_coverage.py +265 -3
- tests/test_staking_simple.py +478 -6
- {iwa-0.0.62.dist-info → iwa-0.0.64.dist-info}/WHEEL +0 -0
- {iwa-0.0.62.dist-info → iwa-0.0.64.dist-info}/entry_points.txt +0 -0
- {iwa-0.0.62.dist-info → iwa-0.0.64.dist-info}/licenses/LICENSE +0 -0
- {iwa-0.0.62.dist-info → iwa-0.0.64.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Tests for contract instance caching."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture(autouse=True)
|
|
10
|
+
def reset_singleton():
|
|
11
|
+
"""Reset the ContractCache singleton before each test."""
|
|
12
|
+
from iwa.core.contracts.cache import ContractCache
|
|
13
|
+
|
|
14
|
+
ContractCache._instance = None
|
|
15
|
+
yield
|
|
16
|
+
ContractCache._instance = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestContractCache:
|
|
20
|
+
"""Test ContractCache singleton and caching behavior."""
|
|
21
|
+
|
|
22
|
+
def test_singleton_pattern(self):
|
|
23
|
+
"""Test that ContractCache is a singleton."""
|
|
24
|
+
from iwa.core.contracts.cache import ContractCache
|
|
25
|
+
|
|
26
|
+
c1 = ContractCache()
|
|
27
|
+
c2 = ContractCache()
|
|
28
|
+
assert c1 is c2
|
|
29
|
+
|
|
30
|
+
def test_get_contract_creates_new_instance(self):
|
|
31
|
+
"""Test get_contract creates new instance when not cached."""
|
|
32
|
+
from iwa.core.contracts.cache import ContractCache
|
|
33
|
+
|
|
34
|
+
cache = ContractCache()
|
|
35
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
36
|
+
mock_instance = MagicMock()
|
|
37
|
+
mock_cls.return_value = mock_instance
|
|
38
|
+
|
|
39
|
+
result = cache.get_contract(
|
|
40
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
mock_cls.assert_called_once_with(
|
|
44
|
+
"0x1234567890123456789012345678901234567890", chain_name="gnosis"
|
|
45
|
+
)
|
|
46
|
+
assert result is mock_instance
|
|
47
|
+
|
|
48
|
+
def test_get_contract_returns_cached_instance(self):
|
|
49
|
+
"""Test get_contract returns cached instance on second call."""
|
|
50
|
+
from iwa.core.contracts.cache import ContractCache
|
|
51
|
+
|
|
52
|
+
cache = ContractCache()
|
|
53
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
54
|
+
mock_instance = MagicMock()
|
|
55
|
+
mock_cls.return_value = mock_instance
|
|
56
|
+
|
|
57
|
+
result1 = cache.get_contract(
|
|
58
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
59
|
+
)
|
|
60
|
+
result2 = cache.get_contract(
|
|
61
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Should only create once
|
|
65
|
+
mock_cls.assert_called_once()
|
|
66
|
+
assert result1 is result2
|
|
67
|
+
|
|
68
|
+
def test_get_contract_raises_on_empty_address(self):
|
|
69
|
+
"""Test get_contract raises ValueError for empty address."""
|
|
70
|
+
from iwa.core.contracts.cache import ContractCache
|
|
71
|
+
|
|
72
|
+
cache = ContractCache()
|
|
73
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
74
|
+
|
|
75
|
+
with pytest.raises(ValueError, match="Address is required"):
|
|
76
|
+
cache.get_contract(mock_cls, "", "gnosis")
|
|
77
|
+
|
|
78
|
+
def test_get_contract_respects_ttl_expiry(self):
|
|
79
|
+
"""Test get_contract recreates instance after TTL expires."""
|
|
80
|
+
from iwa.core.contracts.cache import ContractCache
|
|
81
|
+
|
|
82
|
+
cache = ContractCache()
|
|
83
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
84
|
+
mock_cls.return_value = MagicMock()
|
|
85
|
+
|
|
86
|
+
# First call
|
|
87
|
+
cache.get_contract(
|
|
88
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis", ttl=0
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Wait for expiry (TTL=0 means immediate expiry)
|
|
92
|
+
time.sleep(0.01)
|
|
93
|
+
|
|
94
|
+
# Second call should create new instance
|
|
95
|
+
cache.get_contract(
|
|
96
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis", ttl=0
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
assert mock_cls.call_count == 2
|
|
100
|
+
|
|
101
|
+
def test_get_if_cached_returns_cached_instance(self):
|
|
102
|
+
"""Test get_if_cached returns cached instance."""
|
|
103
|
+
from iwa.core.contracts.cache import ContractCache
|
|
104
|
+
|
|
105
|
+
cache = ContractCache()
|
|
106
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
107
|
+
mock_instance = MagicMock()
|
|
108
|
+
mock_cls.return_value = mock_instance
|
|
109
|
+
|
|
110
|
+
# First populate cache
|
|
111
|
+
cache.get_contract(
|
|
112
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# get_if_cached should return it
|
|
116
|
+
result = cache.get_if_cached(
|
|
117
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
assert result is mock_instance
|
|
121
|
+
|
|
122
|
+
def test_get_if_cached_returns_none_when_not_cached(self):
|
|
123
|
+
"""Test get_if_cached returns None when not cached."""
|
|
124
|
+
from iwa.core.contracts.cache import ContractCache
|
|
125
|
+
|
|
126
|
+
cache = ContractCache()
|
|
127
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
128
|
+
|
|
129
|
+
result = cache.get_if_cached(
|
|
130
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
assert result is None
|
|
134
|
+
|
|
135
|
+
def test_get_if_cached_returns_none_for_empty_address(self):
|
|
136
|
+
"""Test get_if_cached returns None for empty address."""
|
|
137
|
+
from iwa.core.contracts.cache import ContractCache
|
|
138
|
+
|
|
139
|
+
cache = ContractCache()
|
|
140
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
141
|
+
|
|
142
|
+
result = cache.get_if_cached(mock_cls, "", "gnosis")
|
|
143
|
+
|
|
144
|
+
assert result is None
|
|
145
|
+
|
|
146
|
+
def test_get_if_cached_returns_none_after_expiry(self):
|
|
147
|
+
"""Test get_if_cached returns None after TTL expires."""
|
|
148
|
+
from iwa.core.contracts.cache import ContractCache
|
|
149
|
+
|
|
150
|
+
cache = ContractCache()
|
|
151
|
+
cache.ttl = 0 # Immediate expiry
|
|
152
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
153
|
+
mock_cls.return_value = MagicMock()
|
|
154
|
+
|
|
155
|
+
# Populate cache
|
|
156
|
+
cache.get_contract(
|
|
157
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
time.sleep(0.01)
|
|
161
|
+
|
|
162
|
+
# get_if_cached should return None due to expiry
|
|
163
|
+
result = cache.get_if_cached(
|
|
164
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert result is None
|
|
168
|
+
|
|
169
|
+
def test_clear_removes_all_entries(self):
|
|
170
|
+
"""Test clear removes all cached contracts."""
|
|
171
|
+
from iwa.core.contracts.cache import ContractCache
|
|
172
|
+
|
|
173
|
+
cache = ContractCache()
|
|
174
|
+
mock_cls = MagicMock(__name__="MockContract")
|
|
175
|
+
mock_cls.return_value = MagicMock()
|
|
176
|
+
|
|
177
|
+
# Populate cache
|
|
178
|
+
cache.get_contract(
|
|
179
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
cache.clear()
|
|
183
|
+
|
|
184
|
+
# get_if_cached should return None
|
|
185
|
+
result = cache.get_if_cached(
|
|
186
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
187
|
+
)
|
|
188
|
+
assert result is None
|
|
189
|
+
|
|
190
|
+
def test_invalidate_removes_specific_entry(self):
|
|
191
|
+
"""Test invalidate removes specific cached contract."""
|
|
192
|
+
from iwa.core.contracts.cache import ContractCache
|
|
193
|
+
|
|
194
|
+
cache = ContractCache()
|
|
195
|
+
mock_cls1 = MagicMock(__name__="Contract1")
|
|
196
|
+
mock_cls2 = MagicMock(__name__="Contract2")
|
|
197
|
+
mock_cls1.return_value = MagicMock()
|
|
198
|
+
mock_cls2.return_value = MagicMock()
|
|
199
|
+
|
|
200
|
+
# Populate cache with two contracts
|
|
201
|
+
cache.get_contract(
|
|
202
|
+
mock_cls1, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
203
|
+
)
|
|
204
|
+
cache.get_contract(
|
|
205
|
+
mock_cls2, "0xABCDEF1234567890123456789012345678901234", "gnosis"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Invalidate only the first one
|
|
209
|
+
cache.invalidate(
|
|
210
|
+
mock_cls1, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# First should be gone
|
|
214
|
+
result1 = cache.get_if_cached(
|
|
215
|
+
mock_cls1, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
216
|
+
)
|
|
217
|
+
assert result1 is None
|
|
218
|
+
|
|
219
|
+
# Second should still exist
|
|
220
|
+
result2 = cache.get_if_cached(
|
|
221
|
+
mock_cls2, "0xABCDEF1234567890123456789012345678901234", "gnosis"
|
|
222
|
+
)
|
|
223
|
+
assert result2 is not None
|
|
224
|
+
|
|
225
|
+
def test_invalidate_nonexistent_does_nothing(self):
|
|
226
|
+
"""Test invalidate does nothing for non-existent entry."""
|
|
227
|
+
from iwa.core.contracts.cache import ContractCache
|
|
228
|
+
|
|
229
|
+
cache = ContractCache()
|
|
230
|
+
mock_cls = MagicMock(__name__="Contract")
|
|
231
|
+
|
|
232
|
+
# Should not raise
|
|
233
|
+
cache.invalidate(
|
|
234
|
+
mock_cls, "0x1234567890123456789012345678901234567890", "gnosis"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def test_env_ttl_configuration(self):
|
|
238
|
+
"""Test TTL is configurable via environment variable."""
|
|
239
|
+
from iwa.core.contracts.cache import ContractCache
|
|
240
|
+
|
|
241
|
+
with patch.dict("os.environ", {"IWA_CONTRACT_CACHE_TTL": "7200"}):
|
|
242
|
+
ContractCache._instance = None
|
|
243
|
+
cache = ContractCache()
|
|
244
|
+
assert cache.ttl == 7200
|
|
245
|
+
|
|
246
|
+
def test_invalid_env_ttl_uses_default(self):
|
|
247
|
+
"""Test invalid TTL env var uses default value."""
|
|
248
|
+
from iwa.core.contracts.cache import ContractCache
|
|
249
|
+
|
|
250
|
+
with patch.dict("os.environ", {"IWA_CONTRACT_CACHE_TTL": "invalid"}):
|
|
251
|
+
ContractCache._instance = None
|
|
252
|
+
cache = ContractCache()
|
|
253
|
+
assert cache.ttl == 3600
|
tests/test_drain_coverage.py
CHANGED
|
@@ -46,7 +46,7 @@ def test_claim_rewards_claim_tx_fails(mock_drain_manager):
|
|
|
46
46
|
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
47
47
|
mock_staking = mock_staking_cls.return_value
|
|
48
48
|
mock_staking.get_staking_state.return_value = StakingState.STAKED
|
|
49
|
-
mock_staking.
|
|
49
|
+
mock_staking.calculate_staking_reward.return_value = 1000000000000000000
|
|
50
50
|
mock_staking.prepare_claim_tx.return_value = None # Failed to prepare
|
|
51
51
|
|
|
52
52
|
success, amount = mock_drain_manager.claim_rewards()
|
|
@@ -63,7 +63,7 @@ def test_claim_rewards_send_fails(mock_drain_manager):
|
|
|
63
63
|
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
64
64
|
mock_staking = mock_staking_cls.return_value
|
|
65
65
|
mock_staking.get_staking_state.return_value = StakingState.STAKED
|
|
66
|
-
mock_staking.
|
|
66
|
+
mock_staking.calculate_staking_reward.return_value = 1000000000000000000
|
|
67
67
|
mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
|
|
68
68
|
mock_drain_manager.wallet.sign_and_send_transaction.return_value = (False, None)
|
|
69
69
|
|
|
@@ -81,7 +81,7 @@ def test_claim_rewards_success_no_event(mock_drain_manager):
|
|
|
81
81
|
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
82
82
|
mock_staking = mock_staking_cls.return_value
|
|
83
83
|
mock_staking.get_staking_state.return_value = StakingState.STAKED
|
|
84
|
-
mock_staking.
|
|
84
|
+
mock_staking.calculate_staking_reward.return_value = 1000000000000000000
|
|
85
85
|
mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
|
|
86
86
|
mock_staking.extract_events.return_value = [] # No RewardClaimed event
|
|
87
87
|
mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
|
|
@@ -94,6 +94,33 @@ def test_claim_rewards_success_no_event(mock_drain_manager):
|
|
|
94
94
|
assert amount == 1000000000000000000
|
|
95
95
|
|
|
96
96
|
|
|
97
|
+
def test_claim_rewards_fallback_to_accrued(mock_drain_manager):
|
|
98
|
+
"""Test claim_rewards falls back to get_accrued_rewards when calculate_staking_reward fails."""
|
|
99
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
100
|
+
mock_drain_manager.service.service_id = 1
|
|
101
|
+
mock_drain_manager.wallet.master_account.address = "0xMaster"
|
|
102
|
+
|
|
103
|
+
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
104
|
+
mock_staking = mock_staking_cls.return_value
|
|
105
|
+
mock_staking.get_staking_state.return_value = StakingState.STAKED
|
|
106
|
+
# calculate_staking_reward fails, should fallback to get_accrued_rewards
|
|
107
|
+
mock_staking.calculate_staking_reward.side_effect = Exception("RPC error")
|
|
108
|
+
mock_staking.get_accrued_rewards.return_value = 2000000000000000000 # 2 OLAS
|
|
109
|
+
mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
|
|
110
|
+
mock_staking.extract_events.return_value = []
|
|
111
|
+
mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
|
|
112
|
+
True,
|
|
113
|
+
{"transactionHash": "0xHash"},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
success, amount = mock_drain_manager.claim_rewards()
|
|
117
|
+
assert success
|
|
118
|
+
assert amount == 2000000000000000000
|
|
119
|
+
# Verify both methods were called
|
|
120
|
+
mock_staking.calculate_staking_reward.assert_called_once()
|
|
121
|
+
mock_staking.get_accrued_rewards.assert_called_once()
|
|
122
|
+
|
|
123
|
+
|
|
97
124
|
def test_withdraw_rewards_fallback_to_master(mock_drain_manager):
|
|
98
125
|
"""Test withdraw_rewards falls back to master account when not configured."""
|
|
99
126
|
mock_drain_manager.service.multisig_address = "0x1111111111111111111111111111111111111111"
|
|
@@ -178,3 +205,238 @@ def test_drain_owner_skipped_when_equals_target(mock_drain_manager):
|
|
|
178
205
|
# Should skip and return None without calling drain
|
|
179
206
|
assert result is None
|
|
180
207
|
mock_drain_manager.wallet.drain.assert_not_called()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_claim_rewards_staking_contract_load_fails(mock_drain_manager):
|
|
211
|
+
"""Test claim_rewards when StakingContract fails to load."""
|
|
212
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
213
|
+
mock_drain_manager.service.service_id = 1
|
|
214
|
+
|
|
215
|
+
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
216
|
+
mock_staking_cls.side_effect = Exception("Failed to load contract")
|
|
217
|
+
|
|
218
|
+
success, amount = mock_drain_manager.claim_rewards()
|
|
219
|
+
assert not success
|
|
220
|
+
assert amount == 0
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_claim_rewards_not_staked_state(mock_drain_manager):
|
|
224
|
+
"""Test claim_rewards when service staking state is not STAKED."""
|
|
225
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
226
|
+
mock_drain_manager.service.service_id = 1
|
|
227
|
+
|
|
228
|
+
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
229
|
+
mock_staking = mock_staking_cls.return_value
|
|
230
|
+
mock_staking.get_staking_state.return_value = StakingState.NOT_STAKED
|
|
231
|
+
|
|
232
|
+
success, amount = mock_drain_manager.claim_rewards()
|
|
233
|
+
assert not success
|
|
234
|
+
assert amount == 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_claim_rewards_zero_rewards(mock_drain_manager):
|
|
238
|
+
"""Test claim_rewards when claimable rewards is zero."""
|
|
239
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
240
|
+
mock_drain_manager.service.service_id = 1
|
|
241
|
+
|
|
242
|
+
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
243
|
+
mock_staking = mock_staking_cls.return_value
|
|
244
|
+
mock_staking.get_staking_state.return_value = StakingState.STAKED
|
|
245
|
+
mock_staking.calculate_staking_reward.return_value = 0
|
|
246
|
+
|
|
247
|
+
success, amount = mock_drain_manager.claim_rewards()
|
|
248
|
+
assert not success
|
|
249
|
+
assert amount == 0
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def test_claim_rewards_with_event_amount(mock_drain_manager):
|
|
253
|
+
"""Test claim_rewards extracts amount from RewardClaimed event."""
|
|
254
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
255
|
+
mock_drain_manager.service.service_id = 1
|
|
256
|
+
mock_drain_manager.wallet.master_account.address = "0xMaster"
|
|
257
|
+
|
|
258
|
+
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
259
|
+
mock_staking = mock_staking_cls.return_value
|
|
260
|
+
mock_staking.get_staking_state.return_value = StakingState.STAKED
|
|
261
|
+
mock_staking.calculate_staking_reward.return_value = 1000000000000000000
|
|
262
|
+
mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
|
|
263
|
+
# RewardClaimed event with 'amount' field
|
|
264
|
+
mock_staking.extract_events.return_value = [
|
|
265
|
+
{"name": "RewardClaimed", "args": {"amount": 1500000000000000000}}
|
|
266
|
+
]
|
|
267
|
+
mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
|
|
268
|
+
True,
|
|
269
|
+
{"transactionHash": "0xHash"},
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
success, amount = mock_drain_manager.claim_rewards()
|
|
273
|
+
assert success
|
|
274
|
+
# Should use amount from event, not the estimate
|
|
275
|
+
assert amount == 1500000000000000000
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_claim_rewards_with_event_reward_field(mock_drain_manager):
|
|
279
|
+
"""Test claim_rewards extracts from 'reward' field when 'amount' missing."""
|
|
280
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
281
|
+
mock_drain_manager.service.service_id = 1
|
|
282
|
+
mock_drain_manager.wallet.master_account.address = "0xMaster"
|
|
283
|
+
|
|
284
|
+
with patch("iwa.plugins.olas.service_manager.drain.StakingContract") as mock_staking_cls:
|
|
285
|
+
mock_staking = mock_staking_cls.return_value
|
|
286
|
+
mock_staking.get_staking_state.return_value = StakingState.STAKED
|
|
287
|
+
mock_staking.calculate_staking_reward.return_value = 1000000000000000000
|
|
288
|
+
mock_staking.prepare_claim_tx.return_value = {"to": "0x", "data": "0x"}
|
|
289
|
+
# RewardClaimed event with 'reward' field (no 'amount')
|
|
290
|
+
mock_staking.extract_events.return_value = [
|
|
291
|
+
{"name": "RewardClaimed", "args": {"reward": 2000000000000000000}}
|
|
292
|
+
]
|
|
293
|
+
mock_drain_manager.wallet.sign_and_send_transaction.return_value = (
|
|
294
|
+
True,
|
|
295
|
+
{"transactionHash": "0xHash"},
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
success, amount = mock_drain_manager.claim_rewards()
|
|
299
|
+
assert success
|
|
300
|
+
assert amount == 2000000000000000000
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def test_withdraw_rewards_no_service(mock_drain_manager):
|
|
304
|
+
"""Test withdraw_rewards with no active service."""
|
|
305
|
+
mock_drain_manager.service = None
|
|
306
|
+
success, amount = mock_drain_manager.withdraw_rewards()
|
|
307
|
+
assert not success
|
|
308
|
+
assert amount == 0
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_withdraw_rewards_no_multisig(mock_drain_manager):
|
|
312
|
+
"""Test withdraw_rewards when service has no multisig."""
|
|
313
|
+
mock_drain_manager.service.multisig_address = None
|
|
314
|
+
success, amount = mock_drain_manager.withdraw_rewards()
|
|
315
|
+
assert not success
|
|
316
|
+
assert amount == 0
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_withdraw_rewards_success(mock_drain_manager):
|
|
320
|
+
"""Test withdraw_rewards succeeds with balance."""
|
|
321
|
+
mock_drain_manager.service.multisig_address = "0xSafe"
|
|
322
|
+
mock_drain_manager.olas_config.withdrawal_address = "0xWithdraw"
|
|
323
|
+
mock_drain_manager.wallet.account_service.get_tag_by_address.return_value = None
|
|
324
|
+
mock_drain_manager.wallet.send.return_value = "0xTxHash"
|
|
325
|
+
|
|
326
|
+
with patch("iwa.plugins.olas.service_manager.drain.ERC20Contract") as mock_erc20_cls:
|
|
327
|
+
mock_erc20 = mock_erc20_cls.return_value
|
|
328
|
+
mock_erc20.balance_of_wei.return_value = 5000000000000000000 # 5 OLAS
|
|
329
|
+
|
|
330
|
+
success, amount = mock_drain_manager.withdraw_rewards()
|
|
331
|
+
assert success
|
|
332
|
+
assert amount == 5.0 # 5 OLAS
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_withdraw_rewards_transfer_fails(mock_drain_manager):
|
|
336
|
+
"""Test withdraw_rewards when transfer fails."""
|
|
337
|
+
mock_drain_manager.service.multisig_address = "0xSafe"
|
|
338
|
+
mock_drain_manager.olas_config.withdrawal_address = "0xWithdraw"
|
|
339
|
+
mock_drain_manager.wallet.account_service.get_tag_by_address.return_value = None
|
|
340
|
+
mock_drain_manager.wallet.send.return_value = None # Transfer failed
|
|
341
|
+
|
|
342
|
+
with patch("iwa.plugins.olas.service_manager.drain.ERC20Contract") as mock_erc20_cls:
|
|
343
|
+
mock_erc20 = mock_erc20_cls.return_value
|
|
344
|
+
mock_erc20.balance_of_wei.return_value = 5000000000000000000
|
|
345
|
+
|
|
346
|
+
success, amount = mock_drain_manager.withdraw_rewards()
|
|
347
|
+
assert not success
|
|
348
|
+
assert amount == 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_drain_service_full_flow(mock_drain_manager):
|
|
352
|
+
"""Test drain_service drains all accounts."""
|
|
353
|
+
mock_drain_manager.service.key = "test_service"
|
|
354
|
+
mock_drain_manager.service.staking_contract_address = None # No staking
|
|
355
|
+
mock_drain_manager.service.multisig_address = "0xSafe"
|
|
356
|
+
mock_drain_manager.service.agent_address = "0xAgent"
|
|
357
|
+
mock_drain_manager.service.service_owner_address = "0xOwner"
|
|
358
|
+
mock_drain_manager.wallet.master_account.address = "0xMaster"
|
|
359
|
+
|
|
360
|
+
# Mock drain returns
|
|
361
|
+
mock_drain_manager.wallet.drain.return_value = (True, {"transactionHash": b"0x123"})
|
|
362
|
+
|
|
363
|
+
result = mock_drain_manager.drain_service(target_address="0xTarget")
|
|
364
|
+
|
|
365
|
+
assert "safe" in result or "agent" in result or "owner" in result
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_drain_safe_account_with_retry(mock_drain_manager):
|
|
369
|
+
"""Test _drain_safe_account retries when rewards were claimed."""
|
|
370
|
+
mock_drain_manager.service.multisig_address = "0xSafe"
|
|
371
|
+
# First call returns None (balance not updated yet), second returns result
|
|
372
|
+
mock_drain_manager.wallet.drain.side_effect = [
|
|
373
|
+
None,
|
|
374
|
+
(True, {"transactionHash": b"0xhash"}),
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
with patch("time.sleep"): # Don't actually sleep
|
|
378
|
+
mock_drain_manager._drain_safe_account(
|
|
379
|
+
"0xTarget", "gnosis", claimed_rewards=1000
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Should have retried
|
|
383
|
+
assert mock_drain_manager.wallet.drain.call_count >= 1
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def test_drain_agent_account_success(mock_drain_manager):
|
|
387
|
+
"""Test _drain_agent_account success."""
|
|
388
|
+
mock_drain_manager.service.agent_address = "0xAgent"
|
|
389
|
+
mock_drain_manager.wallet.drain.return_value = (True, {"transactionHash": b"0xhash"})
|
|
390
|
+
|
|
391
|
+
result = mock_drain_manager._drain_agent_account("0xTarget", "gnosis")
|
|
392
|
+
|
|
393
|
+
assert result is not None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def test_drain_owner_account_success(mock_drain_manager):
|
|
397
|
+
"""Test _drain_owner_account success."""
|
|
398
|
+
mock_drain_manager.service.service_owner_address = "0xOwner"
|
|
399
|
+
mock_drain_manager.wallet.drain.return_value = (True, {"transactionHash": b"0xhash"})
|
|
400
|
+
|
|
401
|
+
result = mock_drain_manager._drain_owner_account("0xTarget", "gnosis")
|
|
402
|
+
|
|
403
|
+
assert result is not None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def test_normalize_drain_result_passthrough(mock_drain_manager):
|
|
407
|
+
"""Test _normalize_drain_result passes through non-tuple results."""
|
|
408
|
+
result = mock_drain_manager._normalize_drain_result({"some": "dict"})
|
|
409
|
+
assert result == {"some": "dict"}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def test_normalize_drain_result_string_hash(mock_drain_manager):
|
|
413
|
+
"""Test _normalize_drain_result handles string transaction hash."""
|
|
414
|
+
result = mock_drain_manager._normalize_drain_result(
|
|
415
|
+
(True, {"transactionHash": "0xABCDEF"})
|
|
416
|
+
)
|
|
417
|
+
assert result == "0xABCDEF"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_claim_rewards_if_needed_success(mock_drain_manager):
|
|
421
|
+
"""Test _claim_rewards_if_needed returns claimed amount on success."""
|
|
422
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
423
|
+
mock_drain_manager.claim_rewards = MagicMock(return_value=(True, 1000000000000000000))
|
|
424
|
+
|
|
425
|
+
result = mock_drain_manager._claim_rewards_if_needed(claim_rewards=True)
|
|
426
|
+
assert result == 1000000000000000000
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_claim_rewards_if_needed_disabled(mock_drain_manager):
|
|
430
|
+
"""Test _claim_rewards_if_needed returns 0 when disabled."""
|
|
431
|
+
mock_drain_manager.service.staking_contract_address = "0xStaking"
|
|
432
|
+
|
|
433
|
+
result = mock_drain_manager._claim_rewards_if_needed(claim_rewards=False)
|
|
434
|
+
assert result == 0
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_claim_rewards_if_needed_no_staking(mock_drain_manager):
|
|
438
|
+
"""Test _claim_rewards_if_needed returns 0 when not staked."""
|
|
439
|
+
mock_drain_manager.service.staking_contract_address = None
|
|
440
|
+
|
|
441
|
+
result = mock_drain_manager._claim_rewards_if_needed(claim_rewards=True)
|
|
442
|
+
assert result == 0
|