Merge pull request #194 from cosmos/feat/validator-dropdown

Add validator dropdown
This commit is contained in:
Abel Fernández 2024-02-08 12:38:07 +01:00 committed by GitHub
commit cc86a74f35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 203 additions and 7 deletions

View File

@ -0,0 +1,83 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useChains } from "@/context/ChainsContext";
import { cn } from "@/lib/utils";
import { Check, ChevronsUpDown } from "lucide-react";
import { useState } from "react";
interface SelectValidatorProps {
readonly validatorAddress: string;
readonly setValidatorAddress: (validatorAddress: string) => void;
}
export default function SelectValidator({
validatorAddress,
setValidatorAddress,
}: SelectValidatorProps) {
const {
validatorState: { validators },
} = useChains();
const [open, setOpen] = useState(false);
const [searchText, setSearchText] = useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="mb-4 w-full max-w-[300px] justify-between border-white bg-fuchsia-900 hover:bg-fuchsia-900"
>
{validatorAddress
? validators?.find(
(validatorItem) => validatorAddress === validatorItem.operatorAddress,
)?.description.moniker || "Unknown validator"
: "Select validator…"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command className="bg-fuchsia-900">
<CommandInput
placeholder="Search validator…"
value={searchText}
onValueChange={setSearchText}
/>
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup className="max-h-[400px] overflow-y-auto">
{validators?.map((validatorItem) => (
<CommandItem
className="aria-selected:bg-fuchsia-800"
key={validatorItem.operatorAddress}
onSelect={() => {
setValidatorAddress(validatorItem.operatorAddress);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
validatorAddress === validatorItem.operatorAddress
? "opacity-100"
: "opacity-0",
)}
/>
{validatorItem.description.moniker}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -1,3 +1,4 @@
import SelectValidator from "@/components/SelectValidator";
import { MsgWithdrawDelegatorRewardEncodeObject } from "@cosmjs/stargate";
import { useEffect, useState } from "react";
import { MsgGetter } from "..";
@ -63,6 +64,10 @@ const MsgClaimRewardsForm = ({
</button>
<h2>MsgWithdrawDelegatorReward</h2>
<div className="form-item">
<SelectValidator
validatorAddress={validatorAddress}
setValidatorAddress={setValidatorAddress}
/>
<Input
label="Validator Address"
name="validator-address"

View File

@ -1,3 +1,4 @@
import SelectValidator from "@/components/SelectValidator";
import { MsgDelegateEncodeObject } from "@cosmjs/stargate";
import { useEffect, useState } from "react";
import { MsgGetter } from "..";
@ -90,6 +91,10 @@ const MsgDelegateForm = ({ delegatorAddress, setMsgGetter, deleteMsg }: MsgDeleg
</button>
<h2>MsgDelegate</h2>
<div className="form-item">
<SelectValidator
validatorAddress={validatorAddress}
setValidatorAddress={setValidatorAddress}
/>
<Input
label="Validator Address"
name="validator-address"

View File

@ -1,3 +1,4 @@
import SelectValidator from "@/components/SelectValidator";
import { EncodeObject } from "@cosmjs/proto-signing";
import { useEffect, useState } from "react";
import { MsgGetter } from "..";
@ -106,6 +107,10 @@ const MsgRedelegateForm = ({
</button>
<h2>MsgBeginRedelegate</h2>
<div className="form-item">
<SelectValidator
validatorAddress={validatorSrcAddress}
setValidatorAddress={setValidatorSrcAddress}
/>
<Input
label="Source Validator Address"
name="src-validator-address"
@ -119,6 +124,10 @@ const MsgRedelegateForm = ({
/>
</div>
<div className="form-item">
<SelectValidator
validatorAddress={validatorDstAddress}
setValidatorAddress={setValidatorDstAddress}
/>
<Input
label="Destination Validator Address"
name="dst-validator-address"

View File

@ -1,3 +1,4 @@
import SelectValidator from "@/components/SelectValidator";
import { MsgUndelegateEncodeObject } from "@cosmjs/stargate";
import { useEffect, useState } from "react";
import { MsgGetter } from "..";
@ -94,6 +95,10 @@ const MsgUndelegateForm = ({
</button>
<h2>MsgUndelegate</h2>
<div className="form-item">
<SelectValidator
validatorAddress={validatorAddress}
setValidatorAddress={setValidatorAddress}
/>
<Input
label="Validator Address"
name="validator-address"

View File

@ -1,3 +1,4 @@
import { loadValidators } from "@/context/ChainsContext/helpers";
import { EncodeObject } from "@cosmjs/proto-signing";
import { Account, calculateFee } from "@cosmjs/stargate";
import { assert } from "@cosmjs/utils";
@ -25,7 +26,11 @@ interface CreateTxFormProps {
}
const CreateTxForm = ({ router, senderAddress, accountOnChain }: CreateTxFormProps) => {
const { chain } = useChains();
const {
chain,
validatorState: { validators },
chainsDispatch,
} = useChains();
const [processing, setProcessing] = useState(false);
const [msgTypes, setMsgTypes] = useState<readonly MsgTypeUrl[]>([]);
@ -45,6 +50,14 @@ const CreateTxForm = ({ router, senderAddress, accountOnChain }: CreateTxFormPro
});
};
const addMsgWithValidator = (newMsgType: MsgTypeUrl) => {
if (!validators.length) {
loadValidators(chainsDispatch);
}
addMsgType(newMsgType);
};
const createTx = async () => {
try {
setShowTxError(false);
@ -185,15 +198,18 @@ const CreateTxForm = ({ router, senderAddress, accountOnChain }: CreateTxFormPro
<label>Staking</label>
<ul>
<li>
<Button label="Delegate" onClick={() => addMsgType(MsgTypeUrls.Delegate)} />
<Button label="Delegate" onClick={() => addMsgWithValidator(MsgTypeUrls.Delegate)} />
</li>
<li>
<Button label="Undelegate" onClick={() => addMsgType(MsgTypeUrls.Undelegate)} />
<Button
label="Undelegate"
onClick={() => addMsgWithValidator(MsgTypeUrls.Undelegate)}
/>
</li>
<li>
<Button
label="BeginRedelegate"
onClick={() => addMsgType(MsgTypeUrls.BeginRedelegate)}
onClick={() => addMsgWithValidator(MsgTypeUrls.BeginRedelegate)}
/>
</li>
</ul>
@ -201,7 +217,7 @@ const CreateTxForm = ({ router, senderAddress, accountOnChain }: CreateTxFormPro
<li>
<Button
label="WithdrawDelegatorReward"
onClick={() => addMsgType(MsgTypeUrls.WithdrawDelegatorReward)}
onClick={() => addMsgWithValidator(MsgTypeUrls.WithdrawDelegatorReward)}
/>
</li>
<li>

View File

@ -41,6 +41,10 @@ export const setChain = (dispatch: Dispatch, chain: ChainInfo) => {
dispatch({ type: "setChain", payload: chain });
};
export const loadValidators = (dispatch: Dispatch) => {
dispatch({ type: "loadValidators" });
};
export const setNewConnection = (dispatch: Dispatch, newConnection: NewConnection) => {
dispatch({ type: "setNewConnection", payload: newConnection });
};

View File

@ -1,3 +1,4 @@
import { getAllValidators } from "@/lib/staking";
import { ReactNode, createContext, useContext, useEffect, useReducer } from "react";
import { emptyChain, isChainInfoFilled, setChain, setChains, setChainsError } from "./helpers";
import { getChain, getNodeFromArray, useChainsFromRegistry } from "./service";
@ -6,7 +7,7 @@ import { Action, ChainsContextType, State } from "./types";
const ChainsContext = createContext<ChainsContextType | undefined>(undefined);
const chainsReducer = (state: State, action: Action) => {
const chainsReducer = (state: State, action: Action): State => {
switch (action.type) {
case "setChains": {
return { ...state, chains: action.payload };
@ -26,11 +27,23 @@ const chainsReducer = (state: State, action: Action) => {
addRecentChainNameInStorage(action.payload.registryName);
setChainInUrl(action.payload, state.chains);
return { ...state, chain: action.payload };
return {
...state,
chain: action.payload,
validatorState: { validators: [], status: "initial" },
};
}
case "addNodeAddress": {
return { ...state, chain: { ...state.chain, nodeAddress: action.payload } };
}
case "loadValidators": {
return state.validatorState.status === "initial"
? { ...state, validatorState: { ...state.validatorState, status: "loading" } }
: state;
}
case "setValidatorState": {
return { ...state, validatorState: action.payload };
}
case "setNewConnection": {
return { ...state, newConnection: action.payload };
}
@ -52,6 +65,7 @@ export const ChainsProvider = ({ children }: ChainsProviderProps) => {
chain: emptyChain,
chains: { mainnets: new Map(), testnets: new Map(), localnets: new Map() },
newConnection: { action: "edit" },
validatorState: { validators: [], status: "initial" },
});
const { chainItems, chainItemsError } = useChainsFromRegistry();
@ -78,6 +92,20 @@ export const ChainsProvider = ({ children }: ChainsProviderProps) => {
})();
}, [state.chain]);
useEffect(() => {
(async function loadValidators() {
if (state.validatorState.status === "loading" && state.chain.nodeAddress) {
try {
const validators = await getAllValidators(state.chain.nodeAddress);
dispatch({ type: "setValidatorState", payload: { validators, status: "done" } });
} catch (e) {
console.error(e instanceof Error ? e.message : "Failed to load validators");
dispatch({ type: "setValidatorState", payload: { validators: [], status: "error" } });
}
}
})();
}, [state.chain.nodeAddress, state.validatorState.status]);
return <ChainsContext.Provider value={{ state, dispatch }}>{children}</ChainsContext.Provider>;
};

View File

@ -1,3 +1,4 @@
import { Validator } from "cosmjs-types/cosmos/staking/v1beta1/staking";
import { RegistryAsset } from "../../types/chainRegistry";
export interface ChainsContextType {
@ -8,6 +9,7 @@ export interface ChainsContextType {
export interface State {
readonly chains: ChainItems;
readonly chain: ChainInfo;
readonly validatorState: ValidatorState;
readonly newConnection: NewConnection;
readonly chainsError?: string | null;
}
@ -36,6 +38,11 @@ export interface ChainInfo {
readonly explorerLinks: ExplorerLinks;
}
export interface ValidatorState {
readonly validators: readonly Validator[];
readonly status: "initial" | "loading" | "done" | "error";
}
export type ExplorerLinks = {
readonly tx: string;
readonly account: string;
@ -64,6 +71,13 @@ export type Action =
readonly type: "addNodeAddress";
readonly payload: string;
}
| {
readonly type: "loadValidators";
}
| {
readonly type: "setValidatorState";
readonly payload: ValidatorState;
}
| {
readonly type: "setNewConnection";
readonly payload: NewConnection;

25
lib/staking.ts Normal file
View File

@ -0,0 +1,25 @@
import { QueryClient, StakingExtension, setupStakingExtension } from "@cosmjs/stargate";
import { connectComet } from "@cosmjs/tendermint-rpc";
import { Validator } from "cosmjs-types/cosmos/staking/v1beta1/staking";
const getValidatorsPage = (
queryClient: QueryClient & StakingExtension,
paginationKey: Uint8Array | undefined,
) => queryClient.staking.validators("BOND_STATUS_BONDED", paginationKey);
export const getAllValidators = async (rpcUrl: string): Promise<readonly Validator[]> => {
const validators: Validator[] = [];
const cometClient = await connectComet(rpcUrl);
const queryClient = QueryClient.withExtensions(cometClient, setupStakingExtension);
let paginationKey: Uint8Array | undefined = undefined;
do {
const response = await getValidatorsPage(queryClient, paginationKey);
validators.push(...response.validators);
paginationKey = response.pagination?.nextKey;
} while (paginationKey?.length);
return validators;
};

1
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@cosmjs/math": "^0.32.2",
"@cosmjs/proto-signing": "^0.32.2",
"@cosmjs/stargate": "^0.32.2",
"@cosmjs/tendermint-rpc": "^0.32.2",
"@cosmjs/utils": "^0.32.2",
"@hookform/resolvers": "^3.3.1",
"@keplr-wallet/types": "^0.12.23",

View File

@ -20,6 +20,7 @@
"@cosmjs/math": "^0.32.2",
"@cosmjs/proto-signing": "^0.32.2",
"@cosmjs/stargate": "^0.32.2",
"@cosmjs/tendermint-rpc": "^0.32.2",
"@cosmjs/utils": "^0.32.2",
"@hookform/resolvers": "^3.3.1",
"@keplr-wallet/types": "^0.12.23",