diff --git a/backend/validators/admin.py b/backend/validators/admin.py index bce1e3df..751c2e7e 100644 --- a/backend/validators/admin.py +++ b/backend/validators/admin.py @@ -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): @@ -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' + + class ValidatorInline(admin.StackedInline): model = Validator extra = 0 @@ -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, { @@ -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' + + @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..cd9788e3 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() @@ -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() @@ -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 # Always fetch identity to capture updates t0 = time.time() diff --git a/backend/validators/migrations/0017_validatoroperatorwallet.py b/backend/validators/migrations/0017_validatoroperatorwallet.py new file mode 100644 index 00000000..2964cd2c --- /dev/null +++ b/backend/validators/migrations/0017_validatoroperatorwallet.py @@ -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), + ] diff --git a/backend/validators/models.py b/backend/validators/models.py index e4f8b876..c200fcea 100644 --- a/backend/validators/models.py +++ b/backend/validators/models.py @@ -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. diff --git a/backend/validators/serializers.py b/backend/validators/serializers.py index 24c9a9f3..f64dbb97 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,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. diff --git a/backend/validators/tests/test_api.py b/backend/validators/tests/test_api.py index 473c586e..4c58689e 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,185 @@ 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_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( + 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..922939e2 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,90 @@ 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=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) + 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( - "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..fd847f4e 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,78 @@ 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} +