569 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|