ipor-fusion 4.0.0.dev41__tar.gz → 4.0.0.dev45__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 (286) hide show
  1. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/PKG-INFO +1 -1
  2. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/pyproject.toml +1 -1
  3. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cache.py +32 -12
  4. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/vault_cmd.py +1 -1
  5. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/explorer.py +4 -4
  6. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/mcp/server.py +6 -10
  7. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/morpho_api.py +14 -12
  8. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/vault_data.py +11 -10
  9. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/fixtures/vault_info_golden/clean.json +2 -1
  10. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/fixtures/vault_info_golden/clean.txt +1 -1
  11. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/fixtures/vault_info_golden/rich.json +4 -2
  12. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/fixtures/vault_info_golden/rich.txt +3 -3
  13. ipor_fusion-4.0.0.dev45/tests/test_cache.py +99 -0
  14. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_market_morpho_blue.py +30 -0
  15. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_vault_info_golden.py +5 -1
  16. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_explorer.py +1 -1
  17. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_mcp_server.py +19 -6
  18. ipor_fusion-4.0.0.dev41/tests/test_cache.py +0 -44
  19. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.claude-plugin/marketplace.json +0 -0
  20. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.claude-plugin/plugin.json +0 -0
  21. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.env.example +0 -0
  22. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.gemini/settings.json +0 -0
  23. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.git-blame-ignore-revs +0 -0
  24. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/cd.yml +0 -0
  25. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/ci.yml +0 -0
  26. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/commit-lint.yml +0 -0
  27. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/notify-slack.yml +0 -0
  28. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/python-build.yml +0 -0
  29. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/python-publish.yml +0 -0
  30. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/release.yml +0 -0
  31. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.github/workflows/report-slack.yml +0 -0
  32. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.gitignore +0 -0
  33. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.mcp.json +0 -0
  34. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.pre-commit-config.yaml +0 -0
  35. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/.python-version +0 -0
  36. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/AGENTS.md +0 -0
  37. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/CHANGELOG.md +0 -0
  38. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/CLAUDE.md +0 -0
  39. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/CODEOWNERS +0 -0
  40. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/LICENSE +0 -0
  41. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/README.md +0 -0
  42. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/build.sh +0 -0
  43. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/CONTRIBUTING.md +0 -0
  44. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/README.md +0 -0
  45. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/advanced_euler_v2_credit_market_base.py +0 -0
  46. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/advanced_euler_v2_looping_arbitrum.py +0 -0
  47. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/composed_hypercore_flow_arbitrum_hyperevm.py +0 -0
  48. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/crosschain_ccip_usdc_arbitrum_hyperevm.py +0 -0
  49. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/external_state_margin_leg_base.py +0 -0
  50. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/examples/simple_aave_v3_supply_base.py +0 -0
  51. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/pytest.ini +0 -0
  52. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/scripts/commit-check.sh +0 -0
  53. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/scripts/crosschain_readiness.py +0 -0
  54. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/skills/ipor-deploy-vault/SKILL.md +0 -0
  55. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/__init__.py +0 -0
  56. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/about.py +0 -0
  57. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/chains.py +0 -0
  58. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/__init__.py +0 -0
  59. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/changelog_cmd.py +0 -0
  60. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/config_cmd.py +0 -0
  61. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/config_store.py +0 -0
  62. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/main.py +0 -0
  63. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/market_cmd.py +0 -0
  64. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/options.py +0 -0
  65. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/oracle_mapping_view.py +0 -0
  66. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/vault_health.py +0 -0
  67. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/vault_info_view.py +0 -0
  68. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/vault_markets_view.py +0 -0
  69. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/cli/vault_rendering.py +0 -0
  70. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/config/__init__.py +0 -0
  71. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/config/roles.py +0 -0
  72. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/__init__.py +0 -0
  73. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/access.py +0 -0
  74. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/address.py +0 -0
  75. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/context.py +0 -0
  76. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/contract.py +0 -0
  77. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/erc20.py +0 -0
  78. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/external_state_executor.py +0 -0
  79. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/fee_manager.py +0 -0
  80. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/fuse_action.py +0 -0
  81. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/fusion_factory.py +0 -0
  82. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/logs.py +0 -0
  83. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/multicall.py +0 -0
  84. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/oracle.py +0 -0
  85. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/plasma_vault.py +0 -0
  86. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/rewards_manager.py +0 -0
  87. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/simulation.py +0 -0
  88. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/core/withdraw_manager.py +0 -0
  89. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/__init__.py +0 -0
  90. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/__init__.py +0 -0
  91. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/chainlink.py +0 -0
  92. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/codec.py +0 -0
  93. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/contracts.py +0 -0
  94. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/events.py +0 -0
  95. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/indexer.py +0 -0
  96. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/lane.py +0 -0
  97. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/quote.py +0 -0
  98. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/ccip/transport.py +0 -0
  99. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/contracts.py +0 -0
  100. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/discovery.py +0 -0
  101. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/errors.py +0 -0
  102. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/lane.py +0 -0
  103. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/logs.py +0 -0
  104. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/messages.py +0 -0
  105. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/simulation.py +0 -0
  106. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/stargate/__init__.py +0 -0
  107. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/stargate/contracts.py +0 -0
  108. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/stargate/lane.py +0 -0
  109. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/stargate/layerzero.py +0 -0
  110. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/stargate/transport.py +0 -0
  111. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/crosschain/transport.py +0 -0
  112. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/dependency_graph.py +0 -0
  113. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/entry_cli.py +0 -0
  114. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/entry_mcp.py +0 -0
  115. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/errors.py +0 -0
  116. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/field_docs.py +0 -0
  117. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/__init__.py +0 -0
  118. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/aave_v3.py +0 -0
  119. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/async_action.py +0 -0
  120. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/base.py +0 -0
  121. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/compound_v3.py +0 -0
  122. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/crosschain/__init__.py +0 -0
  123. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/crosschain/base.py +0 -0
  124. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/crosschain/ccip.py +0 -0
  125. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/crosschain/stargate.py +0 -0
  126. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/erc4626.py +0 -0
  127. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/euler_v2.py +0 -0
  128. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/events.py +0 -0
  129. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/external_state.py +0 -0
  130. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/external_state_events.py +0 -0
  131. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/fluid_instadapp.py +0 -0
  132. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/gearbox_v3.py +0 -0
  133. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/hypercore.py +0 -0
  134. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/hypercore_events.py +0 -0
  135. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/merkl.py +0 -0
  136. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/morpho.py +0 -0
  137. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/ramses_v2.py +0 -0
  138. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/uniswap_v3.py +0 -0
  139. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/fuses/universal.py +0 -0
  140. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/guide/__init__.py +0 -0
  141. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/guide/architecture.md +0 -0
  142. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/guide/glossary.md +0 -0
  143. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/guide/invariants.md +0 -0
  144. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/guide/quickstart.md +0 -0
  145. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/market_ids.py +0 -0
  146. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/market_info.py +0 -0
  147. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/mcp/__init__.py +0 -0
  148. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/mcp/guide.py +0 -0
  149. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/mcp/models.py +0 -0
  150. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/py.typed +0 -0
  151. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/__init__.py +0 -0
  152. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/aave_v3.py +0 -0
  153. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/aave_v4.py +0 -0
  154. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/compound_v3.py +0 -0
  155. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/hypercore.py +0 -0
  156. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/hypercore_outcome.py +0 -0
  157. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/lending_health.py +0 -0
  158. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/morpho.py +0 -0
  159. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/oracle_mapping.py +0 -0
  160. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/position_manager.py +0 -0
  161. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/ramses_v2.py +0 -0
  162. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/uniswap_v3.py +0 -0
  163. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/readers/unpriceable.py +0 -0
  164. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/substrates.py +0 -0
  165. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/types.py +0 -0
  166. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/validation.py +0 -0
  167. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/src/ipor_fusion/vault_info.py +0 -0
  168. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/UniswapV3UniversalRouter.py +0 -0
  169. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_crosschain.py +0 -0
  170. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_crosschain_lifecycle.py +0 -0
  171. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_crosschain_recovery.py +0 -0
  172. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_euler_v2.py +0 -0
  173. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_foundry.py +0 -0
  174. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_guide.py +0 -0
  175. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_hypercore_model.py +0 -0
  176. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_hypercore_shadow.py +0 -0
  177. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_multicall.py +0 -0
  178. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_simulate.py +0 -0
  179. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/_validation.py +0 -0
  180. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/addresses.py +0 -0
  181. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/conftest.py +0 -0
  182. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/constants.py +0 -0
  183. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/fixtures/hypercore_hip3_flow.json +0 -0
  184. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/fixtures/hypercore_market55.json +0 -0
  185. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_about.py +0 -0
  186. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_access_manager.py +0 -0
  187. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_chains.py +0 -0
  188. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_changelog.py +0 -0
  189. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_commands.py +0 -0
  190. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_config_store.py +0 -0
  191. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_helpers.py +0 -0
  192. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_hypercore.py +0 -0
  193. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_lending_health.py +0 -0
  194. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_oracle_mapping.py +0 -0
  195. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_pending_requests_scan.py +0 -0
  196. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_role_accounts.py +0 -0
  197. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_vault_predates_getters.py +0 -0
  198. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_cli_vault_validate.py +0 -0
  199. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_contract_call.py +0 -0
  200. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_contract_encoder.py +0 -0
  201. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_ccip_events.py +0 -0
  202. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_ccip_indexer.py +0 -0
  203. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_ccip_quote.py +0 -0
  204. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_chainlink.py +0 -0
  205. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_contracts.py +0 -0
  206. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_encoding.py +0 -0
  207. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_errors.py +0 -0
  208. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_lane.py +0 -0
  209. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_layering.py +0 -0
  210. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_readiness.py +0 -0
  211. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_readiness_report.py +0 -0
  212. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_crosschain_transport.py +0 -0
  213. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_erc20.py +0 -0
  214. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_examples_composed_hypercore.py +0 -0
  215. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_examples_crosschain.py +0 -0
  216. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_examples_euler_looping_arbitrum.py +0 -0
  217. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_examples_vaults.py +0 -0
  218. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_external_state_events.py +0 -0
  219. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_external_state_executor.py +0 -0
  220. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_fee_manager.py +0 -0
  221. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_fuse_encoding.py +0 -0
  222. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_guide.py +0 -0
  223. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_hypercore_deployment.py +0 -0
  224. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_hypercore_events.py +0 -0
  225. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_hypercore_flow.py +0 -0
  226. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_hypercore_live.py +0 -0
  227. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_hypercore_outcome.py +0 -0
  228. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_hypercore_readers.py +0 -0
  229. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_hypercore_simulation.py +0 -0
  230. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_logs.py +0 -0
  231. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_mcp_hypercore.py +0 -0
  232. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_mcp_models.py +0 -0
  233. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_mcp_vault_validate.py +0 -0
  234. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_multicall.py +0 -0
  235. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_oracle.py +0 -0
  236. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_oracle_mapping.py +0 -0
  237. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_plasma_vault.py +0 -0
  238. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_reader_encoding.py +0 -0
  239. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_readiness_selection.py +0 -0
  240. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_revert_decoding.py +0 -0
  241. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_rewards_manager.py +0 -0
  242. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_roles.py +0 -0
  243. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_sdk_layering.py +0 -0
  244. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_borrow_aave_v3_mainnet.py +0 -0
  245. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_claim_merkl_wrapper_base.py +0 -0
  246. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_claim_morpho_rewards.py +0 -0
  247. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_crosschain_canary.py +0 -0
  248. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_crosschain_hypercore.py +0 -0
  249. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_crosschain_hyperevm.py +0 -0
  250. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_crosschain_lifecycle.py +0 -0
  251. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_euler_v2_batch_base.py +0 -0
  252. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_euler_v2_credit_market_base.py +0 -0
  253. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_euler_v2_swap_deploy_base.py +0 -0
  254. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_euler_v2_swap_reconfigure_base.py +0 -0
  255. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_euler_v2_swap_registry_base.py +0 -0
  256. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_examples_arbitrum.py +0 -0
  257. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_examples_base.py +0 -0
  258. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_examples_crosschain.py +0 -0
  259. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_external_state_executor_base.py +0 -0
  260. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_fusion_factory_clone_base.py +0 -0
  261. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_looping_aave_v3_base.py +0 -0
  262. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_looping_morpho_blue_base.py +0 -0
  263. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_morpho_blue.py +0 -0
  264. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_pilot_v3.py +0 -0
  265. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_pilot_v4_uniswap.py +0 -0
  266. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_pilot_v5_ramses.py +0 -0
  267. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_pilot_v5_scheduled.py +0 -0
  268. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_plasma_vault_setters_base.py +0 -0
  269. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_readers_integration.py +0 -0
  270. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_release_funds.py +0 -0
  271. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_swap_on_base.py +0 -0
  272. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_simulate_vault_from_scratch_base.py +0 -0
  273. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_solidity_compiler.py +0 -0
  274. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_solidity_mirrors.py +0 -0
  275. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_substrates.py +0 -0
  276. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_types_and_events.py +0 -0
  277. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_unpriceable_tokens.py +0 -0
  278. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_validation.py +0 -0
  279. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_vault_dep_graph.py +0 -0
  280. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_vault_info_onchain_base.py +0 -0
  281. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_vault_info_onchain_base_no_withdraw_manager.py +0 -0
  282. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_vault_simulator_blocks.py +0 -0
  283. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_web3_context.py +0 -0
  284. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/test_withdraw_manager.py +0 -0
  285. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/tests/typed_client/consumer.py +0 -0
  286. {ipor_fusion-4.0.0.dev41 → ipor_fusion-4.0.0.dev45}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ipor-fusion
3
- Version: 4.0.0.dev41
3
+ Version: 4.0.0.dev45
4
4
  Summary: The IPOR Fusion SDK for Python
5
5
  Project-URL: homepage, https://ipor.io
6
6
  Project-URL: repository, https://github.com/IPOR-Labs/ipor-fusion.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ipor-fusion"
3
- version = "4.0.0.dev41"
3
+ version = "4.0.0.dev45"
4
4
  description = "The IPOR Fusion SDK for Python"
5
5
  authors = [
6
6
  { name = "Adam Mydlarz", email = "amydlarz@gmail.com" },
@@ -9,6 +9,8 @@ import os
9
9
  import threading
10
10
  from pathlib import Path
11
11
 
12
+ from eth_typing import HexAddress
13
+
12
14
 
13
15
  def _xdg_cache_home() -> Path:
14
16
  return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
@@ -21,11 +23,35 @@ DEPLOYMENT_CACHE_FILE = CACHE_DIR / "deployment_cache.json"
21
23
  _cache_lock = threading.Lock()
22
24
 
23
25
 
26
+ def cache_key(scope: object, address: HexAddress) -> str:
27
+ """``<scope>:<address>`` with the address lowercased: one entry per address
28
+ whatever spelling the caller holds. ``scope`` is a chain id or a field name
29
+ (``symbol``, ``decimals``)."""
30
+ return f"{scope}:{address.lower()}"
31
+
32
+
33
+ def _canonical(entries: dict) -> dict:
34
+ """Entries keyed by `cache_key` spelling. Caches written before 4.0 keyed
35
+ the address as the caller spelled it; an entry already canonical wins over
36
+ a differently spelled duplicate."""
37
+ out: dict = {}
38
+ for key, value in entries.items():
39
+ scope, sep, address = key.rpartition(":")
40
+ canonical = cache_key(scope, address) if sep else key
41
+ if canonical == key or canonical not in out:
42
+ out[canonical] = value
43
+ return out
44
+
45
+
46
+ def _read(path: Path) -> dict:
47
+ if not path.exists():
48
+ return {}
49
+ return _canonical(json.loads(path.read_text(encoding="utf-8")))
50
+
51
+
24
52
  def load_contract_cache() -> dict[str, str]:
25
53
  with _cache_lock:
26
- if not CACHE_FILE.exists():
27
- return {}
28
- return json.loads(CACHE_FILE.read_text(encoding="utf-8")) # type: ignore[no-any-return]
54
+ return _read(CACHE_FILE)
29
55
 
30
56
 
31
57
  def save_contract_cache(cache: dict[str, str]) -> None:
@@ -37,9 +63,7 @@ def save_contract_cache(cache: dict[str, str]) -> None:
37
63
  def update_contract_cache(key: str, value: str) -> None:
38
64
  with _cache_lock:
39
65
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
40
- existing: dict[str, str] = {}
41
- if CACHE_FILE.exists():
42
- existing = json.loads(CACHE_FILE.read_text(encoding="utf-8"))
66
+ existing: dict[str, str] = _read(CACHE_FILE)
43
67
  existing[key] = value
44
68
  CACHE_FILE.write_text(json.dumps(existing, indent=2), encoding="utf-8")
45
69
 
@@ -49,17 +73,13 @@ _deployment_cache_lock = threading.Lock()
49
73
 
50
74
  def load_deployment_cache() -> dict[str, dict[str, int]]:
51
75
  with _deployment_cache_lock:
52
- if not DEPLOYMENT_CACHE_FILE.exists():
53
- return {}
54
- return json.loads(DEPLOYMENT_CACHE_FILE.read_text(encoding="utf-8")) # type: ignore[no-any-return]
76
+ return _read(DEPLOYMENT_CACHE_FILE)
55
77
 
56
78
 
57
79
  def update_deployment_cache(key: str, block: int, timestamp: int) -> None:
58
80
  with _deployment_cache_lock:
59
81
  CACHE_DIR.mkdir(parents=True, exist_ok=True)
60
- existing: dict[str, dict[str, int]] = {}
61
- if DEPLOYMENT_CACHE_FILE.exists():
62
- existing = json.loads(DEPLOYMENT_CACHE_FILE.read_text(encoding="utf-8"))
82
+ existing: dict[str, dict[str, int]] = _read(DEPLOYMENT_CACHE_FILE)
63
83
  existing[key] = {"block": block, "timestamp": timestamp}
64
84
  DEPLOYMENT_CACHE_FILE.write_text(
65
85
  json.dumps(existing, indent=2), encoding="utf-8"
@@ -131,7 +131,7 @@ def add(address: ChecksumAddress, label: str | None, chain_id: ChainId | None) -
131
131
 
132
132
  @vault.command("remove")
133
133
  @click.argument("address", type=ADDRESS)
134
- def remove(address: str) -> None:
134
+ def remove(address: ChecksumAddress) -> None:
135
135
  """Remove a saved vault."""
136
136
  cfg = load_config()
137
137
  before = len(cfg.vaults)
@@ -12,7 +12,7 @@ from urllib.request import urlopen
12
12
 
13
13
  from eth_typing import HexAddress, HexStr
14
14
 
15
- from ipor_fusion.cache import load_contract_cache, update_contract_cache
15
+ from ipor_fusion.cache import cache_key, load_contract_cache, update_contract_cache
16
16
  from ipor_fusion.types import ChainId, TxHash
17
17
 
18
18
  ETHERSCAN_V2_URL = "https://api.etherscan.io/v2/api"
@@ -44,13 +44,13 @@ def get_contract_name(
44
44
  chain_id: ChainId, address: HexAddress, api_key: str | None = None
45
45
  ) -> str:
46
46
  cache = load_contract_cache()
47
- cache_key = f"{chain_id}:{address}"
47
+ key = cache_key(chain_id, address)
48
48
 
49
- if cached := cache.get(cache_key):
49
+ if cached := cache.get(key):
50
50
  return cached
51
51
 
52
52
  if name := _fetch_contract_name(chain_id, address, api_key):
53
- update_contract_cache(cache_key, name)
53
+ update_contract_cache(key, name)
54
54
  return name
55
55
  return ""
56
56
 
@@ -437,6 +437,7 @@ def vault_add(
437
437
  label: Human-readable label (fetched from on-chain name() if empty).
438
438
  chain_id: Chain ID (auto-detected when only one provider configured).
439
439
  """
440
+ checksum = Web3.to_checksum_address(address)
440
441
  cfg = load_config()
441
442
 
442
443
  if not chain_id:
@@ -451,26 +452,21 @@ def vault_add(
451
452
  if not label:
452
453
  provider_url = _resolve_provider(cfg, chain_id)
453
454
  ctx = Web3Context.from_url(provider_url)
454
- checksum = Web3.to_checksum_address(address)
455
455
  try:
456
456
  label = PlasmaVault(ctx, checksum).name().call()
457
457
  except Exception:
458
458
  label = checksum
459
459
 
460
460
  for vault_entry in cfg.vaults:
461
- if vault_entry.address.lower() == address.lower():
461
+ if vault_entry.address.lower() == checksum.lower():
462
462
  vault_entry.label = label
463
463
  vault_entry.chain_id = chain_id
464
464
  save_config(cfg)
465
- return ActionResult(message=f"Vault {label} ({address}) updated.")
465
+ return ActionResult(message=f"Vault {label} ({checksum}) updated.")
466
466
 
467
- cfg.vaults.append(
468
- VaultEntry(
469
- address=Web3.to_checksum_address(address), label=label, chain_id=chain_id
470
- )
471
- )
467
+ cfg.vaults.append(VaultEntry(address=checksum, label=label, chain_id=chain_id))
472
468
  save_config(cfg)
473
- return ActionResult(message=f"Vault {label} ({address}) added.")
469
+ return ActionResult(message=f"Vault {label} ({checksum}) added.")
474
470
 
475
471
 
476
472
  @mcp.tool()
@@ -635,7 +631,7 @@ def market_meta_morpho(
635
631
  chain_id: EVM chain ID.
636
632
  """
637
633
  try:
638
- info = fetch_vault(vault_address, chain_id)
634
+ info = fetch_vault(Web3.to_checksum_address(vault_address), chain_id)
639
635
  except MorphoApiError as exc:
640
636
  raise ValueError(str(exc)) from exc
641
637
  payload = build_meta_morpho_json(info, chain_id)
@@ -91,7 +91,7 @@ class VaultFlowCap:
91
91
  fee_wei: int
92
92
  max_in: int
93
93
  max_out: int
94
- admin: str | None # the address that can configure these flow caps
94
+ admin: ChecksumAddress | None # the address that can configure these flow caps
95
95
 
96
96
 
97
97
  @dataclass(slots=True)
@@ -106,7 +106,7 @@ class VaultAllocation:
106
106
  total_assets: int
107
107
  supply_assets: int # currently allocated to *this* market
108
108
  supply_cap: int # cap configured by curator for *this* market
109
- allocators: list[str] # addresses with ALLOCATOR_ROLE on the vault
109
+ allocators: list[ChecksumAddress] # addresses with ALLOCATOR_ROLE on the vault
110
110
  flow_cap: VaultFlowCap | None # None if PublicAllocator not configured
111
111
 
112
112
 
@@ -122,8 +122,8 @@ class MorphoApiMarket:
122
122
  collateral_token: ChecksumAddress
123
123
  collateral_symbol: str
124
124
  collateral_decimals: int
125
- oracle: str
126
- irm: str
125
+ oracle: ChecksumAddress
126
+ irm: ChecksumAddress
127
127
  supply_assets: int
128
128
  borrow_assets: int
129
129
  liquidity_assets: int
@@ -190,8 +190,8 @@ def _parse_market(raw: dict) -> MorphoApiMarket:
190
190
  collateral_token=_api_address(raw["collateralAsset"]["address"]),
191
191
  collateral_symbol=raw["collateralAsset"]["symbol"],
192
192
  collateral_decimals=int(raw["collateralAsset"]["decimals"]),
193
- oracle=raw["oracle"]["address"],
194
- irm=raw["irmAddress"],
193
+ oracle=_api_address(raw["oracle"]["address"]),
194
+ irm=_api_address(raw["irmAddress"]),
195
195
  supply_assets=int(state.get("supplyAssets") or 0),
196
196
  borrow_assets=int(state.get("borrowAssets") or 0),
197
197
  liquidity_assets=int(state.get("liquidityAssets") or 0),
@@ -283,8 +283,8 @@ class VaultV2Cap:
283
283
  market_id: MorphoBlueMarketId | None
284
284
  loan_token: ChecksumAddress | None
285
285
  collateral_token: ChecksumAddress | None
286
- oracle: str | None
287
- irm: str | None
286
+ oracle: ChecksumAddress | None
287
+ irm: ChecksumAddress | None
288
288
  lltv: int | None
289
289
 
290
290
  @property
@@ -378,7 +378,7 @@ class VaultV1Info:
378
378
 
379
379
 
380
380
  def fetch_vault(
381
- address: str, chain_id: ChainId, *, timeout: float = 10.0
381
+ address: ChecksumAddress, chain_id: ChainId, *, timeout: float = 10.0
382
382
  ) -> VaultV2Info | VaultV1Info:
383
383
  """Fetch a Morpho vault by address. Tries V2 first, falls back to V1.
384
384
 
@@ -625,7 +625,7 @@ def _parse_vault_v1(raw: dict) -> VaultV1Info:
625
625
  fee_wei=int(pac.get("fee") or 0),
626
626
  max_in=0,
627
627
  max_out=0,
628
- admin=pac.get("admin"),
628
+ admin=_api_optional_address(pac.get("admin")),
629
629
  )
630
630
  for fc in pac.get("flowCaps") or []:
631
631
  if raw_mid := (fc.get("market") or {}).get("marketId"):
@@ -687,10 +687,12 @@ def _parse_vault(raw: dict, market_id: MorphoBlueMarketId) -> VaultAllocation:
687
687
  fee_wei=int(pac.get("fee") or 0),
688
688
  max_in=int(flow_for_market.get("maxIn") or 0),
689
689
  max_out=int(flow_for_market.get("maxOut") or 0),
690
- admin=pac.get("admin"),
690
+ admin=_api_optional_address(pac.get("admin")),
691
691
  )
692
692
  allocators = [
693
- a["address"] for a in (raw.get("allocators") or []) if a.get("address")
693
+ _api_address(a["address"])
694
+ for a in (raw.get("allocators") or [])
695
+ if a.get("address")
694
696
  ]
695
697
  return VaultAllocation(
696
698
  vault_address=_api_address(raw["address"]),
@@ -25,6 +25,7 @@ from web3.exceptions import ContractLogicError, TimeExhausted, Web3RPCError
25
25
  from web3.types import ChecksumAddress, HexStr
26
26
 
27
27
  from ipor_fusion.cache import (
28
+ cache_key,
28
29
  load_contract_cache,
29
30
  load_deployment_cache,
30
31
  update_contract_cache,
@@ -325,14 +326,14 @@ def resolve_token_symbol(ctx: Web3Context, address: HexAddress) -> str:
325
326
  """ERC-20 symbol, cached; ``"no contract"`` when nothing is deployed at
326
327
  ``address`` and ``""`` when the symbol cannot be read."""
327
328
  cache = load_contract_cache()
328
- cache_key = f"symbol:{address}"
329
- if cached := cache.get(cache_key):
329
+ key = cache_key("symbol", address)
330
+ if cached := cache.get(key):
330
331
  return cached
331
332
 
332
333
  checksum = Web3.to_checksum_address(address)
333
334
  code = ctx.web3.eth.get_code(checksum)
334
335
  if not code or code == b"":
335
- update_contract_cache(cache_key, _NO_CONTRACT)
336
+ update_contract_cache(key, _NO_CONTRACT)
336
337
  return _NO_CONTRACT
337
338
 
338
339
  try:
@@ -340,15 +341,15 @@ def resolve_token_symbol(ctx: Web3Context, address: HexAddress) -> str:
340
341
  except Exception:
341
342
  symbol = ""
342
343
  if symbol:
343
- update_contract_cache(cache_key, symbol)
344
+ update_contract_cache(key, symbol)
344
345
  return symbol
345
346
 
346
347
 
347
348
  def resolve_token_decimals(ctx: Web3Context, address: HexAddress) -> int | None:
348
349
  """Resolve ERC-20 decimals, cached. Returns None if address is not a contract."""
349
350
  cache = load_contract_cache()
350
- cache_key = f"decimals:{address}"
351
- if cached := cache.get(cache_key):
351
+ key = cache_key("decimals", address)
352
+ if cached := cache.get(key):
352
353
  return int(cached)
353
354
 
354
355
  checksum = Web3.to_checksum_address(address)
@@ -360,7 +361,7 @@ def resolve_token_decimals(ctx: Web3Context, address: HexAddress) -> int | None:
360
361
  decimals = ERC20(ctx, checksum).decimals().call()
361
362
  except Exception:
362
363
  return None
363
- update_contract_cache(cache_key, str(decimals))
364
+ update_contract_cache(key, str(decimals))
364
365
  return decimals
365
366
 
366
367
 
@@ -1053,9 +1054,9 @@ def fetch_deployment_info(
1053
1054
  Base/Optimism on the free Etherscan tier). ``None`` means either success or
1054
1055
  a benign empty response.
1055
1056
  """
1056
- cache_key = f"{chain_id}:{vault_address}"
1057
+ key = cache_key(chain_id, vault_address)
1057
1058
  cache = load_deployment_cache()
1058
- if entry := cache.get(cache_key):
1059
+ if entry := cache.get(key):
1059
1060
  return entry["block"], entry["timestamp"], None
1060
1061
 
1061
1062
  tx_hash, error = get_deployment_tx(chain_id, vault_address, api_key)
@@ -1067,7 +1068,7 @@ def fetch_deployment_info(
1067
1068
  block_number: int = tx["blockNumber"]
1068
1069
  block_info = ctx.web3.eth.get_block(block_number)
1069
1070
  timestamp: int = block_info["timestamp"]
1070
- update_deployment_cache(cache_key, block_number, timestamp)
1071
+ update_deployment_cache(key, block_number, timestamp)
1071
1072
  return block_number, timestamp, None
1072
1073
  except Exception:
1073
1074
  return None, None, "rpc-fetch-failed"
@@ -69,7 +69,8 @@
69
69
  "AAVE_V3 (1)": [
70
70
  {
71
71
  "address": "0x00000000000000000000000000000000000000a1",
72
- "symbol": "USDC"
72
+ "symbol": "USDC",
73
+ "contract": "FiatTokenProxy"
73
74
  }
74
75
  ]
75
76
  },
@@ -36,7 +36,7 @@ Balance Fuses (1):
36
36
 
37
37
  Substrates per Market:
38
38
  AAVE_V3 (1):
39
- 0x00000000000000000000000000000000000000a1 (symbol=USDC)
39
+ 0x00000000000000000000000000000000000000a1 (symbol=USDC, contract=FiatTokenProxy)
40
40
 
41
41
  ERC20 Balances (vault holdings):
42
42
  (no ERC20_VAULT_BALANCE market)
@@ -143,7 +143,8 @@
143
143
  "AAVE_V3 (1)": [
144
144
  {
145
145
  "address": "0x00000000000000000000000000000000000000a1",
146
- "symbol": "USDC"
146
+ "symbol": "USDC",
147
+ "contract": "FiatTokenProxy"
147
148
  }
148
149
  ],
149
150
  "MORPHO (14)": [
@@ -155,7 +156,8 @@
155
156
  "ERC20_VAULT_BALANCE (7)": [
156
157
  {
157
158
  "address": "0x00000000000000000000000000000000000000a1",
158
- "symbol": "USDC"
159
+ "symbol": "USDC",
160
+ "contract": "FiatTokenProxy"
159
161
  },
160
162
  {
161
163
  "address": "0x00000000000000000000000000000000000000b1",
@@ -53,17 +53,17 @@ Dependency Balance Graph:
53
53
 
54
54
  Substrates per Market:
55
55
  AAVE_V3 (1):
56
- 0x00000000000000000000000000000000000000a1 (symbol=USDC)
56
+ 0x00000000000000000000000000000000000000a1 (symbol=USDC, contract=FiatTokenProxy)
57
57
  MORPHO (14):
58
58
  0xabababababababababababababababababababababababababababababababab (morpho_market_id)
59
59
  ERC20_VAULT_BALANCE (7):
60
- 0x00000000000000000000000000000000000000a1 (symbol=USDC)
60
+ 0x00000000000000000000000000000000000000a1 (symbol=USDC, contract=FiatTokenProxy)
61
61
  0x00000000000000000000000000000000000000b1 (symbol=STALE)
62
62
  0x00000000000000000000000000000000000000b2 (symbol=NOPRICE)
63
63
  0x00000000000000000000000000000000000000b3 (symbol=EMPTY)
64
64
  0x00000000000000000000000000000000000000b4 (symbol=BROKEN)
65
65
  AAVE_V3 (1):
66
- 0x00000000000000000000000000000000000000a1 (symbol=USDC)
66
+ 0x00000000000000000000000000000000000000a1 (symbol=USDC, contract=FiatTokenProxy)
67
67
 
68
68
  ERC20 Balances (vault holdings):
69
69
  Token Symbol Balance Price Note
@@ -0,0 +1,99 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from ipor_fusion import cache
6
+ from ipor_fusion.cache import load_contract_cache, save_contract_cache
7
+
8
+
9
+ @pytest.fixture(autouse=True)
10
+ def _patch_paths(tmp_path, monkeypatch):
11
+ cache_dir = tmp_path / ".cache"
12
+ monkeypatch.setattr(cache, "CACHE_DIR", cache_dir)
13
+ monkeypatch.setattr(cache, "CACHE_FILE", cache_dir / "contract_cache.json")
14
+
15
+
16
+ class TestLoadContractCache:
17
+ def test_returns_empty_dict_when_no_file(self):
18
+ assert load_contract_cache() == {}
19
+
20
+ def test_loads_existing_cache(self, tmp_path):
21
+ cache_dir = tmp_path / ".cache"
22
+ cache_dir.mkdir()
23
+ cache = {"0xABC": "ERC20", "0xDEF": "PlasmaVault"}
24
+ (cache_dir / "contract_cache.json").write_text(
25
+ json.dumps(cache), encoding="utf-8"
26
+ )
27
+
28
+ assert load_contract_cache() == cache
29
+
30
+
31
+ class TestSaveContractCache:
32
+ def test_creates_directory_and_writes_json(self, tmp_path):
33
+ cache = {"0xABC": "ERC20"}
34
+ save_contract_cache(cache)
35
+
36
+ cache_file = tmp_path / ".cache" / "contract_cache.json"
37
+ assert cache_file.exists()
38
+ assert json.loads(cache_file.read_text(encoding="utf-8")) == cache
39
+
40
+ def test_roundtrip(self):
41
+ original = {"0x111": "TokenA", "0x222": "TokenB"}
42
+ save_contract_cache(original)
43
+ loaded = load_contract_cache()
44
+ assert loaded == original
45
+
46
+
47
+ class TestCanonicalKeys:
48
+ CHECKSUM = "0xAbCdEf0123456789aBcDeF0123456789AbCdEf01"
49
+
50
+ def _write(self, tmp_path, name, entries):
51
+ cache_dir = tmp_path / ".cache"
52
+ cache_dir.mkdir(exist_ok=True)
53
+ (cache_dir / name).write_text(json.dumps(entries), encoding="utf-8")
54
+
55
+ def test_a_key_lowercases_only_the_address(self):
56
+ assert cache.cache_key("symbol", self.CHECKSUM) == (
57
+ "symbol:" + self.CHECKSUM.lower()
58
+ )
59
+ assert cache.cache_key(1, self.CHECKSUM) == "1:" + self.CHECKSUM.lower()
60
+
61
+ def test_a_legacy_key_spelled_by_the_caller_still_hits(self, tmp_path):
62
+ self._write(tmp_path, "contract_cache.json", {f"1:{self.CHECKSUM}": "Vault"})
63
+ assert load_contract_cache()[cache.cache_key(1, self.CHECKSUM)] == "Vault"
64
+
65
+ def test_an_already_canonical_entry_wins_over_a_duplicate(self, tmp_path):
66
+ self._write(
67
+ tmp_path,
68
+ "contract_cache.json",
69
+ {
70
+ f"symbol:{self.CHECKSUM.lower()}": "NEW",
71
+ f"symbol:{self.CHECKSUM}": "OLD",
72
+ },
73
+ )
74
+ assert load_contract_cache() == {f"symbol:{self.CHECKSUM.lower()}": "NEW"}
75
+
76
+ def test_an_update_rewrites_legacy_keys(self, tmp_path):
77
+ self._write(tmp_path, "contract_cache.json", {f"1:{self.CHECKSUM}": "Vault"})
78
+ cache.update_contract_cache(cache.cache_key("decimals", self.CHECKSUM), "6")
79
+ on_disk = json.loads(
80
+ (tmp_path / ".cache" / "contract_cache.json").read_text(encoding="utf-8")
81
+ )
82
+ assert on_disk == {
83
+ f"1:{self.CHECKSUM.lower()}": "Vault",
84
+ f"decimals:{self.CHECKSUM.lower()}": "6",
85
+ }
86
+
87
+ def test_deployment_entries_are_canonical(self, tmp_path, monkeypatch):
88
+ monkeypatch.setattr(
89
+ cache,
90
+ "DEPLOYMENT_CACHE_FILE",
91
+ tmp_path / ".cache" / "deployment_cache.json",
92
+ )
93
+ self._write(
94
+ tmp_path,
95
+ "deployment_cache.json",
96
+ {f"1:{self.CHECKSUM}": {"block": 5, "timestamp": 6}},
97
+ )
98
+ loaded = cache.load_deployment_cache()
99
+ assert loaded[cache.cache_key(1, self.CHECKSUM)] == {"block": 5, "timestamp": 6}
@@ -4,6 +4,7 @@
4
4
  import json
5
5
  from unittest.mock import MagicMock, patch
6
6
 
7
+ import pytest
7
8
  from click.testing import CliRunner
8
9
  from eth_abi import encode
9
10
  from web3 import Web3
@@ -18,6 +19,7 @@ from ipor_fusion.morpho_api import (
18
19
  MorphoApiMarket,
19
20
  VaultAllocation,
20
21
  VaultFlowCap,
22
+ _parse_market,
21
23
  fetch_market,
22
24
  fetch_vault,
23
25
  )
@@ -786,3 +788,31 @@ class TestMetaMorphoCommand:
786
788
  assert len(market_caps) == 1
787
789
  assert market_caps[0]["loan_token"] == LOAN_TOKEN
788
790
  assert market_caps[0]["lltv"] == str(915 * 10**15)
791
+
792
+
793
+ class TestMorphoApiAddresses:
794
+ def _raw(self, oracle: str) -> dict:
795
+ return {
796
+ "marketId": "0x" + MARKET_ID,
797
+ "lltv": "915000000000000000",
798
+ "loanAsset": {"address": LOAN_TOKEN.lower(), "symbol": "U", "decimals": 6},
799
+ "collateralAsset": {
800
+ "address": COLLATERAL_TOKEN,
801
+ "symbol": "C",
802
+ "decimals": 18,
803
+ },
804
+ "oracle": {"address": oracle},
805
+ "irmAddress": IRM.lower(),
806
+ "state": {},
807
+ "supplyingVaults": [],
808
+ }
809
+
810
+ def test_addresses_are_checksummed(self):
811
+ market = _parse_market(self._raw(ORACLE.lower()))
812
+ assert market.loan_token == LOAN_TOKEN
813
+ assert market.oracle == ORACLE
814
+ assert market.irm == IRM
815
+
816
+ def test_a_malformed_address_is_an_api_error(self):
817
+ with pytest.raises(MorphoApiError):
818
+ _parse_market(self._raw("invalid oracle"))
@@ -362,6 +362,10 @@ def test_vault_info_output_matches_fixture(scenario: str, json_output: bool) ->
362
362
  json.loads(rendered)
363
363
 
364
364
 
365
+ # The CLI's cached lookup matches an address in any spelling; so does this one.
366
+ _NAMES_BY_ADDRESS = {address.lower(): name for address, name in NAMES.items()}
367
+
368
+
365
369
  @pytest.mark.usefixtures("isolated_cache")
366
370
  @pytest.mark.parametrize("scenario", sorted(SCENARIOS))
367
371
  def test_public_builder_renders_the_cli_json(scenario: str) -> None:
@@ -377,7 +381,7 @@ def test_public_builder_renders_the_cli_json(scenario: str) -> None:
377
381
  VAULT,
378
382
  1,
379
383
  "ethereum",
380
- resolve_name=lambda address: NAMES.get(address, ""),
384
+ resolve_name=lambda address: _NAMES_BY_ADDRESS.get(address.lower(), ""),
381
385
  )
382
386
  expected = (FIXTURES / f"{scenario}.json").read_text(encoding="utf-8")
383
387
  assert json.dumps(result, indent=2) + "\n" == expected
@@ -59,7 +59,7 @@ class TestGetContractName:
59
59
  cache_file = tmp_path / ".cache" / "contract_cache.json"
60
60
  assert cache_file.exists()
61
61
  cached = json.loads(cache_file.read_text(encoding="utf-8"))
62
- assert cached["1:0xDEF"] == "PlasmaVault"
62
+ assert cached["1:0xdef"] == "PlasmaVault"
63
63
 
64
64
  def test_returns_empty_for_unsupported_chain(self):
65
65
  result = get_contract_name(99999, "0xABC", api_key="key123")
@@ -89,12 +89,15 @@ def _config_with_keyed_provider():
89
89
  return FusionConfig(providers={"1": "https://rpc.example.com/v2/SECRETKEY"})
90
90
 
91
91
 
92
- def _config_with_vault():
92
+ _SAVED_VAULT = "0x43ee0243ea8cf02f7087d8b16c8d2007cc9c7ca2"
93
+
94
+
95
+ def _config_with_vault(address: str = _SAVED_VAULT):
93
96
  from ipor_fusion.cli.config_store import FusionConfig, VaultEntry
94
97
 
95
98
  return FusionConfig(
96
99
  providers={"1": "https://rpc.example.com"},
97
- vaults=[VaultEntry(address="0xABC", label="Test", chain_id=1)],
100
+ vaults=[VaultEntry(address=address, label="Test", chain_id=1)],
98
101
  )
99
102
 
100
103
 
@@ -264,7 +267,7 @@ class TestConfigShow:
264
267
  def test_config_with_vault(self, _):
265
268
  result = config_show()
266
269
  assert len(result.vaults) == 1
267
- assert result.vaults[0].address == "0xABC"
270
+ assert result.vaults[0].address == Web3.to_checksum_address(_SAVED_VAULT)
268
271
 
269
272
  @patch(
270
273
  "ipor_fusion.mcp.server.load_config",
@@ -319,7 +322,7 @@ class TestVaultList:
319
322
  def test_with_vault(self, _):
320
323
  result = vault_list()
321
324
  assert len(result) == 1
322
- assert result[0].address == "0xABC"
325
+ assert result[0].address == Web3.to_checksum_address(_SAVED_VAULT)
323
326
  assert result[0].label == "Test"
324
327
  assert result[0].chain_id == 1
325
328
 
@@ -348,13 +351,23 @@ class TestVaultAdd:
348
351
  with pytest.raises(ValueError):
349
352
  vault_add(address="0xDEF", label="New Vault", chain_id=1)
350
353
 
354
+ @patch("ipor_fusion.mcp.server.save_config")
355
+ @patch(
356
+ "ipor_fusion.mcp.server.load_config",
357
+ return_value=_config_with_vault("0xABC"),
358
+ )
359
+ def test_a_malformed_saved_entry_is_not_updated(self, mock_load, mock_save):
360
+ with pytest.raises(ValueError):
361
+ vault_add(address="0xABC", label="Updated", chain_id=1)
362
+ mock_save.assert_not_called()
363
+
351
364
  @patch("ipor_fusion.mcp.server.save_config")
352
365
  @patch(
353
366
  "ipor_fusion.mcp.server.load_config",
354
367
  return_value=_config_with_vault(),
355
368
  )
356
369
  def test_update_existing(self, mock_load, mock_save):
357
- result = vault_add(address="0xABC", label="Updated", chain_id=1)
370
+ result = vault_add(address=_SAVED_VAULT, label="Updated", chain_id=1)
358
371
  assert "updated" in result.message.lower()
359
372
  saved_cfg = mock_save.call_args[0][0]
360
373
  assert len(saved_cfg.vaults) == 1
@@ -368,7 +381,7 @@ class TestVaultRemove:
368
381
  return_value=_config_with_vault(),
369
382
  )
370
383
  def test_remove_existing(self, mock_load, mock_save):
371
- result = vault_remove(address="0xABC")
384
+ result = vault_remove(address=_SAVED_VAULT)
372
385
  assert "removed" in result.message.lower()
373
386
  saved_cfg = mock_save.call_args[0][0]
374
387
  assert len(saved_cfg.vaults) == 0