Repository: rotki/rotki

Issue:
CSV export should also contain human readable addresses from the users + global addressbook
## Abstract

The notes for transaction events of the PnL export are exported raw. In the UI all hexadecimal addresses are checked against addressbook and replaced if existing. But the CSV export does not do this.

It has been requested we do this as it is a value add for the user. cc @Buttaa 

## Task

- If the event in the CSV export has any addresses in the notes add the label next to it in brackets. For example `<address> [label]` This will require some text processing with regexes, similar to what the frontend does on top of the notes.



Discussion:
@LefterisJP i would *not* replace it but add it next to it, e.g `.... <address> [label]...` 

> @LefterisJP i would _not_ replace it but add it next to it, e.g `.... <address> [label]...`

Can do. Sure. In the UI it's replaced but can also hover over it and see addy. In a CSV export your suggestion is better.

Resolving patch:
diff --git a/rotkehlchen/accounting/export/csv.py b/rotkehlchen/accounting/export/csv.py
--- a/rotkehlchen/accounting/export/csv.py
+++ b/rotkehlchen/accounting/export/csv.py
@@ -307,6 +307,7 @@ def to_csv_entry(self, event: 'ProcessedAccountingEvent') -> dict[str, Any]:
         dict_event = event.to_exported_dict(
             ts_converter=self.timestamp_to_date,
             export_type=AccountingEventExportType.CSV,
+            database=self.database,
             evm_explorer=evm_explorer,
         )
         # For CSV also convert timestamp to date
diff --git a/rotkehlchen/accounting/structures/processed_event.py b/rotkehlchen/accounting/structures/processed_event.py
--- a/rotkehlchen/accounting/structures/processed_event.py
+++ b/rotkehlchen/accounting/structures/processed_event.py
@@ -1,5 +1,6 @@
 import builtins
 import json
+import re
 from collections.abc import Callable
 from dataclasses import dataclass, field
 from enum import Enum, auto
@@ -9,13 +10,16 @@
 from rotkehlchen.accounting.mixins.event import AccountingEventType
 from rotkehlchen.accounting.pnl import PNL
 from rotkehlchen.assets.asset import Asset
+from rotkehlchen.chain.evm.constants import EVM_ADDRESS_REGEX
+from rotkehlchen.chain.evm.types import string_to_evm_address
 from rotkehlchen.constants import ZERO
+from rotkehlchen.db.dbhandler import DBHandler
 from rotkehlchen.errors.asset import UnknownAsset
 from rotkehlchen.errors.serialization import DeserializationError
 from rotkehlchen.fval import FVal
 from rotkehlchen.history.deserialization import deserialize_price
 from rotkehlchen.serialization.deserialize import deserialize_fval
-from rotkehlchen.types import Location, Price, Timestamp
+from rotkehlchen.types import EVM_LOCATIONS, ChainAddress, ChainID, Location, Price, Timestamp
 from rotkehlchen.utils.serialization import rlk_jsondumps
 
 T = TypeVar('T', bound='ProcessedAccountingEvent')
@@ -61,11 +65,25 @@ def to_string(self, ts_converter: Callable[[Timestamp], str]) -> str:
 
         return desc
 
+    def _maybe_add_label_with_address(
+            self,
+            database: DBHandler,
+            matched_address: re.Match[str],
+    ) -> str:
+        """Aux method to enrich addresses in the event notes using the addressbook"""
+        chain_address = ChainAddress(
+            address=string_to_evm_address(matched_address.group()),
+            blockchain=ChainID(self.location.to_chain_id()).to_blockchain(),
+        )
+        name = database.get_blockchain_account_label(chain_address)
+        return f'{chain_address.address} [{name}]' if name else chain_address.address
+
     @overload
     def to_exported_dict(
             self,
             ts_converter: Callable[[Timestamp], str],
             export_type: Literal[AccountingEventExportType.CSV],
+            database: DBHandler,
             evm_explorer: str | None,
     ) -> dict[str, Any]:
         ...
@@ -82,6 +100,7 @@ def to_exported_dict(
             self,
             ts_converter: Callable[[Timestamp], str],
             export_type: AccountingEventExportType,
+            database: DBHandler | None = None,
             evm_explorer: str | None = None,
     ) -> dict[str, Any]:
         """These are the fields that will appear in CSV, report API and are also exported to the
@@ -117,6 +136,15 @@ def to_exported_dict(
                 exported_dict['asset'] = ''
             if tx_hash is not None:
                 exported_dict['notes'] = f'{evm_explorer}{tx_hash}  ->  {self.notes}'
+            if self.location in EVM_LOCATIONS and database is not None:
+                # call _maybe_add_label_with_address on each address in the note
+                exported_dict['notes'] = EVM_ADDRESS_REGEX.sub(
+                    repl=lambda matched_address: self._maybe_add_label_with_address(
+                        database=database,
+                        matched_address=matched_address,
+                    ),
+                    string=exported_dict['notes'],  # type: ignore [call-overload]  # exported_dict['notes'] is always a string
+                )
         else:  # for the other types of export we include the cost basis information
             cost_basis = None
             if self.cost_basis is not None:
diff --git a/rotkehlchen/chain/evm/constants.py b/rotkehlchen/chain/evm/constants.py
--- a/rotkehlchen/chain/evm/constants.py
+++ b/rotkehlchen/chain/evm/constants.py
@@ -1,3 +1,4 @@
+import re
 from typing import Final
 
 from rotkehlchen.types import deserialize_evm_tx_hash
@@ -9,6 +10,7 @@
 ETH_SPECIAL_ADDRESS = string_to_evm_address('0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE')
 ZERO_32_BYTES_HEX = '0x' + '0' * 64
 GENESIS_HASH = deserialize_evm_tx_hash(ZERO_32_BYTES_HEX)  # hash for transactions in genesis block
+EVM_ADDRESS_REGEX = re.compile(r'\b0x[a-fA-F0-9]{40}\b')
 
 # Fake receipt with values taken from ethereum mainnet, to emulate a receipt for the
 # genesis transactions


Test patch:
diff --git a/rotkehlchen/tests/api/test_pnl_csv.py b/rotkehlchen/tests/api/test_pnl_csv.py
--- a/rotkehlchen/tests/api/test_pnl_csv.py
+++ b/rotkehlchen/tests/api/test_pnl_csv.py
@@ -16,7 +16,9 @@
 from rotkehlchen.accounting.mixins.event import AccountingEventType
 from rotkehlchen.accounting.structures.balance import Balance
 from rotkehlchen.assets.asset import CustomAsset
+from rotkehlchen.chain.accounts import BlockchainAccountData
 from rotkehlchen.chain.evm.constants import GENESIS_HASH
+from rotkehlchen.chain.evm.types import string_to_evm_address
 from rotkehlchen.constants.assets import A_ETH
 from rotkehlchen.db.custom_assets import DBCustomAssets
 from rotkehlchen.fval import FVal
@@ -29,7 +31,7 @@
 from rotkehlchen.tests.utils.factories import make_evm_tx_hash
 from rotkehlchen.tests.utils.history import prepare_rotki_for_history_processing_test, prices
 from rotkehlchen.tests.utils.pnl_report import query_api_create_and_get_report
-from rotkehlchen.types import Location, Timestamp, TimestampMS
+from rotkehlchen.types import Location, SupportedBlockchain, Timestamp, TimestampMS
 from rotkehlchen.utils.misc import create_timestamp
 
 
@@ -82,6 +84,7 @@ def assert_csv_export_response(response, csv_dir, is_download=False, expected_nu
             rows.append(row)
 
     assert count == expected_num_of_events
+    return rows
 
 
 @pytest.mark.parametrize('have_decoders', [True])
@@ -184,13 +187,25 @@ def test_history_export_download_csv(
             balance=Balance(amount=FVal('1.5')),
             event_type=HistoryEventType.RECEIVE,
             event_subtype=HistoryEventSubType.NONE,
+            notes=f'Test note to check if label is added with {ETH_ADDRESS1} address',
         )],
     )
+    # add a label for this address in the DB to check if it's added in the exported note
+    with rotkehlchen_api_server_with_exchanges.rest_api.rotkehlchen.data.db.user_write() as cursor:
+        rotkehlchen_api_server_with_exchanges.rest_api.rotkehlchen.data.db.edit_blockchain_accounts(
+            write_cursor=cursor,
+            account_data=[BlockchainAccountData(
+                chain=SupportedBlockchain.ETHEREUM,
+                address=string_to_evm_address(ETH_ADDRESS1),
+                label='ETH_ADDRESS1',
+            )],
+        )
     response = requests.get(
         api_url_for(rotkehlchen_api_server_with_exchanges, 'historyexportingresource'),
         json={'directory_path': csv_dir},
     )
-    assert_csv_export_response(response, csv_dir, expected_num_of_events=1)
+    rows = assert_csv_export_response(response, csv_dir, expected_num_of_events=1)
+    assert rows[0]['notes'] == f'Test note to check if label is added with {ETH_ADDRESS1} [ETH_ADDRESS1] address'  # noqa: E501
 
 
 @pytest.mark.parametrize('mocked_price_queries', [{'ETH': {'EUR': {1569924574: 1}}}])
diff --git a/rotkehlchen/tests/unit/test_evm_misc.py b/rotkehlchen/tests/unit/test_evm_misc.py
--- a/rotkehlchen/tests/unit/test_evm_misc.py
+++ b/rotkehlchen/tests/unit/test_evm_misc.py
@@ -1,3 +1,4 @@
+from rotkehlchen.chain.evm.constants import EVM_ADDRESS_REGEX
 from rotkehlchen.chain.evm.types import asset_id_is_evm_token, string_to_evm_address
 from rotkehlchen.types import ChainID
 
@@ -22,3 +23,20 @@ def test_asset_id_is_evm_token():
     assert asset_id_is_evm_token('ETH') is None
     assert asset_id_is_evm_token('BTC') is None
     assert asset_id_is_evm_token('eip155:125/erc721:0xC36442b4a4522E871399CD717aBDD847Ab11FE88') is None  # noqa: E501
+
+
+def test_address_regex() -> None:
+    cases: dict[str, bool] = {
+        '0x0F5D2fB29fb7d3CFeE444a200298f468908cC942': True,  # address
+        '0x9b675024d8648c3b590eff411fcf75a1199d10d1a3fe2ddbe50e166ce8b87cc9': False,  # transaction hash  # noqa: E501
+        'https://etherscan.io/address/0x0F5D2fB29fb7d3CFeE444a200298f468908cC942': True,  # etherscan link of an address  # noqa: E501
+        'https://etherscan.io/tx/0x9b675024d8648c3b590eff411fcf75a1199d10d1a3fe2ddbe50e166ce8b87cc9': False,  # etherscan link of a transaction  # noqa: E501
+        '0x0F5D2fB29fb7d3CFeE444a200298f468908cC942a': False,  # invalid address length
+        '0x0F5D2fB29fb7d3CFeE444a200298f468908zC942': False,  # invalid address characters
+        '0x0F5D2fB29fb7d3CFeE444a200298f468908cC942 Note with address at the start': True,
+        'Note with address in the end 0x0F5D2fB29fb7d3CFeE444a200298f468908cC942': True,
+        'A full stop after the address 0x0F5D2fB29fb7d3CFeE444a200298f468908cC942.': True,
+    }
+
+    for case, expected_result in cases.items():
+        assert (EVM_ADDRESS_REGEX.search(case) is not None) == expected_result