iwa 0.0.1a6__py3-none-any.whl → 0.0.10__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 (50) hide show
  1. iwa/core/chain/interface.py +30 -23
  2. iwa/core/chain/models.py +21 -0
  3. iwa/core/contracts/contract.py +8 -2
  4. iwa/core/pricing.py +30 -21
  5. iwa/core/services/safe.py +13 -8
  6. iwa/core/services/transaction.py +15 -4
  7. iwa/core/utils.py +22 -0
  8. iwa/plugins/gnosis/safe.py +4 -3
  9. iwa/plugins/gnosis/tests/test_safe.py +9 -7
  10. iwa/plugins/olas/contracts/service.py +4 -4
  11. iwa/plugins/olas/contracts/staking.py +2 -3
  12. iwa/plugins/olas/plugin.py +14 -7
  13. iwa/plugins/olas/service_manager/lifecycle.py +109 -48
  14. iwa/plugins/olas/service_manager/mech.py +1 -1
  15. iwa/plugins/olas/service_manager/staking.py +92 -34
  16. iwa/plugins/olas/tests/test_plugin.py +6 -1
  17. iwa/plugins/olas/tests/test_plugin_full.py +12 -7
  18. iwa/plugins/olas/tests/test_service_manager_validation.py +16 -15
  19. iwa/tools/list_contracts.py +2 -2
  20. iwa/web/dependencies.py +1 -3
  21. iwa/web/routers/accounts.py +1 -2
  22. iwa/web/routers/olas/admin.py +1 -3
  23. iwa/web/routers/olas/funding.py +1 -3
  24. iwa/web/routers/olas/general.py +1 -3
  25. iwa/web/routers/olas/services.py +1 -2
  26. iwa/web/routers/olas/staking.py +19 -22
  27. iwa/web/routers/swap.py +1 -2
  28. iwa/web/routers/transactions.py +0 -2
  29. iwa/web/server.py +8 -6
  30. iwa/web/static/app.js +22 -0
  31. iwa/web/tests/test_web_endpoints.py +1 -1
  32. iwa/web/tests/test_web_olas.py +1 -1
  33. {iwa-0.0.1a6.dist-info → iwa-0.0.10.dist-info}/METADATA +1 -1
  34. {iwa-0.0.1a6.dist-info → iwa-0.0.10.dist-info}/RECORD +50 -49
  35. tests/test_chain_interface_coverage.py +3 -2
  36. tests/test_contract.py +165 -0
  37. tests/test_keys.py +2 -1
  38. tests/test_legacy_wallet.py +11 -0
  39. tests/test_pricing.py +32 -15
  40. tests/test_safe_coverage.py +3 -3
  41. tests/test_safe_service.py +3 -6
  42. tests/test_service_transaction.py +8 -3
  43. tests/test_staking_router.py +6 -3
  44. tests/test_transaction_service.py +4 -0
  45. tools/create_and_stake_service.py +103 -0
  46. tools/verify_drain.py +1 -4
  47. {iwa-0.0.1a6.dist-info → iwa-0.0.10.dist-info}/WHEEL +0 -0
  48. {iwa-0.0.1a6.dist-info → iwa-0.0.10.dist-info}/entry_points.txt +0 -0
  49. {iwa-0.0.1a6.dist-info → iwa-0.0.10.dist-info}/licenses/LICENSE +0 -0
  50. {iwa-0.0.1a6.dist-info → iwa-0.0.10.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  """Integration tests for OlasPlugin."""
2
2
 
3
- from unittest.mock import MagicMock, patch
3
+ from unittest.mock import patch
4
4
 
5
5
  import pytest
6
6
  import typer
@@ -103,15 +103,18 @@ def test_import_services_cli_full(plugin, runner):
103
103
  def test_get_safe_signers_edge_cases(plugin):
104
104
  """Test _get_safe_signers with various failure scenarios."""
105
105
  # 1. No RPC configured
106
- with patch("iwa.core.secrets.secrets") as mock_settings:
107
- mock_settings.gnosis_rpc = None
106
+ with patch("iwa.core.chain.ChainInterfaces") as mock_ci_cls:
107
+ mock_ci = mock_ci_cls.return_value
108
+ mock_ci.get.return_value.chain.rpcs = []
108
109
  signers, exists = plugin._get_safe_signers("0x1", "gnosis")
109
110
  assert signers is None
110
111
  assert exists is None
111
112
 
112
113
  # 2. Safe doesn't exist (raises exception)
113
- with patch("iwa.core.secrets.secrets") as mock_settings:
114
- mock_settings.gnosis_rpc = MagicMock()
114
+ with patch("iwa.core.chain.ChainInterfaces") as mock_ci_cls:
115
+ mock_ci = mock_ci_cls.return_value
116
+ mock_ci.get.return_value.chain.rpcs = ["http://rpc"]
117
+ mock_ci.get.return_value.chain.rpc = "http://rpc"
115
118
  with patch("safe_eth.eth.EthereumClient"), patch("safe_eth.safe.Safe") as mock_safe_cls:
116
119
  mock_safe = mock_safe_cls.return_value
117
120
  mock_safe.retrieve_owners.side_effect = Exception("Generic error")
@@ -121,8 +124,10 @@ def test_get_safe_signers_edge_cases(plugin):
121
124
  assert exists is False
122
125
 
123
126
  # 3. Success path
124
- with patch("iwa.core.secrets.secrets") as mock_settings:
125
- mock_settings.gnosis_rpc = MagicMock()
127
+ with patch("iwa.core.chain.ChainInterfaces") as mock_ci_cls:
128
+ mock_ci = mock_ci_cls.return_value
129
+ mock_ci.get.return_value.chain.rpcs = ["http://rpc"]
130
+ mock_ci.get.return_value.chain.rpc = "http://rpc"
126
131
  with patch("safe_eth.eth.EthereumClient"), patch("safe_eth.safe.Safe") as mock_safe_cls:
127
132
  mock_safe = mock_safe_cls.return_value
128
133
  mock_safe.retrieve_owners.return_value = ["0xAgent"]
@@ -45,21 +45,22 @@ def test_drain_service_partial_failures(sm, mock_wallet):
45
45
  # 2. Safe drain failure
46
46
  # 3. Agent drain success
47
47
 
48
- with patch.object(sm, "claim_rewards", return_value=(True, 10**18)):
49
- # Wallet.drain is called for Safe and Agent
50
- def mock_drain(from_address_or_tag=None, to_address_or_tag=None, chain_name=None):
51
- if from_address_or_tag == VALID_ADDR_2: # Safe
52
- raise Exception("Safe drain failed")
53
- return {"native": 0.5}
54
-
55
- mock_wallet.drain.side_effect = mock_drain
56
-
57
- result = sm.drain_service()
58
-
59
- assert "safe" not in result
60
- assert "agent" in result
61
- assert result["agent"]["native"] == 0.5
62
- # Verify it continued after Safe failure
48
+ with patch("time.sleep"): # Avoid real delays in drain operations
49
+ with patch.object(sm, "claim_rewards", return_value=(True, 10**18)):
50
+ # Wallet.drain is called for Safe and Agent
51
+ def mock_drain(from_address_or_tag=None, to_address_or_tag=None, chain_name=None):
52
+ if from_address_or_tag == VALID_ADDR_2: # Safe
53
+ raise Exception("Safe drain failed")
54
+ return {"native": 0.5}
55
+
56
+ mock_wallet.drain.side_effect = mock_drain
57
+
58
+ result = sm.drain_service()
59
+
60
+ assert "safe" not in result
61
+ assert "agent" in result
62
+ assert result["agent"]["native"] == 0.5
63
+ # Verify it continued after Safe failure
63
64
 
64
65
 
65
66
  def test_unstake_failed_event_extraction(sm):
@@ -11,8 +11,8 @@ from iwa.core.utils import configure_logger
11
11
  from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
12
12
  from iwa.plugins.olas.contracts.staking import StakingContract
13
13
 
14
- # Configure logger to avoid noise during execution
15
- logger = configure_logger()
14
+ # Configure logger and silence noisy third-party loggers
15
+ configure_logger()
16
16
  logging.getLogger("web3").setLevel(logging.WARNING)
17
17
  logging.getLogger("urllib3").setLevel(logging.WARNING)
18
18
 
iwa/web/dependencies.py CHANGED
@@ -1,16 +1,14 @@
1
1
  """Shared dependencies for Web API routers."""
2
2
 
3
- import logging
4
3
  import secrets
5
4
  from typing import Optional
6
5
 
7
6
  from fastapi import Header, HTTPException, Security
8
7
  from fastapi.security import APIKeyHeader
8
+ from loguru import logger
9
9
 
10
10
  from iwa.core.wallet import Wallet
11
11
 
12
- logger = logging.getLogger(__name__)
13
-
14
12
  # Singleton wallet instance for the web app
15
13
  wallet = Wallet()
16
14
 
@@ -1,16 +1,15 @@
1
1
  """Accounts Router for Web API."""
2
2
 
3
- import logging
4
3
  import time
5
4
 
6
5
  from fastapi import APIRouter, Depends, HTTPException, Request
6
+ from loguru import logger
7
7
  from slowapi import Limiter
8
8
  from slowapi.util import get_remote_address
9
9
 
10
10
  from iwa.web.dependencies import verify_auth, wallet
11
11
  from iwa.web.models import AccountCreateRequest, SafeCreateRequest
12
12
 
13
- logger = logging.getLogger(__name__)
14
13
  router = APIRouter(prefix="/api/accounts", tags=["accounts"])
15
14
 
16
15
  # Rate limiter for this router
@@ -1,8 +1,7 @@
1
1
  """Olas Admin Router."""
2
2
 
3
- import logging
4
-
5
3
  from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from loguru import logger
6
5
  from slowapi import Limiter
7
6
  from slowapi.util import get_remote_address
8
7
 
@@ -10,7 +9,6 @@ from iwa.core.models import Config
10
9
  from iwa.plugins.olas.models import OlasConfig
11
10
  from iwa.web.dependencies import verify_auth, wallet
12
11
 
13
- logger = logging.getLogger(__name__)
14
12
  router = APIRouter(tags=["olas"])
15
13
  limiter = Limiter(key_func=get_remote_address)
16
14
 
@@ -1,8 +1,7 @@
1
1
  """Olas Funding Router."""
2
2
 
3
- import logging
4
-
5
3
  from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from loguru import logger
6
5
  from pydantic import BaseModel, Field
7
6
  from slowapi import Limiter
8
7
  from slowapi.util import get_remote_address
@@ -11,7 +10,6 @@ from iwa.core.models import Config
11
10
  from iwa.plugins.olas.models import OlasConfig
12
11
  from iwa.web.dependencies import verify_auth, wallet
13
12
 
14
- logger = logging.getLogger(__name__)
15
13
  router = APIRouter(tags=["olas"])
16
14
  limiter = Limiter(key_func=get_remote_address)
17
15
 
@@ -1,12 +1,10 @@
1
1
  """Olas General Router."""
2
2
 
3
- import logging
4
-
5
3
  from fastapi import APIRouter, Depends
4
+ from loguru import logger
6
5
 
7
6
  from iwa.web.dependencies import verify_auth
8
7
 
9
- logger = logging.getLogger(__name__)
10
8
  router = APIRouter(tags=["olas"])
11
9
 
12
10
 
@@ -1,16 +1,15 @@
1
1
  """Olas Services Router."""
2
2
 
3
- import logging
4
3
  from typing import Optional
5
4
 
6
5
  from fastapi import APIRouter, Depends, HTTPException
6
+ from loguru import logger
7
7
  from pydantic import BaseModel, Field
8
8
 
9
9
  from iwa.core.models import Config
10
10
  from iwa.plugins.olas.models import OlasConfig
11
11
  from iwa.web.dependencies import verify_auth, wallet
12
12
 
13
- logger = logging.getLogger(__name__)
14
13
  router = APIRouter(tags=["olas"])
15
14
 
16
15
 
@@ -1,9 +1,9 @@
1
1
  """Olas Staking Router."""
2
2
 
3
- import logging
4
3
  from typing import Optional
5
4
 
6
5
  from fastapi import APIRouter, Depends, HTTPException, Request
6
+ from loguru import logger
7
7
  from slowapi import Limiter
8
8
  from slowapi.util import get_remote_address
9
9
 
@@ -11,7 +11,6 @@ from iwa.core.models import Config
11
11
  from iwa.plugins.olas.models import OlasConfig
12
12
  from iwa.web.dependencies import get_config, verify_auth, wallet
13
13
 
14
- logger = logging.getLogger(__name__)
15
14
  router = APIRouter(tags=["olas"])
16
15
  limiter = Limiter(key_func=get_remote_address)
17
16
 
@@ -34,25 +33,18 @@ def get_staking_contracts(
34
33
  raise HTTPException(status_code=400, detail="Invalid chain name")
35
34
 
36
35
  try:
37
- import json
38
-
39
- from iwa.core.chain import ChainInterface
36
+ from iwa.core.chain import ChainInterfaces
40
37
  from iwa.plugins.olas.constants import OLAS_TRADER_STAKING_CONTRACTS
41
- from iwa.plugins.olas.contracts.base import OLAS_ABI_PATH
42
38
 
43
39
  contracts = OLAS_TRADER_STAKING_CONTRACTS.get(chain, {})
44
40
 
45
41
  # Get service bond and token if filtered
46
42
  service_bond, service_token = _get_service_filter_info(service_key)
47
43
 
48
- # Load ABI once
49
- with open(OLAS_ABI_PATH / "staking.json", "r") as f:
50
- abi = json.load(f)
51
-
52
- # Get correct web3 instance
53
- w3 = ChainInterface(chain).web3
44
+ # Get correct interface from singleton manager
45
+ interface = ChainInterfaces().get(chain)
54
46
 
55
- results = _fetch_all_contracts(contracts, w3, abi)
47
+ results = _fetch_all_contracts(contracts, interface)
56
48
  filtered_results = _filter_contracts(results, service_bond, service_token)
57
49
 
58
50
  # Return with filter metadata so frontend can explain filtering
@@ -109,14 +101,20 @@ def _get_service_filter_info(service_key: Optional[str]) -> tuple[Optional[int],
109
101
  return service_bond, service_token
110
102
 
111
103
 
112
- def _check_availability(name, address, w3, abi):
104
+ def _check_availability(name, address, interface):
113
105
  """Check availability of a single staking contract."""
106
+ # Import at module level would cause circular import, but we can cache it
107
+ # The import is cached by Python after first call so this is efficient
108
+ from iwa.plugins.olas.contracts.staking import StakingContract
109
+
114
110
  try:
115
- contract = w3.eth.contract(address=address, abi=abi)
116
- service_ids = contract.functions.getServiceIds().call()
117
- max_services = contract.functions.maxNumServices().call()
118
- min_deposit = contract.functions.minStakingDeposit().call()
119
- staking_token = contract.functions.stakingToken().call()
111
+ contract = StakingContract(address, chain_name=interface.chain.name)
112
+
113
+ # StakingContract uses .call() which handles with_retry and rotation
114
+ service_ids = contract.call("getServiceIds")
115
+ max_services = contract.call("maxNumServices")
116
+ min_deposit = contract.call("minStakingDeposit")
117
+ staking_token = contract.call("stakingToken")
120
118
  used = len(service_ids)
121
119
 
122
120
  return {
@@ -141,15 +139,14 @@ def _check_availability(name, address, w3, abi):
141
139
  }
142
140
 
143
141
 
144
- def _fetch_all_contracts(contracts: dict, w3, abi) -> list:
142
+ def _fetch_all_contracts(contracts: dict, interface) -> list:
145
143
  """Fetch availability for all contracts using threads."""
146
144
  from concurrent.futures import ThreadPoolExecutor
147
145
 
148
146
  results = []
149
147
  with ThreadPoolExecutor(max_workers=10) as executor:
150
- # Pass w3 and abi to the helper
151
148
  futures = [
152
- executor.submit(_check_availability, name, addr, w3, abi)
149
+ executor.submit(_check_availability, name, addr, interface)
153
150
  for name, addr in contracts.items()
154
151
  ]
155
152
  for future in futures:
iwa/web/routers/swap.py CHANGED
@@ -1,12 +1,12 @@
1
1
  """Swap Router for Web API."""
2
2
 
3
3
  import asyncio
4
- import logging
5
4
  from concurrent.futures import ThreadPoolExecutor
6
5
  from functools import lru_cache
7
6
  from typing import Any, Optional
8
7
 
9
8
  from fastapi import APIRouter, Depends, HTTPException, Request
9
+ from loguru import logger
10
10
  from pydantic import BaseModel, Field, field_validator
11
11
  from slowapi import Limiter
12
12
  from slowapi.util import get_remote_address
@@ -16,7 +16,6 @@ from iwa.core.chain import ChainInterfaces, SupportedChain
16
16
  from iwa.plugins.gnosis.cow import CowSwap
17
17
  from iwa.web.dependencies import verify_auth, wallet
18
18
 
19
- logger = logging.getLogger(__name__)
20
19
  router = APIRouter(prefix="/api/swap", tags=["swap"])
21
20
 
22
21
  limiter = Limiter(key_func=get_remote_address)
@@ -2,7 +2,6 @@
2
2
 
3
3
  import datetime
4
4
  import json
5
- import logging
6
5
 
7
6
  from fastapi import APIRouter, Depends, HTTPException, Request
8
7
  from pydantic import BaseModel, Field, field_validator
@@ -13,7 +12,6 @@ from web3 import Web3
13
12
  from iwa.core.db import SentTransaction
14
13
  from iwa.web.dependencies import verify_auth, wallet
15
14
 
16
- logger = logging.getLogger(__name__)
17
15
  router = APIRouter(prefix="/api", tags=["transactions"])
18
16
 
19
17
  # Rate limiter for this router
iwa/web/server.py CHANGED
@@ -9,11 +9,13 @@ from fastapi import FastAPI, Request
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
10
  from fastapi.responses import HTMLResponse, JSONResponse
11
11
  from fastapi.staticfiles import StaticFiles
12
+ from loguru import logger
12
13
  from slowapi import Limiter, _rate_limit_exceeded_handler
13
14
  from slowapi.errors import RateLimitExceeded
14
15
  from slowapi.util import get_remote_address
15
16
  from starlette.middleware.base import BaseHTTPMiddleware
16
17
 
18
+ from iwa.core.utils import configure_logger
17
19
  from iwa.core.wallet import init_db
18
20
 
19
21
  # Pre-load cowdao_cowpy modules BEFORE async loop starts
@@ -21,15 +23,15 @@ from iwa.core.wallet import init_db
21
23
  # which fails if called from an already running event loop
22
24
  from iwa.plugins.gnosis.cow_utils import get_cowpy_module
23
25
 
24
- get_cowpy_module("DEFAULT_APP_DATA_HASH") # Forces import now, not during async
25
-
26
- # Import dependencies to ensure initialization
27
26
  # Import routers
28
- from iwa.web.routers import accounts, olas, state, swap, transactions # noqa: E402
27
+ from iwa.web.routers import accounts, olas, state, swap, transactions
28
+
29
+ get_cowpy_module("DEFAULT_APP_DATA_HASH") # Forces import now, not during async
29
30
 
30
- # Configure logging
31
+ # Configure logging (writes to iwa.log for frontend visibility)
32
+ configure_logger()
33
+ # Initialize standard logging for third-party libs (silenced by configure_logger but needed for basics)
31
34
  logging.basicConfig(level=logging.INFO)
32
- logger = logging.getLogger(__name__)
33
35
 
34
36
 
35
37
  # Rate limiter (in-memory storage, resets on restart)
iwa/web/static/app.js CHANGED
@@ -2564,6 +2564,8 @@ document.addEventListener("DOMContentLoaded", () => {
2564
2564
  // Show spinner, hide select, disable button
2565
2565
  select.style.display = "none";
2566
2566
  spinnerDiv.style.display = "block";
2567
+ spinnerDiv.innerHTML =
2568
+ '<span class="loading-spinner"></span> Loading contracts...';
2567
2569
  confirmBtn.disabled = true;
2568
2570
  modal.classList.add("active");
2569
2571
 
@@ -2689,6 +2691,8 @@ document.addEventListener("DOMContentLoaded", () => {
2689
2691
  // Load staking contracts
2690
2692
  contractSelect.style.display = "none";
2691
2693
  spinnerDiv.style.display = "block";
2694
+ spinnerDiv.innerHTML =
2695
+ '<span class="loading-spinner"></span> Loading contracts...';
2692
2696
  submitBtn.disabled = true;
2693
2697
 
2694
2698
  try {
@@ -2877,22 +2881,40 @@ document.addEventListener("DOMContentLoaded", () => {
2877
2881
  'button[type="submit"]',
2878
2882
  );
2879
2883
  contractSelect.style.display = "none";
2884
+
2885
+ // Remove hidden class to ensure visibility (overrides CSS !important)
2886
+ spinnerDiv.classList.remove("hidden");
2880
2887
  spinnerDiv.style.display = "block";
2888
+ spinnerDiv.innerHTML =
2889
+ '<span class="loading-spinner"></span> Loading contracts...';
2890
+
2881
2891
  submitBtn.disabled = true;
2892
+
2882
2893
  authFetch("/api/olas/staking-contracts?chain=gnosis")
2883
2894
  .then((resp) => resp.json())
2884
2895
  .then((contracts) => {
2885
2896
  state.stakingContractsCache = contracts;
2886
2897
  contractSelect.innerHTML = renderContractOptions(contracts);
2898
+
2887
2899
  contractSelect.style.display = "";
2900
+ contractSelect.classList.remove("hidden");
2901
+
2902
+ // Hide spinner
2888
2903
  spinnerDiv.style.display = "none";
2904
+ spinnerDiv.classList.add("hidden");
2905
+
2889
2906
  submitBtn.disabled = false;
2890
2907
  })
2891
2908
  .catch(() => {
2892
2909
  contractSelect.innerHTML =
2893
2910
  '<option value="">None (don\'t stake)</option>';
2894
2911
  contractSelect.style.display = "";
2912
+ contractSelect.classList.remove("hidden");
2913
+
2914
+ // Hide spinner even on error
2895
2915
  spinnerDiv.style.display = "none";
2916
+ spinnerDiv.classList.add("hidden");
2917
+
2896
2918
  submitBtn.disabled = false;
2897
2919
  });
2898
2920
  }
@@ -26,7 +26,7 @@ async def override_verify_auth():
26
26
  app.dependency_overrides[verify_auth] = override_verify_auth
27
27
 
28
28
 
29
- @pytest.fixture
29
+ @pytest.fixture(scope="module")
30
30
  def client():
31
31
  """TestClient for FastAPI app."""
32
32
  return TestClient(app, raise_server_exceptions=False)
@@ -28,7 +28,7 @@ async def override_verify_auth():
28
28
  app.dependency_overrides[verify_auth] = override_verify_auth
29
29
 
30
30
 
31
- @pytest.fixture
31
+ @pytest.fixture(scope="module")
32
32
  def client():
33
33
  """TestClient for FastAPI app."""
34
34
  return TestClient(app)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.1a6
3
+ Version: 0.0.10
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