From e527c193011bdb360c223495b22f954e5ff4b4e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mois=C3=A9s?= <7888669+moisses89@users.noreply.github.com> Date: Wed, 28 Feb 2024 13:41:23 +0100 Subject: [PATCH] Add model for locking events (#12) --- config/settings/test.py | 2 +- requirements.txt | 2 +- .../locking_events/migrations/0001_initial.py | 109 ++++++++++++++++++ safe_locking_service/locking_events/models.py | 86 +++++++++++++- .../locking_events/tests/factories.py | 58 ++++++++++ .../locking_events/tests/test_models.py | 61 ++++++++++ 6 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 safe_locking_service/locking_events/migrations/0001_initial.py create mode 100644 safe_locking_service/locking_events/tests/factories.py create mode 100644 safe_locking_service/locking_events/tests/test_models.py diff --git a/config/settings/test.py b/config/settings/test.py index 51b02b8..6095280 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -8,7 +8,7 @@ # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = True +DEBUG = False # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env( "DJANGO_SECRET_KEY", diff --git a/requirements.txt b/requirements.txt index 80d3e5d..8a11616 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,5 +19,5 @@ hexbytes==0.3.1 packaging>=21 psycopg2==2.9.9 requests==2.31.0 -safe-eth-py[django]==6.0.0b16 +safe-eth-py[django]==6.0.0b17 web3==6.15.1 diff --git a/safe_locking_service/locking_events/migrations/0001_initial.py b/safe_locking_service/locking_events/migrations/0001_initial.py new file mode 100644 index 0000000..2f783ff --- /dev/null +++ b/safe_locking_service/locking_events/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 4.2.10 on 2024-02-27 17:41 + +import django.db.models.deletion +from django.db import migrations, models + +import gnosis.eth.django.models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="EthereumTx", + fields=[ + ( + "tx_hash", + gnosis.eth.django.models.Keccak256Field( + primary_key=True, serialize=False + ), + ), + ("block_hash", gnosis.eth.django.models.Keccak256Field()), + ("block_number", models.PositiveIntegerField()), + ("block_timestamp", models.DateTimeField()), + ], + ), + migrations.CreateModel( + name="UnlockEvent", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("timestamp", models.DateTimeField()), + ("log_index", models.PositiveIntegerField()), + ("holder", gnosis.eth.django.models.EthereumAddressV2Field()), + ("amount", gnosis.eth.django.models.Uint96Field()), + ("unlock_index", models.PositiveIntegerField()), + ( + "ethereum_tx", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="locking_events.ethereumtx", + ), + ), + ], + ), + migrations.CreateModel( + name="WithdrawnEvent", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("timestamp", models.DateTimeField()), + ("log_index", models.PositiveIntegerField()), + ("holder", gnosis.eth.django.models.EthereumAddressV2Field()), + ("amount", gnosis.eth.django.models.Uint96Field()), + ( + "ethereum_tx", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="locking_events.ethereumtx", + ), + ), + ( + "unlock_index", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="locking_events.unlockevent", + ), + ), + ], + ), + migrations.CreateModel( + name="LockEvent", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("timestamp", models.DateTimeField()), + ("log_index", models.PositiveIntegerField()), + ("holder", gnosis.eth.django.models.EthereumAddressV2Field()), + ("amount", gnosis.eth.django.models.Uint96Field()), + ( + "ethereum_tx", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="locking_events.ethereumtx", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="withdrawnevent", + constraint=models.UniqueConstraint( + fields=("holder", "unlock_index"), name="unique_withdrawn_event_index" + ), + ), + migrations.AddConstraint( + model_name="unlockevent", + constraint=models.UniqueConstraint( + fields=("holder", "unlock_index"), name="unique_unlock_event_index" + ), + ), + migrations.AddConstraint( + model_name="lockevent", + constraint=models.UniqueConstraint( + fields=("ethereum_tx", "log_index"), name="unique_ethereum_tx_log_index" + ), + ), + ] diff --git a/safe_locking_service/locking_events/models.py b/safe_locking_service/locking_events/models.py index 0b4331b..a4f1037 100644 --- a/safe_locking_service/locking_events/models.py +++ b/safe_locking_service/locking_events/models.py @@ -1,3 +1,85 @@ -# from django.db import models +from django.db import models -# Create your models here. +from gnosis.eth.django.models import EthereumAddressV2Field, Keccak256Field, Uint96Field + + +class EthereumTx(models.Model): + tx_hash = Keccak256Field(primary_key=True) + block_hash = Keccak256Field() + block_number = models.PositiveIntegerField() + block_timestamp = models.DateTimeField() + + def __str__(self): + return f"Transaction hash {self.tx_hash}" + + +class CommonEvent(models.Model): + """ + Abstract model that defines generic fields of a locking event. (Abstract model doesn't create tables) + The timestamp is stored also in this model to improve the query performance. + """ + + id = models.AutoField(primary_key=True) + timestamp = models.DateTimeField() + ethereum_tx = models.ForeignKey(EthereumTx, on_delete=models.CASCADE) + log_index = models.PositiveIntegerField() + holder = EthereumAddressV2Field() + amount = Uint96Field() + + class Meta: + abstract = True + constraints = [ + models.UniqueConstraint( + fields=["ethereum_tx", "log_index"], name="unique_ethereum_tx_log_index" + ) + ] + + def __str__(self): + return f"timestamp={self.timestamp} tx-hash={self.ethereum_tx_id} log_index={self.log_index} holder={self.holder} amount={self.amount}" + + +class LockEvent(CommonEvent): + """ + Model to store event Locked(address indexed holder, uint96 amount) + """ + + pass + + def __str__(self): + return "LockEvent: " + super().__str__() + + +class UnlockEvent(CommonEvent): + """ + Model to store event Unlocked(address indexed holder, uint32 indexed index, uint96 amount) + """ + + unlock_index = models.PositiveIntegerField() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["holder", "unlock_index"], name="unique_unlock_event_index" + ) + ] + + def __str__(self): + return "UnlockEvent: " + super().__str__() + + +class WithdrawnEvent(CommonEvent): + """ + Model to store event Withdrawn(address indexed holder, uint32 indexed index, uint96 amount) + """ + + unlock_index = models.ForeignKey(UnlockEvent, on_delete=models.CASCADE) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["holder", "unlock_index"], name="unique_withdrawn_event_index" + ) + ] + + def __str__(self): + return "WithdrawnEvent: " + super().__str__() diff --git a/safe_locking_service/locking_events/tests/factories.py b/safe_locking_service/locking_events/tests/factories.py new file mode 100644 index 0000000..150a4e8 --- /dev/null +++ b/safe_locking_service/locking_events/tests/factories.py @@ -0,0 +1,58 @@ +from django.utils import timezone + +from eth_account import Account +from factory import LazyFunction, Sequence, SubFactory, fuzzy +from factory.django import DjangoModelFactory +from web3 import Web3 + +from safe_locking_service.locking_events.models import ( + EthereumTx, + LockEvent, + UnlockEvent, + WithdrawnEvent, +) + + +class EthereumTxFactory(DjangoModelFactory): + class Meta: + model = EthereumTx + + tx_hash = Sequence(lambda n: Web3.keccak(text=f"tx_hash-{n}").hex()) + block_hash = Sequence(lambda n: Web3.keccak(text=f"tx_hash-{n}").hex()) + block_number = Sequence(lambda n: n + 1) + block_timestamp = LazyFunction(timezone.now) + + +class LockEventFactory(DjangoModelFactory): + class Meta: + model = LockEvent + + timestamp = LazyFunction(timezone.now) + ethereum_tx = SubFactory(EthereumTxFactory) + log_index = Sequence(lambda n: n) + amount = fuzzy.FuzzyInteger(0, 1000) + holder = LazyFunction(lambda: Account.create().address) + + +class UnlockEventFactory(DjangoModelFactory): + class Meta: + model = UnlockEvent + + timestamp = LazyFunction(timezone.now) + ethereum_tx = SubFactory(EthereumTxFactory) + log_index = Sequence(lambda n: n) + holder = LazyFunction(lambda: Account.create().address) + amount = fuzzy.FuzzyInteger(0, 1000) + unlock_index = Sequence(lambda n: n + 1) + + +class WithdrawnEventFactory(DjangoModelFactory): + class Meta: + model = WithdrawnEvent + + timestamp = LazyFunction(timezone.now) + ethereum_tx = SubFactory(EthereumTxFactory) + log_index = Sequence(lambda n: n) + holder = LazyFunction(lambda: Account.create().address) + amount = fuzzy.FuzzyInteger(0, 1000) + unlock_index = SubFactory(UnlockEventFactory) diff --git a/safe_locking_service/locking_events/tests/test_models.py b/safe_locking_service/locking_events/tests/test_models.py new file mode 100644 index 0000000..50ea99d --- /dev/null +++ b/safe_locking_service/locking_events/tests/test_models.py @@ -0,0 +1,61 @@ +from django.db import IntegrityError +from django.test import TestCase + +from eth_account import Account + +from safe_locking_service.locking_events.models import ( + EthereumTx, + LockEvent, + UnlockEvent, + WithdrawnEvent, +) +from safe_locking_service.locking_events.tests.factories import ( + LockEventFactory, + UnlockEventFactory, + WithdrawnEventFactory, +) + + +class TestLockingModel(TestCase): + def test_create_lock_event(self): + safe_address = Account.create().address + ethereum_tx = LockEventFactory(holder=safe_address, amount=1000).ethereum_tx + self.assertEqual(EthereumTx.objects.count(), 1) + lock_event = LockEvent.objects.filter( + holder=safe_address, ethereum_tx=ethereum_tx + )[0] + self.assertEqual(lock_event.holder, safe_address) + self.assertEqual(lock_event.amount, 1000) + + def test_create_unlock_event(self): + safe_address = Account.create().address + ethereum_tx = UnlockEventFactory(holder=safe_address, amount=1000).ethereum_tx + self.assertEqual(EthereumTx.objects.count(), 1) + unlock_event = UnlockEvent.objects.filter( + holder=safe_address, ethereum_tx=ethereum_tx + )[0] + self.assertEqual(unlock_event.holder, safe_address) + self.assertEqual(unlock_event.amount, 1000) + with self.assertRaisesMessage(IntegrityError, "violates unique constraint"): + UnlockEventFactory( + holder=safe_address, amount=1000, unlock_index=unlock_event.unlock_index + ) + + def test_create_withdrawn_event(self): + safe_address = Account.create().address + ethereum_tx = WithdrawnEventFactory( + holder=safe_address, amount=1000 + ).ethereum_tx + # Expected at least two transactions, one for unlock and other for withdrawn + self.assertEqual(EthereumTx.objects.count(), 2) + withdrawn_event = WithdrawnEvent.objects.filter( + holder=safe_address, ethereum_tx=ethereum_tx + )[0] + self.assertEqual(withdrawn_event.holder, safe_address) + self.assertEqual(withdrawn_event.amount, 1000) + with self.assertRaisesMessage(IntegrityError, "violates unique constraint"): + WithdrawnEventFactory( + holder=safe_address, + amount=1000, + unlock_index=withdrawn_event.unlock_index, + )