From 51e378d0f6b069061a77edfd3a8afd1b4c780069 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 6 Jul 2026 18:01:42 +0200 Subject: [PATCH 1/4] Support multiple validator operator wallets --- backend/validators/admin.py | 34 +++- .../validators/genlayer_validators_service.py | 45 ++++-- .../0016_validatoroperatorwallet.py | 53 ++++++ backend/validators/models.py | 26 +++ backend/validators/serializers.py | 36 ++++- backend/validators/tests/test_api.py | 151 +++++++++++++++++- backend/validators/views.py | 80 +++++----- frontend/src/routes/ProfileEdit.svelte | 108 +++++++++---- 8 files changed, 446 insertions(+), 87 deletions(-) create mode 100644 backend/validators/migrations/0016_validatoroperatorwallet.py diff --git a/backend/validators/admin.py b/backend/validators/admin.py index bce1e3df..01dfcdb0 100644 --- a/backend/validators/admin.py +++ b/backend/validators/admin.py @@ -4,7 +4,7 @@ from utils.admin_mixins import CloudinaryUploadMixin -from .models import Validator, ValidatorWallet, ValidatorWalletStatusSnapshot +from .models import Validator, ValidatorOperatorWallet, ValidatorWallet, ValidatorWalletStatusSnapshot class ValidatorWalletInline(admin.TabularInline): @@ -19,6 +19,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 ValidatorWallet.objects.filter(operator_address__iexact=obj.address).count() + wallet_count.short_description = 'Matching Wallets' + + class ValidatorInline(admin.StackedInline): model = Validator extra = 0 @@ -66,7 +82,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, { @@ -84,6 +100,20 @@ 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 wallet_count(self, obj): + return ValidatorWallet.objects.filter(operator_address__iexact=obj.address).count() + wallet_count.short_description = 'Matching Wallets' + + @admin.register(ValidatorWallet) class ValidatorWalletAdmin(CloudinaryUploadMixin, admin.ModelAdmin): cloudinary_upload_fields = { diff --git a/backend/validators/genlayer_validators_service.py b/backend/validators/genlayer_validators_service.py index 5e550d3a..41763267 100644 --- a/backend/validators/genlayer_validators_service.py +++ b/backend/validators/genlayer_validators_service.py @@ -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 = { @@ -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() @@ -555,16 +554,36 @@ def _process_validator( wallet.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. If the + # operator address is the user's portal login wallet, keep the legacy + # auto-link behavior by creating a first-party operator claim. + desired_operator = None + if wallet.operator_address: + operator_link = ( + ValidatorOperatorWallet.objects + .select_related('validator') + .filter(address__iexact=wallet.operator_address) + .first() + ) + if operator_link: + desired_operator = operator_link.validator + else: + try: + user = User.objects.get(address__iexact=wallet.operator_address) + if hasattr(user, 'validator'): + operator_link, _ = ValidatorOperatorWallet.objects.get_or_create( + address=wallet.operator_address.lower(), + defaults={ + 'validator': user.validator, + }, + ) + desired_operator = operator_link.validator + except User.DoesNotExist: + pass + + if wallet.operator_id != (desired_operator.id if desired_operator else None): + wallet.operator = desired_operator + has_changes = True # Always fetch identity to capture updates t0 = time.time() diff --git a/backend/validators/migrations/0016_validatoroperatorwallet.py b/backend/validators/migrations/0016_validatoroperatorwallet.py new file mode 100644 index 00000000..c9637ab2 --- /dev/null +++ b/backend/validators/migrations/0016_validatoroperatorwallet.py @@ -0,0 +1,53 @@ +# Generated by Django 6.0.6 on 2026-07-06 12:57 + +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', '0015_validatorwalletstatussnapshot_logs_samples_and_more'), + ] + + 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), + ] diff --git a/backend/validators/models.py b/backend/validators/models.py index 26347765..40f5086d 100644 --- a/backend/validators/models.py +++ b/backend/validators/models.py @@ -89,6 +89,32 @@ 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 __str__(self): + return f"{self.address} -> {self.validator_id}" + + class Validator(NodeVersionMixin, BaseModel): """ Represents a validator with their node version information. diff --git a/backend/validators/serializers.py b/backend/validators/serializers.py index 24c9a9f3..b618ee15 100644 --- a/backend/validators/serializers.py +++ b/backend/validators/serializers.py @@ -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): @@ -57,6 +57,40 @@ 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 _wallets(self, obj): + return ValidatorWallet.objects.filter(operator_address__iexact=obj.address) + + def get_wallet_count(self, obj): + return self._wallets(obj).count() + + def get_active_wallet_count(self, obj): + return self._wallets(obj).filter(status='active').count() + + def get_networks(self, obj): + return sorted(set(self._wallets(obj).values_list('network', flat=True))) + + class GrafanaValidatorSerializer(serializers.ModelSerializer): """ Minimal validator roster for the Grafana Infinity datasource. diff --git a/backend/validators/tests/test_api.py b/backend/validators/tests/test_api.py index 473c586e..4bec929b 100644 --- a/backend/validators/tests/test_api.py +++ b/backend/validators/tests/test_api.py @@ -1,11 +1,13 @@ from datetime import timedelta +from unittest.mock import patch from django.test import override_settings from django.contrib.auth import get_user_model from rest_framework.test import APITestCase from rest_framework import status from django.utils import timezone -from validators.models import SyncLock, Validator +from validators.genlayer_validators_service import GenLayerValidatorsService +from validators.models import SyncLock, Validator, ValidatorOperatorWallet, ValidatorWallet from validators.views import ValidatorWalletViewSet from contributions.models import Category @@ -94,6 +96,153 @@ def test_get_validator_profile_exists(self): self.assertEqual(response.data['node_version_asimov'], '1.2.3') +class ValidatorOperatorWalletLinkTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email='validator@example.com', + password='testpass123', + address='0x1111111111111111111111111111111111111111', + name='Validator', + ) + self.validator = Validator.objects.create(user=self.user) + self.client.force_authenticate(user=self.user) + + def _claim_operator(self, address): + return self.client.post('/api/v1/validators/link-by-operator/', { + 'operator_address': address, + }, format='json') + + def test_operator_wallet_claim_links_matching_wallets_across_networks(self): + operator_address = '0x2222222222222222222222222222222222222222' + asimov_wallet = ValidatorWallet.objects.create( + address='0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + network='asimov', + operator_address=operator_address, + status='active', + ) + bradbury_wallet = ValidatorWallet.objects.create( + address='0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + network='bradbury', + operator_address=operator_address, + status='active', + ) + + response = self._claim_operator(operator_address) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['wallets_linked'], 2) + link = ValidatorOperatorWallet.objects.get(address=operator_address) + self.assertEqual(link.validator, self.validator) + asimov_wallet.refresh_from_db() + bradbury_wallet.refresh_from_db() + self.assertEqual(asimov_wallet.operator, self.validator) + self.assertEqual(bradbury_wallet.operator, self.validator) + + def test_validator_can_claim_multiple_operator_wallets(self): + first = '0x2222222222222222222222222222222222222222' + second = '0x3333333333333333333333333333333333333333' + ValidatorWallet.objects.create( + address='0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + network='asimov', + operator_address=first, + status='active', + ) + ValidatorWallet.objects.create( + address='0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + network='bradbury', + operator_address=second, + status='active', + ) + + first_response = self._claim_operator(first) + second_response = self._claim_operator(second) + + self.assertEqual(first_response.status_code, status.HTTP_200_OK) + self.assertEqual(second_response.status_code, status.HTTP_200_OK) + self.assertEqual( + set(self.validator.operator_wallets.values_list('address', flat=True)), + {first, second}, + ) + + def test_operator_wallet_claim_is_first_come_first_served(self): + operator_address = '0x2222222222222222222222222222222222222222' + other_user = User.objects.create_user( + email='other-validator@example.com', + password='testpass123', + address='0x4444444444444444444444444444444444444444', + ) + other_validator = Validator.objects.create(user=other_user) + ValidatorOperatorWallet.objects.create( + validator=other_validator, + address=operator_address, + ) + ValidatorWallet.objects.create( + address='0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + network='asimov', + operator_address=operator_address, + status='active', + ) + + response = self._claim_operator(operator_address) + + self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) + + def test_operator_wallet_claim_requires_existing_synced_wallet(self): + response = self._claim_operator('0x2222222222222222222222222222222222222222') + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_sync_uses_claimed_operator_wallet_link(self): + link = ValidatorOperatorWallet.objects.create( + validator=self.validator, + address='0x2222222222222222222222222222222222222222', + ) + service = GenLayerValidatorsService.__new__(GenLayerValidatorsService) + service.network_key = 'bradbury' + stats = {'rpc_time_operator': 0.0, 'rpc_time_identity': 0.0, 'rpc_time_view': 0.0, 'created': 0, 'updated': 0} + + with patch.object(service, 'fetch_operator_for_wallet', return_value=link.address), \ + patch.object(service, 'fetch_validator_identity', return_value={'moniker': 'Bradbury One'}), \ + patch.object(service, 'fetch_validator_view', return_value={'v_stake': '1', 'd_stake': '2'}): + service._process_validator( + address='0xcccccccccccccccccccccccccccccccccccccccc', + is_active=True, + banned_info=None, + stats=stats, + ) + + wallet = ValidatorWallet.objects.get(address='0xcccccccccccccccccccccccccccccccccccccccc') + self.assertEqual(wallet.network, 'bradbury') + self.assertEqual(wallet.operator, self.validator) + + def test_sync_clears_stale_operator_link_when_chain_operator_changes(self): + old_operator = self.user.address.lower() + wallet = ValidatorWallet.objects.create( + address='0xdddddddddddddddddddddddddddddddddddddddd', + network='bradbury', + operator=self.validator, + operator_address=old_operator, + status='active', + ) + service = GenLayerValidatorsService.__new__(GenLayerValidatorsService) + service.network_key = 'bradbury' + stats = {'rpc_time_operator': 0.0, 'rpc_time_identity': 0.0, 'rpc_time_view': 0.0, 'created': 0, 'updated': 0} + + with patch.object(service, 'fetch_operator_for_wallet', return_value='0x3333333333333333333333333333333333333333'), \ + patch.object(service, 'fetch_validator_identity', return_value=None), \ + patch.object(service, 'fetch_validator_view', return_value=None): + service._process_validator( + address=wallet.address, + is_active=True, + banned_info=None, + stats=stats, + ) + + wallet.refresh_from_db() + self.assertEqual(wallet.operator_address, '0x3333333333333333333333333333333333333333') + self.assertIsNone(wallet.operator) + + @override_settings(CRON_SYNC_TOKEN='test-cron-token') class ValidatorSyncLockTestCase(APITestCase): def test_sync_returns_409_while_non_stale_lock_is_held(self): diff --git a/backend/validators/views.py b/backend/validators/views.py index ef5c6bb0..68ca5336 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -7,15 +7,15 @@ from rest_framework.permissions import IsAuthenticated, AllowAny from utils.throttling import WalletLinkRateThrottle -from django.shortcuts import get_object_or_404 from django.db.models import Min, Q, Count from django.db import IntegrityError, transaction from django.conf import settings from django.utils import timezone from django.core.cache import cache -from .models import SyncLock, Validator, ValidatorWallet +from .models import SyncLock, Validator, ValidatorOperatorWallet, ValidatorWallet from .serializers import ( GrafanaValidatorSerializer, + ValidatorOperatorWalletSerializer, ValidatorWalletSerializer, WallOfShameSerializer, grafana_explorer_url, @@ -203,8 +203,13 @@ def my_wallets(self, request): ).order_by('-created_at') serializer = ValidatorWalletSerializer(wallets, many=True) + operator_wallets = ValidatorOperatorWallet.objects.filter( + validator=request.user.validator + ).order_by('address') + operator_wallet_serializer = ValidatorOperatorWalletSerializer(operator_wallets, many=True) return Response({ 'wallets': serializer.data, + 'operator_wallets': operator_wallet_serializer.data, 'active_count': wallets.filter(status='active').count(), 'total_count': wallets.count() }) @@ -213,83 +218,74 @@ def my_wallets(self, request): throttle_classes=[WalletLinkRateThrottle]) def link_by_operator(self, request): """ - Link validator wallets to the current user by operator address. - Only available for validators who don't have any wallets linked yet. + First-come-first-served operator wallet claim. - NOTE: linking is first-come-first-served on a public operator address - and carries no cryptographic ownership proof; the throttle bounds - mass-claiming, and mistaken/abusive links are logged and reversible - by staff via the admin. + A validator profile can claim multiple operator wallets. Each operator + address can only be claimed by one validator profile. """ user = request.user - - # Verify user is a validator if not hasattr(user, 'validator'): return Response( - {'error': 'Only validators can link wallets'}, + {'error': 'Only validators can link operator wallets'}, status=status.HTTP_403_FORBIDDEN ) - validator = user.validator - - # Check user has no linked wallets - if ValidatorWallet.objects.filter(operator=validator).exists(): - return Response( - {'error': 'You already have validator wallets linked'}, - status=status.HTTP_400_BAD_REQUEST - ) - operator_address = request.data.get('operator_address', '').strip().lower() - - # Validate format (0x + 40 hex chars) if not re.match(r'^0x[a-fA-F0-9]{40}$', operator_address): return Response( {'error': 'Invalid Ethereum address format'}, status=status.HTTP_400_BAD_REQUEST ) - # Claim atomically: lock the matching wallet rows so two concurrent - # requests cannot both pass the "not linked yet" checks and link the - # same operator (or give one user two operators). + validator = user.validator with transaction.atomic(): + existing_claim = ( + ValidatorOperatorWallet.objects + .select_for_update() + .filter(address__iexact=operator_address) + .first() + ) + if existing_claim and existing_claim.validator_id != validator.id: + return Response( + {'error': 'This operator wallet is already linked to another validator'}, + status=status.HTTP_409_CONFLICT + ) + wallets = ( ValidatorWallet.objects .select_for_update() .filter(operator_address__iexact=operator_address) ) - if not wallets.exists(): return Response( {'error': 'No validator wallets found for this operator address'}, status=status.HTTP_404_NOT_FOUND ) - # Re-check under the lock that the caller still has no wallets - if ValidatorWallet.objects.filter(operator=validator).exists(): + conflicting_wallet = wallets.exclude( + Q(operator__isnull=True) | Q(operator=validator) + ).first() + if conflicting_wallet: return Response( - {'error': 'You already have validator wallets linked'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Check if any wallet is already linked to another validator - already_linked = wallets.exclude(operator__isnull=True).first() - if already_linked: - return Response( - {'error': 'This operator address is already linked to another validator'}, + {'error': 'A matching validator wallet is linked to another validator'}, status=status.HTTP_409_CONFLICT ) - # Link all wallets to this validator - count = wallets.update(operator=validator) + operator_wallet = existing_claim or ValidatorOperatorWallet.objects.create( + validator=validator, + address=operator_address, + ) + wallets_linked = wallets.update(operator=validator) logger.info( - "Validator wallet link: user=%s (id=%s) claimed %s wallet(s) for operator %s", - user.address, user.id, count, operator_address, + "Validator operator wallet claimed: user=%s (id=%s) operator=%s wallets_linked=%s", + user.address, user.id, operator_address, wallets_linked, ) return Response({ 'success': True, - 'wallets_linked': count + 'wallets_linked': wallets_linked, + 'operator_wallet': ValidatorOperatorWalletSerializer(operator_wallet).data, }) diff --git a/frontend/src/routes/ProfileEdit.svelte b/frontend/src/routes/ProfileEdit.svelte index e2a487f4..3f3af578 100644 --- a/frontend/src/routes/ProfileEdit.svelte +++ b/frontend/src/routes/ProfileEdit.svelte @@ -46,10 +46,11 @@ let cropperCallback = $state(null); let uploadingImage = $state(false); - // Operator address linking state + // Operator wallet linking state let operatorAddress = $state(""); let isLinkingWallets = $state(false); let validatorWallets = $state([]); + let operatorWallets = $state([]); let isRefreshingDiscordRoles = $state(false); let roleRefreshClock = $state(Date.now()); let roleRefreshTimer = null; @@ -129,8 +130,10 @@ try { const walletsResponse = await validatorsAPI.getMyValidatorWallets(); validatorWallets = walletsResponse.data.wallets || []; + operatorWallets = walletsResponse.data.operator_wallets || []; } catch (err) { validatorWallets = []; + operatorWallets = []; } } } catch (err) { @@ -229,6 +232,17 @@ push(`/participant/${$authState.address}`); } + function truncateAddress(address) { + if (!address || address.length <= 12) return address || ""; + return `${address.slice(0, 6)}...${address.slice(-4)}`; + } + + function networkName(network) { + if (network === "asimov") return "Asimov"; + if (network === "bradbury") return "Bradbury"; + return network; + } + async function handleLinkWallets() { if (!operatorAddress.trim()) { showError("Please enter an operator address"); @@ -239,7 +253,7 @@ try { const response = await validatorsAPI.linkValidatorWalletsByOperator(operatorAddress.trim()); const walletsLinked = response.data.wallets_linked; - showSuccess(`Successfully linked ${walletsLinked} wallet${walletsLinked !== 1 ? "s" : ""}`); + showSuccess(`Operator wallet linked. Matched ${walletsLinked} validator wallet${walletsLinked !== 1 ? "s" : ""}.`); // Refresh data to update the UI await loadUserData(); operatorAddress = ""; @@ -808,39 +822,77 @@ Validator Settings - - {#if validatorWallets.length === 0} -
- -

- Enter the operator wallet address you used when creating your validator on GenLayer. -

-
-
- -
+
+
+
+

+ Operator wallets +

+

+ Linked operators +

+

+ Add each operator wallet used by your validator nodes on Asimov or Bradbury. +

+
+
+
- {/if} + + {#if operatorWallets.length > 0} +
+ {#each operatorWallets as wallet} +
+
+
+

{truncateAddress(wallet.address)}

+

+ {wallet.wallet_count} matched wallet{wallet.wallet_count === 1 ? "" : "s"} + ยท {wallet.active_wallet_count} active +

+
+ + Linked + +
+
+ {#if wallet.networks?.length} + {#each wallet.networks as network} + + {networkName(network)} + + {/each} + {:else} + + Awaiting chain sync + + {/if} +
+
+ {/each} +
+ {:else} +
+ No operator wallets linked yet. +
+ {/if} +
From edbe023b3d32bb435bb28a709542e8a9a13d240e Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 6 Jul 2026 18:10:29 +0200 Subject: [PATCH 2/4] Merge validator migration branches --- .../validators/migrations/0017_merge_20260706_1608.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 backend/validators/migrations/0017_merge_20260706_1608.py diff --git a/backend/validators/migrations/0017_merge_20260706_1608.py b/backend/validators/migrations/0017_merge_20260706_1608.py new file mode 100644 index 00000000..0b4eb6d1 --- /dev/null +++ b/backend/validators/migrations/0017_merge_20260706_1608.py @@ -0,0 +1,11 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('validators', '0016_backfill_graduated_validator_profiles'), + ('validators', '0016_validatoroperatorwallet'), + ] + + operations = [] From 650c6992f69d68238de29c7bdc88e029d484da54 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 6 Jul 2026 18:13:44 +0200 Subject: [PATCH 3/4] Renumber validator operator wallet migration --- .../validators/migrations/0017_merge_20260706_1608.py | 11 ----------- ...ratorwallet.py => 0017_validatoroperatorwallet.py} | 4 +--- 2 files changed, 1 insertion(+), 14 deletions(-) delete mode 100644 backend/validators/migrations/0017_merge_20260706_1608.py rename backend/validators/migrations/{0016_validatoroperatorwallet.py => 0017_validatoroperatorwallet.py} (92%) diff --git a/backend/validators/migrations/0017_merge_20260706_1608.py b/backend/validators/migrations/0017_merge_20260706_1608.py deleted file mode 100644 index 0b4eb6d1..00000000 --- a/backend/validators/migrations/0017_merge_20260706_1608.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('validators', '0016_backfill_graduated_validator_profiles'), - ('validators', '0016_validatoroperatorwallet'), - ] - - operations = [] diff --git a/backend/validators/migrations/0016_validatoroperatorwallet.py b/backend/validators/migrations/0017_validatoroperatorwallet.py similarity index 92% rename from backend/validators/migrations/0016_validatoroperatorwallet.py rename to backend/validators/migrations/0017_validatoroperatorwallet.py index c9637ab2..2964cd2c 100644 --- a/backend/validators/migrations/0016_validatoroperatorwallet.py +++ b/backend/validators/migrations/0017_validatoroperatorwallet.py @@ -1,5 +1,3 @@ -# Generated by Django 6.0.6 on 2026-07-06 12:57 - from django.db import migrations, models import django.db.models.deletion @@ -32,7 +30,7 @@ def backfill_operator_wallets(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('validators', '0015_validatorwalletstatussnapshot_logs_samples_and_more'), + ('validators', '0016_backfill_graduated_validator_profiles'), ] operations = [ From 39e9262168ab5b9dac1d6f4d196936fd6848a334 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 6 Jul 2026 18:26:26 +0200 Subject: [PATCH 4/4] Address validator operator wallet review fixes --- backend/validators/admin.py | 26 ++++++++++- .../validators/genlayer_validators_service.py | 46 ++++++++++++------- backend/validators/models.py | 5 ++ backend/validators/serializers.py | 18 ++++++-- backend/validators/tests/test_api.py | 32 +++++++++++++ backend/validators/views.py | 26 +++++++++-- frontend/src/routes/ProfileEdit.svelte | 1 + 7 files changed, 126 insertions(+), 28 deletions(-) diff --git a/backend/validators/admin.py b/backend/validators/admin.py index 01dfcdb0..751c2e7e 100644 --- a/backend/validators/admin.py +++ b/backend/validators/admin.py @@ -1,4 +1,6 @@ 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 @@ -31,7 +33,7 @@ class ValidatorOperatorWalletInline(admin.TabularInline): def wallet_count(self, obj): if not obj.pk: return '-' - return ValidatorWallet.objects.filter(operator_address__iexact=obj.address).count() + return obj.matching_wallet_count() wallet_count.short_description = 'Matching Wallets' @@ -109,8 +111,28 @@ class ValidatorOperatorWalletAdmin(admin.ModelAdmin): 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): - return ValidatorWallet.objects.filter(operator_address__iexact=obj.address).count() + 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' diff --git a/backend/validators/genlayer_validators_service.py b/backend/validators/genlayer_validators_service.py index 41763267..cd9788e3 100644 --- a/backend/validators/genlayer_validators_service.py +++ b/backend/validators/genlayer_validators_service.py @@ -543,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() @@ -552,34 +554,46 @@ 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 - # Resolve portal attribution from claimed operator wallets. If the - # operator address is the user's portal login wallet, keep the legacy - # auto-link behavior by creating a first-party operator claim. + # 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__iexact=wallet.operator_address) + .filter(address=claim_address) .first() ) if operator_link: desired_operator = operator_link.validator else: - try: - user = User.objects.get(address__iexact=wallet.operator_address) - if hasattr(user, 'validator'): - operator_link, _ = ValidatorOperatorWallet.objects.get_or_create( - address=wallet.operator_address.lower(), - defaults={ - 'validator': user.validator, - }, - ) - desired_operator = operator_link.validator - except User.DoesNotExist: - pass + 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 diff --git a/backend/validators/models.py b/backend/validators/models.py index f112d946..c200fcea 100644 --- a/backend/validators/models.py +++ b/backend/validators/models.py @@ -112,6 +112,11 @@ def save(self, *args, **kwargs): 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}" diff --git a/backend/validators/serializers.py b/backend/validators/serializers.py index b618ee15..f64dbb97 100644 --- a/backend/validators/serializers.py +++ b/backend/validators/serializers.py @@ -78,17 +78,25 @@ class Meta: ] read_only_fields = fields - def _wallets(self, obj): - return ValidatorWallet.objects.filter(operator_address__iexact=obj.address) + 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 self._wallets(obj).count() + return len(self._wallet_rows(obj)) def get_active_wallet_count(self, obj): - return self._wallets(obj).filter(status='active').count() + return sum(1 for row in self._wallet_rows(obj) if row['status'] == 'active') def get_networks(self, obj): - return sorted(set(self._wallets(obj).values_list('network', flat=True))) + return sorted({row['network'] for row in self._wallet_rows(obj)}) class GrafanaValidatorSerializer(serializers.ModelSerializer): diff --git a/backend/validators/tests/test_api.py b/backend/validators/tests/test_api.py index 4bec929b..4c58689e 100644 --- a/backend/validators/tests/test_api.py +++ b/backend/validators/tests/test_api.py @@ -215,6 +215,38 @@ def test_sync_uses_claimed_operator_wallet_link(self): self.assertEqual(wallet.network, 'bradbury') self.assertEqual(wallet.operator, self.validator) + def test_sync_preserves_existing_link_when_operator_unchanged_and_claim_missing(self): + operator_address = '0x2222222222222222222222222222222222222222' + wallet = ValidatorWallet.objects.create( + address='0xdddddddddddddddddddddddddddddddddddddddd', + network='bradbury', + operator=self.validator, + operator_address=operator_address, + status='active', + ) + service = GenLayerValidatorsService.__new__(GenLayerValidatorsService) + service.network_key = 'bradbury' + stats = {'rpc_time_operator': 0.0, 'rpc_time_identity': 0.0, 'rpc_time_view': 0.0, 'created': 0, 'updated': 0} + + with patch.object(service, 'fetch_operator_for_wallet', return_value=operator_address), \ + patch.object(service, 'fetch_validator_identity', return_value=None), \ + patch.object(service, 'fetch_validator_view', return_value=None): + service._process_validator( + address=wallet.address, + is_active=True, + banned_info=None, + stats=stats, + ) + + wallet.refresh_from_db() + self.assertEqual(wallet.operator, self.validator) + self.assertTrue( + ValidatorOperatorWallet.objects.filter( + validator=self.validator, + address=operator_address, + ).exists() + ) + def test_sync_clears_stale_operator_link_when_chain_operator_changes(self): old_operator = self.user.address.lower() wallet = ValidatorWallet.objects.create( diff --git a/backend/validators/views.py b/backend/validators/views.py index 68ca5336..922939e2 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -242,7 +242,7 @@ def link_by_operator(self, request): existing_claim = ( ValidatorOperatorWallet.objects .select_for_update() - .filter(address__iexact=operator_address) + .filter(address=operator_address) .first() ) if existing_claim and existing_claim.validator_id != validator.id: @@ -271,10 +271,26 @@ def link_by_operator(self, request): status=status.HTTP_409_CONFLICT ) - operator_wallet = existing_claim or ValidatorOperatorWallet.objects.create( - validator=validator, - address=operator_address, - ) + if existing_claim: + operator_wallet = existing_claim + else: + try: + with transaction.atomic(): + operator_wallet = ValidatorOperatorWallet.objects.create( + validator=validator, + address=operator_address, + ) + except IntegrityError: + operator_wallet = ( + ValidatorOperatorWallet.objects + .select_for_update() + .get(address=operator_address) + ) + if operator_wallet.validator_id != validator.id: + return Response( + {'error': 'This operator wallet is already linked to another validator'}, + status=status.HTTP_409_CONFLICT + ) wallets_linked = wallets.update(operator=validator) logger.info( diff --git a/frontend/src/routes/ProfileEdit.svelte b/frontend/src/routes/ProfileEdit.svelte index 3f3af578..fd847f4e 100644 --- a/frontend/src/routes/ProfileEdit.svelte +++ b/frontend/src/routes/ProfileEdit.svelte @@ -836,6 +836,7 @@

+