iwa 0.0.13__py3-none-any.whl → 0.0.15__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.
@@ -379,7 +379,8 @@ class ChainInterface:
379
379
  """
380
380
  try:
381
381
  # Call decimals() directly without with_retry to avoid error logging
382
- contract = self.web3.eth.contract(
382
+ # Use _web3 directly to ensure current provider after RPC rotation
383
+ contract = self.web3._web3.eth.contract(
383
384
  address=self.web3.to_checksum_address(address),
384
385
  abi=[{"constant": True, "inputs": [], "name": "decimals", "outputs": [{"type": "uint8"}], "type": "function"}]
385
386
  )
@@ -19,7 +19,18 @@ logger = configure_logger()
19
19
  ERROR_SELECTOR = "0x08c379a0" # Error(string)
20
20
  PANIC_SELECTOR = "0x4e487b71" # Panic(uint256)
21
21
 
22
+ # Global cache for ABIs and error selectors to avoid redundant disk I/O and parsing
23
+ # Format: {abi_path: {"abi": [...], "selectors": {...}}}
24
+ _ABI_CACHE: Dict[str, Dict[str, Any]] = {}
25
+
26
+
27
+ def clear_abi_cache() -> None:
28
+ """Clear the global ABI cache (mainly for testing)."""
29
+ global _ABI_CACHE
30
+ _ABI_CACHE = {}
31
+
22
32
  # Panic codes (from Solidity)
33
+ # ... (rest of PANIC_CODES) ...
23
34
  PANIC_CODES = {
24
35
  0x00: "Generic compiler inserted panic",
25
36
  0x01: "Assert failed",
@@ -46,16 +57,25 @@ class ContractInstance:
46
57
  self.abi = None
47
58
  self.chain_interface = ChainInterfaces().get(chain_name)
48
59
 
49
- with open(self.abi_path, "r", encoding="utf-8") as abi_file:
50
- contract_abi = json.load(abi_file)
60
+ # Check global cache first
61
+ cache_key = str(self.abi_path)
62
+ if cache_key in _ABI_CACHE:
63
+ self.abi = _ABI_CACHE[cache_key]["abi"]
64
+ self.error_selectors = _ABI_CACHE[cache_key]["selectors"]
65
+ else:
66
+ with open(self.abi_path, "r", encoding="utf-8") as abi_file:
67
+ contract_abi = json.load(abi_file)
51
68
 
52
- if isinstance(contract_abi, dict) and "abi" in contract_abi:
53
- self.abi = contract_abi.get("abi")
54
- else:
55
- self.abi = contract_abi
69
+ if isinstance(contract_abi, dict) and "abi" in contract_abi:
70
+ self.abi = contract_abi.get("abi")
71
+ else:
72
+ self.abi = contract_abi
73
+
74
+ self.error_selectors = self.load_error_selectors()
75
+ # Store in global cache
76
+ _ABI_CACHE[cache_key] = {"abi": self.abi, "selectors": self.error_selectors}
56
77
 
57
78
  self._contract_cache = None
58
- self.error_selectors = self.load_error_selectors()
59
79
 
60
80
  @property
61
81
  def contract(self) -> Contract:
@@ -63,10 +83,15 @@ class ContractInstance:
63
83
 
64
84
  This property ensures that after an RPC rotation, contract calls
65
85
  use the updated provider instead of the original one.
86
+
87
+ Note: We use _web3 directly (not the RateLimitedWeb3 wrapper) to ensure
88
+ the contract is bound to the current provider. The wrapper's set_backend()
89
+ updates _web3, but contracts created via the wrapper may cache old providers.
66
90
  """
67
91
  # Always create a fresh contract to use the current Web3 provider
68
92
  # This is necessary because RPC rotation changes the underlying provider
69
- return self.chain_interface.web3.eth.contract(address=self.address, abi=self.abi)
93
+ # Access _web3 directly to ensure we get the current provider
94
+ return self.chain_interface.web3._web3.eth.contract(address=self.address, abi=self.abi)
70
95
 
71
96
  def load_error_selectors(self) -> Dict[str, Any]:
72
97
  """Load error selectors from the contract ABI."""
iwa/core/pricing.py CHANGED
@@ -12,12 +12,14 @@ from iwa.core.secrets import secrets
12
12
  # Global cache shared across all PriceService instances
13
13
  _PRICE_CACHE: Dict[str, Dict] = {}
14
14
  _CACHE_TTL = timedelta(minutes=30)
15
+ _NEGATIVE_CACHE_TTL = timedelta(minutes=5)
15
16
 
16
17
 
17
18
  class PriceService:
18
19
  """Service to fetch token prices from CoinGecko."""
19
20
 
20
21
  BASE_URL = "https://api.coingecko.com/api/v3"
22
+ DEMO_URL = "https://demo-api.coingecko.com/api/v3"
21
23
 
22
24
  def __init__(self):
23
25
  """Initialize PriceService."""
@@ -41,15 +43,16 @@ class PriceService:
41
43
  """
42
44
  cache_key = f"{token_id}_{vs_currency}"
43
45
 
44
- # Check global cache
46
+ # Check global cache (including negative cache)
45
47
  if cache_key in _PRICE_CACHE:
46
48
  entry = _PRICE_CACHE[cache_key]
47
- if datetime.now() - entry["timestamp"] < _CACHE_TTL:
49
+ ttl = _CACHE_TTL if entry["price"] is not None else _NEGATIVE_CACHE_TTL
50
+ if datetime.now() - entry["timestamp"] < ttl:
48
51
  return entry["price"]
49
52
 
50
53
  price = self._fetch_price_from_api(token_id, vs_currency)
51
- if price is not None:
52
- _PRICE_CACHE[cache_key] = {"price": price, "timestamp": datetime.now()}
54
+ # We always cache, even if price is None (negative caching)
55
+ _PRICE_CACHE[cache_key] = {"price": price, "timestamp": datetime.now()}
53
56
  return price
54
57
 
55
58
  def _fetch_price_from_api(self, token_id: str, vs_currency: str) -> Optional[float]:
@@ -57,7 +60,10 @@ class PriceService:
57
60
  max_retries = 2
58
61
  for attempt in range(max_retries + 1):
59
62
  try:
60
- url = f"{self.BASE_URL}/simple/price"
63
+ # Use demo URL if API key is present, otherwise standard URL
64
+ # NOTE: Demo URL is significantly more reliable for demo keys
65
+ base_url = self.DEMO_URL if self.api_key else self.BASE_URL
66
+ url = f"{base_url}/simple/price"
61
67
  params = {"ids": token_id, "vs_currencies": vs_currency}
62
68
  headers = {}
63
69
  if self.api_key:
@@ -69,6 +75,8 @@ class PriceService:
69
75
  logger.warning("CoinGecko API key invalid (401). Retrying without key...")
70
76
  self.api_key = None
71
77
  headers.pop("x-cg-demo-api-key", None)
78
+ # Re-run with base URL
79
+ url = f"{self.BASE_URL}/simple/price"
72
80
  response = requests.get(url, params=params, headers=headers, timeout=10)
73
81
 
74
82
  if response.status_code == 429:
@@ -87,13 +95,16 @@ class PriceService:
87
95
  if token_id in data and vs_currency in data[token_id]:
88
96
  return float(data[token_id][vs_currency])
89
97
 
90
- logger.warning(
98
+ # If we got response but price not found, it's likely a wrong ID
99
+ logger.debug(
91
100
  f"Price for {token_id} in {vs_currency} not found in response: {data}"
92
101
  )
93
102
  return None
94
103
 
95
104
  except Exception as e:
96
- logger.error(f"Failed to fetch price for {token_id} (Attempt {attempt + 1}): {e}")
105
+ # Only log error on last attempt to avoid spamming
106
+ if attempt == max_retries:
107
+ logger.error(f"Failed to fetch price for {token_id}: {e}")
97
108
  if attempt < max_retries:
98
109
  time.sleep(1)
99
110
  continue
iwa/core/utils.py CHANGED
@@ -87,7 +87,7 @@ def configure_logger():
87
87
 
88
88
  def get_version(package_name: str) -> str:
89
89
  """Get package version."""
90
- from importlib.metadata import version, PackageNotFoundError
90
+ from importlib.metadata import PackageNotFoundError, version
91
91
  try:
92
92
  return version(package_name)
93
93
  except PackageNotFoundError:
@@ -51,6 +51,10 @@ class ServiceManagerBase:
51
51
 
52
52
  def _init_contracts(self, chain_name: str) -> None:
53
53
  """Initialize contracts for the given chain."""
54
+ # OPTIMIZATION: Skip if already initialized for this chain
55
+ if getattr(self, "chain_name", None) == chain_name.lower() and hasattr(self, "registry"):
56
+ return
57
+
54
58
  chain_interface = ChainInterfaces().get(chain_name)
55
59
 
56
60
  # Get protocol contracts from plugin-local constants
@@ -63,9 +67,9 @@ class ServiceManagerBase:
63
67
 
64
68
  self.registry = ServiceRegistryContract(registry_address, chain_name=chain_name)
65
69
  self.manager = ServiceManagerContract(manager_address, chain_name=chain_name)
66
- logger.info(f"[SM-INIT] ServiceManager initialized. Chain: {chain_name}")
67
- logger.info(f"[SM-INIT] Registry Address: {self.registry.address}")
68
- logger.info(f"[SM-INIT] Manager Address: {self.manager.address}")
70
+ logger.debug(f"[SM-INIT] ServiceManager initialized. Chain: {chain_name}")
71
+ logger.debug(f"[SM-INIT] Registry Address: {self.registry.address}")
72
+ logger.debug(f"[SM-INIT] Manager Address: {self.manager.address}")
69
73
  self.chain_interface = chain_interface
70
74
  self.chain_name = chain_name.lower()
71
75
 
@@ -194,9 +194,10 @@ def test_staking_contract(tmp_path): # noqa: C901
194
194
  mock_chain = MagicMock()
195
195
  mock_interfaces.return_value.get.return_value = mock_chain
196
196
 
197
- # Mock web3
197
+ # Mock web3 - use _web3 since contract.py now accesses _web3 directly
198
198
  mock_web3 = MagicMock()
199
199
  mock_chain.web3 = mock_web3
200
+ mock_chain.web3._web3 = mock_web3 # For RPC rotation fix
200
201
 
201
202
  # Mock contract factory
202
203
  mock_contract = MagicMock()
@@ -252,6 +253,9 @@ def test_staking_contract(tmp_path): # noqa: C901
252
253
 
253
254
  staking.ts_checkpoint = MagicMock(return_value=0)
254
255
 
256
+ # Mock get_required_requests to return an int (not MagicMock)
257
+ staking.get_required_requests = MagicMock(return_value=2)
258
+
255
259
  # Trigger original_open hit
256
260
  try:
257
261
  test_file = tmp_path / "test_lookup.txt"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.13
3
+ Version: 0.0.15
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
@@ -10,22 +10,22 @@ iwa/core/mnemonic.py,sha256=LiG1VmpydQoHQ0pHUJ1OIlrWJry47VSMnOqPM_Yk-O8,12930
10
10
  iwa/core/models.py,sha256=kBQ0cBe6uFmL2QfW7mjKiMFeZxhT-FRN-RyK3Ko0vE8,12849
11
11
  iwa/core/monitor.py,sha256=OmhKVMkfhvtxig3wDUL6iGwBIClTx0YUqMncCao4SqI,7953
12
12
  iwa/core/plugins.py,sha256=FLvOG4S397fKi0aTH1fWBEtexn4yvGv_QzGWqFrhSKE,1102
13
- iwa/core/pricing.py,sha256=B8dAUszv9t5EqvXQ7gh9CNBqlMLR598-c6Pv3NqtpO4,3561
13
+ iwa/core/pricing.py,sha256=U-vIjJwzh6O7MZTAFZx6mFFxaiLcwn6PnsZc4g0z4IQ,4293
14
14
  iwa/core/secrets.py,sha256=U7DZKrwKuSOFV00Ij3ISrrO1cWn_t1GBW_0PyAqjcD4,2588
15
15
  iwa/core/tables.py,sha256=y7Cg67PAGHYVMVyAjbo_CQ9t2iz7UXE-OTuUHRyFRTo,2021
16
16
  iwa/core/test.py,sha256=gey0dql5eajo1itOhgkSrgfyGWue2eSfpr0xzX3vc38,643
17
17
  iwa/core/types.py,sha256=EfDfIwLajTNK-BP9K17QLOIsGCs8legplKI_bUD_NjM,1992
18
18
  iwa/core/ui.py,sha256=DglmrI7XhUmOpLn9Nog9Cej4r-VT0JGFkuSNBx-XorQ,3131
19
- iwa/core/utils.py,sha256=TYgRQVbSJ0qSLh1azhX9DP9APlwf9QQa4FvGXvEpQ3o,4279
19
+ iwa/core/utils.py,sha256=shJuANkXSWVO3NF49syPA9hCG7H5AzaMJOG8V4fo6IM,4279
20
20
  iwa/core/wallet.py,sha256=sNFK-_0y-EgeLpNHt9o5tCqTM0oVqJra-eAWjR7AgyU,13038
21
21
  iwa/core/chain/__init__.py,sha256=XJMmn0ed-_aVkY2iEMKpuTxPgIKBd41dexSVmEZTa-o,1604
22
22
  iwa/core/chain/errors.py,sha256=9SEbhxZ-qASPkzt-DoI51qq0GRJVqRgqgL720gO7a64,1275
23
- iwa/core/chain/interface.py,sha256=GRfWvSPUKdFNrkFD-lR7qFJYbQJs-oE7G_tQMBn4jA0,21097
23
+ iwa/core/chain/interface.py,sha256=BIUPJUAV8oeeiUtZcj0zi2B0mGad2qS5ajy1PhBT-1k,21182
24
24
  iwa/core/chain/manager.py,sha256=cFEzh6pK5OyVhjhpeMAqhc9RnRDQR1DjIGiGKp-FXBI,1159
25
25
  iwa/core/chain/models.py,sha256=0OgBo08FZEQisOdd00YUMXSAV7BC0CcWpqJ2y-gs0cI,4863
26
26
  iwa/core/chain/rate_limiter.py,sha256=gU7TmWdH9D_wbXKT1X7mIgoIUCWVuebgvRhxiyLGAmI,6613
27
27
  iwa/core/contracts/__init__.py,sha256=P5GFY_pnuI02teqVY2U0t98bn1_SSPAbcAzRMpCdTi4,34
28
- iwa/core/contracts/contract.py,sha256=qHU1bvo1uhL2smY-dOrLSwVo6fb0ouWcpMsralGwJio,11440
28
+ iwa/core/contracts/contract.py,sha256=N2I4-38O_6awqVrTEdJFh4XhsMbeu2JtFvkboUC4ZMg,12517
29
29
  iwa/core/contracts/erc20.py,sha256=VqriOdUXej0ilTgpukpm1FUF_9sSrVMAPuEpIvyZ2SQ,2646
30
30
  iwa/core/contracts/multisend.py,sha256=tSdBCWe7LSdBoKZ7z2QebmRFK4M2ln7H3kmJBeEb4Ho,2431
31
31
  iwa/core/contracts/abis/erc20.json,sha256=vrdExMWcIogg_nO59j1Pmipmpa2Ulj3oCCdcdrrFVCE,16995
@@ -79,7 +79,7 @@ iwa/plugins/olas/contracts/abis/staking_token.json,sha256=cuUOmi1s4Z6VSIX0an_IxK
79
79
  iwa/plugins/olas/scripts/test_full_mech_flow.py,sha256=id9IxC06FOKwexgwsG5nbsTB2rQLlIq5a5soMvK0IgY,9021
80
80
  iwa/plugins/olas/scripts/test_simple_lifecycle.py,sha256=8T50tOZx3afeECSfCNAb0rAHNtYOsBaeXlMwKXElCk8,2099
81
81
  iwa/plugins/olas/service_manager/__init__.py,sha256=GXiThMEY3nPgHUl1i-DLrF4h96z9jPxxI8Jepo2E1PM,1926
82
- iwa/plugins/olas/service_manager/base.py,sha256=V4o71ZYUSc_GzM-RWocaAGsrqI-fa5V7CcyloVpcjwE,4666
82
+ iwa/plugins/olas/service_manager/base.py,sha256=CCTH7RiYtgyFwRszrMLxNf1rNM_6leWHuJJmse4m2wI,4854
83
83
  iwa/plugins/olas/service_manager/drain.py,sha256=IS7YYKuQdkULcNdxfHVzjcq95pXKdpajolzLL78u4jc,12430
84
84
  iwa/plugins/olas/service_manager/lifecycle.py,sha256=DIB6yrP0VPICu6558uQJuFp2sgrA66iVNTzZVUUowGw,47159
85
85
  iwa/plugins/olas/service_manager/mech.py,sha256=72-tEap-aYd0gebcH6y_De1SNeL6OrXn7sWuv_Ok_-Y,12224
@@ -104,7 +104,7 @@ iwa/plugins/olas/tests/test_service_manager_mech.py,sha256=qG6qu5IPRNypXUsblU2OE
104
104
  iwa/plugins/olas/tests/test_service_manager_rewards.py,sha256=hIVckGl5rEr-KHGNUxxVopal0Tpw4EZharBt0MbaCVA,11798
105
105
  iwa/plugins/olas/tests/test_service_manager_validation.py,sha256=ajlfH5uc4mAHf8A7GLE5cW7X8utM2vUilM0JdGDdlVg,5382
106
106
  iwa/plugins/olas/tests/test_service_staking.py,sha256=XkC8H_qVumOQ1i4_vidEdXqkK5stInd2tgT50l0SRMk,12201
107
- iwa/plugins/olas/tests/test_staking_integration.py,sha256=zQdhhSLz5h2OvQ6GOJ-Yl_Y17itPML4Pr9VPkjWoz9c,9420
107
+ iwa/plugins/olas/tests/test_staking_integration.py,sha256=QCBQf6P2ZmmsEGt2k8W2r53lG2aVRuoMJE-aFxVDLss,9701
108
108
  iwa/plugins/olas/tests/test_staking_validation.py,sha256=J7DgDdIiVTkKv_7obtSHQ2lgfUGPmUwuPjTUkiT4cbs,4198
109
109
  iwa/plugins/olas/tui/__init__.py,sha256=5ZRsbC7J3z1xfkZRiwr4bLEklf78rNVjdswe2p7SlS8,28
110
110
  iwa/plugins/olas/tui/olas_view.py,sha256=qcPxhurDPJjHWln6R64ZVAJ2h82IXzw48unhRvQVZqQ,36448
@@ -150,7 +150,7 @@ iwa/web/tests/test_web_endpoints.py,sha256=C264MH-CTyDW4GLUrTXBgLJKUk4-89pFAScBd
150
150
  iwa/web/tests/test_web_olas.py,sha256=0CVSsrncOeJ3x0ECV7mVLQV_CXZRrOqGiVjgLIi6hZ8,16308
151
151
  iwa/web/tests/test_web_swap.py,sha256=7A4gBJFL01kIXPtW1E1J17SCsVc_0DmUn-R8kKrnnVA,2974
152
152
  iwa/web/tests/test_web_swap_coverage.py,sha256=zGNrzlhZ_vWDCvWmLcoUwFgqxnrp_ACbo49AtWBS_Kw,5584
153
- iwa-0.0.13.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
153
+ iwa-0.0.15.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
154
154
  tests/legacy_cow.py,sha256=oOkZvIxL70ReEoD9oHQbOD5GpjIr6AGNHcOCgfPlerU,8389
155
155
  tests/legacy_safe.py,sha256=AssM2g13E74dNGODu_H0Q0y412lgqsrYnEzI97nm_Ts,2972
156
156
  tests/legacy_transaction_retry_logic.py,sha256=D9RqZ7DBu61Xr2djBAodU2p9UE939LL-DnQXswX5iQk,1497
@@ -159,11 +159,11 @@ tests/legacy_wallets_screen.py,sha256=9hZnX-VhKgwH9w8MxbNdboRyNxLDhOakLKJECsw_vh
159
159
  tests/legacy_web.py,sha256=q2ERIriaDHT3Q8axG2N3ucO7f2VSvV_WkuPR00DVko4,8577
160
160
  tests/test_account_service.py,sha256=g_AIVT2jhlvUtbFTaCd-d15x4CmXJQaV66tlAgnaXwY,3745
161
161
  tests/test_balance_service.py,sha256=86iEkPd2M1-UFy3qOxV1EguQOEYbboy2-2mAyS3ctGs,6549
162
- tests/test_chain.py,sha256=naDz3WNrB8J3hAcId8of8R4_jN0vEWMC28-70hQEU_s,17240
162
+ tests/test_chain.py,sha256=z3Mo9mHNQZ0aXSlrHUcdtNgGqsNyOwWYQAmmKa_dqiM,17221
163
163
  tests/test_chain_interface.py,sha256=Wu0q0sREtmYBp7YvWrBIrrSTtqeQj18oJp2VmMUEMec,8312
164
164
  tests/test_chain_interface_coverage.py,sha256=fvrVvw8-DMwdsSFKQHUhpbfutrVRxnnTc-tjB7Bb-jo,3327
165
165
  tests/test_cli.py,sha256=WW6EDeHLws5-BqFNOy11pH_D5lttuyspD5hrDCFpR0Q,3968
166
- tests/test_contract.py,sha256=MuL9dI_EJXo7hNiapUPNqmP87LRh3_HluAtOEGEWyus,14116
166
+ tests/test_contract.py,sha256=tApHAxsfKGawYJWA9PhTNrOZUE0VVAq79ruIe3KxeWY,14412
167
167
  tests/test_db.py,sha256=dmbrupj0qlUeiiycZ2mzMFjf7HrDa6tcqMPY8zpiKIk,5710
168
168
  tests/test_drain_coverage.py,sha256=jtN5tIXzSTlS2IjwLS60azyMYsjFDlSTUa98JM1bMic,6786
169
169
  tests/test_erc20.py,sha256=kNEw1afpm5EbXRNXkjpkBNZIy7Af1nqGlztKH5IWAwU,3074
@@ -181,6 +181,7 @@ tests/test_plugin_service.py,sha256=ZEe37kV_sv4Eb04032O1hZIoo9yf5gJo83ks7Grzrng,
181
181
  tests/test_pricing.py,sha256=ptu_2Csc6d64bIzMMw3TheJge2Kfn05Gs-twz_KmBzg,5276
182
182
  tests/test_rate_limiter.py,sha256=DOIlrBP2AtVFHCpznIoFn2FjFc33emG7M_FffLh4MGE,7314
183
183
  tests/test_reset_tenderly.py,sha256=GVoqbDT3n4_GnlKF5Lx-8ew15jT8I2hIPdTulQDb6dI,7215
184
+ tests/test_rpc_rotation.py,sha256=YU21TrMnEbcb36zu_L8dpCSbaHX7CkqXLjipz1swkkc,10554
184
185
  tests/test_rpc_view.py,sha256=sgZ53KEHl8VGb7WKYa0VI7Cdxbf8JH1SdroHYbWHjfQ,2031
185
186
  tests/test_safe_coverage.py,sha256=g9Bdrpkc-Mc8HBjk07lYNRkzxWZiF922uiVLZqhehBE,5093
186
187
  tests/test_safe_service.py,sha256=nxDYmGd6p2gGe7BEeMxsqS8CgeJarPofV38HC6Cop44,5770
@@ -201,8 +202,8 @@ tests/test_utils.py,sha256=vkP49rYNI8BRzLpWR3WnKdDr8upeZjZcs7Rx0pjbQMo,1292
201
202
  tests/test_workers.py,sha256=MInwdkFY5LdmFB3o1odIaSD7AQZb3263hNafO1De5PE,2793
202
203
  tools/create_and_stake_service.py,sha256=1xwy_bJQI1j9yIQ968Oc9Db_F6mk1659LuuZntTASDE,3742
203
204
  tools/verify_drain.py,sha256=PkMjblyOOAuQge88FwfEzRtCYeEtJxXhPBmtQYCoQ-8,6743
204
- iwa-0.0.13.dist-info/METADATA,sha256=P1a89NYKFwWM2PaXwT_8j8GLbPOT2cob0P2rsLshjeY,7295
205
- iwa-0.0.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
206
- iwa-0.0.13.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
207
- iwa-0.0.13.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
208
- iwa-0.0.13.dist-info/RECORD,,
205
+ iwa-0.0.15.dist-info/METADATA,sha256=J9pChdhUaRH85yUje-9tNp2xCTdncgmxJHnMuV9kv7M,7295
206
+ iwa-0.0.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
207
+ iwa-0.0.15.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
208
+ iwa-0.0.15.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
209
+ iwa-0.0.15.dist-info/RECORD,,
tests/test_chain.py CHANGED
@@ -340,10 +340,10 @@ def test_chain_interface_with_real_chains():
340
340
  sym = interface.get_token_symbol(valid_addr_1)
341
341
  assert sym == "SYM"
342
342
 
343
- # get_token_decimals uses web3.eth.contract directly, not ERC20Contract
343
+ # get_token_decimals uses web3._web3.eth.contract directly
344
344
  mock_contract = MagicMock()
345
345
  mock_contract.functions.decimals.return_value.call.return_value = 18
346
- interface.web3.eth.contract.return_value = mock_contract
346
+ interface.web3._web3.eth.contract.return_value = mock_contract
347
347
 
348
348
  dec = interface.get_token_decimals(valid_addr_1)
349
349
  assert dec == 18
@@ -443,8 +443,8 @@ def test_get_token_decimals_fallback_on_error(mock_web3):
443
443
 
444
444
  ci = ChainInterface(chain)
445
445
 
446
- # get_token_decimals uses web3.eth.contract directly, mock it to raise error
447
- ci.web3.eth.contract.side_effect = Exception("Contract not found")
446
+ # get_token_decimals uses web3._web3.eth.contract directly
447
+ ci.web3._web3.eth.contract.side_effect = Exception("Contract not found")
448
448
 
449
449
  decimals = ci.get_token_decimals("0x1234567890123456789012345678901234567890")
450
450
 
tests/test_contract.py CHANGED
@@ -4,14 +4,23 @@ from unittest.mock import MagicMock, mock_open, patch
4
4
  import pytest
5
5
  from web3.exceptions import ContractCustomError
6
6
 
7
- from iwa.core.contracts.contract import ContractInstance
7
+ from iwa.core.contracts.contract import ContractInstance, clear_abi_cache
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def clean_abi_cache():
12
+ """Clear global ABI cache before each test."""
13
+ clear_abi_cache()
14
+ yield
15
+ clear_abi_cache()
8
16
 
9
17
 
10
18
  @pytest.fixture
11
19
  def mock_chain_interface():
12
20
  with patch("iwa.core.contracts.contract.ChainInterfaces") as mock:
13
21
  mock_ci = mock.return_value.get.return_value
14
- mock_ci.web3.eth.contract.return_value = MagicMock()
22
+ # contract.py now uses web3._web3.eth.contract directly for RPC rotation compatibility
23
+ mock_ci.web3._web3.eth.contract.return_value = MagicMock()
15
24
  yield mock_ci
16
25
 
17
26
 
@@ -226,7 +235,7 @@ def test_call_reevaluates_contract_on_retry(mock_chain_interface, mock_abi_file)
226
235
  mock.functions.testFunc.return_value.call.return_value = "success"
227
236
  return mock
228
237
 
229
- mock_chain_interface.web3.eth.contract.side_effect = counting_contract_factory
238
+ mock_chain_interface.web3._web3.eth.contract.side_effect = counting_contract_factory
230
239
 
231
240
  # Implement with_retry that actually retries on 429
232
241
  def real_with_retry(fn, max_retries=6, operation_name="operation"):
@@ -274,7 +283,7 @@ def test_call_uses_fresh_provider_after_rotation(mock_chain_interface, mock_abi_
274
283
  provider_versions.append(current_provider_version[0])
275
284
  return mock
276
285
 
277
- mock_chain_interface.web3.eth.contract.side_effect = mock_contract_factory
286
+ mock_chain_interface.web3._web3.eth.contract.side_effect = mock_contract_factory
278
287
 
279
288
  # Simulate RPC rotation by incrementing provider version
280
289
  def simulate_rotation():
@@ -335,7 +344,7 @@ def test_call_with_429_triggers_retry_with_new_contract(mock_chain_interface, mo
335
344
  contract_call_count[0] += 1
336
345
  return result
337
346
 
338
- mock_chain_interface.web3.eth.contract.side_effect = mock_contract_factory
347
+ mock_chain_interface.web3._web3.eth.contract.side_effect = mock_contract_factory
339
348
 
340
349
  # Implement with_retry that actually retries
341
350
  def real_with_retry(fn, max_retries=6, operation_name="operation"):
@@ -0,0 +1,287 @@
1
+ """Comprehensive RPC rotation tests.
2
+
3
+ These tests verify that RPC rotation works correctly when rate limit errors occur,
4
+ ensuring that after rotation, requests actually go to the new RPC.
5
+ """
6
+
7
+ from unittest.mock import MagicMock, PropertyMock, patch
8
+
9
+ import pytest
10
+
11
+ from iwa.core.chain import ChainInterface, SupportedChain
12
+
13
+
14
+ class MockHTTPError(Exception):
15
+ """Mock HTTP 429 error with URL info."""
16
+
17
+ def __init__(self, url: str):
18
+ self.url = url
19
+ super().__init__(f"429 Client Error: Too Many Requests for url: {url}")
20
+
21
+
22
+ @pytest.fixture
23
+ def multi_rpc_chain():
24
+ """Create a chain with multiple RPCs for testing rotation."""
25
+ chain = MagicMock(spec=SupportedChain)
26
+ chain.name = "TestChain"
27
+ chain.rpcs = [
28
+ "https://rpc1.example.com",
29
+ "https://rpc2.example.com",
30
+ "https://rpc3.example.com",
31
+ "https://rpc4.example.com",
32
+ "https://rpc5.example.com",
33
+ ]
34
+ chain.chain_id = 1
35
+ chain.native_currency = "ETH"
36
+ chain.tokens = {}
37
+ type(chain).rpc = PropertyMock(return_value=chain.rpcs[0])
38
+ return chain
39
+
40
+
41
+ @pytest.fixture
42
+ def mock_web3_factory():
43
+ """Factory to create mock Web3 instances that track their RPC URL."""
44
+
45
+ def create_mock_web3(rpc_url: str):
46
+ mock = MagicMock()
47
+ mock.provider.endpoint_uri = rpc_url
48
+ mock.eth.block_number = 12345
49
+ return mock
50
+
51
+ return create_mock_web3
52
+
53
+
54
+ def test_rpc_rotation_basic(multi_rpc_chain):
55
+ """Test that RPC rotation cycles through all available RPCs."""
56
+ with patch("iwa.core.chain.interface.RateLimitedWeb3", side_effect=lambda w3, rl, ci: w3):
57
+ ci = ChainInterface(multi_rpc_chain)
58
+
59
+ # Should start at index 0
60
+ assert ci._current_rpc_index == 0
61
+
62
+ # Rotate through all RPCs
63
+ for expected_index in [1, 2, 3, 4, 0, 1]: # Wraps around
64
+ result = ci.rotate_rpc()
65
+ assert result is True
66
+ assert ci._current_rpc_index == expected_index
67
+
68
+
69
+ def test_rpc_rotation_updates_provider():
70
+ """Test that after rotation, set_backend is called to update the provider.
71
+
72
+ The current implementation uses set_backend() to hot-swap the underlying
73
+ Web3 instance rather than creating a new RateLimitedWeb3 wrapper.
74
+ """
75
+ chain = MagicMock(spec=SupportedChain)
76
+ chain.name = "TestChain"
77
+ chain.rpcs = ["https://rpc1.example.com", "https://rpc2.example.com"]
78
+ type(chain).rpc = PropertyMock(return_value=chain.rpcs[0])
79
+
80
+ set_backend_calls = []
81
+
82
+ class MockRateLimitedWeb3:
83
+ def __init__(self, w3, rl, ci):
84
+ self._web3 = w3
85
+ self.set_backend = MagicMock(side_effect=lambda new_w3: set_backend_calls.append(new_w3))
86
+
87
+ def __getattr__(self, name):
88
+ return getattr(self._web3, name)
89
+
90
+ with patch("iwa.core.chain.interface.Web3") as mock_web3_class:
91
+ with patch("iwa.core.chain.interface.RateLimitedWeb3", MockRateLimitedWeb3):
92
+ # Make Web3 return a mock with the correct provider URL
93
+ def create_web3_mock(provider):
94
+ mock = MagicMock()
95
+ mock.provider = provider
96
+ return mock
97
+
98
+ mock_web3_class.side_effect = create_web3_mock
99
+ mock_web3_class.HTTPProvider = lambda url, **kwargs: MagicMock(endpoint_uri=url)
100
+
101
+ ci = ChainInterface(chain)
102
+
103
+ # Initially no set_backend calls
104
+ assert len(set_backend_calls) == 0
105
+
106
+ # Rotate
107
+ ci.rotate_rpc()
108
+
109
+ # After rotation, set_backend should have been called
110
+ assert len(set_backend_calls) == 1
111
+ # And it should have been called with a new Web3 instance
112
+ assert set_backend_calls[0].provider.endpoint_uri == "https://rpc2.example.com"
113
+
114
+
115
+ def test_rate_limit_triggers_rotation(multi_rpc_chain):
116
+ """Test that a 429 rate limit error triggers RPC rotation."""
117
+ with patch("iwa.core.chain.interface.RateLimitedWeb3", side_effect=lambda w3, rl, ci: w3):
118
+ ci = ChainInterface(multi_rpc_chain)
119
+
120
+ initial_index = ci._current_rpc_index
121
+ assert initial_index == 0
122
+
123
+ # Simulate a rate limit error
124
+ error = MockHTTPError("https://rpc1.example.com")
125
+ result = ci._handle_rpc_error(error)
126
+
127
+ # Should have detected rate limit and rotated
128
+ assert result["is_rate_limit"] is True
129
+ assert result["rotated"] is True
130
+ assert result["should_retry"] is True
131
+ assert ci._current_rpc_index == 1
132
+
133
+
134
+ def test_with_retry_rotates_on_rate_limit(multi_rpc_chain):
135
+ """Test that with_retry properly rotates RPC on rate limit and succeeds on new RPC."""
136
+ with patch("iwa.core.chain.interface.RateLimitedWeb3", side_effect=lambda w3, rl, ci: w3):
137
+ ci = ChainInterface(multi_rpc_chain)
138
+
139
+ call_count = 0
140
+ rpc_indices_seen = []
141
+
142
+ def flaky_operation():
143
+ nonlocal call_count
144
+ call_count += 1
145
+ rpc_indices_seen.append(ci._current_rpc_index)
146
+
147
+ # Fail on first RPC, succeed on second
148
+ if ci._current_rpc_index == 0:
149
+ raise MockHTTPError(ci.chain.rpcs[0])
150
+ return "success"
151
+
152
+ with patch("time.sleep"): # Skip actual delays
153
+ result = ci.with_retry(flaky_operation, operation_name="test_operation")
154
+
155
+ assert result == "success"
156
+ assert 0 in rpc_indices_seen # Started on RPC 0
157
+ assert 1 in rpc_indices_seen # Rotated to RPC 1
158
+ assert ci._current_rpc_index == 1 # Ended on RPC 1
159
+
160
+
161
+ def test_with_retry_exhausts_all_rpcs_then_backs_off(multi_rpc_chain):
162
+ """Test that when all RPCs fail, we trigger backoff."""
163
+ with patch("iwa.core.chain.interface.RateLimitedWeb3", side_effect=lambda w3, rl, ci: w3):
164
+ ci = ChainInterface(multi_rpc_chain)
165
+
166
+ # All RPCs fail
167
+ def always_fail():
168
+ raise MockHTTPError(ci.chain.rpcs[ci._current_rpc_index])
169
+
170
+ with patch("time.sleep"):
171
+ with pytest.raises(MockHTTPError):
172
+ ci.with_retry(always_fail, max_retries=6, operation_name="doomed_operation")
173
+
174
+ # Should have rotated through multiple RPCs
175
+ assert ci._current_rpc_index > 0
176
+
177
+
178
+ def test_rotation_applies_to_subsequent_calls():
179
+ """Test that after rotation, subsequent method calls use the new RPC.
180
+
181
+ This is the critical test - verifying that the fix for the RPC rotation
182
+ bug actually works. After rotation, calls should go to the new RPC.
183
+ """
184
+ chain = MagicMock(spec=SupportedChain)
185
+ chain.name = "TestChain"
186
+ chain.rpcs = ["https://rpc1.example.com", "https://rpc2.example.com"]
187
+ type(chain).rpc = PropertyMock(return_value=chain.rpcs[0])
188
+
189
+ with patch("iwa.core.chain.interface.Web3") as mock_web3_class:
190
+ # Track which provider is being used for each call
191
+ current_provider_url = ["https://rpc1.example.com"]
192
+
193
+ def create_web3(provider):
194
+ mock = MagicMock()
195
+ mock.provider = provider
196
+
197
+ # eth.get_balance should use the current provider
198
+ def mock_get_balance(addr):
199
+ return f"balance_from_{current_provider_url[0]}"
200
+
201
+ mock.eth.get_balance = mock_get_balance
202
+ mock.eth.block_number = 12345
203
+ return mock
204
+
205
+ def create_provider(url, **kwargs):
206
+ prov = MagicMock()
207
+ prov.endpoint_uri = url
208
+ current_provider_url[0] = url
209
+ return prov
210
+
211
+ mock_web3_class.side_effect = create_web3
212
+ mock_web3_class.HTTPProvider = create_provider
213
+
214
+ with patch(
215
+ "iwa.core.chain.interface.RateLimitedWeb3", side_effect=lambda w3, rl, ci: w3
216
+ ):
217
+ ci = ChainInterface(chain)
218
+
219
+ # First call uses RPC 1
220
+ result1 = ci.web3.eth.get_balance("0xtest")
221
+ assert "rpc1" in result1
222
+
223
+ # Rotate to RPC 2
224
+ ci.rotate_rpc()
225
+
226
+ # Second call should use RPC 2
227
+ result2 = ci.web3.eth.get_balance("0xtest")
228
+ assert "rpc2" in result2
229
+
230
+
231
+ def test_contract_uses_current_provider_after_rotation():
232
+ """Test that ContractInstance.contract property recreates contract each time.
233
+
234
+ This verifies the contract property doesn't cache and always
235
+ uses the current provider.
236
+ """
237
+ # This is a simplified test that verifies the contract property
238
+ # creates a new contract each time it's accessed
239
+ from pathlib import Path
240
+
241
+ from iwa.core.contracts.contract import ContractInstance
242
+
243
+ # Mock a minimal chain interface
244
+ mock_web3 = MagicMock()
245
+ mock_web3._web3.eth.contract = MagicMock()
246
+
247
+ mock_chain_interface = MagicMock()
248
+ mock_chain_interface.web3 = mock_web3
249
+
250
+ # Create a ContractInstance with mocked dependencies
251
+ with patch("iwa.core.chain.ChainInterfaces") as mock_interfaces:
252
+ mock_interfaces.return_value.get.return_value = mock_chain_interface
253
+
254
+ # Mock the ABI loading
255
+ mock_abi = [{"type": "function", "name": "test", "inputs": [], "outputs": []}]
256
+
257
+ with patch("builtins.open", MagicMock()):
258
+ with patch("json.load", return_value=mock_abi):
259
+ # Need to mock abi_path for ContractInstance
260
+ with patch.object(ContractInstance, "abi_path", Path("/fake/path.json")):
261
+ with patch.object(ContractInstance, "load_error_selectors", return_value={}):
262
+ instance = ContractInstance.__new__(ContractInstance)
263
+ instance.address = "0x1234567890123456789012345678901234567890"
264
+ instance.abi = mock_abi
265
+ instance.chain_interface = mock_chain_interface
266
+ instance._contract_cache = None
267
+ instance.error_selectors = {}
268
+
269
+ # Access contract property twice
270
+ # The key test: verify it calls web3._web3.eth.contract each time
271
+ _ = instance.contract
272
+ _ = instance.contract
273
+
274
+ # Should have called contract() twice (no caching)
275
+ assert mock_web3._web3.eth.contract.call_count == 2
276
+
277
+
278
+ def test_single_rpc_no_rotation(multi_rpc_chain):
279
+ """Test that rotation returns False when there's only one RPC."""
280
+ multi_rpc_chain.rpcs = ["https://only-one.example.com"]
281
+
282
+ with patch("iwa.core.chain.interface.RateLimitedWeb3", side_effect=lambda w3, rl, ci: w3):
283
+ ci = ChainInterface(multi_rpc_chain)
284
+
285
+ result = ci.rotate_rpc()
286
+ assert result is False
287
+ assert ci._current_rpc_index == 0
File without changes