iwa 0.0.32__py3-none-any.whl → 0.0.58__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 (65) hide show
  1. iwa/core/chain/interface.py +116 -8
  2. iwa/core/chain/models.py +15 -3
  3. iwa/core/chain/rate_limiter.py +54 -12
  4. iwa/core/cli.py +1 -1
  5. iwa/core/ipfs.py +24 -2
  6. iwa/core/keys.py +59 -15
  7. iwa/core/models.py +60 -13
  8. iwa/core/pricing.py +24 -2
  9. iwa/core/secrets.py +27 -0
  10. iwa/core/services/account.py +1 -1
  11. iwa/core/services/balance.py +0 -22
  12. iwa/core/services/safe.py +64 -43
  13. iwa/core/services/safe_executor.py +316 -0
  14. iwa/core/services/transaction.py +11 -1
  15. iwa/core/services/transfer/erc20.py +14 -2
  16. iwa/core/services/transfer/native.py +14 -31
  17. iwa/core/services/transfer/swap.py +1 -0
  18. iwa/core/tests/test_gnosis_fee.py +87 -0
  19. iwa/core/tests/test_ipfs.py +85 -0
  20. iwa/core/tests/test_pricing.py +65 -0
  21. iwa/core/tests/test_regression_fixes.py +100 -0
  22. iwa/core/wallet.py +3 -3
  23. iwa/plugins/gnosis/cow/quotes.py +2 -2
  24. iwa/plugins/gnosis/cow/swap.py +18 -32
  25. iwa/plugins/gnosis/tests/test_cow.py +19 -10
  26. iwa/plugins/olas/importer.py +5 -7
  27. iwa/plugins/olas/models.py +0 -3
  28. iwa/plugins/olas/service_manager/drain.py +16 -7
  29. iwa/plugins/olas/service_manager/lifecycle.py +15 -4
  30. iwa/plugins/olas/service_manager/staking.py +4 -4
  31. iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  32. iwa/plugins/olas/tests/test_olas_archiving.py +73 -0
  33. iwa/plugins/olas/tests/test_olas_view.py +5 -1
  34. iwa/plugins/olas/tests/test_service_manager.py +7 -7
  35. iwa/plugins/olas/tests/test_service_manager_errors.py +1 -1
  36. iwa/plugins/olas/tests/test_service_manager_flows.py +1 -1
  37. iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  38. iwa/tools/drain_accounts.py +60 -0
  39. iwa/tools/list_contracts.py +2 -0
  40. iwa/tui/screens/wallets.py +2 -2
  41. iwa/web/routers/accounts.py +1 -1
  42. iwa/web/static/app.js +21 -9
  43. iwa/web/static/style.css +4 -0
  44. iwa/web/tests/test_web_endpoints.py +2 -2
  45. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/METADATA +6 -3
  46. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/RECORD +64 -54
  47. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/WHEEL +1 -1
  48. tests/test_balance_service.py +0 -41
  49. tests/test_chain.py +13 -4
  50. tests/test_cli.py +2 -2
  51. tests/test_drain_coverage.py +12 -6
  52. tests/test_keys.py +23 -23
  53. tests/test_rate_limiter.py +2 -2
  54. tests/test_rate_limiter_retry.py +108 -0
  55. tests/test_rpc_rate_limit.py +33 -0
  56. tests/test_rpc_rotation.py +55 -7
  57. tests/test_safe_coverage.py +37 -23
  58. tests/test_safe_executor.py +335 -0
  59. tests/test_safe_integration.py +148 -0
  60. tests/test_safe_service.py +1 -1
  61. tests/test_transfer_swap_unit.py +5 -1
  62. tests/test_pricing.py +0 -160
  63. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/entry_points.txt +0 -0
  64. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/licenses/LICENSE +0 -0
  65. {iwa-0.0.32.dist-info → iwa-0.0.58.dist-info}/top_level.txt +0 -0
@@ -134,8 +134,5 @@ class OlasConfig(BaseModel):
134
134
  target = multisig_address.lower()
135
135
  for service in self.services.values():
136
136
  if service.multisig_address and str(service.multisig_address).lower() == target:
137
- # The following line is from the Code Edit, but it does not fit syntactically here.
138
- # It appears to be from a different file (decoder.py) as indicated by the instruction.
139
- # args_str = ", ".join(f"{n}={v}" for n, v in zip(d["arg_names"], decoded, strict=False))
140
137
  return service
141
138
  return None
@@ -107,12 +107,18 @@ class DrainManagerMixin:
107
107
  logger.error("Service has no multisig address")
108
108
  return False, 0
109
109
 
110
- if not self.olas_config.withdrawal_address:
111
- logger.error("No withdrawal address configured in OlasConfig")
112
- return False, 0
113
-
110
+ withdrawal_address = (
111
+ str(self.olas_config.withdrawal_address)
112
+ if self.olas_config.withdrawal_address
113
+ else str(self.wallet.master_account.address)
114
+ )
114
115
  multisig_address = str(self.service.multisig_address)
115
- withdrawal_address = str(self.olas_config.withdrawal_address)
116
+
117
+ if not self.olas_config.withdrawal_address:
118
+ logger.warning(
119
+ "No withdrawal address configured. Defaulting to master account: "
120
+ f"{withdrawal_address}"
121
+ )
116
122
 
117
123
  # Get OLAS balance of the Safe
118
124
  olas_token = ERC20Contract(
@@ -126,8 +132,11 @@ class DrainManagerMixin:
126
132
  return False, 0
127
133
 
128
134
  olas_amount = olas_balance / 1e18
135
+ withdrawal_tag = self.wallet.get_tag_by_address(withdrawal_address) or withdrawal_address
136
+ multisig_tag = self.wallet.get_tag_by_address(multisig_address) or multisig_address
137
+
129
138
  logger.info(
130
- f"Withdrawing {olas_amount:.4f} OLAS from {multisig_address} to {withdrawal_address}"
139
+ f"Withdrawing {olas_amount:.4f} OLAS from {multisig_tag} to {withdrawal_tag}"
131
140
  )
132
141
 
133
142
  # Transfer from Safe to withdrawal address
@@ -143,7 +152,7 @@ class DrainManagerMixin:
143
152
  logger.error("Failed to transfer OLAS")
144
153
  return False, 0
145
154
 
146
- logger.info(f"Withdrew {olas_amount:.4f} OLAS to {withdrawal_address}")
155
+ logger.info(f"Withdrew {olas_amount:.4f} OLAS to {withdrawal_tag}")
147
156
  return True, olas_amount
148
157
 
149
158
  def drain_service(
@@ -632,7 +632,7 @@ class LifecycleManagerMixin:
632
632
  # Use service_name for consistency with Safe naming
633
633
  agent_tag = f"{self.service.service_name}_agent"
634
634
  try:
635
- agent_account = self.wallet.key_storage.create_account(agent_tag)
635
+ agent_account = self.wallet.key_storage.generate_new_account(agent_tag)
636
636
  agent_account_address = agent_account.address
637
637
  logger.info(f"Created new agent account: {agent_account_address}")
638
638
 
@@ -855,16 +855,27 @@ class LifecycleManagerMixin:
855
855
  _, agent_instances = self.registry.call("getAgentInstances", self.service.service_id)
856
856
  service_info = self.registry.get_service(self.service.service_id)
857
857
  threshold = service_info["threshold"]
858
+ # Store the multisig in the wallet with tag
859
+ multisig_tag = f"{self.service.service_name}_multisig"
860
+
861
+ # ARCHIVING LOGIC: If tag is already taken by a different address, rename the old one
862
+ existing = self.wallet.key_storage.find_stored_account(multisig_tag)
863
+ if existing and existing.address != multisig_address:
864
+ archive_tag = f"{multisig_tag}_old_{existing.address[:6]}"
865
+ logger.info(f"[DEPLOY] Archiving old multisig: {multisig_tag} -> {archive_tag}")
866
+ try:
867
+ self.wallet.key_storage.rename_account(existing.address, archive_tag)
868
+ except Exception as ex:
869
+ logger.warning(f"[DEPLOY] Failed to rename old multisig (collision?): {ex}")
858
870
 
859
871
  safe_account = StoredSafeAccount(
860
- tag=f"{self.service.service_name}_multisig",
872
+ tag=multisig_tag,
861
873
  address=multisig_address,
862
874
  chains=[self.chain_name],
863
875
  threshold=threshold,
864
876
  signers=agent_instances,
865
877
  )
866
- self.wallet.key_storage.accounts[multisig_address] = safe_account
867
- self.wallet.key_storage.save()
878
+ self.wallet.key_storage.register_account(safe_account)
868
879
  logger.debug("[DEPLOY] Registered multisig in wallet")
869
880
  except Exception as e:
870
881
  logger.warning(f"[DEPLOY] Failed to register multisig in wallet: {e}")
@@ -211,14 +211,14 @@ class StakingManagerMixin:
211
211
  # Helper to safely get min_staking_duration
212
212
  try:
213
213
  min_duration = staking.min_staking_duration
214
- logger.info(f"min_staking_duration: {min_duration}")
214
+ logger.debug(f"min_staking_duration: {min_duration}")
215
215
  except Exception as e:
216
216
  logger.error(f"Failed to get min_staking_duration: {e}")
217
217
  min_duration = 0
218
218
 
219
219
  unstake_at = None
220
220
  ts_start = info.get("ts_start", 0)
221
- logger.info(f"ts_start: {ts_start}")
221
+ logger.debug(f"ts_start: {ts_start}")
222
222
 
223
223
  if ts_start > 0:
224
224
  try:
@@ -227,12 +227,12 @@ class StakingManagerMixin:
227
227
  unstake_ts,
228
228
  tz=timezone.utc,
229
229
  ).isoformat()
230
- logger.info(f"unstake_available_at: {unstake_at} (ts={unstake_ts})")
230
+ logger.debug(f"unstake_available_at: {unstake_at} (ts={unstake_ts})")
231
231
  except Exception as e:
232
232
  logger.error(f"calc error: {e}")
233
233
  pass
234
234
  else:
235
- logger.warning("ts_start is 0, cannot calculate unstake time")
235
+ logger.debug("ts_start is 0, cannot calculate unstake time")
236
236
 
237
237
  return unstake_at, ts_start, min_duration
238
238
 
@@ -296,6 +296,12 @@ def test_import_key_success(importer):
296
296
  importer.key_storage.find_stored_account.return_value = None
297
297
  importer.key_storage.accounts = {}
298
298
 
299
+ # Define side effect to update accounts dict when register_account is called
300
+ def mock_register(acc):
301
+ importer.key_storage.accounts[acc.address] = acc
302
+
303
+ importer.key_storage.register_account.side_effect = mock_register
304
+
299
305
  with patch("iwa.core.keys.EncryptedAccount.encrypt_private_key") as mock_enc:
300
306
  mock_enc.return_value = MagicMock(address=addr)
301
307
  success, msg = importer._import_key(key, "my_service")
@@ -310,10 +316,17 @@ def test_import_safe_success(importer):
310
316
  importer.key_storage.find_stored_account.return_value = None
311
317
  importer.key_storage.accounts = {}
312
318
 
319
+ # Define side effect to update accounts dict when register_account is called
320
+ def mock_register(acc):
321
+ importer.key_storage.accounts[acc.address] = acc
322
+
323
+ importer.key_storage.register_account.side_effect = mock_register
324
+
313
325
  success, msg = importer._import_safe(safe_address, service_name="my_service")
314
326
  assert success is True
315
327
  assert msg == "ok"
316
328
  assert safe_address in importer.key_storage.accounts
329
+ assert importer.key_storage.accounts[safe_address].tag == "my_service_multisig"
317
330
 
318
331
 
319
332
  def test_import_service_config_success(importer):
@@ -0,0 +1,73 @@
1
+ """Tests for Olas multisig archiving logic."""
2
+
3
+ import unittest
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from iwa.core.models import StoredAccount
7
+ from iwa.plugins.olas.contracts.service import ServiceState
8
+ from iwa.plugins.olas.models import Service
9
+ from iwa.plugins.olas.service_manager import ServiceManager
10
+
11
+
12
+ class TestOlasArchiving(unittest.TestCase):
13
+ """Test multisig archiving logic during deployment."""
14
+
15
+ def setUp(self):
16
+ """Set up test fixtures."""
17
+ self.mock_wallet = MagicMock()
18
+ # Mock KeyStorage
19
+ self.mock_key_storage = MagicMock()
20
+ self.mock_wallet.key_storage = self.mock_key_storage
21
+
22
+ # Initialize ServiceManager
23
+ with patch("iwa.core.models.Config"), \
24
+ patch("iwa.plugins.olas.service_manager.base.ChainInterfaces"), \
25
+ patch("iwa.plugins.olas.service_manager.base.ContractCache"), \
26
+ patch("iwa.plugins.olas.service_manager.base.ServiceRegistryContract"):
27
+ self.sm = ServiceManager(self.mock_wallet)
28
+ self.sm.service = Service(service_name="trader_psi", chain_name="gnosis", service_id=1)
29
+ self.sm.chain_name = "gnosis"
30
+
31
+ def test_multisig_archiving_logic(self):
32
+ """Test that old multisig is archived if tag is taken by different address."""
33
+ multisig_tag = "trader_psi_multisig"
34
+ old_address = "0x1111111111111111111111111111111111111111"
35
+ new_address = "0x2222222222222222222222222222222222222222"
36
+
37
+ # 1. Simulate existing multisig in wallet with same tag
38
+ existing_acc = StoredAccount(address=old_address, tag=multisig_tag)
39
+ self.mock_key_storage.find_stored_account.return_value = existing_acc
40
+
41
+ # 2. Mock required contract calls for deploy()
42
+ self.sm.registry.get_service.return_value = {
43
+ "state": ServiceState.FINISHED_REGISTRATION, # DEPLOYED
44
+ "security_deposit": 0,
45
+ "multisig": new_address,
46
+ "threshold": 1,
47
+ "configHash": b"\x00" * 32
48
+ }
49
+ self.sm.registry.call.return_value = (None, []) # getAgentInstances
50
+
51
+ # 3. Trigger deploy (archiving happens here)
52
+ with patch.object(self.mock_wallet, "sign_and_send_transaction", return_value=(True, {"status": 1})), \
53
+ patch.object(self.sm.registry, "extract_events", return_value=[
54
+ {"name": "DeployService", "args": {}},
55
+ {"name": "CreateMultisigWithAgents", "args": {"multisig": new_address}}
56
+ ]), \
57
+ patch("iwa.plugins.olas.service_manager.lifecycle.get_tx_hash", return_value="0xhash"), \
58
+ patch("iwa.core.models.StoredSafeAccount") as mock_safe_cls:
59
+
60
+ self.sm.deploy(fund_multisig=False)
61
+
62
+ # 4. Verify rename_account was called for the old address
63
+ archive_tag = f"{multisig_tag}_old_{old_address[:6]}"
64
+ self.mock_key_storage.rename_account.assert_called_with(old_address, archive_tag)
65
+
66
+ # 5. Verify new account constructor was called with correct params
67
+ mock_safe_cls.assert_called()
68
+ _, kwargs = mock_safe_cls.call_args
69
+ self.assertEqual(kwargs["address"], new_address)
70
+ self.assertEqual(kwargs["tag"], multisig_tag)
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -147,21 +147,25 @@ async def test_olas_view_actions(mock_wallet, mock_olas_config):
147
147
 
148
148
  app = OlasTestApp(mock_wallet)
149
149
  async with app.run_test() as pilot:
150
- await pilot.pause()
150
+ # Wait for initial load to finish
151
+ await pilot.pause(0.5)
151
152
 
152
153
  # 1. Test Claim
153
154
  with patch.object(OlasView, "claim_rewards") as mock_claim:
154
155
  await pilot.click("#claim-gnosis_1")
156
+ await pilot.pause()
155
157
  mock_claim.assert_called_with("gnosis:1")
156
158
 
157
159
  # 2. Test Unstake
158
160
  with patch.object(OlasView, "unstake_service") as mock_unstake:
159
161
  await pilot.click("#unstake-gnosis_1")
162
+ await pilot.pause()
160
163
  mock_unstake.assert_called_with("gnosis:1")
161
164
 
162
165
  # 3. Test Checkpoint
163
166
  with patch.object(OlasView, "checkpoint_service") as mock_checkpoint:
164
167
  await pilot.click("#checkpoint-gnosis_1")
168
+ await pilot.pause()
165
169
  mock_checkpoint.assert_called_with("gnosis:1")
166
170
 
167
171
 
@@ -65,11 +65,11 @@ def mock_wallet():
65
65
  wallet.master_account.address = "0x1234567890123456789012345678901234567890"
66
66
  wallet.key_storage = MagicMock()
67
67
  wallet.key_storage.get_account.return_value = None # Default
68
- # Mock create_account which returns a StoredAccount or similar
68
+ # Mock generate_new_account which returns a StoredAccount or similar
69
69
  new_acc = MagicMock()
70
70
  new_acc.address = "0x0987654321098765432109876543210987654321"
71
- wallet.key_storage.create_account.return_value = new_acc
72
- wallet.key_storage.create_account.return_value = new_acc
71
+ wallet.key_storage.generate_new_account.return_value = new_acc
72
+ wallet.key_storage.generate_new_account.return_value = new_acc
73
73
  # Mock account_service
74
74
  wallet.account_service = MagicMock()
75
75
  wallet.account_service.get_tag_by_address.return_value = "mock_tag"
@@ -219,7 +219,7 @@ def test_register_agent_success(service_manager, mock_wallet):
219
219
  "security_deposit": 50000000000000000000,
220
220
  }
221
221
 
222
- # create_account is already mocked
222
+ # generate_new_account is already mocked
223
223
  mock_wallet.send.return_value = "0xMockTxHash" # wallet.send returns tx_hash
224
224
  mock_wallet.sign_and_send_transaction.return_value = (True, {})
225
225
  service_manager.registry.extract_events.return_value = [{"name": "RegisterInstance"}]
@@ -331,7 +331,7 @@ def test_register_agent_with_existing_address(service_manager, mock_wallet):
331
331
  assert service_manager.register_agent(agent_address=existing_agent) is True
332
332
  assert service_manager.service.agent_address == TEST_EXISTING_AGENT_ADDR
333
333
  # Should NOT create a new account
334
- mock_wallet.key_storage.create_account.assert_not_called()
334
+ mock_wallet.key_storage.generate_new_account.assert_not_called()
335
335
  # Should NOT fund the agent (only for new accounts)
336
336
  mock_wallet.send.assert_not_called()
337
337
 
@@ -349,7 +349,7 @@ def test_register_agent_creates_new_if_none(service_manager, mock_wallet):
349
349
 
350
350
  assert service_manager.register_agent() is True
351
351
  # Should create a new account
352
- mock_wallet.key_storage.create_account.assert_called()
352
+ mock_wallet.key_storage.generate_new_account.assert_called()
353
353
  # Should fund the new agent
354
354
  mock_wallet.send.assert_called()
355
355
 
@@ -661,7 +661,7 @@ def test_spin_up_with_existing_agent(service_manager, mock_wallet):
661
661
  existing_agent = TEST_EXISTING_AGENT_ADDR
662
662
  assert service_manager.spin_up(agent_address=existing_agent) is True
663
663
  # Verify agent address was not newly created
664
- mock_wallet.key_storage.create_account.assert_not_called()
664
+ mock_wallet.key_storage.generate_new_account.assert_not_called()
665
665
 
666
666
 
667
667
  # --- Tests for wind_down ---
@@ -18,7 +18,7 @@ def mock_wallet():
18
18
  wallet.master_account.address = VALID_ADDR
19
19
  wallet.chain_interface.chain_name = "gnosis"
20
20
  wallet.sign_and_send_transaction.return_value = (True, {"transactionHash": b"\x00" * 32})
21
- wallet.key_storage.create_account.return_value.address = VALID_ADDR
21
+ wallet.key_storage.generate_new_account.return_value.address = VALID_ADDR
22
22
  wallet.key_storage.get_account.return_value.address = VALID_ADDR
23
23
  return wallet
24
24
 
@@ -17,7 +17,7 @@ def mock_wallet():
17
17
  wallet.key_storage.get_account.return_value.address = (
18
18
  "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
19
19
  )
20
- wallet.key_storage.create_account.return_value.address = (
20
+ wallet.key_storage.generate_new_account.return_value.address = (
21
21
  "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
22
22
  )
23
23
  wallet.sign_and_send_transaction.return_value = (True, {"status": 1})
@@ -197,7 +197,11 @@ def test_withdraw_rewards_no_withdrawal_address(mock_wallet):
197
197
  )
198
198
  manager.olas_config.withdrawal_address = None
199
199
 
200
- success, amount = manager.withdraw_rewards()
200
+ with patch("iwa.plugins.olas.service_manager.drain.ERC20Contract") as mock_erc20_cls:
201
+ mock_erc20 = mock_erc20_cls.return_value
202
+ mock_erc20.balance_of_wei.return_value = 0
203
+
204
+ success, amount = manager.withdraw_rewards()
201
205
 
202
206
  assert success is False
203
207
  assert amount == 0
@@ -0,0 +1,60 @@
1
+
2
+ """Tool to drain specific accounts to a master address."""
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ # Ensure src is in pythonpath
10
+ sys.path.append(str(Path(__file__).resolve().parents[2]))
11
+
12
+ from loguru import logger
13
+
14
+ from iwa.core.constants import SECRETS_PATH
15
+ from iwa.core.wallet import Wallet
16
+
17
+
18
+ def main() -> None:
19
+ """Run the account draining tool."""
20
+ parser = argparse.ArgumentParser(description="Drain specific accounts to master.")
21
+ parser.add_argument(
22
+ "tags",
23
+ nargs="+",
24
+ help="List of account tags to drain (or 'all' for all configured accounts)",
25
+ )
26
+ parser.add_argument(
27
+ "--chain",
28
+ "-c",
29
+ default="gnosis",
30
+ help="Target chain (default: gnosis)",
31
+ )
32
+ args = parser.parse_args()
33
+
34
+ # Load secrets
35
+ if SECRETS_PATH.exists():
36
+ load_dotenv(SECRETS_PATH, override=True)
37
+
38
+ wallet = Wallet()
39
+
40
+ tags = args.tags
41
+ if "all" in tags:
42
+ # Get all configured accounts except master
43
+ all_accounts = wallet.account_service.get_account_data()
44
+ tags = [acc.tag for acc in all_accounts.values() if acc.tag != "master"]
45
+ logger.info(f"Draining ALL accounts: {tags}")
46
+
47
+ for tag in tags:
48
+ logger.info(f"Processing drain for account: {tag}")
49
+ try:
50
+ tx_hash = wallet.drain(from_address_or_tag=tag, chain_name=args.chain)
51
+ if tx_hash:
52
+ logger.success(f"Drain tx sent for {tag}: {tx_hash}")
53
+ # Optional: link to explorer
54
+ else:
55
+ logger.warning(f"Drain failed or nothing to drain for {tag}")
56
+ except Exception:
57
+ logger.exception(f"Error draining {tag}")
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -101,6 +101,7 @@ def print_table(console, contract_data, chain_name, sort_criterion):
101
101
  table.add_column("Available Rewards", justify="right", style="yellow")
102
102
  table.add_column("Contract Balance", justify="right", style="blue")
103
103
  table.add_column("Epoch End (UTC)", justify="right", style="white")
104
+ table.add_column("Epoch End (Local)", justify="right", style="white")
104
105
 
105
106
  for item in contract_data:
106
107
  if item.get("error"):
@@ -113,6 +114,7 @@ def print_table(console, contract_data, chain_name, sort_criterion):
113
114
  f"{item['rewards_olas']:,.2f} OLAS",
114
115
  f"{item['balance_olas']:,.2f} OLAS",
115
116
  item["epoch_end"].strftime("%Y-%m-%d %H:%M:%S"),
117
+ item["epoch_end"].astimezone().strftime("%Y-%m-%d %H:%M:%S"),
116
118
  )
117
119
  console.print(table)
118
120
 
@@ -330,7 +330,7 @@ class WalletsScreen(VerticalScroll):
330
330
 
331
331
  def _fetch_single_token_balance(self, address: str, token: str, chain_name: str) -> str:
332
332
  """Fetch a single token balance using BalanceService."""
333
- val_token = self.wallet.balance_service.get_erc20_balance_with_retry(
333
+ val_token = self.wallet.balance_service.get_erc20_balance_eth(
334
334
  address, token, chain_name
335
335
  )
336
336
  val_token_str = f"{val_token:.4f}" if val_token is not None else "-"
@@ -442,7 +442,7 @@ class WalletsScreen(VerticalScroll):
442
442
  def handler(tag):
443
443
  if tag is not None:
444
444
  tag = tag or f"Account {len(self.wallet.key_storage.accounts) + 1}"
445
- self.wallet.key_storage.create_account(tag)
445
+ self.wallet.key_storage.generate_new_account(tag)
446
446
  self.notify(f"Created new EOA: {escape(tag)}")
447
447
  self.refresh_accounts()
448
448
 
@@ -68,7 +68,7 @@ def get_accounts(
68
68
  def create_eoa(request: Request, req: AccountCreateRequest, auth: bool = Depends(verify_auth)):
69
69
  """Create a new EOA account with the given tag."""
70
70
  try:
71
- wallet.key_storage.create_account(req.tag)
71
+ wallet.key_storage.generate_new_account(req.tag)
72
72
  return {"status": "success"}
73
73
  except Exception as e:
74
74
  raise HTTPException(status_code=400, detail=str(e)) from None
iwa/web/static/app.js CHANGED
@@ -2096,13 +2096,24 @@ document.addEventListener("DOMContentLoaded", () => {
2096
2096
  <span class="value ${
2097
2097
  isLoading
2098
2098
  ? ""
2099
- : isStaked
2100
- ? "staked"
2101
- : service.state === "DEPLOYED"
2102
- ? "deployed"
2103
- : "not-staked"
2099
+ : staking.staking_state === "EVICTED"
2100
+ ? "evicted"
2101
+ : isStaked
2102
+ ? "staked"
2103
+ : service.state === "DEPLOYED"
2104
+ ? "deployed"
2105
+ : "not-staked"
2104
2106
  }">
2105
- ${isLoading ? '<span class="cell-spinner"></span>' : service.state ? service.state : isStaked ? "✓ STAKED" : "○ NOT STAKED"}
2107
+ ${
2108
+ isLoading
2109
+ ? '<span class="cell-spinner"></span>'
2110
+ : (service.state || "UNKNOWN") +
2111
+ (staking.staking_state
2112
+ ? `, ${staking.staking_state.replace(/_/g, " ")}`
2113
+ : isStaked
2114
+ ? ", STAKED"
2115
+ : ", NOT STAKED")
2116
+ }
2106
2117
  </span>
2107
2118
  </div>
2108
2119
  <div class="staking-row">
@@ -2638,11 +2649,11 @@ document.addEventListener("DOMContentLoaded", () => {
2638
2649
 
2639
2650
  // Show select, hide spinner
2640
2651
  select.style.display = "";
2641
- spinnerDiv.style.display = "none";
2652
+ spinnerDiv.classList.add("hidden");
2642
2653
  } catch (err) {
2643
2654
  select.innerHTML = '<option value="">Error loading contracts</option>';
2644
2655
  select.style.display = "";
2645
- spinnerDiv.style.display = "none";
2656
+ spinnerDiv.classList.add("hidden");
2646
2657
  confirmBtn.disabled = false;
2647
2658
  }
2648
2659
  };
@@ -2699,7 +2710,8 @@ document.addEventListener("DOMContentLoaded", () => {
2699
2710
 
2700
2711
  // Load staking contracts
2701
2712
  contractSelect.style.display = "none";
2702
- spinnerDiv.style.display = "block";
2713
+ spinnerDiv.classList.remove("hidden");
2714
+ spinnerDiv.style.display = "block"; // Ensure display block for visibility
2703
2715
  spinnerDiv.innerHTML =
2704
2716
  '<span class="loading-spinner"></span> Loading contracts...';
2705
2717
  submitBtn.disabled = true;
iwa/web/static/style.css CHANGED
@@ -1101,6 +1101,10 @@ textarea:focus {
1101
1101
  color: var(--warning);
1102
1102
  }
1103
1103
 
1104
+ .staking-row .value.evicted {
1105
+ color: var(--error);
1106
+ }
1107
+
1104
1108
  .staking-row .value.countdown {
1105
1109
  color: var(--accent-color);
1106
1110
  }
@@ -284,7 +284,7 @@ def test_safe_create_request_validation(client):
284
284
 
285
285
  def test_create_eoa_success(client):
286
286
  """Cover create_eoa success (lines 308-315)."""
287
- wallet.key_storage.create_account = MagicMock()
287
+ wallet.key_storage.generate_new_account = MagicMock()
288
288
 
289
289
  response = client.post("/api/accounts/eoa", json={"tag": "my_wallet"})
290
290
  assert response.status_code == 200
@@ -293,7 +293,7 @@ def test_create_eoa_success(client):
293
293
 
294
294
  def test_create_eoa_error(client):
295
295
  """Cover create_eoa error (lines 314-315)."""
296
- wallet.key_storage.create_account = MagicMock(side_effect=Exception("Tag exists"))
296
+ wallet.key_storage.generate_new_account = MagicMock(side_effect=Exception("Tag exists"))
297
297
 
298
298
  response = client.post("/api/accounts/eoa", json={"tag": "existing"})
299
299
  assert response.status_code == 400
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.32
3
+ Version: 0.0.58
4
4
  Summary: A secure, modular, and plugin-based framework for crypto agents and ops
5
5
  Requires-Python: <4.0,>=3.12
6
6
  Description-Content-Type: text/markdown
@@ -17,6 +17,7 @@ Requires-Dist: tomli-w>=1.2.0
17
17
  Requires-Dist: typer>=0.19.2
18
18
  Requires-Dist: web3>=7.13.0
19
19
  Requires-Dist: pyyaml<7.0.0,>=6.0.3
20
+ Requires-Dist: ruamel.yaml>=0.18.0
20
21
  Requires-Dist: safe-eth-py>=7.14.0
21
22
  Requires-Dist: twine>=6.2.0
22
23
  Requires-Dist: cowdao-cowpy>=1.0.1
@@ -82,7 +83,7 @@ iwa/
82
83
  ├── core/ # Core wallet functionality
83
84
  │ ├── keys.py # KeyStorage - Encrypted key management
84
85
  │ ├── wallet.py # Wallet - High-level interface
85
- │ ├── chain.py # ChainInterface - Blockchain interaction with rate limiting
86
+ │ ├── chain/ # Blockchain interface with rate limiting
86
87
  │ ├── services/ # Service layer (accounts, balances, transactions)
87
88
  │ └── contracts/ # Contract abstractions (ERC20, Safe)
88
89
  ├── plugins/ # Protocol integrations
@@ -134,6 +135,9 @@ GNOSIS_RPC=https://rpc.gnosis.io,https://gnosis.drpc.org
134
135
  ETHEREUM_RPC=https://mainnet.infura.io/v3/YOUR_KEY
135
136
  BASE_RPC=https://mainnet.base.org
136
137
 
138
+ # Testing mode (default: true uses Tenderly test RPCs)
139
+ TESTING=false
140
+
137
141
  # Optional
138
142
  GNOSISSCAN_API_KEY=your_api_key
139
143
  COINGECKO_API_KEY=your_api_key
@@ -150,7 +154,6 @@ just web
150
154
 
151
155
  # Use CLI
152
156
  iwa wallet list --chain gnosis
153
- iwa wallet balance <address> --chain gnosis
154
157
  ```
155
158
 
156
159
  ### Running Tests