iwa 0.0.12__py3-none-any.whl → 0.0.14__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
  )
@@ -63,10 +63,15 @@ class ContractInstance:
63
63
 
64
64
  This property ensures that after an RPC rotation, contract calls
65
65
  use the updated provider instead of the original one.
66
+
67
+ Note: We use _web3 directly (not the RateLimitedWeb3 wrapper) to ensure
68
+ the contract is bound to the current provider. The wrapper's set_backend()
69
+ updates _web3, but contracts created via the wrapper may cache old providers.
66
70
  """
67
71
  # Always create a fresh contract to use the current Web3 provider
68
72
  # This is necessary because RPC rotation changes the underlying provider
69
- return self.chain_interface.web3.eth.contract(address=self.address, abi=self.abi)
73
+ # Access _web3 directly to ensure we get the current provider
74
+ return self.chain_interface.web3._web3.eth.contract(address=self.address, abi=self.abi)
70
75
 
71
76
  def load_error_selectors(self) -> Dict[str, Any]:
72
77
  """Load error selectors from the contract ABI."""
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:
@@ -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"
iwa/web/static/app.js CHANGED
@@ -2358,7 +2358,7 @@ document.addEventListener("DOMContentLoaded", () => {
2358
2358
  ? "Claim staking rewards"
2359
2359
  : "No rewards available to claim";
2360
2360
  const claimLabel = hasRewards
2361
- ? `Claim ${escapeHtml(staking.accrued_reward_olas)} OLAS`
2361
+ ? `Claim ${escapeHtml(formatBalance(staking.accrued_reward_olas))} OLAS`
2362
2362
  : "Claim";
2363
2363
  return `
2364
2364
  <button class="btn-primary btn-sm"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.12
3
+ Version: 0.0.14
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
@@ -16,16 +16,16 @@ 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=Gx9L9wRithMALhdnQeF-0JA30_ZRHzlczAGiJXEfU5k,11766
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
@@ -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
@@ -143,14 +143,14 @@ iwa/web/routers/olas/funding.py,sha256=f8fADNtbZEBFl-vuVKfas6os38Vot6K5tJBTenZmC
143
143
  iwa/web/routers/olas/general.py,sha256=dPsBQppTGoQY1RztliUhseOHOZGeeCR10lhThD9kyXo,803
144
144
  iwa/web/routers/olas/services.py,sha256=IpjvkpJeCwREbdHt47gov-fvTl9bY4EBUmZZZEHi3iI,16310
145
145
  iwa/web/routers/olas/staking.py,sha256=N1pMcPjhpfSPi7Sl7XIIAPoFxf0hptdvkUqkTtgqPcc,12424
146
- iwa/web/static/app.js,sha256=BCpAp1BcFVcfEfqXa-9gOtjb-8YlV5NIPWgSeCchD1E,113472
146
+ iwa/web/static/app.js,sha256=CWm_TR2nKSDe8z0-nUQp7VaBHIGJg7mAOU-XJDveFsk,113487
147
147
  iwa/web/static/index.html,sha256=q7s7plnMbN1Nkzr5bRxZgvgOFerUChEGIZW7SpAVtPc,28514
148
148
  iwa/web/static/style.css,sha256=aTtE42mmfYV6y7xfo9cUgUhT8x-KyNC1zmPjSdskxIk,24315
149
149
  iwa/web/tests/test_web_endpoints.py,sha256=C264MH-CTyDW4GLUrTXBgLJKUk4-89pFAScBddd4Fvk,23995
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.12.dist-info/licenses/LICENSE,sha256=eIubm_IlBHPYRQlLNZKbBNKhJUUP3JH0A2miZUhAVfI,1078
153
+ iwa-0.0.14.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=EP0dJzViJn0Hbqw3hkCN6nygx5O1v2-DH7Lu8iDBACM,14235
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.12.dist-info/METADATA,sha256=4V-Vxvi5u3z7CGm5nROLqsdmPLD-aFYJtzpo9Jl21Ow,7295
205
- iwa-0.0.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
206
- iwa-0.0.12.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
207
- iwa-0.0.12.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
208
- iwa-0.0.12.dist-info/RECORD,,
205
+ iwa-0.0.14.dist-info/METADATA,sha256=7Ymm-F6pLqq7zyN1r-5FQAfLhhtEDzjpPpgGuMf1-Vo,7295
206
+ iwa-0.0.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
207
+ iwa-0.0.14.dist-info/entry_points.txt,sha256=nwB6kscrfA7M00pYmL2j-sBH6eF6h2ga9IK1BZxdiyQ,241
208
+ iwa-0.0.14.dist-info/top_level.txt,sha256=kedS9cRUbm4JE2wYeabIXilhHjN8KCw0IGbqqqsw0Bs,16
209
+ iwa-0.0.14.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
@@ -11,7 +11,8 @@ from iwa.core.contracts.contract import ContractInstance
11
11
  def mock_chain_interface():
12
12
  with patch("iwa.core.contracts.contract.ChainInterfaces") as mock:
13
13
  mock_ci = mock.return_value.get.return_value
14
- mock_ci.web3.eth.contract.return_value = MagicMock()
14
+ # contract.py now uses web3._web3.eth.contract directly for RPC rotation compatibility
15
+ mock_ci.web3._web3.eth.contract.return_value = MagicMock()
15
16
  yield mock_ci
16
17
 
17
18
 
@@ -226,7 +227,7 @@ def test_call_reevaluates_contract_on_retry(mock_chain_interface, mock_abi_file)
226
227
  mock.functions.testFunc.return_value.call.return_value = "success"
227
228
  return mock
228
229
 
229
- mock_chain_interface.web3.eth.contract.side_effect = counting_contract_factory
230
+ mock_chain_interface.web3._web3.eth.contract.side_effect = counting_contract_factory
230
231
 
231
232
  # Implement with_retry that actually retries on 429
232
233
  def real_with_retry(fn, max_retries=6, operation_name="operation"):
@@ -274,7 +275,7 @@ def test_call_uses_fresh_provider_after_rotation(mock_chain_interface, mock_abi_
274
275
  provider_versions.append(current_provider_version[0])
275
276
  return mock
276
277
 
277
- mock_chain_interface.web3.eth.contract.side_effect = mock_contract_factory
278
+ mock_chain_interface.web3._web3.eth.contract.side_effect = mock_contract_factory
278
279
 
279
280
  # Simulate RPC rotation by incrementing provider version
280
281
  def simulate_rotation():
@@ -335,7 +336,7 @@ def test_call_with_429_triggers_retry_with_new_contract(mock_chain_interface, mo
335
336
  contract_call_count[0] += 1
336
337
  return result
337
338
 
338
- mock_chain_interface.web3.eth.contract.side_effect = mock_contract_factory
339
+ mock_chain_interface.web3._web3.eth.contract.side_effect = mock_contract_factory
339
340
 
340
341
  # Implement with_retry that actually retries
341
342
  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