ghost-lite/src/containers/Nominations.tsx
Uncle Fatso a0a076b6dc
update app based on new chain metadata
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
2025-12-01 17:20:40 +03:00

569 lines
24 KiB
TypeScript

import React, { useEffect, useState, useMemo } from "react"
import { Nut, NutOff, Users, PiggyBank, RefreshCcw } from "lucide-react"
import { ss58Decode } from "@polkadot-labs/hdkd-helpers"
import {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectGroup,
SelectItem,
} from "../components/ui/select"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../components/ui/accordion"
import { Checkbox } from "../components/ui/checkbox"
import { Input } from "../components/ui/input"
import { Button } from "../components/ui/button"
import {
useChainSpecV1,
useEraIndex,
useNominations,
useLedger,
usePayee,
useSlasingSpans,
useEraRewardPoints,
useCurrentValidators,
useValidatorsOverview,
useBondedAddress,
useNominateCalldata,
useWithdrawCalldata,
usePayeeCalldata,
useSystemAccount,
useBondCalldata,
useUnbondCalldata,
useBondExtraCalldata,
useUnstableProvider,
useTransactionStatusProvider,
RewardPoints,
Unlocking,
RewardDestination,
} from "../hooks"
import { AddressBookRecord } from "../types"
import { Sender } from "./Accounts"
import { Row } from "./Row"
interface ItemProps {
name: string | undefined
address: string
symbol: string
points: number
commission: number
nominatorCount: number
decimals: number
totalStake: bigint
ownStake: bigint
blocked: boolean
nominated: boolean
checkedAddresses: string[]
setIsCheckedAddresses: React.Dispatch<React.SetStateAction<string[]>>
}
const Item: React.FC<ItemProps> = (props) => {
const {
name,
address,
points,
commission,
blocked,
nominatorCount,
totalStake,
ownStake,
decimals,
symbol,
nominated,
checkedAddresses,
setIsCheckedAddresses,
} = props
const [copied, setCopied] = useState<boolean>(false)
const convertToFixed = (value: bigint, decimals: number) => {
if (!value || !decimals) {
return parseFloat("0").toFixed(5)
}
const number = Number(value) / Math.pow(10, decimals)
return parseFloat(number.toString()).toFixed(5)
}
const handleCopy = (textToCopy: string) => {
if (!textToCopy) return
navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
const handleOnCheck = () => {
setIsCheckedAddresses((prev: string[]) => {
if (address && prev.includes(address ?? "")) {
return prev.filter(item => item !== address)
} else {
return [...prev, address]
}
})
}
return (
<AccordionItem className="bg-muted rounded px-4 flex flew-row items-center justify-between" value={address}>
<Checkbox checked={checkedAddresses.includes(address)} onCheckedChange={handleOnCheck} />
<div>
<AccordionTrigger>
<div className="w-100 flex flex-row items-center justify-start gap-4 space-x-2 cursor-pointer">
<div className={`w-[300px] overflow-hidden whitespace-nowrap text-ellipsis text-left ${nominated ? "text-foreground" : ""}`}>
{name ?? (address.slice(0, 15) + "..." + address.slice(-15))}
</div>
<div>{points}</div>
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2">
<hr className="my-4" />
<Row title="Commission" element={<Input
readOnly
aria-label="Validator Commission"
type="text"
className="sm:w-[320px] w-full"
placeholder={`${(commission / 10000000).toFixed(2)}%`}
/>} />
<Row title="Nominators" element={<Input
readOnly
aria-label="Validator Nominators"
type="text"
className="sm:w-[320px] w-full"
placeholder={nominatorCount.toString()}
/>} />
<Row title="Status" element={<Input
readOnly
aria-label="Validator Status"
type="text"
className="sm:w-[320px] w-full"
placeholder={blocked ? "Blocked" : "Available"}
/>} />
<hr className="my-4" />
<Row title="Total Stake" element={<Input
readOnly
aria-label="Validator Total Stake"
type="text"
className="sm:w-[320px] w-full"
placeholder={`${convertToFixed(totalStake, decimals)} ${symbol}`}
/>} />
<Row title="Own Stake" element={<Input
readOnly
aria-label="Validator Own Stake"
type="text"
className="sm:w-[320px] w-full"
placeholder={`${convertToFixed(ownStake, decimals)} ${symbol}`}
/>} />
<hr className="my-4" />
<div
onClick={() => handleCopy(address)}
className="flex justify-center items-center cursor-pointer hover:text-foreground"
>
{copied ? "Address copid to clipboard" : address}
</div>
</AccordionContent>
</div>
</AccordionItem>
)
}
const HeaderInfo = ({ text, value }: { text: string, value: string }) => {
return (
<div className="w-[40%] flex flex-col justify-center items-center">
<span>{text}</span>
<span>{value}</span>
</div>
)
}
export const Nominations = () => {
const addressBook: AddressBookRecord[] = JSON.parse(localStorage.getItem('addressBook') ?? '[]')
const [checkedAddresses, setIsCheckedAddresses] = useState<string[]>([])
const [interestingValidator, setInterestingValidator] = useState<string | undefined>(undefined)
const [amount, setAmount] = useState<string>("")
const [destinationReceiver, setDestinationReceiver] = useState<string>("")
const [expectedPayee, setExpectedPayee] = useState<string>("")
const {
setTransactionStatus,
setError,
isSubmittingTransaction,
handleTransaction,
renderStatus,
} = useTransactionStatusProvider()
const { account, accounts, connectAccount } = useUnstableProvider()
const chainSpecV1 = useChainSpecV1()
const eraIndex = useEraIndex()
const nominations = useNominations({ address: account?.address })
const ledger = useLedger({ address: account?.address })
const payee = usePayee({ address: account?.address })
const slashingSpans = useSlasingSpans({ address: account?.address })
const eraRewardPoints = useEraRewardPoints({ eraIndex: eraIndex?.index })
const currentValidators = useCurrentValidators({ address: interestingValidator })
const validatorOverview = useValidatorsOverview({ eraIndex: eraIndex?.index, address: interestingValidator })
console.log(eraRewardPoints)
const tokenDecimals: number = chainSpecV1?.properties?.tokenDecimals ?? 0
const tokenSymbol: string = chainSpecV1?.properties?.tokenSymbol ?? ""
const ss58Format: number = chainSpecV1?.properties?.ss58Format ?? 1995
const senderAccount = useSystemAccount({
account: account
? account.address
: undefined
})
const destinationReceiverIsValid = useMemo(() => {
try {
const [, prefix] = ss58Decode(destinationReceiver)
if (prefix !== ss58Format) {
throw new Error("bad prefix")
}
return true
} catch {
return false
}
}, [destinationReceiver, ss58Format])
const convertedAmount = useMemo(() => {
try {
return BigInt(Number(amount) * Math.pow(10, tokenDecimals))
} catch {
return 0n
}
}, [amount, tokenDecimals])
const bondedAddress = useBondedAddress({ address: account?.address })
const payeeCalldata = usePayeeCalldata(expectedPayee, destinationReceiver)
const withdrawCalldata = useWithdrawCalldata((slashingSpans?.prior?.length ?? 0) + 1)
const nominateCalldata = useNominateCalldata(checkedAddresses)
const bondExtraCalldata = useBondExtraCalldata(
convertedAmount > 0n ? convertedAmount : undefined
)
const bondCalldata = useBondCalldata(
convertedAmount > 0n ? convertedAmount : undefined
)
const unbondCalldata = useUnbondCalldata(
convertedAmount > 0n ? convertedAmount : undefined
)
const payeeDescription = (destination: string | undefined) => {
let description = "Unknown reward destination"
switch (destination) {
case "Staked":
description = "Re-stake upcoming rewards"
break
case "Stash":
description = "Withdraw rewards to free"
break
case "Account":
description = `Rewards to account`
break
case "None":
description = "Refuse to receive rewards"
break
}
return description
}
const readyToWithdraw = useMemo(() => {
return ledger?.unlocking.reduce((acc: bigint, item: Unlocking) => {
if ((eraIndex?.index ?? 0) >= item.era) {
return item.value
}
return 0n
}, 0n)
}, [ledger, eraIndex])
const waitingForWithdraw = useMemo(() => {
return ledger?.unlocking.reduce((acc: bigint, item: Unlocking) => {
if ((eraIndex?.index ?? 0) < item.era) {
return item.value
}
return 0n
}, 0n)
}, [ledger, eraIndex])
const latestWithdrawEra = useMemo(() => {
if (!ledger || ledger.unlocking.length === 0) {
return 0
}
const index = eraIndex?.index ?? 0
return Math.max(...ledger.unlocking.map((el: Unlocking) => el.era - index), 0)
}, [eraIndex, ledger])
useEffect(() => {
setIsCheckedAddresses([])
}, [account])
useEffect(() => {
if (expectedPayee === "") setExpectedPayee(payee?.type ?? "")
}, [expectedPayee, setExpectedPayee, payee])
useEffect(() => {
if (checkedAddresses.length === 0 && nominations) {
setIsCheckedAddresses(nominations?.targets ?? [])
}
}, [checkedAddresses, nominations])
useEffect(() => {
if (amount !== "") {
setError(undefined)
setTransactionStatus(undefined)
}
}, [amount, setError, setTransactionStatus])
useEffect(() => {
if (!isSubmittingTransaction) {
setAmount("")
}
}, [isSubmittingTransaction])
useEffect(() => {
setDestinationReceiver("")
}, [expectedPayee])
const applyDecimals = (value = 0n, decimals = 0, tokenSymbol = "CSPR") => {
if (!value) return `0 ${tokenSymbol}`
const numberValue = Number(value) / Math.pow(10, decimals)
const formatter = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 6,
maximumFractionDigits: 6,
})
return `${formatter.format(numberValue)} ${tokenSymbol}`
}
const handleOnBond = () => handleTransaction({ calldata: bondCalldata, txName: "bond" })
const handleOnExtraBond = () => handleTransaction({ calldata: bondExtraCalldata, txName: "bond_extra" })
const handleOnUnbond = () => handleTransaction({ calldata: unbondCalldata, txName: "unbond" })
const handleOnNominate = () => handleTransaction({ calldata: nominateCalldata, txName: "nominate" })
const handleOnWithdraw = () => handleTransaction({ calldata: withdrawCalldata, txName: "withdraw" })
const handleOnSetPayee = () => handleTransaction({ calldata: payeeCalldata, txName: "set_payee" })
return (
<div className="sm:w-[500px] w-[85%] h-fit flex flex-col flex-1 gap-8 justify-center self-center rounded py-2">
<div className="bg-muted p-4 rounded flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<HeaderInfo text="Current Era" value={`#${eraIndex?.index.toString() ?? "..."}`} />
<HeaderInfo text="Total Points" value={eraRewardPoints?.total.toString() ?? "..."} />
{nominations && (
<HeaderInfo text="Submitted at" value={`#${nominations?.submitted_in.toString() ?? "..."}`} />
)}
</div>
<hr />
<Sender
account={account?.address ?? ""}
accounts={accounts?.map(acc => acc?.address ?? "") ?? []}
senderAccount={senderAccount}
senderBalance={applyDecimals(senderAccount?.data.free ?? 0n, tokenDecimals, tokenSymbol)}
tokenDecimals={tokenDecimals}
tokenSymbol={tokenSymbol}
connectAccount={connectAccount}
applyDecimals={applyDecimals}
/>
{ledger && ledger.total > 0n && (<Accordion type="multiple" className="w-full flex flex-col gap-4 mb-4">
<AccordionItem className="bg-muted rounded text-sm" value="Bonding Details">
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
Bonding Details
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pl-8">
{payee && (<Row title="Destination" element={<Select
value={expectedPayee}
onValueChange={(payeeType) => setExpectedPayee(payeeType)}
>
<SelectTrigger
className={"text-muted-foreground sm:w-[300px] w-full"}
data-testid="destination-select"
>
<SelectValue placeholder="Select Destination" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.keys(RewardDestination()).map((destinationType, index) => (
<SelectItem
key={index}
data-testid={`destination-${destinationType}`}
value={destinationType}
>
{payeeDescription(destinationType)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
} />)}
{payee?.type === "Account" && (
<Row title="" element={<Input
readOnly
aria-label="Destination Account Address"
type="text"
className="sm:w-[300px] w-full"
placeholder={payee?.value}
/>} />
)}
{expectedPayee !== payee?.type && expectedPayee === "Account" && (
<Row title="Receiver" element={<Input
aria-label="Destination Account"
type="text"
className="sm:w-[300px] w-full"
placeholder="Input destination account"
onChange={e => setDestinationReceiver(e.target.value)}
value={destinationReceiver}
/>} />
)}
{expectedPayee !== payee?.type && (
<Button
type="button"
variant="secondary"
className="text-sm my-4 w-full"
onClick={handleOnSetPayee}
disabled={
isSubmittingTransaction
|| !payeeCalldata
|| (expectedPayee === "Account" && !destinationReceiverIsValid)
}
>
<RefreshCcw className="w-4 h-4 inline-block mr-2" />
Change Destination
</Button>
)}
<Row title="Total Bond" element={<Input
readOnly
aria-label="Total Bond"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(ledger?.total, tokenDecimals, tokenSymbol)}
/>} />
<Row title="Active Bond" element={<Input
readOnly
aria-label="Active Bond"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(ledger?.active, tokenDecimals, tokenSymbol)}
/>} />
{ledger && ledger.unlocking.length > 0 && (
<>
<hr className="my-2" />
<Row title="Ready" element={<Input
readOnly
aria-label="Ready"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(readyToWithdraw, tokenDecimals, tokenSymbol)}
/>} />
{latestWithdrawEra > 0 && (<Row title={`After ${latestWithdrawEra} era${latestWithdrawEra > 1 ? "s" : ""}`} element={<Input
readOnly
aria-label="Pending"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(waitingForWithdraw, tokenDecimals, tokenSymbol)}
/>} />)}
<Button
type="button"
variant="secondary"
className="text-sm my-4 w-full"
onClick={handleOnWithdraw}
disabled={isSubmittingTransaction || readyToWithdraw === 0n}
>
<PiggyBank className="w-4 h-4 inline-block mr-2" />
Withdraw
</Button>
</>
)}
</AccordionContent>
</AccordionItem>
</Accordion>)}
<Input
value={amount}
onChange={e => setAmount(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Amount"
type="text"
className="w-full"
placeholder="Input amount to bond or unbond"
/>
<div className="flex justify-between gap-2">
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={bondedAddress ? handleOnExtraBond : handleOnBond}
disabled={isSubmittingTransaction || convertedAmount === 0n}
>
<Nut className="w-4 h-4 inline-block mr-2" />
Bond
</Button>
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={handleOnUnbond}
disabled={isSubmittingTransaction || convertedAmount === 0n}
>
<NutOff className="w-4 h-4 inline-block mr-2" />
Unbond
</Button>
</div>
<div>
{bondedAddress && (
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={handleOnNominate}
disabled={isSubmittingTransaction || checkedAddresses.length === 0}
>
<Users className="w-4 h-4 inline-block mr-2" />
Nominate
</Button>
)}
</div>
{eraRewardPoints?.individual.some((indivial: RewardPoints) => indivial?.at(0) === account?.address) && (
<p className="text-xs text-destructive">
You are attempting to use the nomination functionality from the current validator account, which is mutually exclusive. Please switch accounts or proceed at your own risk.
</p>
)}
{renderStatus()}
</div>
{eraRewardPoints && (
<Accordion collapsible onValueChange={setInterestingValidator} type="single" className="sm:w-[500px] w-[85%] h-fit flex flex-col flex-1 gap-4 justify-center self-center sm:text-base text-xs">
{eraRewardPoints?.individual.map((indivial: RewardPoints, idx: number) => (
<Item
key={idx}
name={addressBook?.find((record: AddressBookRecord) => record.address === indivial.at(0))?.name}
address={indivial.at(0) as string ?? ""}
points={indivial.at(1) as number ?? 0}
commission={currentValidators?.commission ?? 0}
blocked={currentValidators?.blocked ?? false}
totalStake={validatorOverview?.total ?? 0n}
ownStake={validatorOverview?.own ?? 0n}
nominatorCount={validatorOverview?.nominator_count ?? 0}
decimals={tokenDecimals ?? 18}
symbol={tokenSymbol ?? "CSPR"}
setIsCheckedAddresses={setIsCheckedAddresses}
checkedAddresses={checkedAddresses}
nominated={nominations?.targets.includes(indivial.at(0)) ?? false}
/>
))}
</Accordion>
)}
{!eraRewardPoints && (
<div className="flex justify-center items-center">
Waiting for validators list...
</div>
)}
</div>
)
}