diff --git a/safe_transaction_service/history/models.py b/safe_transaction_service/history/models.py index aee87224c..1e3b852e4 100644 --- a/safe_transaction_service/history/models.py +++ b/safe_transaction_service/history/models.py @@ -2194,3 +2194,6 @@ class TransactionServiceEventType(Enum): MESSAGE_CONFIRMATION = 11 DELETED_MULTISIG_TRANSACTION = 12 REORG_DETECTED = 13 + NEW_DELEGATE = 14 + UPDATED_DELEGATE = 15 + DELETED_DELEGATE = 16 diff --git a/safe_transaction_service/history/services/notification_service.py b/safe_transaction_service/history/services/notification_service.py index 624cb1a2c..429f9a632 100644 --- a/safe_transaction_service/history/services/notification_service.py +++ b/safe_transaction_service/history/services/notification_service.py @@ -1,6 +1,6 @@ import json from datetime import timedelta -from typing import Any, Dict, List, Type, TypedDict, Union +from typing import Any, Dict, List, Optional, Type, TypedDict, Union from django.db.models import Model from django.utils import timezone @@ -15,6 +15,7 @@ MultisigConfirmation, MultisigTransaction, SafeContract, + SafeContractDelegate, TokenTransfer, TransactionServiceEventType, ) @@ -204,3 +205,54 @@ def build_reorg_payload(block_number: int) -> ReorgPayload: blockNumber=block_number, chainId=str(get_chain_id()), ) + + +class DelegatePayload(TypedDict): + type: str + safeAddress: Optional[str] + delegate: str + delegator: str + label: str + expiryDate: Optional[int] + + +def _build_delegate_payload( + event_type: Union[ + TransactionServiceEventType.NEW_DELEGATE, + TransactionServiceEventType.UPDATED_DELEGATE, + TransactionServiceEventType.DELETED_DELEGATE, + ], + instance: SafeContractDelegate, +) -> DelegatePayload: + """ + Build a delegate payload with the specified event type and SafeContractDelegate instance data. + + :param event_type: The transaction event type, restricted to NEW_DELEGATE, UPDATED_DELEGATE, or DELETED_DELEGATE. + :param instance: An instance of SafeContractDelegate. + :return: A DelegatePayload dictionary with details about the delegate. + """ + return DelegatePayload( + type=event_type.name, + safeAddress=instance.safe_contract.address if instance.safe_contract else None, + delegate=instance.delegate, + delegator=instance.delegator, + label=instance.label, + expiryDate=( + int(instance.expiry_date.timestamp()) if instance.expiry_date else None + ), + ) + + +def build_save_delegate_payload( + instance: SafeContractDelegate, created: bool = True +) -> DelegatePayload: + event_type = TransactionServiceEventType.NEW_DELEGATE + if not created: + event_type = TransactionServiceEventType.UPDATED_DELEGATE + return _build_delegate_payload(event_type, instance) + + +def build_delete_delegate_payload(instance: SafeContractDelegate) -> DelegatePayload: + return _build_delegate_payload( + TransactionServiceEventType.DELETED_DELEGATE, instance + ) diff --git a/safe_transaction_service/history/signals.py b/safe_transaction_service/history/signals.py index 906368081..832559630 100644 --- a/safe_transaction_service/history/signals.py +++ b/safe_transaction_service/history/signals.py @@ -20,12 +20,18 @@ MultisigConfirmation, MultisigTransaction, SafeContract, + SafeContractDelegate, SafeLastStatus, SafeMasterCopy, SafeStatus, TokenTransfer, ) -from .services.notification_service import build_event_payload, is_relevant_notification +from .services.notification_service import ( + build_delete_delegate_payload, + build_event_payload, + build_save_delegate_payload, + is_relevant_notification, +) logger = getLogger(__name__) @@ -288,3 +294,32 @@ def add_to_historical_table( safe_status = SafeStatus.from_status_instance(instance) safe_status.save() return safe_status + + +@receiver( + post_save, + sender=SafeContractDelegate, + dispatch_uid="safe_contract_delegate.process_save_delegate_user_event", +) +def process_save_delegate_user_event( + sender: Type[Model], + instance: SafeContractDelegate, + created: bool, + **kwargs, +): + payload_event = build_save_delegate_payload(instance, created) + queue_service = get_queue_service() + queue_service.send_event(payload_event) + + +@receiver( + post_delete, + sender=SafeContractDelegate, + dispatch_uid="safe_contract_delegate.process_delete_delegate_user_event", +) +def process_delete_delegate_user_event( + sender: Type[Model], instance: SafeContractDelegate, *args, **kwargs +): + payload_event = build_delete_delegate_payload(instance) + queue_service = get_queue_service() + queue_service.send_event(payload_event) diff --git a/safe_transaction_service/history/tests/test_signals.py b/safe_transaction_service/history/tests/test_signals.py index 4de40a259..cf12046c2 100644 --- a/safe_transaction_service/history/tests/test_signals.py +++ b/safe_transaction_service/history/tests/test_signals.py @@ -1,9 +1,11 @@ +import datetime from datetime import timedelta from unittest import mock from unittest.mock import MagicMock from django.db.models.signals import post_save from django.test import TestCase +from django.utils import timezone import factory from safe_eth.eth import EthereumNetwork @@ -28,6 +30,8 @@ InternalTxFactory, MultisigConfirmationFactory, MultisigTransactionFactory, + SafeContractDelegateFactory, + SafeContractFactory, ) @@ -195,3 +199,62 @@ def test_signals_are_correctly_fired(self, send_event_mock: MagicMock): "chainId": str(EthereumNetwork.GANACHE.value), } send_event_mock.assert_called_with(deleted_multisig_transaction_payload) + + @mock.patch.object(QueueService, "send_event") + def test_delegates_signals_are_correctly_fired(self, send_event_mock: MagicMock): + # New delegate should fire an event + delegate_for_safe = SafeContractDelegateFactory() + new_delegate_user_payload = { + "type": TransactionServiceEventType.NEW_DELEGATE.name, + "safeAddress": delegate_for_safe.safe_contract.address, + "delegate": delegate_for_safe.delegate, + "delegator": delegate_for_safe.delegator, + "label": delegate_for_safe.label, + "expiryDate": int(delegate_for_safe.expiry_date.timestamp()), + } + send_event_mock.assert_called_with(new_delegate_user_payload) + + permanent_delegate_without_safe = SafeContractDelegateFactory( + safe_contract=None, expiry_date=None + ) + new_delegate_user_payload = { + "type": TransactionServiceEventType.NEW_DELEGATE.name, + "safeAddress": None, + "delegate": permanent_delegate_without_safe.delegate, + "delegator": permanent_delegate_without_safe.delegator, + "label": permanent_delegate_without_safe.label, + "expiryDate": None, + } + send_event_mock.assert_called_with(new_delegate_user_payload) + + # Updated delegate should fire an event + delegate_to_update = SafeContractDelegateFactory() + new_safe = SafeContractFactory() + new_label = "Updated Label" + new_expiry_date = timezone.now() + datetime.timedelta(minutes=5) + delegate_to_update.safe_contract = new_safe + delegate_to_update.label = new_label + delegate_to_update.expiry_date = new_expiry_date + delegate_to_update.save() + updated_delegate_user_payload = { + "type": TransactionServiceEventType.UPDATED_DELEGATE.name, + "safeAddress": new_safe.address, + "delegate": delegate_to_update.delegate, + "delegator": delegate_to_update.delegator, + "label": new_label, + "expiryDate": int(new_expiry_date.timestamp()), + } + send_event_mock.assert_called_with(updated_delegate_user_payload) + + # Deleted delegate should fire an event + delegate_to_delete = SafeContractDelegateFactory() + delegate_to_delete.delete() + updated_delegate_user_payload = { + "type": TransactionServiceEventType.DELETED_DELEGATE.name, + "safeAddress": delegate_to_delete.safe_contract.address, + "delegate": delegate_to_delete.delegate, + "delegator": delegate_to_delete.delegator, + "label": delegate_to_delete.label, + "expiryDate": int(delegate_to_delete.expiry_date.timestamp()), + } + send_event_mock.assert_called_with(updated_delegate_user_payload)