diff --git a/safe_transaction_service/history/serializers.py b/safe_transaction_service/history/serializers.py index dab9267ea..1516a4feb 100644 --- a/safe_transaction_service/history/serializers.py +++ b/safe_transaction_service/history/serializers.py @@ -1343,6 +1343,35 @@ class SafeDeploymentSerializer(serializers.Serializer): contracts = SafeDeploymentContractSerializer(many=True) +class SafeExportTransactionRequestParams(serializers.Serializer): + execution_date__gte = serializers.DateTimeField(required=False, allow_null=True) + execution_date__lte = serializers.DateTimeField(required=False, allow_null=True) + + +class SafeExportTransactionSerializer(serializers.Serializer): + """ + Serializer for the export endpoint that returns transaction data optimized for CSV export + """ + + safe = EthereumAddressField() + from_ = EthereumAddressField(source="_from") + to = EthereumAddressField() + amount = serializers.CharField(source="_value") + asset_type = serializers.CharField() + asset_address = EthereumAddressField(allow_null=True) + asset_symbol = serializers.CharField(allow_null=True) + asset_decimals = serializers.IntegerField(allow_null=True) + proposer_address = EthereumAddressField(allow_null=True) + proposed_at = serializers.DateTimeField(allow_null=True) + executor_address = EthereumAddressField(allow_null=True) + executed_at = serializers.DateTimeField(allow_null=True) + note = serializers.CharField(allow_null=True) + transaction_hash = Sha3HashField() + safe_tx_hash = Sha3HashField(allow_null=True) + method = serializers.CharField(allow_null=True) + contract_address = EthereumAddressField(allow_null=True) + + class CodeErrorResponse(serializers.Serializer): code = serializers.IntegerField() message = serializers.CharField() diff --git a/safe_transaction_service/history/services/transaction_service.py b/safe_transaction_service/history/services/transaction_service.py index 93649db78..da27947a6 100644 --- a/safe_transaction_service/history/services/transaction_service.py +++ b/safe_transaction_service/history/services/transaction_service.py @@ -2,16 +2,20 @@ import pickle import zlib from collections import defaultdict -from datetime import timedelta -from typing import Any, Optional, Sequence, Union +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union from django.conf import settings -from django.db.models import QuerySet +from django.db import connection +from django.db.models import F, QuerySet +from django.db.models.expressions import RawSQL from django.utils import timezone -from eth_typing import HexStr +from eth_typing import ChecksumAddress, HexStr +from hexbytes import HexBytes from redis import Redis from safe_eth.eth import EthereumClient, get_auto_ethereum_client +from safe_eth.eth.utils import fast_to_checksum_address from safe_transaction_service.tokens.models import Token from safe_transaction_service.utils.redis import get_redis @@ -371,3 +375,505 @@ def serialize_all_txs_v2( logger.debug("Serialized all transactions") return results + + def get_export_transactions( + self, + safe_address: ChecksumAddress, + execution_date_gte: Optional[datetime] = None, + execution_date_lte: Optional[datetime] = None, + limit: int = 1000, + offset: int = 0, + ) -> Tuple[List[Dict[str, Any]], int]: + """ + Get transactions optimized for CSV export using raw SQL queries + + :param safe_address: Safe address to get transactions for + :param execution_date_gte: Filter transactions executed after this date + :param execution_date_lte: Filter transactions executed before this date + :param limit: Maximum number of transactions to return + :param offset: Number of transactions to skip + :return: Tuple of (transactions, total_count) + """ + logger.debug( + "[%s] Getting export transactions with raw SQL: gte=%s, lte=%s, limit=%d, offset=%d", + safe_address, + execution_date_gte, + execution_date_lte, + limit, + offset, + ) + + # Base WHERE conditions for the final SELECT + where_conditions = [] + params = [] + + if execution_date_gte: + assert type(execution_date_gte) is datetime + where_conditions.append("execution_date >= %s") + params.append(execution_date_gte) + if execution_date_lte: + assert type(execution_date_lte) is datetime + where_conditions.append("execution_date <= %s") + params.append(execution_date_lte) + + where_clause = " AND ".join(where_conditions) if where_conditions else "1=1" + + # Main query that unions all transaction types with their transfers + main_query = f""" + WITH export_data AS ( + -- ERC20 Transfers from multisigtransactions + SELECT + encode(mt.safe, 'hex') as safe_address, + encode(erc20._from, 'hex') as from_address, + encode(erc20.to, 'hex') as to_address, + erc20.value::text as amount, + 'erc20' as asset_type, + encode(erc20.address, 'hex') as asset_address, + t.symbol as asset_symbol, + t.decimals as asset_decimals, + encode(mt.proposer, 'hex') as proposer_address, + mt.created as proposed_at, + encode(et._from, 'hex') as executor_address, + erc20.timestamp as execution_date, + erc20.timestamp as executed_at, + COALESCE(mt.origin->>'note', '') as note, + encode(mt.ethereum_tx_id, 'hex') as transaction_hash, + encode(mt.safe_tx_hash, 'hex') as safe_tx_hash, + null as method, + encode(mt.to, 'hex') as contract_address, + COALESCE(erc20.timestamp, mt.created) as sort_date + FROM history_multisigtransaction mt + JOIN history_ethereumtx et ON mt.ethereum_tx_id = et.tx_hash + JOIN history_erc20transfer erc20 ON et.tx_hash = erc20.ethereum_tx_id + LEFT JOIN tokens_token t ON erc20.address = t.address + WHERE mt.safe = %s + + UNION ALL + + -- ERC20 Transfers (standalone) incoming, or outgoing with token approval + SELECT + encode(CASE + WHEN erc20.to = %s THEN erc20.to + ELSE erc20._from + END, 'hex') as safe_address, + encode(erc20._from, 'hex') as from_address, + encode(erc20.to, 'hex') as to_address, + erc20.value::text as amount, + 'erc20' as asset_type, + encode(erc20.address, 'hex') as asset_address, + t.symbol as asset_symbol, + t.decimals as asset_decimals, + null as proposer_address, + null as proposed_at, + encode(et._from, 'hex') as executor_address, + erc20.timestamp as execution_date, + erc20.timestamp as executed_at, + '' as note, + encode(erc20.ethereum_tx_id, 'hex') as transaction_hash, + null as safe_tx_hash, + null as method, + null as contract_address, + erc20.timestamp as sort_date + FROM history_erc20transfer erc20 + JOIN history_ethereumtx et ON erc20.ethereum_tx_id = et.tx_hash + LEFT JOIN tokens_token t ON erc20.address = t.address + WHERE (erc20.to = %s OR erc20._from = %s) + AND NOT EXISTS ( + SELECT 1 FROM history_multisigtransaction mt + WHERE mt.ethereum_tx_id = erc20.ethereum_tx_id + ) + AND NOT EXISTS ( + SELECT 1 FROM history_moduletransaction modtx + JOIN history_internaltx itx ON modtx.internal_tx_id = itx.id + WHERE itx.ethereum_tx_id = erc20.ethereum_tx_id + ) + + UNION ALL + + -- Multisig Transactions with ERC721 Transfers + SELECT + encode(mt.safe, 'hex') as safe_address, + encode(erc721._from, 'hex') as from_address, + encode(erc721.to, 'hex') as to_address, + erc721.token_id::text as amount, + 'erc721' as asset_type, + encode(erc721.address, 'hex') as asset_address, + t.symbol as asset_symbol, + t.decimals as asset_decimals, + encode(mt.proposer, 'hex') as proposer_address, + mt.created as proposed_at, + encode(et._from, 'hex') as executor_address, + erc721.timestamp as execution_date, + erc721.timestamp as executed_at, + COALESCE(mt.origin->>'note', '') as note, + encode(mt.ethereum_tx_id, 'hex') as transaction_hash, + encode(mt.safe_tx_hash, 'hex') as safe_tx_hash, + null as method, + encode(mt.to, 'hex') as contract_address, + COALESCE(erc721.timestamp, mt.created) as sort_date + FROM history_multisigtransaction mt + JOIN history_ethereumtx et ON mt.ethereum_tx_id = et.tx_hash + JOIN history_erc721transfer erc721 ON et.tx_hash = erc721.ethereum_tx_id + LEFT JOIN tokens_token t ON erc721.address = t.address + WHERE mt.safe = %s + + UNION ALL + + -- Multisig Transactions (standalone, without transfers) + SELECT + encode(mt.safe, 'hex') as safe_address, + encode(mt.safe, 'hex') as from_address, + encode(mt.to, 'hex') as to_address, + itx.value::text as amount, + 'native' as asset_type, + null as asset_address, + 'ETH' as asset_symbol, + 18 as asset_decimals, + encode(mt.proposer, 'hex') as proposer_address, + mt.created as proposed_at, + encode(et._from, 'hex') as executor_address, + itx.timestamp as execution_date, + itx.timestamp as executed_at, + COALESCE(mt.origin->>'note', '') as note, + encode(mt.ethereum_tx_id, 'hex') as transaction_hash, + encode(mt.safe_tx_hash, 'hex') as safe_tx_hash, + null as method, + encode(mt.to, 'hex') as contract_address, + COALESCE(itx.timestamp, mt.created) as sort_date + FROM history_multisigtransaction mt + JOIN history_ethereumtx et ON mt.ethereum_tx_id = et.tx_hash + JOIN history_internaltx itx ON itx.ethereum_tx_id = et.tx_hash + WHERE mt.safe = %s + AND itx.call_type = 0 -- CALL + AND itx.value > 0 + AND NOT EXISTS ( + SELECT 1 FROM history_erc20transfer erc20 + WHERE erc20.ethereum_tx_id = et.tx_hash + ) + AND NOT EXISTS ( + SELECT 1 FROM history_erc721transfer erc721 + WHERE erc721.ethereum_tx_id = et.tx_hash + ) + + UNION ALL + + -- Module Transactions with ERC20 Transfers + SELECT + encode(modtx.safe, 'hex') as safe_address, + encode(COALESCE(erc20._from, modtx.module), 'hex') as from_address, + encode(COALESCE(erc20.to, modtx.to), 'hex') as to_address, + COALESCE(erc20.value::text, modtx.value::text) as amount, + CASE + WHEN erc20.address IS NOT NULL THEN 'erc20' + ELSE 'native' + END as asset_type, + encode(erc20.address, 'hex') as asset_address, + t.symbol as asset_symbol, + t.decimals as asset_decimals, + null as proposer_address, + null as proposed_at, + encode(modtx.module, 'hex') as executor_address, + itx.timestamp as execution_date, + itx.timestamp as executed_at, + '' as note, + encode(itx.ethereum_tx_id, 'hex') as transaction_hash, + null as safe_tx_hash, + null as method, + encode(modtx.to, 'hex') as contract_address, + itx.timestamp as sort_date + FROM history_moduletransaction modtx + JOIN history_internaltx itx ON modtx.internal_tx_id = itx.id + JOIN history_erc20transfer erc20 ON itx.ethereum_tx_id = erc20.ethereum_tx_id + LEFT JOIN tokens_token t ON erc20.address = t.address + WHERE modtx.safe = %s + + UNION ALL + + -- Module Transactions with ERC721 Transfers + SELECT + encode(modtx.safe, 'hex') as safe_address, + encode(COALESCE(erc721._from, modtx.module), 'hex') as from_address, + encode(COALESCE(erc721.to, modtx.to), 'hex') as to_address, + COALESCE(erc721.token_id::text, modtx.value::text) as amount, + CASE + WHEN erc721.address IS NOT NULL THEN 'erc721' + ELSE 'native' + END as asset_type, + encode(erc721.address, 'hex') as asset_address, + t.symbol as asset_symbol, + t.decimals as asset_decimals, + null as proposer_address, + null as proposed_at, + encode(modtx.module, 'hex') as executor_address, + itx.timestamp as execution_date, + itx.timestamp as executed_at, + '' as note, + encode(itx.ethereum_tx_id, 'hex') as transaction_hash, + null as safe_tx_hash, + null as method, + encode(modtx.to, 'hex') as contract_address, + itx.timestamp as sort_date + FROM history_moduletransaction modtx + JOIN history_internaltx itx ON modtx.internal_tx_id = itx.id + JOIN history_erc721transfer erc721 ON itx.ethereum_tx_id = erc721.ethereum_tx_id + LEFT JOIN tokens_token t ON erc721.address = t.address + WHERE modtx.safe = %s + + UNION ALL + + -- Module Transactions with ethereum Transfers + SELECT + encode(modtx.safe, 'hex') as safe_address, + encode(COALESCE(itx._from, modtx.module), 'hex') as from_address, + encode(COALESCE(itx.to, modtx.to), 'hex') as to_address, + itx.value::text as amount, + 'native' as asset_type, + null as asset_address, + 'ETH' as asset_symbol, + 18 as asset_decimals, + null as proposer_address, + null as proposed_at, + encode(modtx.module, 'hex') as executor_address, + itx.timestamp as execution_date, + itx.timestamp as executed_at, + '' as note, + encode(itx.ethereum_tx_id, 'hex') as transaction_hash, + null as safe_tx_hash, + null as method, + encode(modtx.to, 'hex') as contract_address, + itx.timestamp as sort_date + FROM history_moduletransaction modtx + JOIN history_internaltx itx ON modtx.internal_tx_id = itx.id + WHERE itx.to = %s OR itx._from = %s and itx.value > 0 + AND NOT EXISTS ( + SELECT 1 FROM history_erc20transfer erc20 + WHERE erc20.ethereum_tx_id = itx.ethereum_tx_id + ) + AND NOT EXISTS ( + SELECT 1 FROM history_erc721transfer erc721 + WHERE erc721.ethereum_tx_id = itx.ethereum_tx_id + ) + + UNION ALL + + -- ERC721 Transfers (standalone) + SELECT + encode(CASE + WHEN erc721.to = %s THEN erc721.to + ELSE erc721._from + END, 'hex') as safe_address, + encode(erc721._from, 'hex') as from_address, + encode(erc721.to, 'hex') as to_address, + erc721.token_id::text as amount, + 'erc721' as asset_type, + encode(erc721.address, 'hex') as asset_address, + t.symbol as asset_symbol, + t.decimals as asset_decimals, + null as proposer_address, + null as proposed_at, + encode(et._from, 'hex') as executor_address, + erc721.timestamp as execution_date, + erc721.timestamp as executed_at, + '' as note, + encode(erc721.ethereum_tx_id, 'hex') as transaction_hash, + null as safe_tx_hash, + null as method, + null as contract_address, + erc721.timestamp as sort_date + FROM history_erc721transfer erc721 + JOIN history_ethereumtx et ON erc721.ethereum_tx_id = et.tx_hash + LEFT JOIN tokens_token t ON erc721.address = t.address + WHERE (erc721.to = %s OR erc721._from = %s) + AND NOT EXISTS ( + SELECT 1 FROM history_multisigtransaction mt + WHERE mt.ethereum_tx_id = erc721.ethereum_tx_id + ) + AND NOT EXISTS ( + SELECT 1 FROM history_moduletransaction modtx + JOIN history_internaltx itx ON modtx.internal_tx_id = itx.id + WHERE itx.ethereum_tx_id = erc721.ethereum_tx_id + ) + + UNION ALL + + -- Ether Transfers (InternalTx) + SELECT + encode(CASE + WHEN itx.to = %s THEN itx.to + ELSE itx._from + END, 'hex') as safe_address, + encode(itx._from, 'hex') as from_address, + encode(itx.to, 'hex') as to_address, + itx.value::text as amount, + 'native' as asset_type, + null as asset_address, + 'ETH' as asset_symbol, + 18 as asset_decimals, + null as proposer_address, + null as proposed_at, + encode(et._from, 'hex') as executor_address, + itx.timestamp as execution_date, + itx.timestamp as executed_at, + '' as note, + encode(itx.ethereum_tx_id, 'hex') as transaction_hash, + null as safe_tx_hash, + null as method, + null as contract_address, + itx.timestamp as sort_date + FROM history_internaltx itx + JOIN history_ethereumtx et ON itx.ethereum_tx_id = et.tx_hash + WHERE (itx.to = %s OR itx._from = %s) + AND itx.call_type = 0 -- CALL + AND itx.value > 0 + AND NOT EXISTS ( + SELECT 1 FROM history_multisigtransaction mt + WHERE mt.ethereum_tx_id = itx.ethereum_tx_id + ) + AND NOT EXISTS ( + SELECT 1 FROM history_moduletransaction modtx + JOIN history_internaltx itx2 ON modtx.internal_tx_id = itx2.id + WHERE itx2.ethereum_tx_id = itx.ethereum_tx_id + ) + ) + SELECT + safe_address, + from_address, + to_address, + amount, + asset_type, + asset_address, + asset_symbol, + asset_decimals, + proposer_address, + proposed_at, + executor_address, + execution_date, + executed_at, + note, + transaction_hash, + safe_tx_hash, + method, + contract_address + FROM export_data + WHERE {where_clause} + ORDER BY execution_date DESC, transaction_hash + LIMIT %s OFFSET %s + """ + + # Parameters for main query (safe_address repeated for each UNION) + safe_address_bytes = HexBytes(safe_address) + main_params = ( + [safe_address_bytes] * 16 # 16 instances of safe address in the query + + params # date filters + + [limit, offset] + ) + + erc20_transfers = ERC20Transfer.objects.to_or_from(safe_address) + erc721_transfers = ERC721Transfer.objects.to_or_from(safe_address) + ether_transfers = InternalTx.objects.ether_txs_for_address(safe_address) + + if execution_date_gte: + erc20_transfers = erc20_transfers.filter(timestamp__gte=execution_date_gte) + erc721_transfers = erc721_transfers.filter( + timestamp__gte=execution_date_gte + ) + ether_transfers = ether_transfers.filter(timestamp__gte=execution_date_gte) + if execution_date_lte: + erc20_transfers = erc20_transfers.filter(timestamp__lte=execution_date_lte) + erc721_transfers = erc721_transfers.filter( + timestamp__lte=execution_date_lte + ) + ether_transfers = ether_transfers.filter(timestamp__lte=execution_date_lte) + + erc20_transfers = erc20_transfers.annotate( + transaction_hash=F("ethereum_tx_id"), + _log_index=F("log_index"), + _trace_address=RawSQL("NULL", ()), + ).values("transaction_hash", "_log_index", "_trace_address") + erc721_transfers = erc721_transfers.annotate( + transaction_hash=F("ethereum_tx_id"), + _log_index=F("log_index"), + _trace_address=RawSQL("NULL", ()), + ).values("transaction_hash", "_log_index", "_trace_address") + ether_transfers = ether_transfers.annotate( + transaction_hash=F("ethereum_tx_id"), + _log_index=RawSQL("NULL::numeric", ()), + _trace_address=F("trace_address"), + ).values("transaction_hash", "_log_index", "_trace_address") + + total_count = ( + ether_transfers.union(erc20_transfers, all=True) + .union(erc721_transfers, all=True) + .count() + ) + + with connection.cursor() as cursor: + + # Get the data + cursor.execute(main_query, main_params) + columns = [col[0] for col in cursor.description] + results = [] + + for row in cursor.fetchall(): + row_dict = dict(zip(columns, row)) + + # Map to serializer field names + export_item = { + "safe": fast_to_checksum_address(row_dict["safe_address"]), + "_from": fast_to_checksum_address(row_dict["from_address"]), + "to": fast_to_checksum_address(row_dict["to_address"]), + "_value": row_dict["amount"], + "asset_type": row_dict["asset_type"], + "asset_address": ( + fast_to_checksum_address(row_dict["asset_address"]) + if row_dict["asset_address"] + else None + ), + "asset_symbol": ( + row_dict["asset_symbol"] if row_dict["asset_symbol"] else None + ), + "asset_decimals": ( + row_dict["asset_decimals"] + if row_dict["asset_decimals"] + else None + ), + "proposer_address": ( + fast_to_checksum_address(row_dict["proposer_address"]) + if row_dict["proposer_address"] + else None + ), + "proposed_at": ( + row_dict["proposed_at"] if row_dict["proposed_at"] else None + ), + "executor_address": ( + fast_to_checksum_address(row_dict["executor_address"]) + if row_dict["executor_address"] + else None + ), + "executed_at": ( + row_dict["executed_at"] if row_dict["executed_at"] else None + ), + "note": row_dict["note"] if row_dict["note"] else None, + "transaction_hash": "0x" + row_dict["transaction_hash"], + "safe_tx_hash": ( + "0x" + row_dict["safe_tx_hash"] + if row_dict["safe_tx_hash"] + else None + ), + "method": row_dict["method"] if row_dict["method"] else None, + "contract_address": ( + fast_to_checksum_address(row_dict["contract_address"]) + if row_dict["contract_address"] + else None + ), + } + results.append(export_item) + + logger.debug( + "[%s] Got %d export transactions from %d total using raw SQL", + safe_address, + len(results), + total_count, + ) + + return results, total_count diff --git a/safe_transaction_service/history/tests/test_views.py b/safe_transaction_service/history/tests/test_views.py index 0c54e91e7..588b66d7a 100644 --- a/safe_transaction_service/history/tests/test_views.py +++ b/safe_transaction_service/history/tests/test_views.py @@ -3,6 +3,7 @@ import logging from unittest import mock from unittest.mock import MagicMock +from urllib.parse import urlencode from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission @@ -4068,3 +4069,781 @@ def test_estimate_multisig_tx_view(self, estimate_tx_gas_mock: MagicMock): data=data, ) self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + + def test_safe_export_view(self): + """Test the export endpoint for CSV export functionality""" + safe_address = Account.create().address + # Test with non-existent safe + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + # Create safe contract + SafeContractFactory(address=safe_address) + + # Test with no transactions + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + self.assertIsNone(response.data["next"]) + self.assertIsNone(response.data["previous"]) + + # Create some test data + ethereum_tx = EthereumTxFactory() + multisig_tx = MultisigTransactionFactory( + safe=safe_address, ethereum_tx=ethereum_tx, trusted=True + ) + + # Create ERC20 transfer + token = TokenFactory( + address=Account.create().address, symbol="TEST", decimals=18 + ) + erc20_transfer = ERC20TransferFactory( + ethereum_tx=ethereum_tx, + address=token.address, + _from=Account.create().address, + to=safe_address, + value=1000000000000000000, # 1 token with 18 decimals + ) + + # Test basic export + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(len(response.data["results"]), 1) + + result = response.json()["results"][0] + self.assertEqual(result["safe"], safe_address) + self.assertEqual(result["assetType"], "erc20") + self.assertEqual(result["assetAddress"], token.address) + self.assertEqual(result["assetSymbol"], "TEST") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], "1000000000000000000") + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNotNone(result["safeTxHash"]) + + # Test pagination + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)) + + "?limit=1&offset=0", + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(len(response.data["results"]), 1) + self.assertIsNone(response.data["next"]) # No more pages + self.assertIsNone(response.data["previous"]) + + # Test date filtering + future_date = timezone.now() + datetime.timedelta(days=1) + params = urlencode({"execution_date__gte": future_date.isoformat()}) + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)) + f"?{params}", + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + + past_date = timezone.now() - datetime.timedelta(days=1) + params = urlencode({"execution_date__lte": past_date.isoformat()}) + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)) + f"?{params}", + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(response.data["results"], []) + + # Test invalid date format + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)) + + "?execution_date__gte=invalid-date", + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Test limit validation + response = self.client.get( + reverse("v1:history:safe-export", args=(safe_address,)) + "?limit=2000", + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Should default to 1000 + self.assertEqual(len(response.data["results"]), 1) + + def _setup_export_tests(self): + self.token = TokenFactory( + address=Account.create().address, symbol="TEST", decimals=18 + ) + self.nft_token = TokenFactory( + address=Account.create().address, symbol="NFT", decimals=None + ) + self.safe_address = Account.create().address + self.external_address = Account.create().address + SafeContractFactory(address=self.safe_address) + + def test_export_view_erc20_transfers(self): + self._setup_export_tests() + # Test OUTGOING ERC20 from multisig transaction + ethereum_tx_multisig_out = EthereumTxFactory() + multisig_tx_out = MultisigTransactionFactory( + safe=self.safe_address, ethereum_tx=ethereum_tx_multisig_out, trusted=True + ) + multisig_outgoing_erc20_transfer = ERC20TransferFactory( + ethereum_tx=ethereum_tx_multisig_out, + address=self.token.address, + _from=self.safe_address, + to=self.external_address, + value=1000000000000000000, # 1 token with 18 decimals + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(len(response.data["results"]), 1) + + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "erc20") + self.assertEqual(result["assetAddress"], self.token.address) + self.assertEqual(result["assetSymbol"], "TEST") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(multisig_outgoing_erc20_transfer.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNotNone(result["safeTxHash"]) + + # Test INCOMING ERC20 from multisig transaction + ethereum_tx_multisig_in = EthereumTxFactory() + multisig_tx_in = MultisigTransactionFactory( + safe=self.safe_address, ethereum_tx=ethereum_tx_multisig_in, trusted=True + ) + multisig_incoming_erc20_transfer = ERC20TransferFactory( + ethereum_tx=ethereum_tx_multisig_in, + address=self.token.address, + _from=self.external_address, + to=self.safe_address, + value=2000000000000000000, # 2 tokens with 18 decimals + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + self.assertEqual(len(response.data["results"]), 2) + + # Check the incoming transaction (should be first in results due to ordering) + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "erc20") + self.assertEqual(result["assetAddress"], self.token.address) + self.assertEqual(result["assetSymbol"], "TEST") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(multisig_incoming_erc20_transfer.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNotNone(result["safeTxHash"]) + + # Test OUTGOING ERC20 from module transaction + ethereum_tx_module_out = EthereumTxFactory() + module_contract_address = Account.create().address + module_internal_tx_out = InternalTxFactory( + ethereum_tx=ethereum_tx_module_out, _from=self.safe_address, value=0 + ) + module_transaction_out = ModuleTransactionFactory( + internal_tx=module_internal_tx_out, + safe=self.safe_address, + to=module_contract_address, + ) + module_outgoing_erc20 = ERC20TransferFactory( + ethereum_tx=ethereum_tx_module_out, + address=self.token.address, + _from=self.safe_address, + to=self.external_address, + value=3000000000000000000, # 3 tokens with 18 decimals + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), 3) + + # Check the module outgoing transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "erc20") + self.assertEqual(result["assetAddress"], self.token.address) + self.assertEqual(result["assetSymbol"], "TEST") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(module_outgoing_erc20.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertEqual(result["contractAddress"], module_contract_address) + + # Test INCOMING ERC20 from module transaction + ethereum_tx_module_in = EthereumTxFactory() + module_internal_tx_in = InternalTxFactory( + ethereum_tx=ethereum_tx_module_in, _from=self.safe_address, value=0 + ) + module_transaction_in = ModuleTransactionFactory( + internal_tx=module_internal_tx_in, + safe=self.safe_address, + to=module_contract_address, + ) + module_incoming_erc20 = ERC20TransferFactory( + ethereum_tx=ethereum_tx_module_in, + address=self.token.address, + _from=self.external_address, + to=self.safe_address, + value=4000000000000000000, # 4 tokens with 18 decimals + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 4) + self.assertEqual(len(response.data["results"]), 4) + + # Check the module incoming transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "erc20") + self.assertEqual(result["assetAddress"], self.token.address) + self.assertEqual(result["assetSymbol"], "TEST") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(module_incoming_erc20.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertEqual(result["contractAddress"], module_contract_address) + + # Test INCOMING ERC20 from standalone transaction + standalone_incoming_erc20 = ERC20TransferFactory( + address=self.token.address, + _from=self.external_address, + to=self.safe_address, + value=5000000000000000000, # 5 tokens with 18 decimals + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 5) + self.assertEqual(len(response.data["results"]), 5) + + # Check the standalone incoming transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "erc20") + self.assertEqual(result["assetAddress"], self.token.address) + self.assertEqual(result["assetSymbol"], "TEST") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(standalone_incoming_erc20.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertIsNone(result["contractAddress"]) + + # Test OUTGOING ERC20 from standalone transaction + standalone_outgoing_erc20 = ERC20TransferFactory( + address=self.token.address, + _from=self.safe_address, + to=self.external_address, + value=6000000000000000000, # 6 tokens with 18 decimals + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 6) + self.assertEqual(len(response.data["results"]), 6) + + # Check the standalone outgoing transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "erc20") + self.assertEqual(result["assetAddress"], self.token.address) + self.assertEqual(result["assetSymbol"], "TEST") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(standalone_outgoing_erc20.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertIsNone(result["contractAddress"]) + + def test_export_view_erc721_transfers(self): + self._setup_export_tests() + # Test OUTGOING ERC721 from multisig transaction + ethereum_tx = EthereumTxFactory() + multisig_tx = MultisigTransactionFactory( + safe=self.safe_address, ethereum_tx=ethereum_tx, trusted=True + ) + multisig_outgoing_erc721_transfer = ERC721TransferFactory( + ethereum_tx=ethereum_tx, + address=self.nft_token.address, + _from=self.safe_address, + to=self.external_address, + token_id=123, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(len(response.data["results"]), 1) + + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "erc721") + self.assertEqual(result["assetAddress"], self.nft_token.address) + self.assertEqual(result["assetSymbol"], "NFT") + self.assertIsNone(result["assetDecimals"]) + self.assertEqual( + result["amount"], str(multisig_outgoing_erc721_transfer.token_id) + ) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNotNone(result["safeTxHash"]) + + # Test INCOMING ERC721 from multisig transaction + ethereum_tx = EthereumTxFactory() + multisig_tx = MultisigTransactionFactory( + safe=self.safe_address, ethereum_tx=ethereum_tx, trusted=True + ) + multisig_incoming_erc721_transfer = ERC721TransferFactory( + ethereum_tx=ethereum_tx, + address=self.nft_token.address, + _from=self.external_address, + to=self.safe_address, + token_id=456, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + self.assertEqual(len(response.data["results"]), 2) + + # Check the incoming transaction (should be first in results due to ordering) + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "erc721") + self.assertEqual(result["assetAddress"], self.nft_token.address) + self.assertEqual(result["assetSymbol"], "NFT") + self.assertIsNone(result["assetDecimals"]) + self.assertEqual( + result["amount"], str(multisig_incoming_erc721_transfer.token_id) + ) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNotNone(result["safeTxHash"]) + + # Test OUTGOING ERC721 from module transaction + ethereum_tx_module_out = EthereumTxFactory() + module_contract_address = Account.create().address + module_internal_tx_out = InternalTxFactory( + ethereum_tx=ethereum_tx_module_out, _from=self.safe_address, value=0 + ) + module_transaction_out = ModuleTransactionFactory( + internal_tx=module_internal_tx_out, + safe=self.safe_address, + to=module_contract_address, + ) + module_outgoing_erc721 = ERC721TransferFactory( + ethereum_tx=ethereum_tx_module_out, + address=self.nft_token.address, + _from=self.safe_address, + to=self.external_address, + token_id=789, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), 3) + + # Check the module outgoing transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "erc721") + self.assertEqual(result["assetAddress"], self.nft_token.address) + self.assertEqual(result["assetSymbol"], "NFT") + self.assertIsNone(result["assetDecimals"]) + self.assertEqual(result["amount"], str(module_outgoing_erc721.token_id)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertEqual(result["contractAddress"], module_contract_address) + + # Test INCOMING ERC721 from module transaction + ethereum_tx_module_in = EthereumTxFactory() + module_internal_tx_in = InternalTxFactory( + ethereum_tx=ethereum_tx_module_in, _from=self.safe_address, value=0 + ) + module_transaction_in = ModuleTransactionFactory( + internal_tx=module_internal_tx_in, + safe=self.safe_address, + to=module_contract_address, + ) + module_incoming_erc721 = ERC721TransferFactory( + ethereum_tx=ethereum_tx_module_in, + address=self.nft_token.address, + _from=self.external_address, + to=self.safe_address, + token_id=101112, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 4) + self.assertEqual(len(response.data["results"]), 4) + + # Check the module incoming transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "erc721") + self.assertEqual(result["assetAddress"], self.nft_token.address) + self.assertEqual(result["assetSymbol"], "NFT") + self.assertIsNone(result["assetDecimals"]) + self.assertEqual(result["amount"], str(module_incoming_erc721.token_id)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertEqual(result["contractAddress"], module_contract_address) + + # Test INCOMING ERC721 from standalone transaction + standalone_incoming_erc721 = ERC721TransferFactory( + address=self.nft_token.address, + _from=self.external_address, + to=self.safe_address, + token_id=131415, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 5) + self.assertEqual(len(response.data["results"]), 5) + + # Check the standalone incoming transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "erc721") + self.assertEqual(result["assetAddress"], self.nft_token.address) + self.assertEqual(result["assetSymbol"], "NFT") + self.assertIsNone(result["assetDecimals"]) + self.assertEqual(result["amount"], str(standalone_incoming_erc721.token_id)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertIsNone(result["contractAddress"]) + + # Test OUTGOING ERC721 from standalone transaction + standalone_outgoing_erc721 = ERC721TransferFactory( + address=self.nft_token.address, + _from=self.safe_address, + to=self.external_address, + token_id=161718, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 6) + self.assertEqual(len(response.data["results"]), 6) + + # Check the standalone outgoing transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "erc721") + self.assertEqual(result["assetAddress"], self.nft_token.address) + self.assertEqual(result["assetSymbol"], "NFT") + self.assertIsNone(result["assetDecimals"]) + self.assertEqual(result["amount"], str(standalone_outgoing_erc721.token_id)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertIsNone(result["contractAddress"]) + + def test_export_view_ether_transfers(self): + self._setup_export_tests() + # Test OUTGOING Ether from multisig transaction + ethereum_tx_multisig_out = EthereumTxFactory() + value = 1000000000000000000 # 1 ETH + multisig_tx_out = MultisigTransactionFactory( + safe=self.safe_address, + ethereum_tx=ethereum_tx_multisig_out, + trusted=True, + to=self.external_address, + value=value, + ) + multisig_outgoing_internal_tx = InternalTxFactory( + ethereum_tx=ethereum_tx_multisig_out, + _from=self.safe_address, + to=self.external_address, + value=value, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + self.assertEqual(len(response.data["results"]), 1) + + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "native") + self.assertIsNone(result["assetAddress"]) + self.assertEqual(result["assetSymbol"], "ETH") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(multisig_outgoing_internal_tx.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNotNone(result["safeTxHash"]) + + # Test INCOMING Ether from multisig transaction + ethereum_tx_multisig_in = EthereumTxFactory() + value = 2000000000000000000 + multisig_tx_in = MultisigTransactionFactory( + safe=self.safe_address, + ethereum_tx=ethereum_tx_multisig_in, + trusted=True, + value=value, + to=self.safe_address, + ) + multisig_incoming_internal_tx = InternalTxFactory( + ethereum_tx=ethereum_tx_multisig_in, + _from=self.external_address, + to=self.safe_address, + value=value, # 2 ETH + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 2) + self.assertEqual(len(response.data["results"]), 2) + + # Check the incoming transaction (should be first in results due to ordering) + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "native") + self.assertIsNone(result["assetAddress"]) + self.assertEqual(result["assetSymbol"], "ETH") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(multisig_incoming_internal_tx.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNotNone(result["safeTxHash"]) + + # Test OUTGOING Ether from module transaction + ethereum_tx_module_out = EthereumTxFactory() + module_contract_address = Account.create().address + module_internal_tx_out = InternalTxFactory( + ethereum_tx=ethereum_tx_module_out, + _from=self.safe_address, + to=self.external_address, + value=3000000000000000000, + ) + module_transaction_out = ModuleTransactionFactory( + internal_tx=module_internal_tx_out, + safe=self.safe_address, + to=module_contract_address, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 3) + self.assertEqual(len(response.data["results"]), 3) + + # Check the module outgoing transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "native") + self.assertIsNone(result["assetAddress"]) + self.assertEqual(result["assetSymbol"], "ETH") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(module_internal_tx_out.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertEqual(result["contractAddress"], module_contract_address) + + # Test INCOMING Ether from module transaction + ethereum_tx_module_in = EthereumTxFactory() + module_internal_tx_in = InternalTxFactory( + ethereum_tx=ethereum_tx_module_in, + _from=self.external_address, + to=self.safe_address, + value=4000000000000000000, # 4 ETH + ) + module_transaction_in = ModuleTransactionFactory( + internal_tx=module_internal_tx_in, + safe=self.safe_address, + to=module_contract_address, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 4) + self.assertEqual(len(response.data["results"]), 4) + + # Check the module incoming transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "native") + self.assertIsNone(result["assetAddress"]) + self.assertEqual(result["assetSymbol"], "ETH") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(module_internal_tx_in.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertEqual(result["contractAddress"], module_contract_address) + + # Test OUTGOING Ether from standalone transaction + standalone_outgoing_internal_tx = InternalTxFactory( + _from=self.safe_address, + to=self.external_address, + value=5000000000000000000, # 5 ETH + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 5) + self.assertEqual(len(response.data["results"]), 5) + + # Check the standalone outgoing transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.safe_address) + self.assertEqual(result["to"], self.external_address) + self.assertEqual(result["assetType"], "native") + self.assertIsNone(result["assetAddress"]) + self.assertEqual(result["assetSymbol"], "ETH") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(standalone_outgoing_internal_tx.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertIsNone(result["contractAddress"]) + + # Test INCOMING Ether from standalone transaction + standalone_incoming_internal_tx = InternalTxFactory( + _from=self.external_address, + to=self.safe_address, + value=6000000000000000000, # 6 ETH + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 6) + self.assertEqual(len(response.data["results"]), 6) + + # Check the standalone incoming transaction + result = response.json()["results"][0] + self.assertEqual(result["safe"], self.safe_address) + self.assertEqual(result["from_"], self.external_address) + self.assertEqual(result["to"], self.safe_address) + self.assertEqual(result["assetType"], "native") + self.assertIsNone(result["assetAddress"]) + self.assertEqual(result["assetSymbol"], "ETH") + self.assertEqual(result["assetDecimals"], 18) + self.assertEqual(result["amount"], str(standalone_incoming_internal_tx.value)) + self.assertIsNotNone(result["transactionHash"]) + self.assertIsNone(result["safeTxHash"]) + self.assertIsNone(result["contractAddress"]) + + def test_export_view_should_not_include_no_transfer_transactions(self): + self._setup_export_tests() + + ethereum_tx_multisig = EthereumTxFactory() + multisig_tx_out = MultisigTransactionFactory( + safe=self.safe_address, + ethereum_tx=ethereum_tx_multisig, + trusted=True, + to=self.external_address, + value=0, + ) + multisig_internal_tx = InternalTxFactory( + ethereum_tx=ethereum_tx_multisig, + _from=self.safe_address, + to=self.external_address, + value=0, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(len(response.data["results"]), 0) + + ethereum_tx_multisig = EthereumTxFactory() + internal_tx = InternalTxFactory( + ethereum_tx=ethereum_tx_multisig, + _from=self.safe_address, + to=self.external_address, + value=0, + ) + module_tx = ModuleTransactionFactory( + internal_tx=internal_tx, + safe=self.safe_address, + to=Account.create().address, + ) + + response = self.client.get( + reverse("v1:history:safe-export", args=(self.safe_address,)), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 0) + self.assertEqual(len(response.data["results"]), 0) diff --git a/safe_transaction_service/history/urls.py b/safe_transaction_service/history/urls.py index 82571ac03..1eb9921ce 100644 --- a/safe_transaction_service/history/urls.py +++ b/safe_transaction_service/history/urls.py @@ -79,6 +79,11 @@ views.SafeBalanceView.as_view(), name="safe-balances", ), + path( + "safes//export/", + views.SafeExportView.as_view(), + name="safe-export", + ), path( "multisig-transactions//", views.SafeMultisigTransactionDetailView.as_view(), diff --git a/safe_transaction_service/history/views.py b/safe_transaction_service/history/views.py index f961aefde..9ffc1f8be 100644 --- a/safe_transaction_service/history/views.py +++ b/safe_transaction_service/history/views.py @@ -57,7 +57,7 @@ TransferDict, ) from .pagination import DummyPagination -from .serializers import get_data_decoded_from_data +from .serializers import SafeExportTransactionRequestParams, get_data_decoded_from_data from .services import ( BalanceServiceProvider, IndexServiceProvider, @@ -1483,3 +1483,105 @@ def delete(self, request, address, delegate_address, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) return super().delete(request, address, delegate_address, *args, **kwargs) + + +@extend_schema( + parameters=[ + OpenApiParameter( + "execution_date__gte", + location="query", + type=OpenApiTypes.DATETIME, + description="Filter transactions executed after this date (ISO format)", + ), + OpenApiParameter( + "execution_date__lte", + location="query", + type=OpenApiTypes.DATETIME, + description="Filter transactions executed before this date (ISO format)", + ), + OpenApiParameter( + "limit", + location="query", + type=OpenApiTypes.INT, + description="Maximum number of transactions to return (max 1000)", + ), + OpenApiParameter( + "offset", + location="query", + type=OpenApiTypes.INT, + description="Number of transactions to skip", + ), + ], + responses={ + 200: OpenApiResponse( + response=serializers.SafeExportTransactionSerializer(many=True) + ), + 404: OpenApiResponse(description="Safe not found"), + 422: OpenApiResponse( + description="Safe address checksum not valid", + response=serializers.CodeErrorResponse, + ), + }, +) +class SafeExportView(GenericAPIView): + """ + Export endpoint for CSV export feature - optimized for large datasets + """ + + serializer_class = serializers.SafeExportTransactionSerializer + + def get_query_params(self): + serializer = SafeExportTransactionRequestParams( + data=self.request.query_params.dict() + ) + serializer.is_valid(raise_exception=True) + + validated_data = serializer.validated_data + parsed_execution_date_gte = validated_data.get("execution_date__gte") + parsed_execution_date_lte = validated_data.get("execution_date__lte") + return parsed_execution_date_gte, parsed_execution_date_lte + + def get(self, request, address): + """ + Get transactions optimized for CSV export with transfer information. + The maximum limit allowed is 1000. + """ + + if not fast_is_checksum_address(address): + return Response( + status=status.HTTP_422_UNPROCESSABLE_ENTITY, + data={ + "code": 1, + "message": "Checksum address validation failed", + "arguments": [address], + }, + ) + + try: + SafeContract.objects.get(address=address) + except SafeContract.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + # Get query params + parsed_execution_date_gte, parsed_execution_date_lte = self.get_query_params() + + # Get pagination + paginator = pagination.ListPagination(self.request, max_limit=1000) + limit = paginator.get_limit(request) + offset = paginator.get_offset(request) + + # Get transactions from service + transaction_service = TransactionServiceProvider() + transactions, total_count = transaction_service.get_export_transactions( + address, + execution_date_gte=parsed_execution_date_gte, + execution_date_lte=parsed_execution_date_lte, + limit=limit, + offset=offset, + ) + paginator.set_count(total_count) + + # Serialize the data + serializer = self.get_serializer(transactions, many=True) + + return paginator.get_paginated_response(serializer.data)