iwa 0.0.33__tar.gz → 0.0.58__tar.gz

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 (234) hide show
  1. {iwa-0.0.33/src/iwa.egg-info → iwa-0.0.58}/PKG-INFO +6 -3
  2. {iwa-0.0.33 → iwa-0.0.58}/README.md +4 -2
  3. {iwa-0.0.33 → iwa-0.0.58}/pyproject.toml +3 -2
  4. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/chain/interface.py +116 -8
  5. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/chain/models.py +15 -3
  6. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/chain/rate_limiter.py +54 -12
  7. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/cli.py +1 -1
  8. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/ipfs.py +24 -2
  9. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/keys.py +59 -15
  10. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/models.py +60 -13
  11. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/pricing.py +24 -2
  12. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/secrets.py +27 -0
  13. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/account.py +1 -1
  14. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/balance.py +0 -22
  15. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/safe.py +64 -43
  16. iwa-0.0.58/src/iwa/core/services/safe_executor.py +316 -0
  17. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/transaction.py +11 -1
  18. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/transfer/erc20.py +14 -2
  19. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/transfer/native.py +14 -31
  20. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/transfer/swap.py +1 -0
  21. iwa-0.0.58/src/iwa/core/tests/test_gnosis_fee.py +87 -0
  22. iwa-0.0.58/src/iwa/core/tests/test_ipfs.py +85 -0
  23. iwa-0.0.58/src/iwa/core/tests/test_pricing.py +65 -0
  24. iwa-0.0.58/src/iwa/core/tests/test_regression_fixes.py +100 -0
  25. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/wallet.py +3 -3
  26. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/cow/quotes.py +2 -2
  27. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/cow/swap.py +18 -32
  28. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/tests/test_cow.py +19 -10
  29. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/importer.py +5 -7
  30. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/models.py +0 -3
  31. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/service_manager/drain.py +16 -7
  32. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/service_manager/lifecycle.py +15 -4
  33. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/service_manager/staking.py +4 -4
  34. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_importer_error_handling.py +13 -0
  35. iwa-0.0.58/src/iwa/plugins/olas/tests/test_olas_archiving.py +73 -0
  36. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_olas_view.py +5 -1
  37. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_manager.py +7 -7
  38. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_manager_errors.py +1 -1
  39. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_manager_flows.py +1 -1
  40. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_manager_rewards.py +5 -1
  41. iwa-0.0.58/src/iwa/tools/drain_accounts.py +60 -0
  42. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/list_contracts.py +2 -0
  43. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/screens/wallets.py +2 -2
  44. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/accounts.py +1 -1
  45. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/static/app.js +21 -9
  46. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/static/style.css +4 -0
  47. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/tests/test_web_endpoints.py +2 -2
  48. {iwa-0.0.33 → iwa-0.0.58/src/iwa.egg-info}/PKG-INFO +6 -3
  49. {iwa-0.0.33 → iwa-0.0.58}/src/iwa.egg-info/SOURCES.txt +11 -1
  50. {iwa-0.0.33 → iwa-0.0.58}/src/iwa.egg-info/requires.txt +1 -0
  51. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_balance_service.py +0 -41
  52. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_chain.py +13 -4
  53. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_cli.py +2 -2
  54. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_drain_coverage.py +12 -6
  55. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_keys.py +23 -23
  56. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_rate_limiter.py +2 -2
  57. iwa-0.0.58/src/tests/test_rate_limiter_retry.py +108 -0
  58. iwa-0.0.58/src/tests/test_rpc_rate_limit.py +33 -0
  59. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_rpc_rotation.py +55 -7
  60. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_safe_coverage.py +37 -23
  61. iwa-0.0.58/src/tests/test_safe_executor.py +335 -0
  62. iwa-0.0.58/src/tests/test_safe_integration.py +148 -0
  63. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_safe_service.py +1 -1
  64. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_transfer_swap_unit.py +5 -1
  65. iwa-0.0.33/src/tests/test_pricing.py +0 -160
  66. {iwa-0.0.33 → iwa-0.0.58}/LICENSE +0 -0
  67. {iwa-0.0.33 → iwa-0.0.58}/setup.cfg +0 -0
  68. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/__init__.py +0 -0
  69. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/__main__.py +0 -0
  70. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/__init__.py +0 -0
  71. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/chain/__init__.py +0 -0
  72. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/chain/errors.py +0 -0
  73. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/chain/manager.py +0 -0
  74. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/chainlist.py +0 -0
  75. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/constants.py +0 -0
  76. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/__init__.py +0 -0
  77. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/abis/erc20.json +0 -0
  78. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/abis/multisend.json +0 -0
  79. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/abis/multisend_call_only.json +0 -0
  80. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/cache.py +0 -0
  81. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/contract.py +0 -0
  82. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/decoder.py +0 -0
  83. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/erc20.py +0 -0
  84. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/contracts/multisend.py +0 -0
  85. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/db.py +0 -0
  86. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/mnemonic.py +0 -0
  87. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/monitor.py +0 -0
  88. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/plugins.py +0 -0
  89. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/rpc_monitor.py +0 -0
  90. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/__init__.py +0 -0
  91. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/plugin.py +0 -0
  92. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/transfer/__init__.py +0 -0
  93. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/transfer/base.py +0 -0
  94. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/services/transfer/multisend.py +0 -0
  95. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/tables.py +0 -0
  96. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/test.py +0 -0
  97. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/tests/test_wallet.py +0 -0
  98. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/types.py +0 -0
  99. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/ui.py +0 -0
  100. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/core/utils.py +0 -0
  101. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/__init__.py +0 -0
  102. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/__init__.py +0 -0
  103. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/cow/__init__.py +0 -0
  104. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/cow/types.py +0 -0
  105. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/cow_utils.py +0 -0
  106. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/plugin.py +0 -0
  107. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/safe.py +0 -0
  108. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/gnosis/tests/test_safe.py +0 -0
  109. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/__init__.py +0 -0
  110. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/constants.py +0 -0
  111. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/activity_checker.json +0 -0
  112. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/mech.json +0 -0
  113. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/mech_marketplace.json +0 -0
  114. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/mech_marketplace_v1.json +0 -0
  115. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/mech_new.json +0 -0
  116. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/service_manager.json +0 -0
  117. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/service_registry.json +0 -0
  118. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/service_registry_token_utility.json +0 -0
  119. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/staking.json +0 -0
  120. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/abis/staking_token.json +0 -0
  121. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/activity_checker.py +0 -0
  122. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/base.py +0 -0
  123. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/mech.py +0 -0
  124. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/mech_marketplace.py +0 -0
  125. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/mech_marketplace_v1.py +0 -0
  126. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/service.py +0 -0
  127. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/contracts/staking.py +0 -0
  128. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/events.py +0 -0
  129. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/mech_reference.py +0 -0
  130. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/plugin.py +0 -0
  131. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/scripts/test_full_mech_flow.py +0 -0
  132. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/scripts/test_simple_lifecycle.py +0 -0
  133. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/service_manager/__init__.py +0 -0
  134. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/service_manager/base.py +0 -0
  135. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/service_manager/mech.py +0 -0
  136. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/conftest.py +0 -0
  137. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_importer.py +0 -0
  138. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_mech_contracts.py +0 -0
  139. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_olas_contracts.py +0 -0
  140. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_olas_integration.py +0 -0
  141. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_olas_models.py +0 -0
  142. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_olas_view_actions.py +0 -0
  143. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_olas_view_modals.py +0 -0
  144. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_plugin.py +0 -0
  145. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_plugin_full.py +0 -0
  146. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_lifecycle.py +0 -0
  147. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_manager_mech.py +0 -0
  148. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_manager_validation.py +0 -0
  149. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_service_staking.py +0 -0
  150. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_staking_integration.py +0 -0
  151. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tests/test_staking_validation.py +0 -0
  152. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tui/__init__.py +0 -0
  153. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/plugins/olas/tui/olas_view.py +0 -0
  154. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/__init__.py +0 -0
  155. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/check_profile.py +0 -0
  156. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/release.py +0 -0
  157. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/reset_env.py +0 -0
  158. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/reset_tenderly.py +0 -0
  159. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/restore_backup.py +0 -0
  160. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/test_chainlist.py +0 -0
  161. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tools/wallet_check.py +0 -0
  162. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/__init__.py +0 -0
  163. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/app.py +0 -0
  164. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/modals/__init__.py +0 -0
  165. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/modals/base.py +0 -0
  166. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/rpc.py +0 -0
  167. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/screens/__init__.py +0 -0
  168. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/tests/test_app.py +0 -0
  169. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/tests/test_rpc.py +0 -0
  170. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/tests/test_wallets_refactor.py +0 -0
  171. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/tests/test_widgets.py +0 -0
  172. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/widgets/__init__.py +0 -0
  173. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/widgets/base.py +0 -0
  174. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/tui/workers.py +0 -0
  175. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/dependencies.py +0 -0
  176. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/models.py +0 -0
  177. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/olas/__init__.py +0 -0
  178. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/olas/admin.py +0 -0
  179. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/olas/funding.py +0 -0
  180. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/olas/general.py +0 -0
  181. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/olas/services.py +0 -0
  182. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/olas/staking.py +0 -0
  183. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/state.py +0 -0
  184. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/swap.py +0 -0
  185. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/routers/transactions.py +0 -0
  186. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/server.py +0 -0
  187. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/static/index.html +0 -0
  188. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/tests/test_web_olas.py +0 -0
  189. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/tests/test_web_swap.py +0 -0
  190. {iwa-0.0.33 → iwa-0.0.58}/src/iwa/web/tests/test_web_swap_coverage.py +0 -0
  191. {iwa-0.0.33 → iwa-0.0.58}/src/iwa.egg-info/dependency_links.txt +0 -0
  192. {iwa-0.0.33 → iwa-0.0.58}/src/iwa.egg-info/entry_points.txt +0 -0
  193. {iwa-0.0.33 → iwa-0.0.58}/src/iwa.egg-info/top_level.txt +0 -0
  194. {iwa-0.0.33 → iwa-0.0.58}/src/tests/legacy_cow.py +0 -0
  195. {iwa-0.0.33 → iwa-0.0.58}/src/tests/legacy_safe.py +0 -0
  196. {iwa-0.0.33 → iwa-0.0.58}/src/tests/legacy_transaction_retry_logic.py +0 -0
  197. {iwa-0.0.33 → iwa-0.0.58}/src/tests/legacy_tui.py +0 -0
  198. {iwa-0.0.33 → iwa-0.0.58}/src/tests/legacy_wallets_screen.py +0 -0
  199. {iwa-0.0.33 → iwa-0.0.58}/src/tests/legacy_web.py +0 -0
  200. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_account_service.py +0 -0
  201. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_chain_interface.py +0 -0
  202. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_chain_interface_coverage.py +0 -0
  203. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_contract.py +0 -0
  204. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_db.py +0 -0
  205. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_erc20.py +0 -0
  206. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_gnosis_plugin.py +0 -0
  207. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_legacy_wallet.py +0 -0
  208. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_main.py +0 -0
  209. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_migration.py +0 -0
  210. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_mnemonic.py +0 -0
  211. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_modals.py +0 -0
  212. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_models.py +0 -0
  213. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_monitor.py +0 -0
  214. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_multisend.py +0 -0
  215. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_plugin_service.py +0 -0
  216. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_reset_tenderly.py +0 -0
  217. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_rpc_efficiency.py +0 -0
  218. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_rpc_view.py +0 -0
  219. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_service_manager_integration.py +0 -0
  220. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_service_manager_structure.py +0 -0
  221. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_service_transaction.py +0 -0
  222. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_staking_router.py +0 -0
  223. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_staking_simple.py +0 -0
  224. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_tables.py +0 -0
  225. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_transaction_service.py +0 -0
  226. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_transfer_multisend.py +0 -0
  227. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_transfer_native.py +0 -0
  228. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_transfer_security.py +0 -0
  229. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_transfer_structure.py +0 -0
  230. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_ui_coverage.py +0 -0
  231. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_utils.py +0 -0
  232. {iwa-0.0.33 → iwa-0.0.58}/src/tests/test_workers.py +0 -0
  233. {iwa-0.0.33 → iwa-0.0.58}/src/tools/create_and_stake_service.py +0 -0
  234. {iwa-0.0.33 → iwa-0.0.58}/src/tools/verify_drain.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: iwa
3
- Version: 0.0.33
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
@@ -43,7 +43,7 @@ iwa/
43
43
  ├── core/ # Core wallet functionality
44
44
  │ ├── keys.py # KeyStorage - Encrypted key management
45
45
  │ ├── wallet.py # Wallet - High-level interface
46
- │ ├── chain.py # ChainInterface - Blockchain interaction with rate limiting
46
+ │ ├── chain/ # Blockchain interface with rate limiting
47
47
  │ ├── services/ # Service layer (accounts, balances, transactions)
48
48
  │ └── contracts/ # Contract abstractions (ERC20, Safe)
49
49
  ├── plugins/ # Protocol integrations
@@ -95,6 +95,9 @@ GNOSIS_RPC=https://rpc.gnosis.io,https://gnosis.drpc.org
95
95
  ETHEREUM_RPC=https://mainnet.infura.io/v3/YOUR_KEY
96
96
  BASE_RPC=https://mainnet.base.org
97
97
 
98
+ # Testing mode (default: true uses Tenderly test RPCs)
99
+ TESTING=false
100
+
98
101
  # Optional
99
102
  GNOSISSCAN_API_KEY=your_api_key
100
103
  COINGECKO_API_KEY=your_api_key
@@ -111,7 +114,6 @@ just web
111
114
 
112
115
  # Use CLI
113
116
  iwa wallet list --chain gnosis
114
- iwa wallet balance <address> --chain gnosis
115
117
  ```
116
118
 
117
119
  ### Running Tests
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "iwa"
3
- version = "0.0.33"
3
+ version = "0.0.58"
4
4
  description = "A secure, modular, and plugin-based framework for crypto agents and ops"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4.0"
@@ -17,6 +17,7 @@ dependencies = [
17
17
  "typer>=0.19.2",
18
18
  "web3>=7.13.0",
19
19
  "pyyaml (>=6.0.3,<7.0.0)",
20
+ "ruamel.yaml>=0.18.0",
20
21
  "safe-eth-py>=7.14.0",
21
22
  "twine>=6.2.0",
22
23
  "cowdao-cowpy>=1.0.1",
@@ -71,7 +72,7 @@ where = ["src"]
71
72
 
72
73
  [tool.ruff]
73
74
  line-length = 100
74
- target-version = "0.0.33"
75
+ target-version = "0.0.58"
75
76
  fix = true
76
77
 
77
78
  [tool.ruff.lint]
@@ -34,9 +34,11 @@ class ChainInterface:
34
34
  chain: SupportedChain = getattr(SupportedChains(), chain.lower())
35
35
 
36
36
  self.chain = chain
37
- self._rate_limiter = get_rate_limiter(chain.name)
37
+ # Enforce strict 1.0 RPS limit to prevent synchronization issues
38
+ self._rate_limiter = get_rate_limiter(chain.name, rate=1.0, burst=1)
38
39
  self._current_rpc_index = 0
39
40
  self._rpc_failure_counts: Dict[int, int] = {}
41
+ self._last_rotation_time = 0.0 # Monotonic timestamp of last rotation
40
42
 
41
43
  if self.chain.rpc and self.chain.rpc.startswith("http://"):
42
44
  logger.warning(
@@ -213,12 +215,24 @@ class ChainInterface:
213
215
  ]
214
216
  return any(signal in err_text for signal in server_error_signals)
215
217
 
218
+ def _is_gas_error(self, error: Exception) -> bool:
219
+ """Check if error is related to gas limits or fees."""
220
+ err_text = str(error).lower()
221
+ gas_signals = [
222
+ "intrinsic gas too low",
223
+ "feetoolow",
224
+ "gas limit",
225
+ "underpriced",
226
+ ]
227
+ return any(signal in err_text for signal in gas_signals)
228
+
216
229
  def _handle_rpc_error(self, error: Exception) -> Dict[str, Union[bool, int]]:
217
230
  """Handle RPC errors with smart rotation and retry logic."""
218
231
  result: Dict[str, Union[bool, int]] = {
219
232
  "is_rate_limit": self._is_rate_limit_error(error),
220
233
  "is_connection_error": self._is_connection_error(error),
221
234
  "is_server_error": self._is_server_error(error),
235
+ "is_gas_error": self._is_gas_error(error),
222
236
  "is_tenderly_quota": self._is_tenderly_quota_exceeded(error),
223
237
  "rotated": False,
224
238
  "should_retry": False,
@@ -242,9 +256,11 @@ class ChainInterface:
242
256
 
243
257
  if should_rotate:
244
258
  error_type = "rate limit" if result["is_rate_limit"] else "connection"
259
+ # Extract the original URL from the error message for clarity
260
+ error_msg = str(error)
245
261
  logger.warning(
246
262
  f"RPC {error_type} error on {self.chain.name} "
247
- f"(RPC #{self._current_rpc_index}): {error}"
263
+ f"(current RPC #{self._current_rpc_index}): {error_msg}"
248
264
  )
249
265
 
250
266
  if self.rotate_rpc():
@@ -253,27 +269,49 @@ class ChainInterface:
253
269
  logger.info(f"Rotated to RPC #{self._current_rpc_index} for {self.chain.name}")
254
270
  else:
255
271
  if result["is_rate_limit"]:
256
- self._rate_limiter.trigger_backoff(seconds=5.0)
272
+ # Rotation was skipped (cooldown or single RPC) - still allow retry with current RPC
273
+ # We don't trigger backoff here because that would block ALL threads.
274
+ # Instead, we let the individual thread retry (which has its own exponential backoff).
257
275
  result["should_retry"] = True
258
- logger.warning("No other RPCs available, triggered backoff")
276
+ logger.info(
277
+ f"RPC rotation skipped, retrying with current RPC #{self._current_rpc_index}"
278
+ )
259
279
 
260
280
  elif result["is_server_error"]:
261
281
  logger.warning(f"Server error on {self.chain.name}: {error}")
262
282
  result["should_retry"] = True
263
283
 
284
+ elif result["is_gas_error"]:
285
+ logger.warning(f"Gas/Fee error detected: {error}. Allowing retry for adjustment.")
286
+ result["should_retry"] = True
287
+
264
288
  return result
265
289
 
266
290
  def rotate_rpc(self) -> bool:
267
291
  """Rotate to the next available RPC."""
292
+ # Minimum time between rotations to prevent cascade rotations from parallel requests
293
+ # failing simultaneously
294
+ cooldown_seconds = 2.0
295
+
268
296
  with self._rotation_lock:
269
297
  if not self.chain.rpcs or len(self.chain.rpcs) <= 1:
270
298
  return False
271
299
 
300
+ # Cooldown: prevent cascade rotations from in-flight requests
301
+ now = time.monotonic()
302
+ if now - self._last_rotation_time < cooldown_seconds:
303
+ logger.debug(
304
+ f"RPC rotation skipped for {self.chain.name} (cooldown active, "
305
+ f"{cooldown_seconds - (now - self._last_rotation_time):.1f}s remaining)"
306
+ )
307
+ return False
308
+
272
309
  # Simple Round Robin rotation
273
310
  self._current_rpc_index = (self._current_rpc_index + 1) % len(self.chain.rpcs)
274
311
  # Internal call to _init_web3 already expects to be under lock if called from here,
275
312
  # but _init_web3 itself doesn't have a lock. Let's make it consistent.
276
313
  self._init_web3_under_lock()
314
+ self._last_rotation_time = now
277
315
 
278
316
  logger.info(
279
317
  f"Rotated RPC for {self.chain.name} to index {self._current_rpc_index}: {self.chain.rpcs[self._current_rpc_index]}"
@@ -423,18 +461,88 @@ class ChainInterface:
423
461
  return 500_000
424
462
 
425
463
  def calculate_transaction_params(
426
- self, built_method: Callable, tx_params: Dict[str, Union[str, int]]
464
+ self, built_method: Optional[Callable], tx_params: Dict[str, Union[str, int]]
427
465
  ) -> Dict[str, Union[str, int]]:
428
- """Calculate transaction parameters for a contract function call."""
466
+ """Calculate transaction parameters for a contract function call or native transfer."""
467
+ # Baseline parameters
429
468
  params = {
430
469
  "from": tx_params["from"],
431
470
  "value": tx_params.get("value", 0),
432
471
  "nonce": self.web3.eth.get_transaction_count(tx_params["from"]),
433
- "gas": self.estimate_gas(built_method, tx_params),
434
- "gasPrice": self.web3.eth.gas_price,
435
472
  }
473
+
474
+ # Add 'to' only for native transfers (built_method is None)
475
+ # Contract calls already have the target address in the contract object
476
+ if not built_method and "to" in tx_params:
477
+ params["to"] = tx_params["to"]
478
+ elif not built_method and "to" in params: # Fallback if added to params earlier (though not here yet)
479
+ pass
480
+
481
+ # Determine gas
482
+ if built_method:
483
+ # Contract function call
484
+ params["gas"] = self.estimate_gas(built_method, tx_params)
485
+ elif "gas" in tx_params:
486
+ # Manual gas override
487
+ params["gas"] = tx_params["gas"]
488
+ else:
489
+ # Native transfer - dynamic estimation
490
+ try:
491
+ # web3.eth.estimate_gas returns gas for the dict it receives
492
+ est_params = {
493
+ "from": params["from"],
494
+ "to": params["to"],
495
+ "value": params["value"]
496
+ }
497
+ # Remove None 'to' for contract creation simulation if needed, but usually send() has to
498
+ if not est_params["to"]:
499
+ est_params.pop("to")
500
+
501
+ estimated = self.web3.eth.estimate_gas(est_params)
502
+ # Apply 10% buffer for safety
503
+ params["gas"] = int(estimated * 1.1)
504
+ logger.debug(f"[GAS] Estimated native transfer gas: {params['gas']} (raw: {estimated})")
505
+ except Exception as e:
506
+ logger.debug(f"[GAS] Native estimation failed, fallback to 21000: {e}")
507
+ params["gas"] = 21_000
508
+
509
+ # Add EIP-1559 or Legacy fees
510
+ params.update(self.get_suggested_fees())
436
511
  return params
437
512
 
513
+ def get_suggested_fees(self) -> Dict[str, int]:
514
+ """Calculate suggested fees for a transaction (EIP-1559 or legacy)."""
515
+ try:
516
+ # Check for EIP-1559 support
517
+ latest_block = self.web3.eth.get_block("latest")
518
+ base_fee = latest_block.get("baseFeePerGas")
519
+
520
+ if base_fee is not None:
521
+ # EIP-1559 logic
522
+ max_priority_fee = int(self.web3.eth.max_priority_fee)
523
+
524
+ # Gnosis specific: ensure min priority fee (critical for validation)
525
+ if self.chain.name.lower() == "gnosis":
526
+ if max_priority_fee < 1:
527
+ max_priority_fee = 1 # Network minimum is 1 wei
528
+
529
+ # Global minimum for EIP-1559
530
+ if max_priority_fee < 1:
531
+ max_priority_fee = 1
532
+
533
+ # Buffer max_fee to handle base fee expansion
534
+ max_fee = int(base_fee * 1.5) + max_priority_fee
535
+
536
+ return {
537
+ "maxFeePerGas": max_fee,
538
+ "maxPriorityFeePerGas": max_priority_fee
539
+ }
540
+ except Exception as e:
541
+ logger.debug(f"Failed to calculate EIP-1559 fees: {e}, falling back to legacy")
542
+
543
+ # Legacy fallback
544
+ return {"gasPrice": self.web3.eth.gas_price}
545
+
438
546
  def wait_for_no_pending_tx(
439
547
  self, from_address: EthereumAddress, max_wait_seconds: int = 60, poll_interval: float = 2.0
440
548
  ):
@@ -26,6 +26,9 @@ class SupportedChain(BaseModel):
26
26
 
27
27
  def get_token_address(self, token_address_or_name: str) -> Optional[EthereumAddress]:
28
28
  """Get token address"""
29
+ if not token_address_or_name:
30
+ return None
31
+
29
32
  try:
30
33
  address = EthereumAddress(token_address_or_name)
31
34
  except Exception:
@@ -35,9 +38,18 @@ class SupportedChain(BaseModel):
35
38
  return address
36
39
 
37
40
  if address is None:
38
- return self.tokens.get(token_address_or_name, None)
39
-
40
- return None
41
+ # Try direct lookup
42
+ token_addr = self.tokens.get(token_address_or_name, None)
43
+ if token_addr:
44
+ return token_addr
45
+
46
+ # Try case-insensitive lookup
47
+ target_lower = token_address_or_name.lower()
48
+ for name, addr in self.tokens.items():
49
+ if name.lower() == target_lower:
50
+ return addr
51
+
52
+ return None
41
53
 
42
54
  def get_token_name(self, token_address: str) -> Optional[str]:
43
55
  """Get token name from address."""
@@ -108,12 +108,11 @@ def get_rate_limiter(chain_name: str, rate: float = None, burst: int = None) ->
108
108
  class RateLimitedEth:
109
109
  """Wrapper around web3.eth that applies rate limiting transparently."""
110
110
 
111
- RPC_METHODS = {
111
+ READ_METHODS = {
112
112
  "get_balance",
113
113
  "get_code",
114
114
  "get_transaction_count",
115
115
  "estimate_gas",
116
- "send_raw_transaction",
117
116
  "wait_for_transaction_receipt",
118
117
  "get_block",
119
118
  "get_transaction",
@@ -122,6 +121,16 @@ class RateLimitedEth:
122
121
  "get_logs",
123
122
  }
124
123
 
124
+ WRITE_METHODS = {
125
+ "send_raw_transaction",
126
+ }
127
+
128
+ # Helper sets for efficient lookup
129
+ RPC_METHODS = READ_METHODS | WRITE_METHODS
130
+
131
+ DEFAULT_READ_RETRIES = 3
132
+ DEFAULT_READ_RETRY_DELAY = 0.5
133
+
125
134
  def __init__(self, web3_eth, rate_limiter: RPCRateLimiter, chain_interface: "ChainInterface"):
126
135
  """Initialize RateLimitedEth wrapper."""
127
136
  object.__setattr__(self, "_eth", web3_eth)
@@ -133,7 +142,7 @@ class RateLimitedEth:
133
142
  attr = getattr(self._eth, name)
134
143
 
135
144
  if name in self.RPC_METHODS and callable(attr):
136
- return self._wrap_with_rate_limit(attr, name)
145
+ return self._wrap_with_retry(attr, name)
137
146
 
138
147
  return attr
139
148
 
@@ -151,23 +160,56 @@ class RateLimitedEth:
151
160
  else:
152
161
  delattr(self._eth, name)
153
162
 
154
- def _wrap_with_rate_limit(self, method, method_name):
155
- """Wrap a method with rate limiting.
163
+ @property
164
+ def block_number(self):
165
+ """Get block number with retry."""
166
+ return self._execute_with_retry(lambda: self._eth.block_number, "block_number")
156
167
 
157
- Note: Error handling (rotation, retry) is NOT done here.
158
- It is the responsibility of `ChainInterface.with_retry()` to handle
159
- errors and rotate RPCs as needed. This wrapper only ensures
160
- rate limiting.
161
- """
168
+ @property
169
+ def gas_price(self):
170
+ """Get gas price with retry."""
171
+ return self._execute_with_retry(lambda: self._eth.gas_price, "gas_price")
172
+
173
+ def _wrap_with_retry(self, method, method_name):
174
+ """Wrap method with rate limiting and retry for reads."""
162
175
 
163
176
  def wrapper(*args, **kwargs):
164
177
  if not self._rate_limiter.acquire(timeout=30.0):
165
- raise TimeoutError(f"Rate limit timeout waiting for {method_name}")
178
+ raise TimeoutError(f"Rate limit timeout for {method_name}")
179
+
180
+ # Writes: no auto-retry (handled by caller or not safe)
181
+ if method_name in self.WRITE_METHODS:
182
+ return method(*args, **kwargs)
166
183
 
167
- return method(*args, **kwargs)
184
+ # Reads: with retry
185
+ return self._execute_with_retry(method, method_name, *args, **kwargs)
168
186
 
169
187
  return wrapper
170
188
 
189
+ def _execute_with_retry(self, method, method_name, *args, **kwargs):
190
+ """Execute read operation with retry logic."""
191
+ last_error = None
192
+ for attempt in range(self.DEFAULT_READ_RETRIES + 1):
193
+ try:
194
+ return method(*args, **kwargs)
195
+ except Exception as e:
196
+ last_error = e
197
+ # Use chain interface to handle error (logging, rotation, etc.)
198
+ result = self._chain_interface._handle_rpc_error(e)
199
+
200
+ if not result["should_retry"] or attempt >= self.DEFAULT_READ_RETRIES:
201
+ raise
202
+
203
+ delay = self.DEFAULT_READ_RETRY_DELAY * (2**attempt)
204
+ logger.debug(
205
+ f"{method_name} attempt {attempt + 1} failed, retrying in {delay:.1f}s..."
206
+ )
207
+ time.sleep(delay)
208
+
209
+ if last_error:
210
+ raise last_error
211
+ raise RuntimeError(f"{method_name} failed unexpectedly")
212
+
171
213
 
172
214
  class RateLimitedWeb3:
173
215
  """Wrapper around Web3 instance that applies rate limiting transparently."""
@@ -40,7 +40,7 @@ def account_create(
40
40
  """Create a new wallet account"""
41
41
  key_storage = KeyStorage()
42
42
  try:
43
- key_storage.create_account(tag)
43
+ key_storage.generate_new_account(tag)
44
44
  except ValueError as e:
45
45
  typer.echo(f"Error: {e}")
46
46
  raise typer.Exit(code=1) from e
@@ -7,13 +7,20 @@ direct HTTP API calls, avoiding heavy dependencies like open-aea.
7
7
  import hashlib
8
8
  import json
9
9
  import uuid
10
- from typing import Any, Dict, Optional, Tuple
10
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
11
11
 
12
12
  import aiohttp
13
13
  from multiformats import CID
14
14
 
15
15
  from iwa.core.models import Config
16
16
 
17
+ if TYPE_CHECKING:
18
+ import requests
19
+
20
+ # Global session for sync requests
21
+ _SYNC_SESSION: Optional["requests.Session"] = None
22
+
23
+
17
24
 
18
25
  def _compute_cid_v1_hex(data: bytes) -> str:
19
26
  """Compute CIDv1 hex representation from raw data.
@@ -84,6 +91,21 @@ def push_to_ipfs_sync(
84
91
  :return: Tuple of (CIDv1 string, CIDv1 hex representation).
85
92
  """
86
93
  import requests
94
+ from requests.adapters import HTTPAdapter
95
+ from urllib3.util.retry import Retry
96
+
97
+ global _SYNC_SESSION
98
+
99
+ if _SYNC_SESSION is None:
100
+ _SYNC_SESSION = requests.Session()
101
+ retry_strategy = Retry(
102
+ total=3,
103
+ backoff_factor=1,
104
+ status_forcelist=[429, 500, 502, 503, 504],
105
+ )
106
+ adapter = HTTPAdapter(max_retries=retry_strategy)
107
+ _SYNC_SESSION.mount("http://", adapter)
108
+ _SYNC_SESSION.mount("https://", adapter)
87
109
 
88
110
  url = api_url or Config().core.ipfs_api_url
89
111
  endpoint = f"{url}/api/v0/add"
@@ -92,7 +114,7 @@ def push_to_ipfs_sync(
92
114
 
93
115
  files = {"file": ("data", data, "application/octet-stream")}
94
116
 
95
- response = requests.post(endpoint, files=files, params=params, timeout=60)
117
+ response = _SYNC_SESSION.post(endpoint, files=files, params=params, timeout=60)
96
118
  response.raise_for_status()
97
119
  result = response.json()
98
120
 
@@ -210,14 +210,14 @@ class KeyStorage(BaseModel):
210
210
  if not self.get_address_by_tag("master"):
211
211
  logger.info("Master account not found. Creating new 'master' account...")
212
212
  try:
213
- self.create_account("master")
213
+ self.generate_new_account("master")
214
214
  except Exception as e:
215
215
  logger.error(f"Failed to create master account: {e}")
216
216
 
217
217
  @property
218
- def master_account(self) -> EncryptedAccount:
218
+ def master_account(self) -> Optional[Union[EncryptedAccount, StoredSafeAccount]]:
219
219
  """Get the master account"""
220
- master_account = self.get_account("master")
220
+ master_account = self.find_stored_account("master")
221
221
 
222
222
  if not master_account:
223
223
  return list(self.accounts.values())[0]
@@ -231,19 +231,34 @@ class KeyStorage(BaseModel):
231
231
  # Use backup directory relative to wallet path (supports tests with tmp_path)
232
232
  backup_dir = self._path.parent / "backup"
233
233
  backup_dir.mkdir(parents=True, exist_ok=True)
234
+ try:
235
+ os.chmod(backup_dir, 0o700)
236
+ except OSError as e:
237
+ logger.debug(f"Could not chmod backup dir (expected in some Docker setups): {e}")
234
238
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
235
239
  backup_path = backup_dir / f"wallet.json.{timestamp}.bkp"
236
240
  shutil.copy2(self._path, backup_path)
241
+ try:
242
+ os.chmod(backup_path, 0o600)
243
+ except OSError as e:
244
+ logger.debug(f"Could not chmod backup file: {e}")
237
245
  logger.debug(f"Backed up wallet to {backup_path}")
238
246
 
239
247
  # Ensure directory exists
240
248
  self._path.parent.mkdir(parents=True, exist_ok=True)
241
249
 
242
250
  with open(self._path, "w", encoding="utf-8") as f:
243
- json.dump(self.model_dump(), f, indent=4)
251
+ # Use mode='json' to ensure all types (EthereumAddress) are correctly serialized
252
+ json.dump(self.model_dump(mode='json'), f, indent=4)
253
+ f.flush()
254
+ os.fsync(f.fileno()) # Force write to disk (critical for Docker volumes)
244
255
 
245
- # Enforce read/write only for the owner
246
- os.chmod(self._path, 0o600)
256
+ try:
257
+ os.chmod(self._path, 0o600)
258
+ except OSError as e:
259
+ logger.debug(f"Could not chmod wallet file: {e}")
260
+
261
+ logger.info(f"[KeyStorage] Wallet saved to {self._path} ({len(self.accounts)} accounts)")
247
262
 
248
263
  @staticmethod
249
264
  def _encrypt_mnemonic(mnemonic: str, password: str) -> dict:
@@ -328,21 +343,20 @@ class KeyStorage(BaseModel):
328
343
  encrypted_acct = EncryptedAccount.encrypt_private_key(
329
344
  private_key_hex, self._password, "master"
330
345
  )
331
- self.accounts[encrypted_acct.address] = encrypted_acct
332
- self.save()
333
-
346
+ self.register_account(encrypted_acct)
334
347
  return encrypted_acct, mnemonic_str
335
348
 
336
- def create_account(self, tag: str) -> EncryptedAccount:
337
- """Create account. Master is derived from mnemonic, others are random."""
349
+ def generate_new_account(self, tag: str) -> EncryptedAccount:
350
+ """Generate a brand new EOA account and register it with the given tag."""
351
+ # Note: register_account(tag) check is inside, but we handle 'master' logic here
338
352
  tags = [acct.tag for acct in self.accounts.values()]
339
353
  if not tags:
340
354
  tag = "master" # First account is always master
341
- if tag in tags:
342
- raise ValueError(f"Tag '{tag}' already exists in wallet.")
343
355
 
344
356
  # Master account: derive from mnemonic
345
357
  if tag == "master":
358
+ if "master" in tags:
359
+ raise ValueError("Master account already exists in wallet.")
346
360
  encrypted_acct, mnemonic = self._create_master_from_mnemonic()
347
361
  self._pending_mnemonic = mnemonic # Store temporarily for display
348
362
  return encrypted_acct
@@ -350,10 +364,24 @@ class KeyStorage(BaseModel):
350
364
  # Non-master: random key as before
351
365
  acct = Account.create()
352
366
  encrypted = EncryptedAccount.encrypt_private_key(acct.key.hex(), self._password, tag)
353
- self.accounts[acct.address] = encrypted
354
- self.save()
367
+ self.register_account(encrypted)
355
368
  return encrypted
356
369
 
370
+ def register_account(self, account: Union[EncryptedAccount, StoredSafeAccount]):
371
+ """Register an account (EOA or Safe) in the storage with strict tag uniqueness checks."""
372
+ if not account.tag:
373
+ # Allow untagged accounts (rare but possible)
374
+ pass
375
+ else:
376
+ # Check for duplicate tags
377
+ for existing in self.accounts.values():
378
+ if existing.tag == account.tag and existing.address != account.address:
379
+ raise ValueError(f"Tag '{account.tag}' is already used by address {existing.address}")
380
+
381
+ self.accounts[account.address] = account
382
+ logger.info(f"[KeyStorage] Registering account: tag='{account.tag}', address={account.address}")
383
+ self.save()
384
+
357
385
  def get_pending_mnemonic(self) -> Optional[str]:
358
386
  """Get and clear the pending mnemonic (for one-time display).
359
387
 
@@ -419,6 +447,22 @@ class KeyStorage(BaseModel):
419
447
  del self.accounts[account.address]
420
448
  self.save()
421
449
 
450
+ def rename_account(self, address_or_tag: str, new_tag: str):
451
+ """Rename an account's tag with uniqueness check."""
452
+ account = self.find_stored_account(address_or_tag)
453
+ if not account:
454
+ raise ValueError(f"Account '{address_or_tag}' not found.")
455
+
456
+ # Check if new tag is already used by a DIFFERENT account
457
+ for existing in self.accounts.values():
458
+ if existing.tag == new_tag and existing.address != account.address:
459
+ raise ValueError(f"Tag '{new_tag}' is already used by address {existing.address}")
460
+
461
+ old_tag = account.tag
462
+ account.tag = new_tag
463
+ logger.info(f"[KeyStorage] Renaming account: '{old_tag}' -> '{new_tag}' (address={account.address})")
464
+ self.save()
465
+
422
466
  def _get_private_key(self, address: str) -> Optional[str]:
423
467
  """Get private key (Internal)"""
424
468
  account = self.accounts.get(EthereumAddress(address))