Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions backend/validators/admin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from django.contrib import admin
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils.html import format_html
from django.utils.safestring import mark_safe

from utils.admin_mixins import CloudinaryUploadMixin

from .models import Validator, ValidatorWallet, ValidatorWalletStatusSnapshot
from .models import Validator, ValidatorOperatorWallet, ValidatorWallet, ValidatorWalletStatusSnapshot


class ValidatorWalletInline(admin.TabularInline):
Expand All @@ -19,6 +21,22 @@ class ValidatorWalletInline(admin.TabularInline):
ordering = ('network', '-status', '-created_at')


class ValidatorOperatorWalletInline(admin.TabularInline):
model = ValidatorOperatorWallet
extra = 0
fields = ('address', 'wallet_count')
readonly_fields = ('wallet_count',)
verbose_name = "Operator Wallet"
verbose_name_plural = "Operator Wallets"
ordering = ('address',)

def wallet_count(self, obj):
if not obj.pk:
return '-'
return obj.matching_wallet_count()
wallet_count.short_description = 'Matching Wallets'


Comment thread
coderabbitai[bot] marked this conversation as resolved.
class ValidatorInline(admin.StackedInline):
model = Validator
extra = 0
Expand Down Expand Up @@ -66,7 +84,7 @@ class ValidatorAdmin(admin.ModelAdmin):
search_fields = ('user__email', 'user__name', 'node_version_asimov', 'node_version_bradbury')
list_filter = ('created_at', 'updated_at')
ordering = ('display_order', '-created_at')
inlines = [ValidatorWalletInline]
inlines = [ValidatorOperatorWalletInline, ValidatorWalletInline]

fieldsets = (
(None, {
Expand All @@ -84,6 +102,40 @@ def wallet_count(self, obj):
wallet_count.short_description = 'Wallets'


@admin.register(ValidatorOperatorWallet)
class ValidatorOperatorWalletAdmin(admin.ModelAdmin):
list_display = ('address', 'validator', 'wallet_count', 'created_at')
list_filter = ('created_at',)
search_fields = ('address', 'validator__user__email', 'validator__user__name', 'validator__user__address')
raw_id_fields = ('validator',)
readonly_fields = ('created_at', 'updated_at')
ordering = ('address',)

def get_queryset(self, request):
wallet_counts = (
ValidatorWallet.objects
.filter(operator_address=OuterRef('address'))
.values('operator_address')
.annotate(count=Count('id'))
.values('count')
)
return super().get_queryset(request).annotate(
_matching_wallet_count=Coalesce(
Subquery(wallet_counts, output_field=IntegerField()),
Value(0),
)
)

def wallet_count(self, obj):
if not obj.pk:
return '-'
annotated_count = getattr(obj, '_matching_wallet_count', None)
if annotated_count is not None:
return annotated_count
return obj.matching_wallet_count()
wallet_count.short_description = 'Matching Wallets'


Comment thread
coderabbitai[bot] marked this conversation as resolved.
@admin.register(ValidatorWallet)
class ValidatorWalletAdmin(CloudinaryUploadMixin, admin.ModelAdmin):
cloudinary_upload_fields = {
Expand Down
59 changes: 46 additions & 13 deletions backend/validators/genlayer_validators_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,7 @@ def sync_all_validators(self) -> Dict[str, Any]:
Returns:
Dictionary with sync statistics
"""
from .models import ValidatorWallet, Validator
from users.models import User
from .models import ValidatorWallet

sync_start = time.time()
stats = {
Expand Down Expand Up @@ -529,7 +528,7 @@ def _process_validator(
quarantined_info: Quarantine info if validator is quarantined
stats: Statistics dictionary to update
"""
from .models import ValidatorWallet, Validator
from .models import ValidatorOperatorWallet, ValidatorWallet
from users.models import User

address_lower = address.lower()
Expand All @@ -544,6 +543,8 @@ def _process_validator(

# Track if anything changed
has_changes = is_new
previous_operator_address = (wallet.operator_address or '').lower()
operator_address_changed = False

# Always fetch operator address to capture updates (like identity)
t0 = time.time()
Expand All @@ -553,18 +554,50 @@ def _process_validator(
new_operator_address = operator_address.lower()
if wallet.operator_address != new_operator_address:
wallet.operator_address = new_operator_address
operator_address_changed = previous_operator_address != new_operator_address
has_changes = True

# Try to link to existing Validator model if not already linked
# This handles the case where a user creates their profile after their wallet was synced
if wallet.operator_address and not wallet.operator:
try:
user = User.objects.get(address__iexact=wallet.operator_address)
if hasattr(user, 'validator'):
wallet.operator = user.validator
has_changes = True
except User.DoesNotExist:
pass
# Resolve portal attribution from claimed operator wallets. Preserve an
# existing non-claim link only while the on-chain operator address is
# unchanged, and back it with a claim so future syncs are explicit.
desired_operator = None
if wallet.operator_address:
claim_address = wallet.operator_address.lower()
operator_link = (
ValidatorOperatorWallet.objects
.select_related('validator')
.filter(address=claim_address)
.first()
)
if operator_link:
desired_operator = operator_link.validator
else:
user = (
User.objects
.filter(address__iexact=wallet.operator_address, validator__isnull=False)
.select_related('validator')
.first()
)
if user:
operator_link, _ = ValidatorOperatorWallet.objects.get_or_create(
address=claim_address,
defaults={
'validator': user.validator,
},
)
desired_operator = operator_link.validator
elif wallet.operator_id and not operator_address_changed:
operator_link, _ = ValidatorOperatorWallet.objects.get_or_create(
address=claim_address,
defaults={
'validator': wallet.operator,
},
)
desired_operator = operator_link.validator

if wallet.operator_id != (desired_operator.id if desired_operator else None):
wallet.operator = desired_operator
has_changes = True
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Always fetch identity to capture updates
t0 = time.time()
Expand Down
51 changes: 51 additions & 0 deletions backend/validators/migrations/0017_validatoroperatorwallet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from django.db import migrations, models
import django.db.models.deletion


def backfill_operator_wallets(apps, schema_editor):
ValidatorWallet = apps.get_model('validators', 'ValidatorWallet')
ValidatorOperatorWallet = apps.get_model('validators', 'ValidatorOperatorWallet')

seen = set()
wallets = (
ValidatorWallet.objects
.exclude(operator_id__isnull=True)
.exclude(operator_address='')
.order_by('id')
.values('operator_id', 'operator_address')
)
for wallet in wallets:
address = (wallet['operator_address'] or '').lower()
if not address or address in seen:
continue
seen.add(address)
ValidatorOperatorWallet.objects.get_or_create(
address=address,
defaults={
'validator_id': wallet['operator_id'],
},
)


class Migration(migrations.Migration):

dependencies = [
('validators', '0016_backfill_graduated_validator_profiles'),
]

operations = [
migrations.CreateModel(
name='ValidatorOperatorWallet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('address', models.CharField(db_index=True, max_length=42, unique=True)),
('validator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='operator_wallets', to='validators.validator')),
],
options={
'ordering': ['address'],
},
),
migrations.RunPython(backfill_operator_wallets, migrations.RunPython.noop),
]
31 changes: 31 additions & 0 deletions backend/validators/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,37 @@ def __str__(self):
return f"ValidatorWallet {self.address[:10]}... ({self.network}/{self.status})"


class ValidatorOperatorWallet(BaseModel):
"""
Operator wallet claimed by a validator profile.

ValidatorWallet.operator_address comes from chain state. This table stores
the portal-side first-come-first-served attribution for that operator wallet.
"""
validator = models.ForeignKey(
'Validator',
on_delete=models.CASCADE,
related_name='operator_wallets',
)
address = models.CharField(max_length=42, unique=True, db_index=True)

class Meta:
ordering = ['address']

def save(self, *args, **kwargs):
if self.address:
self.address = self.address.lower()
super().save(*args, **kwargs)

def matching_wallet_count(self):
if not self.address:
return 0
return ValidatorWallet.objects.filter(operator_address=self.address.lower()).count()

def __str__(self):
return f"{self.address} -> {self.validator_id}"


class Validator(NodeVersionMixin, BaseModel):
"""
Represents a validator with their node version information.
Expand Down
44 changes: 43 additions & 1 deletion backend/validators/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from rest_framework import serializers
from django.conf import settings
from .models import ValidatorWallet, Validator
from .models import ValidatorOperatorWallet, ValidatorWallet, Validator


def grafana_network_label(network):
Expand Down Expand Up @@ -57,6 +57,48 @@ def get_explorer_url(self, obj):
return network_config.get('explorer_url', '')


class ValidatorOperatorWalletSerializer(serializers.ModelSerializer):
"""
Serializer for operator wallets claimed by a portal validator profile.
"""
wallet_count = serializers.SerializerMethodField()
active_wallet_count = serializers.SerializerMethodField()
networks = serializers.SerializerMethodField()

class Meta:
model = ValidatorOperatorWallet
fields = [
'id',
'address',
'wallet_count',
'active_wallet_count',
'networks',
'created_at',
'updated_at',
]
read_only_fields = fields

def _wallet_rows(self, obj):
cache_name = '_matching_wallet_rows'
if not hasattr(obj, cache_name):
rows = list(
ValidatorWallet.objects
.filter(operator_address=obj.address)
.values('status', 'network')
)
setattr(obj, cache_name, rows)
return getattr(obj, cache_name)

def get_wallet_count(self, obj):
return len(self._wallet_rows(obj))

def get_active_wallet_count(self, obj):
return sum(1 for row in self._wallet_rows(obj) if row['status'] == 'active')

def get_networks(self, obj):
return sorted({row['network'] for row in self._wallet_rows(obj)})


class GrafanaValidatorSerializer(serializers.ModelSerializer):
"""
Minimal validator roster for the Grafana Infinity datasource.
Expand Down
Loading