Repository: ApeWorX/ape

Issue:
Contract ABI contains duplicated items
### Have you ensured that all of these are up to date?

- [x] Ape
- [x] Ape plugins

### What version of Ape are you on?

0.8.30

### What command(s) is the bug in?

_No response_

### Operating System

Linux

### Ape Configuration

_No response_

### Description

When running an ape script (a silverback bot really) in a containerized environment, I am getting a weird issue where the same contract event definition appears multiple times in a contract type

### Reproduction code

Ran this on the current container version

```sh
$ docker run -e ETHERSCAN_API_KEY=$ETHERSCAN_API_KEY -it --entrypoint sh ghcr.io/apeworx/silverback:stable
$ ape console --network :sepolia
INFO:     Connecting to existing Geth node at https://sepolia.gateway.tenderly.co.

In [1]: from apepay import StreamManager

In [2]: sm = StreamManager("0x7B25175142886e34543247f4583fe034eafDee32")

In [3]: sm.contract.StreamFunded.events
Out[3]: StreamFunded(uint256 indexed stream_id, address indexed funder, uint256 funded_amount, uint256 
time_left)
StreamFunded(uint256 indexed stream_id, address indexed funder, uint256 funded_amount, uint256 
time_left)
StreamFunded(uint256 indexed stream_id, address indexed funder, uint256 funded_amount, uint256 
time_left)
StreamFunded(uint256 indexed stream_id, address indexed funder, uint256 funded_amount, uint256 
time_left)
[, , , ]
```

Discussion:
@linear: <p><a href="https://linear.app/apeworx/issue/APE-1923/contract-abi-contains-duplicated-items">APE-1923 Contract ABI contains duplicated items</a></p>@fubuloubu: Noticing that every time `ContractInstance._events_` is called, the number of duplicate events grows by 1 (the first time it is just `ContractEvent`, then becomes `ContractEventWrapper`)@fubuloubu: Okay, seems to stem from the way that contract types are merged with the default, assuming there is a slight naming difference between their ABIs:
https://github.com/ApeWorX/ape/blob/main/src/ape/managers/_contractscache.py#L558
```py
(Pdb) !list(a == b for a, b in zip(default.abi, contract_type.abi))
[True, True, True, True, True, False, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, True, False, True, True]
(Pdb) default.abi[5]
EventABI(type='event', name='StreamClaimed', inputs=[EventABIType(name='stream_id', type='uint256', components=None, internal_type=None, indexed=True), EventABIType(name='claimer', type='address', components=None, internal_type=None, indexed=True), EventABIType(name='is_expired', type='bool', components=None, internal_type=None, indexed=True), EventABIType(name='claim_amount', type='uint256', components=None, internal_type=None, indexed=False)], anonymous=False)
(Pdb) contract_type.abi[5]
EventABI(type='event', name='StreamClaimed', inputs=[EventABIType(name='stream_id', type='uint256', components=None, internal_type=None, indexed=True), EventABIType(name='claimer', type='address', components=None, internal_type=None, indexed=True), EventABIType(name='is_expires', type='bool', components=None, internal_type=None, indexed=True), EventABIType(name='claim_amount', type='uint256', components=None, internal_type=None, indexed=False)], anonymous=False)
(Pdb) default.abi[-3]
MethodABI(type='function', name='control_transfer_proposed', stateMutability='view', inputs=[], outputs=[ABIType(name='', type='uint256', components=None, internal_type=None)])
(Pdb) contract_type.abi[-3]
MethodABI(type='function', name='new_controller_proposed', stateMutability='view', inputs=[], outputs=[ABIType(name='', type='uint256', components=None, internal_type=None)])
```@fubuloubu: Two problems:
1. ApePay ships with Manifest that mismatches old release used at contract address (problem within SDK)
2. The way Ape merges mismatched ABIs is too greedy, it should conservatively OR them together based on their method_id, not totally additive@fubuloubu: #2568 solves the error with this library, apepay technically has an issue in that it's now shipping a mismatched package manifest along with the library vs. what is published (on sepolia only I think), but that's more of a cleanliness thing there, not a major issue to fix in Ape

Solution patch:
diff --git a/setup.py b/setup.py
index 16f03ee8e..6f85642d6 100644
--- a/setup.py
+++ b/setup.py
@@ -121,7 +121,7 @@ setup(
         "web3[tester]>=6.20.1,<8",
         # ** Dependencies maintained by ApeWorX **
         "eip712>=0.2.10,<0.3",
-        "ethpm-types>=0.6.23,<0.7",
+        "ethpm-types>=0.6.25,<0.7",
         "eth_pydantic_types>=0.1.3,<0.2",
         "evmchains>=0.1.0,<0.2",
         "evm-trace>=0.2.3,<0.3",
diff --git a/src/ape/managers/_contractscache.py b/src/ape/managers/_contractscache.py
index d9c901f6e..ccff8ebf2 100644
--- a/src/ape/managers/_contractscache.py
+++ b/src/ape/managers/_contractscache.py
@@ -955,5 +955,9 @@ def _merge_abis(base_contract: ContractType, extra_abis: list[ABI]) -> ContractT
 def _merge_contract_types(
     base_contract_type: ContractType, additive_contract_type: ContractType
 ) -> ContractType:
+    existing_methods = set(_get_relevant_additive_abis(base_contract_type))
     relevant_abis = _get_relevant_additive_abis(additive_contract_type)
-    return _merge_abis(base_contract_type, relevant_abis)
+    return _merge_abis(
+        base_contract_type,
+        [abi for abi in relevant_abis if abi not in existing_methods],
+    )


Test patch:
diff --git a/tests/functional/test_contracts_cache.py b/tests/functional/test_contracts_cache.py
index cd47a7353..bd27e03a3 100644
--- a/tests/functional/test_contracts_cache.py
+++ b/tests/functional/test_contracts_cache.py
@@ -5,6 +5,7 @@ from ape import Contract
 from ape.contracts import ContractInstance
 from ape.exceptions import ContractNotFoundError, ConversionError
 from ape.logging import LogLevel, logger
+from ape.managers._contractscache import _merge_contract_types
 from ape_ethereum.proxies import ProxyInfo, ProxyType, _make_minimal_proxy
 from tests.conftest import explorer_test, skip_if_plugin_installed
 
@@ -19,6 +20,15 @@ def contract_1(solidity_contract_container):
     return solidity_contract_container
 
 
+def test_merge_contract_types(contract_instance):
+    ct = contract_instance.contract_type
+    modified_ct = ct.model_copy(deep=True)
+    modified_ct.view_methods[0].name = "foo"
+    new_ct = _merge_contract_types(ct, modified_ct)
+    assert len(new_ct.abi) == len(ct.abi) + 1 == len(modified_ct.abi) + 1
+    assert len(new_ct.abi) == len(_merge_contract_types(new_ct, modified_ct).abi)
+
+
 def test_instance_at(chain, contract_instance):
     contract = chain.contracts.instance_at(str(contract_instance.address))
     assert contract.contract_type == contract_instance.contract_type