Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

staking panel #115

Open
hitchhooker opened this issue Nov 20, 2024 · 5 comments
Open

staking panel #115

hitchhooker opened this issue Nov 20, 2024 · 5 comments

Comments

@hitchhooker
Copy link

https://polkadot.js.org/apps/#/staking/actions

recreating this functionality would be awesome since right now we do not have any well functioning software in ecosystem for validators to manage their validators. i guess most validators have accustomed to read state storage directly and do extrinsics but that is just a massive drag. im not sure if this is in scope of dot console, but woul easily onboard ~200 active users to use the console if this panel was provided.

@tien
Copy link
Owner

tien commented Nov 22, 2024

Yes this feature could make sense, are you talking specifically about the validators management section? PJS UI is not exactly clean/lean :)) so would be nice if you can provide screenshots to the exact features that you think is useful to have.

@hitchhooker
Copy link
Author

https://kzmovlytt1kq0gd845tk.lite.vusercontent.net/

  • gotta see those session keys (both current and what's coming up next if changed and basicly when its changed or going to change probably most important part of validator N+3 )
  • need a handy three-dot menu or something similar for all the key validator stuff:
    • bonding stuff (unbond when you want out, rebond if you change your mind, throw more tokens in)
    • set up those session keys
    • swap your controller account
    • change where your rewards go
    • hit pause/chill

cool to have:

  • calculator to figure out if you'll make it into the active set
  • see how close you are to getting in
  • nice clean view of session keys ( paranodes.io/session_key explains the logic)

if end up showing all nominators probably best to keep loading them up very lazy

import React, { useState, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Progress } from '@/components/ui/progress';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";

const styles = {
  stakeBar: `
    .stake-bar-self { @apply bg-blue-600; }
    .stake-bar-nominated { @apply bg-green-500; }
  `,
};

const Icons = {
  ChevronDown: () => (
    <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <path d="M6 9l6 6 6-6" />
    </svg>
  ),
  Search: () => (
    <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <circle cx="11" cy="11" r="8" />
      <path d="M21 21l-4.35-4.35" />
    </svg>
  ),
  MoreVertical: () => (
    <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <circle cx="12" cy="12" r="1" />
      <circle cx="12" cy="5" r="1" />
      <circle cx="12" cy="19" r="1" />
    </svg>
  ),
  ChevronUp: () => (
    <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <path d="M18 15l-6-6-6 6" />
    </svg>
  ),
  Info: () => (
    <svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
      <circle cx="12" cy="12" r="10" />
      <path d="M12 16v-4" />
      <path d="M12 8h.01" />
    </svg>
  ),
};

type StashFilter = 'all' | 'stashed' | 'pooled' | 'nominators' | 'validators' | 'inactive';
type NominationStatus = 'active' | 'inactive' | 'waiting';

const MINIMUM_ACTIVE_STAKE = 6500;

// Mock data
const mockData = {
  stashes: [
    {
      id: '1',
      name: 'Alice',
      tag: 'alice_stash',
      controller: { name: 'Alice Controller' },
      staked: true,
      pooled: false,
      isValidator: false,
      selfStake: 1000,
      totalBalance: 10000,
      rewardDestination: 'Staked',
      sessionKey: null,
      nominations: [
        { id: '1', name: 'Bob', stake: 5000, commission: 10, status: 'active' },
        { id: '2', name: 'Charlie', stake: 3000, commission: 8, status: 'waiting' },
        { id: '3', name: 'Dave', stake: 2000, commission: 12, status: 'inactive' },
      ],
      nominationStats: { active: 1, inactive: 1, waiting: 1 },
      era: {
        current: 2400,
        start: 2350,
        length: 100,
      },
      rewards: {
        lastEra: 5.23,
        total: 1250.75,
      },
    },
    {
      id: '2',
      name: 'Bob',
      tag: 'bob_stash',
      controller: { name: 'Bob Controller' },
      staked: true,
      pooled: false,
      isValidator: true,
      selfStake: 150,
      totalBalance: 10000,
      rewardDestination: 'Staked',
      sessionKey: '0x1234...5678',
      commission: 10,
      nominations: [
        { id: '4', name: 'Alice', stake: 2000, isPool: false, status: 'active' },
        { id: '5', name: 'Eve', stake: 3000, isPool: false, status: 'active' },
        { id: '6', name: 'PoolParty Pool5', stake: 3050, isPool: true, status: 'active' },
      ],
      nominationStats: { active: 3, inactive: 0, waiting: 0 },
      era: {
        current: 2400,
        start: 2350,
        length: 100,
      },
      rewards: {
        lastEra: 15.75,
        total: 3750.50,
      },
    },
  ],
};

// StashCard Component
const StashCard = ({ stash, onStop, onUpdateNomination }) => {
  const [isExpanded, setIsExpanded] = useState(false);
  const [activeFilter, setActiveFilter] = useState<NominationStatus | 'all'>('all');
  const [searchQuery, setSearchQuery] = useState('');

  const filteredNominations = stash.nominations.filter(nom => {
    const matchesSearch = nom.name.toLowerCase().includes(searchQuery.toLowerCase());
    const matchesFilter = activeFilter === 'all' || nom.status === activeFilter;
    return matchesSearch && matchesFilter;
  });

  const statusColors = {
    active: 'bg-green-100 text-green-800',
    inactive: 'bg-gray-100 text-gray-800',
    waiting: 'bg-yellow-100 text-yellow-800',
  };

  const handleMoreOptions = (action: string) => {
    console.log(`Performing action: ${action} for stash ${stash.id}`);
    // Implement the actual functionality here
  };

  const totalNominatedStake = stash.nominations.reduce((sum, nom) => sum + nom.stake, 0);
  const totalStake = stash.selfStake + totalNominatedStake;
  const activeStake = stash.nominations
    .filter(nom => nom.status === 'active')
    .reduce((sum, nom) => sum + nom.stake, 0);

  const selfStakePercentage = (stash.selfStake / MINIMUM_ACTIVE_STAKE) * 100;
  const nominatedStakePercentage = (totalNominatedStake / MINIMUM_ACTIVE_STAKE) * 100;
  const totalStakePercentage = (totalStake / MINIMUM_ACTIVE_STAKE) * 100;

  const averageCommissionRate = stash.isValidator
    ? stash.commission
    : stash.nominations
        .filter(nom => nom.status === 'active')
        .reduce((sum, nom, _, array) => sum + nom.commission / array.length, 0);

  return (
    <>
      <style jsx>{styles.stakeBar}</style>
      <Card className="overflow-hidden">
        <CardContent className="p-0">
          <div className="p-4 bg-gray-50 border-b">
            <div className="flex items-center justify-between">
              <div className="flex items-center gap-3">
                <div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground font-bold text-lg">
                  {stash.name[0]}
                </div>
                <div>
                  <h3 className="font-semibold text-lg">{stash.name}</h3>
                  <p className="text-sm text-muted-foreground">/{stash.tag}</p>
                </div>
              </div>
              <div className="flex items-center gap-2">
                <Badge variant={stash.isValidator ? "default" : "secondary"}>
                  {stash.isValidator ? "Validator" : "Nominator"}
                </Badge>
                <DropdownMenu>
                  <DropdownMenuTrigger asChild>
                    <Button variant="ghost" size="sm">
                      <Icons.MoreVertical />
                    </Button>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent align="end">
                    {stash.isValidator && (
                      <DropdownMenuItem onClick={() => handleMoreOptions('setSessionKeys')}>
                        Set Session Keys
                      </DropdownMenuItem>
                    )}
                    <DropdownMenuItem onClick={() => handleMoreOptions('changeController')}>
                      Change Controller
                    </DropdownMenuItem>
                    <DropdownMenuItem onClick={() => handleMoreOptions('changeRewardDestination')}>
                      Change Reward Destination
                    </DropdownMenuItem>
                    <DropdownMenuSeparator />
                    <DropdownMenuItem onClick={() => handleMoreOptions('bondMore')}>
                      Bond More
                    </DropdownMenuItem>
                    <DropdownMenuItem onClick={() => handleMoreOptions('unbond')}>
                      Unbond
                    </DropdownMenuItem>
                    <DropdownMenuItem onClick={() => handleMoreOptions('rebond')}>
                      Rebond
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
              </div>
            </div>
          </div>

          <div className="p-4 space-y-4">
            <div className="grid grid-cols-2 gap-4">
              <div>
                <p className="text-sm font-medium text-muted-foreground">Controller</p>
                <p className="font-medium">{stash.controller.name}</p>
              </div>
              <div>
                <p className="text-sm font-medium text-muted-foreground">Reward Destination</p>
                <p className="font-medium">{stash.rewardDestination}</p>
              </div>
            </div>

            {stash.isValidator && (
              <div>
                <div className="flex justify-between items-center mb-2">
                  <p className="text-sm font-medium text-muted-foreground">Staked</p>
                  <p className="text-sm font-medium">{totalStakePercentage.toFixed(2)}%</p>
                </div>
                <div className="h-4 w-full bg-gray-200 rounded-full overflow-hidden relative">
                  <div
                    className="h-full stake-bar-self absolute left-0 top-0"
                    style={{ width: `${Math.min(selfStakePercentage, 100)}%` }}
                  />
                  <div
                    className="h-full stake-bar-nominated absolute left-0 top-0"
                    style={{ width: `${Math.min(totalStakePercentage, 100)}%`, marginLeft: `${Math.min(selfStakePercentage, 100)}%` }}
                  />
                </div>
                <div className="flex justify-between items-center mt-1">
                  <p className="text-sm">
                    <span className="font-medium">{stash.selfStake.toFixed(2)} KSM</span> self
                  </p>
                  <p className="text-sm">
                    <span className="font-medium">{totalNominatedStake.toFixed(2)} KSM</span> nominated
                  </p>
                  <p className="text-sm">
                    <span className="font-medium">{totalStake.toFixed(2)} KSM</span> total
                  </p>
                </div>
                <p className="text-sm text-muted-foreground mt-1">
                  Minimum active: {MINIMUM_ACTIVE_STAKE} KSM
                </p>
              </div>
            )}
            {!stash.isValidator && (
              <div className="grid grid-cols-2 gap-4">
                <div>
                  <p className="text-sm font-medium text-muted-foreground">Active Stake</p>
                  <p className="font-medium">{activeStake.toFixed(4)} KSM</p>
                </div>
                <div>
                  <p className="text-sm font-medium text-muted-foreground">Avg. Commission Rate</p>
                  <p className="font-medium">{averageCommissionRate.toFixed(2)}%</p>
                </div>
              </div>
            )}

            <div className="grid grid-cols-2 gap-4">
              <div>
                <p className="text-sm font-medium text-muted-foreground">
                  {stash.isValidator ? 'Nominators' : 'Nominated Validators'}
                </p>
                <p className="font-medium">{stash.nominations.length}</p>
              </div>
              <div>
                <p className="text-sm font-medium text-muted-foreground">
                  {stash.isValidator ? 'Commission' : 'Active Nominations'}
                </p>
                <p className="font-medium">
                  {stash.isValidator
                    ? `${stash.commission}%`
                    : stash.nominations.filter(nom => nom.status === 'active').length}
                </p>
              </div>
            </div>

            {stash.isValidator && (
              <div className="grid grid-cols-2 gap-4">
                <div>
                  <p className="text-sm font-medium text-muted-foreground">Session Key</p>
                  <p className="font-medium font-mono text-sm truncate">{stash.sessionKey}</p>
                </div>
              </div>
            )}

            <div className="grid grid-cols-2 gap-4">
              <div>
                <p className="text-sm font-medium text-muted-foreground">Current Era</p>
                <p className="font-medium">{stash.era.current}</p>
              </div>
              <div>
                <p className="text-sm font-medium text-muted-foreground">Era Progress</p>
                <Progress value={((stash.era.current - stash.era.start) / stash.era.length) * 100} className="h-2 mt-2" />
              </div>
            </div>

            <div className="grid grid-cols-2 gap-4">
              <div>
                <p className="text-sm font-medium text-muted-foreground">Last Era Reward</p>
                <p className="font-medium">{stash.rewards.lastEra.toFixed(4)} KSM</p>
              </div>
              <div>
                <p className="text-sm font-medium text-muted-foreground">Total Rewards</p>
                <p className="font-medium">{stash.rewards.total.toFixed(4)} KSM</p>
              </div>
            </div>

            <div className="flex justify-between items-center">
              <Button variant="destructive" size="sm" onClick={() => onStop(stash.id)}>
                {stash.isValidator ? 'Stop Validating' : 'Stop Nominating'}
              </Button>
              <Button
                variant="ghost"
                size="sm"
                onClick={() => setIsExpanded(!isExpanded)}
                className="flex items-center gap-1"
              >
                {stash.isValidator ? 'Nominators' : 'Nominations'}
                {isExpanded ? <Icons.ChevronUp /> : <Icons.ChevronDown />}
              </Button>
            </div>
          </div>

          {isExpanded && (
            <div className="p-4 bg-gray-50 border-t space-y-4">
              <div className="flex flex-col sm:flex-row gap-4">
                <div className="relative flex-1">
                  <div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
                    <Icons.Search />
                  </div>
                  <Input
                    className="pl-10"
                    placeholder={stash.isValidator ? "Search nominators..." : "Search validators..."}
                    value={searchQuery}
                    onChange={(e) => setSearchQuery(e.target.value)}
                  />
                </div>
                <div className="flex gap-2">
                  {(['all', 'active', 'inactive', 'waiting'] as const).map((filter) => (
                    <Button
                      key={filter}
                      variant={activeFilter === filter ? 'default' : 'outline'}
                      size="sm"
                      onClick={() => setActiveFilter(filter)}
                    >
                      {filter.charAt(0).toUpperCase() + filter.slice(1)}
                    </Button>
                  ))}
                </div>
              </div>

              <div className="space-y-2">
                {filteredNominations.map((nomination) => (
                  <div
                    key={nomination.id}
                    className="p-4 bg-white rounded-lg border hover:bg-gray-50 transition-colors"
                  >
                    <div className="flex items-center justify-between">
                      <div className="flex items-center gap-4">
                        <div className="w-8 h-8 bg-primary rounded-full flex items-center justify-center text-primary-foreground font-bold">
                          {nomination.name[0]}
                        </div>
                        <div>
                          <div className="font-medium">{nomination.name}</div>
                          <div className="text-sm text-muted-foreground">
                            Stake: {nomination.stake.toFixed(4)} KSM
                            {stash.isValidator ? (
                              nomination.isPool ? ' • Pool' : ' • Individual'
                            ) : (
                              ` • Commission: ${nomination.commission}%`
                            )}
                          </div>
                        </div>
                      </div>
                      <div className="flex items-center gap-2">
                        <Badge className={statusColors[nomination.status]}>
                          {nomination.status.charAt(0).toUpperCase() + nomination.status.slice(1)}
                        </Badge>
                        <DropdownMenu>
                          <DropdownMenuTrigger asChild>
                            <Button variant="ghost" size="sm">
                              <Icons.MoreVertical />
                            </Button>
                          </DropdownMenuTrigger>
                          <DropdownMenuContent align="end">
                            <DropdownMenuItem onClick={() => onUpdateNomination(nomination.id, 'view')}>
                              View Details
                            </DropdownMenuItem>
                            {!stash.isValidator && (
                              <DropdownMenuItem onClick={() => onUpdateNomination(nomination.id, 'change')}>
                                Change Stake
                              </DropdownMenuItem>
                            )}
                            <DropdownMenuSeparator />
                            <DropdownMenuItem
                              className="text-red-600"
                              onClick={() => onUpdateNomination(nomination.id, 'remove')}
                            >
                              {stash.isValidator ? 'Remove Nominator' : 'Remove Nomination'}
                            </DropdownMenuItem>
                          </DropdownMenuContent>
                        </DropdownMenu>
                      </div>
                    </div>
                  </div>
                ))}
              </div>
            </div>
          )}
        </CardContent>
      </Card>
    </>
  );
};

const StakingDashboard = () => {
  const [stashes, setStashes] = useState(mockData.stashes);
  const [activeFilter, setActiveFilter] = useState<StashFilter>('all');
  const [searchQuery, setSearchQuery] = useState('');

  // Filter handlers
  const filteredStashes = stashes.filter(stash => {
    const matchesSearch =
      stash.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
      stash.tag.toLowerCase().includes(searchQuery.toLowerCase()) ||
      stash.controller.name.toLowerCase().includes(searchQuery.toLowerCase());

    switch (activeFilter) {
      case 'stashed':
        return matchesSearch && stash.staked;
      case 'pooled':
        return matchesSearch && stash.pooled;
      case 'nominators':
        return matchesSearch && !stash.isValidator && stash.nominations.length > 0;
      case 'validators':
        return matchesSearch && stash.isValidator;
      case 'inactive':
        return matchesSearch && !stash.staked;
      default:
        return matchesSearch;
    }
  });

  // Action handlers
  const handleAddNew = async (type: 'nominator' | 'validator' | 'stash') => {
    // Will be replaced with actual API call
    console.log(`Adding new ${type}`);
  };

  const handleStopStaking = async (stashId: string) => {
    // Will be replaced with actual API call
    console.log(`Stopping staking for ${stashId}`);
  };

  const handleUpdateNomination = async (stashId: string, nominationId: string, action: string) => {
    // Will be replaced with actual API call
    console.log(`Updating nomination ${nominationId} of stash ${stashId} with action: ${action}`);
  };

  return (
    <div className="w-full max-w-[1200px] mx-auto px-4">
      {/* Navigation */}
      <div className="mb-8">
        <div className="flex items-center gap-2 mb-6">
          <div className="flex items-center gap-2">
            <div className="w-6 h-6 rounded-full bg-primary" />
            <span className="font-medium">Staking</span>
          </div>
          <Icons.ChevronDown />
        </div>

        <div className="overflow-x-auto -mx-4 px-4">
          <nav className="flex border-b min-w-max">
            <Button variant="ghost" className="border-b-2 border-primary -mb-[2px]">
              Accounts
            </Button>
            <Button variant="ghost">Overview</Button>
            <Button variant="ghost">Payouts</Button>
            <Button variant="ghost">Pools</Button>
            <Button variant="ghost">Targets</Button>
            <Button variant="ghost">Bags</Button>
            <Button variant="ghost">Slashes</Button>
            <Button variant="ghost">Validator stats</Button>
          </nav>
        </div>
      </div>

      {/* Warning Banner */}
      <Alert className="mb-6 bg-yellow-50 border-yellow-200">
        <AlertDescription className="text-yellow-800">
          <h4 className="font-semibold mb-2">Nomination Pools are evolving!</h4>
          <p>Soon you will be able to participate in a pool and in OpenGov with your pooled funds!</p>
          <p className="mt-2 text-sm">
            You do not need to do anything, unless you are participating in a pool and also staking solo from the same account.
          </p>
        </AlertDescription>
      </Alert>

      {/* Search and Filters */}
      <div className="flex flex-col sm:flex-row gap-4 mb-6">
        <div className="relative flex-1">
          <div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
            <Icons.Search />
          </div>
          <Input
            className="pl-10"
            placeholder="Search accounts..."
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
          />
        </div>

        <div className="flex gap-2">
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="outline" className="min-w-[120px]">
                {activeFilter === 'all' ? 'All stashes' :
                  activeFilter.charAt(0).toUpperCase() + activeFilter.slice(1)}
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent>
              <DropdownMenuItem onClick={() => setActiveFilter('all')}>
                All stashes
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => setActiveFilter('stashed')}>
                Stashed
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => setActiveFilter('pooled')}>
                Pooled
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => setActiveFilter('nominators')}>
                Nominators
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => setActiveFilter('validators')}>
                Validators
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => setActiveFilter('inactive')}>
                Inactive
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>

          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button>Add New</Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuItem onClick={() => handleAddNew('nominator')}>
                New Nominator
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => handleAddNew('validator')}>
                New Validator
              </DropdownMenuItem>
              <DropdownMenuItem onClick={() => handleAddNew('stash')}>
                New Stash
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        </div>
      </div>

      {/* Stashes List */}
      <div className="space-y-6">
        {filteredStashes.length === 0 ? (
          <div className="p-8 text-center text-gray-500 bg-white rounded-lg border">
            No stashes found matching your criteria
          </div>
        ) : (
          filteredStashes.map((stash) => (
            <StashCard
              key={stash.id}
              stash={stash}
              onStop={() => handleStopStaking(stash.id)}
              onUpdateNomination={(nominationId, action) =>
                handleUpdateNomination(stash.id, nominationId, action)
              }
            />
          ))
        )}
      </div>
    </div>
  );
};

export default StakingDashboard;

@tien
Copy link
Owner

tien commented Nov 27, 2024

Thanks for taking the time in making that mockup UI!
Yes I'll take a look at implementing this feature in my free time. Could be a great example of how features can be easily implemented with ReactiveDOT 👌

@tien
Copy link
Owner

tien commented Dec 8, 2024

I've created a "readonly" staking panel via #151
Will look into adding management functionalities next week once I've got some time freed up.

@tien
Copy link
Owner

tien commented Dec 8, 2024

This can be found under https://dotconsole.app/accounts/validators

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants