Skip to content

Commit 3027a43

Browse files
committed
Improve safe_contract command
1 parent 7bae2ef commit 3027a43

File tree

4 files changed

+199
-88
lines changed

4 files changed

+199
-88
lines changed

safe_transaction_service/history/indexers/safe_events_indexer.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ def __init__(self, *args, **kwargs):
6868
kwargs.setdefault(
6969
"eth_zksync_compatible_network", settings.ETH_ZKSYNC_COMPATIBLE_NETWORK
7070
)
71+
kwargs.setdefault("ignored_initiators", settings.ETH_EVENTS_IGNORED_INITIATORS)
72+
7173
self.eth_zksync_compatible_network = kwargs["eth_zksync_compatible_network"]
74+
self.ignored_initiators = kwargs["ignored_initiators"]
75+
self.conditional_indexing_enabled = bool(self.ignored_initiators)
7276
# Cache timestamp for block hashes
7377
self.block_hashes_with_timestamp: dict[bytes, datetime.datetime] = {}
7478
super().__init__(*args, **kwargs)
@@ -81,11 +85,7 @@ def process_elements(self, log_receipts: Sequence[LogReceipt]) -> list[InternalT
8185
if not log_receipts:
8286
return []
8387

84-
if not settings.ETH_EVENTS_CONDITIONAL_INDEXING:
85-
return super().process_elements(log_receipts)
86-
87-
ignored_initiators = settings.ETH_EVENTS_IGNORED_INITIATORS
88-
if not ignored_initiators:
88+
if not self.ignored_initiators:
8989
# No blocklist configured, use standard flow
9090
return super().process_elements(log_receipts)
9191

@@ -135,7 +135,7 @@ def process_elements(self, log_receipts: Sequence[LogReceipt]) -> list[InternalT
135135

136136
# Check existing DB txs
137137
for tx_hash, db_tx in db_txs.items():
138-
if db_tx._from not in ignored_initiators:
138+
if db_tx._from not in self.ignored_initiators:
139139
allowed_tx_hashes.add(tx_hash)
140140
else:
141141
logger.debug(
@@ -148,7 +148,7 @@ def process_elements(self, log_receipts: Sequence[LogReceipt]) -> list[InternalT
148148
allowed_fetched_txs: list[TxData] = []
149149
for tx in fetched_txs:
150150
tx_from = tx["from"]
151-
if tx_from not in ignored_initiators:
151+
if tx_from not in self.ignored_initiators:
152152
allowed_fetched_txs.append(tx)
153153
allowed_tx_hashes.add(HexBytes(tx["hash"]))
154154
else:
@@ -883,7 +883,7 @@ def _process_decoded_elements(
883883
# When conditional indexing is enabled, filter non-creation events
884884
# to only process events for Safes that exist in SafeContract table
885885
elements_to_process = decoded_elements
886-
if settings.ETH_EVENTS_CONDITIONAL_INDEXING:
886+
if self.conditional_indexing_enabled:
887887
# Get all unique Safe addresses from non-creation events
888888
non_creation_addresses = {
889889
element["address"]

safe_transaction_service/history/management/commands/safe_contract.py

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
from eth_typing import ChecksumAddress
44
from hexbytes import HexBytes
5+
from safe_eth.eth import get_auto_ethereum_client
56
from web3 import Web3
67

7-
from ...models import EthereumTx, SafeContract
8-
from ...services import IndexServiceProvider
8+
from ...indexers.safe_events_indexer import SafeEventsIndexer
9+
from ...indexers.tx_processor import SafeTxProcessorProvider
10+
from ...models import InternalTxDecoded, SafeContract
911

1012

1113
class Command(BaseCommand):
@@ -69,7 +71,7 @@ def _validate_tx_hash(self, tx_hash: str) -> bytes:
6971
raise CommandError(f"Invalid transaction hash format: {tx_hash}") from e
7072

7173
def _handle_add(self, address: str, tx_hash: str) -> None:
72-
"""Add a SafeContract entry manually."""
74+
"""Add a SafeContract entry by processing creation events through the indexer."""
7375
validated_address = self._validate_address(address)
7476
validated_tx_hash = self._validate_tx_hash(tx_hash)
7577

@@ -82,32 +84,52 @@ def _handle_add(self, address: str, tx_hash: str) -> None:
8284
)
8385
return
8486

85-
# Check if EthereumTx exists, fetch from RPC if not
86-
try:
87-
ethereum_tx = EthereumTx.objects.get(tx_hash=validated_tx_hash)
88-
self.stdout.write(f"Found existing EthereumTx for hash {tx_hash}")
89-
except EthereumTx.DoesNotExist:
90-
self.stdout.write(f"Fetching transaction {tx_hash} from RPC...")
91-
index_service = IndexServiceProvider()
92-
try:
93-
index_service.txs_create_or_update_from_tx_hashes([validated_tx_hash])
94-
ethereum_tx = EthereumTx.objects.get(tx_hash=validated_tx_hash)
95-
self.stdout.write(
96-
self.style.SUCCESS("Successfully fetched and stored transaction")
97-
)
98-
except Exception as e:
99-
raise CommandError(
100-
f"Failed to fetch transaction {tx_hash} from RPC: {e}"
101-
) from e
87+
# Fetch transaction receipt from RPC
88+
self.stdout.write(f"Fetching transaction receipt for {tx_hash} from RPC...")
89+
ethereum_client = get_auto_ethereum_client()
90+
tx_receipt = ethereum_client.get_transaction_receipt(validated_tx_hash)
91+
if not tx_receipt:
92+
raise CommandError(f"Transaction {tx_hash} not found")
93+
94+
# Get logs from the receipt
95+
logs = tx_receipt.get("logs", [])
96+
if not logs:
97+
raise CommandError(f"Transaction {tx_hash} has no logs")
98+
99+
self.stdout.write(f"Found {len(logs)} logs in transaction receipt")
100+
101+
# Process logs through SafeEventsIndexer
102+
# Pass ignored_initiators=set() to bypass conditional indexing filter
103+
self.stdout.write("Processing events through SafeEventsIndexer...")
104+
safe_events_indexer = SafeEventsIndexer(
105+
ethereum_client,
106+
confirmations=0,
107+
blocks_to_reindex_again=0,
108+
ignored_initiators=set(),
109+
)
110+
safe_events_indexer.process_elements(logs)
102111

103-
# Create SafeContract
104-
SafeContract.objects.create(address=validated_address, ethereum_tx=ethereum_tx)
105-
self.stdout.write(
106-
self.style.SUCCESS(
107-
f"Created SafeContract for address={validated_address} "
108-
f"with ethereum_tx={tx_hash}"
112+
# Process decoded transactions to create SafeContract
113+
self.stdout.write("Processing decoded transactions to create SafeContract...")
114+
tx_processor = SafeTxProcessorProvider()
115+
pending_txs = list(InternalTxDecoded.objects.pending_for_safes())
116+
tx_processor.process_decoded_transactions(pending_txs)
117+
118+
# Verify SafeContract was created
119+
if SafeContract.objects.filter(address=validated_address).exists():
120+
self.stdout.write(
121+
self.style.SUCCESS(
122+
f"Successfully created SafeContract for address={validated_address} "
123+
f"with ethereum_tx={tx_hash}"
124+
)
125+
)
126+
else:
127+
self.stdout.write(
128+
self.style.WARNING(
129+
f"SafeContract not created for {validated_address} - "
130+
f"transaction may not be a Safe creation or address doesn't match"
131+
)
109132
)
110-
)
111133

112134
def _handle_remove(self, addresses: list[str]) -> None:
113135
"""Remove SafeContract entries."""

safe_transaction_service/history/tests/test_commands.py

Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
from ..services import IndexServiceProvider
2828
from ..tasks import logger as task_logger
2929
from .factories import (
30-
EthereumTxFactory,
3130
MultisigConfirmationFactory,
3231
MultisigTransactionFactory,
3332
SafeContractFactory,
@@ -741,37 +740,100 @@ def test_validate_tx_integrity(self):
741740
self.assertNotIn("is not matching", text)
742741
self.assertNotIn("is not valid for multisig transaction", text)
743742

744-
def test_safe_contract_add(self):
745-
"""Test adding a SafeContract entry."""
743+
@mock.patch(
744+
"safe_transaction_service.history.management.commands.safe_contract.get_auto_ethereum_client"
745+
)
746+
@mock.patch(
747+
"safe_transaction_service.history.management.commands.safe_contract.SafeEventsIndexer"
748+
)
749+
@mock.patch(
750+
"safe_transaction_service.history.management.commands.safe_contract.SafeTxProcessorProvider"
751+
)
752+
def test_safe_contract_add(
753+
self,
754+
mock_tx_processor_provider: MagicMock,
755+
mock_safe_events_indexer: MagicMock,
756+
mock_get_ethereum_client: MagicMock,
757+
):
758+
"""Test adding a SafeContract entry processes events through the indexer."""
746759
command = "safe_contract"
747760

748-
address = Account.create().address
749-
ethereum_tx = EthereumTxFactory()
750-
tx_hash = ethereum_tx.tx_hash # Already a hex string
761+
safe_address = Account.create().address
762+
tx_hash = "0x" + "ab" * 32
763+
764+
# Mock the ethereum client and its get_transaction_receipt method
765+
mock_client = MagicMock()
766+
mock_get_ethereum_client.return_value = mock_client
767+
mock_client.get_transaction_receipt.return_value = {
768+
"logs": [{"logIndex": 0, "transactionHash": tx_hash}],
769+
"transactionHash": tx_hash,
770+
}
771+
772+
# Mock indexer instance
773+
mock_indexer_instance = MagicMock()
774+
mock_safe_events_indexer.return_value = mock_indexer_instance
775+
776+
# Mock tx processor
777+
mock_tx_processor = MagicMock()
778+
mock_tx_processor_provider.return_value = mock_tx_processor
751779

752780
self.assertEqual(SafeContract.objects.count(), 0)
753781

754-
# Add SafeContract
782+
# Add SafeContract - command will process through indexer
755783
buf = StringIO()
756-
call_command(command, "add", address, tx_hash, stdout=buf)
784+
call_command(command, "add", safe_address, tx_hash, stdout=buf)
757785
output = buf.getvalue()
758-
self.assertIn(f"Created SafeContract for address={address}", output)
759-
self.assertIn(tx_hash, output)
786+
787+
# Verify get_transaction_receipt was called
788+
mock_client.get_transaction_receipt.assert_called_once()
789+
790+
# Verify SafeEventsIndexer was created with ignored_initiators=set()
791+
mock_safe_events_indexer.assert_called_once()
792+
call_kwargs = mock_safe_events_indexer.call_args.kwargs
793+
self.assertEqual(call_kwargs.get("ignored_initiators"), set())
794+
795+
# Verify process_elements was called on the indexer instance
796+
mock_indexer_instance.process_elements.assert_called_once_with(
797+
[{"logIndex": 0, "transactionHash": tx_hash}]
798+
)
799+
800+
# Verify SafeTxProcessor.process_decoded_transactions was called
801+
mock_tx_processor.process_decoded_transactions.assert_called_once()
802+
803+
self.assertIn("Processing events through SafeEventsIndexer", output)
804+
self.assertIn("Processing decoded transactions to create SafeContract", output)
805+
806+
@mock.patch(
807+
"safe_transaction_service.history.management.commands.safe_contract.get_auto_ethereum_client"
808+
)
809+
def test_safe_contract_add_already_exists(
810+
self, mock_get_ethereum_client: MagicMock
811+
):
812+
"""Test adding a SafeContract that already exists shows warning."""
813+
command = "safe_contract"
814+
815+
safe_contract = SafeContractFactory()
816+
safe_address = safe_contract.address
817+
tx_hash = "0x" + "ab" * 32
818+
760819
self.assertEqual(SafeContract.objects.count(), 1)
761-
self.assertTrue(SafeContract.objects.filter(address=address).exists())
762820

763-
# Add same address again (should show warning)
821+
# Add same address again (should show warning without fetching)
764822
buf = StringIO()
765-
call_command(command, "add", address, tx_hash, stdout=buf)
823+
call_command(command, "add", safe_address, tx_hash, stdout=buf)
766824
output = buf.getvalue()
767-
self.assertIn(f"SafeContract already exists for address: {address}", output)
825+
self.assertIn(
826+
f"SafeContract already exists for address: {safe_address}", output
827+
)
768828
self.assertEqual(SafeContract.objects.count(), 1)
769829

830+
# Should not have called ethereum client
831+
mock_get_ethereum_client.return_value.get_transaction_receipt.assert_not_called()
832+
770833
def test_safe_contract_add_invalid_address(self):
771834
"""Test adding with invalid address shows error."""
772835
command = "safe_contract"
773-
ethereum_tx = EthereumTxFactory()
774-
tx_hash = ethereum_tx.tx_hash # Already a hex string
836+
tx_hash = "0x" + "ab" * 32
775837

776838
with self.assertRaises(CommandError) as context:
777839
call_command(command, "add", "invalid-address", tx_hash)
@@ -786,15 +848,46 @@ def test_safe_contract_add_invalid_tx_hash(self):
786848
call_command(command, "add", address, "invalid-tx-hash")
787849
self.assertIn("Invalid transaction hash", str(context.exception))
788850

789-
def test_safe_contract_add_nonexistent_tx(self):
790-
"""Test adding with non-existent tx hash shows error when fetching from RPC fails."""
851+
@mock.patch(
852+
"safe_transaction_service.history.management.commands.safe_contract.get_auto_ethereum_client"
853+
)
854+
def test_safe_contract_add_nonexistent_tx(
855+
self, mock_get_ethereum_client: MagicMock
856+
):
857+
"""Test adding with non-existent tx hash shows error when receipt not found."""
791858
command = "safe_contract"
792859
address = Account.create().address
793860
fake_tx_hash = "0x" + "a" * 64
794861

862+
# Mock the ethereum client to return None for receipt
863+
mock_client = MagicMock()
864+
mock_get_ethereum_client.return_value = mock_client
865+
mock_client.get_transaction_receipt.return_value = None
866+
795867
with self.assertRaises(CommandError) as context:
796868
call_command(command, "add", address, fake_tx_hash)
797-
self.assertIn("Failed to fetch transaction", str(context.exception))
869+
self.assertIn("not found", str(context.exception))
870+
871+
@mock.patch(
872+
"safe_transaction_service.history.management.commands.safe_contract.get_auto_ethereum_client"
873+
)
874+
def test_safe_contract_add_no_logs(self, mock_get_ethereum_client: MagicMock):
875+
"""Test adding with tx that has no logs shows error."""
876+
command = "safe_contract"
877+
address = Account.create().address
878+
tx_hash = "0x" + "ab" * 32
879+
880+
# Mock the ethereum client to return receipt with no logs
881+
mock_client = MagicMock()
882+
mock_get_ethereum_client.return_value = mock_client
883+
mock_client.get_transaction_receipt.return_value = {
884+
"logs": [],
885+
"transactionHash": tx_hash,
886+
}
887+
888+
with self.assertRaises(CommandError) as context:
889+
call_command(command, "add", address, tx_hash)
890+
self.assertIn("has no logs", str(context.exception))
798891

799892
def test_safe_contract_remove(self):
800893
"""Test removing SafeContract entries."""

0 commit comments

Comments
 (0)