nominators tab added with bond and nominate functionality

Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
This commit is contained in:
Uncle Fatso 2025-08-16 20:03:52 +03:00
parent af7ca0da51
commit 5b32bd5500
Signed by: f4ts0
GPG Key ID: 565F4F2860226EBB
22 changed files with 1188 additions and 249 deletions

View File

@ -1,6 +1,6 @@
{
"name": "ghost-lite",
"version": "0.1.0",
"version": "0.1.1",
"description": "Web application for Ghost and Casper chain.",
"author": "Uncle f4ts0 <f4ts0@ghostchain.io>",
"maintainers": [
@ -29,6 +29,7 @@
"@polkadot-api/view-builder": "~0.4.3",
"@polkadot-labs/hdkd-helpers": "^0.0.11",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@substrate/connect-discovery": "^0.2.2",

View File

@ -35,6 +35,9 @@ importers:
'@radix-ui/react-accordion':
specifier: ^1.2.3
version: 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-select':
specifier: ^2.1.6
version: 2.2.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -1346,6 +1349,9 @@ packages:
'@radix-ui/primitive@1.1.2':
resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-accordion@1.2.11':
resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==}
peerDependencies:
@ -1372,6 +1378,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.11':
resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==}
peerDependencies:
@ -1508,6 +1527,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
@ -5528,6 +5560,8 @@ snapshots:
'@radix-ui/primitive@1.1.2': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -5554,6 +5588,22 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-collapsible@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -5675,6 +5725,16 @@ snapshots:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.23)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.23
'@types/react-dom': 18.3.7(@types/react@18.3.23)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.23)(react@18.3.1)

View File

@ -12,6 +12,8 @@ export const Header = () => {
return "Transactions"
case "book":
return "Address Book"
case "nominations":
return "Nominations"
default:
return "Health Check";
}

View File

@ -1,4 +1,4 @@
import { HeartPulse, SendToBack, Book } from "lucide-react"
import { HeartPulse, SendToBack, Book, Users } from "lucide-react"
import { FaGithub } from "react-icons/fa"
import { Link, useLocation } from "react-router-dom"
import { useEffect } from "react"
@ -118,6 +118,12 @@ export const Sidebar = () => {
<span className={`md:block hidden ${cName("title", currentPath, "book")}`} >Address Book</span>
</div>
</Link>
<Link to="/nominations" className="relative">
<div className={cName("item", currentPath, "nominations")}>
<Users className={cName("icon", currentPath, "nominations")} />
<span className={`md:block hidden ${cName("title", currentPath, "nominations")}`} >Nominations</span>
</div>
</Link>
</ul>
<div className="w-full text-center flex-grow flex flex-col justify-end">

View File

@ -0,0 +1,31 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "../../lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:bg-accent data-[state=checked]:border-secondary data-[state=checked]:text-secondary",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

215
src/containers/Accounts.tsx Normal file
View File

@ -0,0 +1,215 @@
import React from "react"
import { Unstable } from "@substrate/connect-discovery"
import {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectGroup,
SelectItem,
} from "../components/ui/select"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../components/ui/accordion"
import { Input } from "../components/ui/input"
import type { SystemAccountStorage } from "../hooks"
import { Row } from "./Row"
interface SenderProps {
account: string
accounts: string[]
senderAccount: SystemAccountStorage | undefined
senderBalance: string
tokenDecimals: number
tokenSymbol: string
connectAccount: (account: Unstable.Account) => void
applyDecimals: (value: bigint, decimals: number, tokenSymbol: string) => string
}
export const Sender: React.FC<SenderProps> = ({
account,
accounts,
senderAccount,
senderBalance,
tokenDecimals,
tokenSymbol,
connectAccount,
applyDecimals
}) => {
return (
<>
<Row title="Account" element={<Select
value={account}
disabled={!accounts || accounts.length === 0}
onValueChange={(address) => {
try {
const unstableAccount: Unstable.Account = { address }
connectAccount(unstableAccount)
} catch (e) {
console.log(e)
}
}}
>
<SelectTrigger
className={"text-muted-foreground sm:w-[300px] w-full"}
data-testid="chain-select"
>
<SelectValue placeholder="Select Account" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{accounts?.map((address, index) => (
<SelectItem
key={index}
data-testid={`address-${address}`}
value={address}>{address.slice(0, 10)}...{address.slice(-10)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>} />
<Row title="Balance" element={<Input
readOnly
aria-label="Account Balance"
type="text"
className="sm:w-[300px] w-full"
placeholder={senderBalance}
/>} />
{senderAccount && (<Accordion type="multiple" className="w-full flex flex-col gap-4 mb-4">
<AccordionItem className="bg-muted rounded text-sm" value="Balance Details">
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
Balance Details
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pl-8">
<Row title={"Free"} element={<Input
readOnly
aria-label="Free"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(
senderAccount?.data?.free - senderAccount?.data?.frozen - senderAccount?.data?.reserved,
tokenDecimals,
tokenSymbol
)}
/>} />
<Row title={"Frozen"} element={<Input
readOnly
aria-label="Frozen"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(senderAccount?.data?.frozen, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Reserved"} element={<Input
readOnly
aria-label="Reserved"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(senderAccount?.data?.reserved, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Nonce"} element={<Input
readOnly
aria-label="Nonce"
type="text"
className="sm:w-[300px] w-full"
placeholder={senderAccount?.nonce ? senderAccount.nonce.toString() : ""}
/>} />
</AccordionContent>
</AccordionItem>
</Accordion>)}
</>
)
}
interface ReceiverProps {
receiver: string
receiverAccount: SystemAccountStorage | undefined
amount: string
tokenDecimals: number
tokenSymbol: string
isSubmittingTransaction: boolean
setReceiver: (receiver: string) => void
setAmount: (amount: string) => void
applyDecimals: (value: bigint, decimals: number, tokenSymbol: string) => string
}
export const Receiver: React.FC<ReceiverProps> = ({
receiver,
receiverAccount,
amount,
tokenDecimals,
tokenSymbol,
isSubmittingTransaction,
setReceiver,
setAmount,
applyDecimals
}) => {
return (
<>
<Row title="Receiver" element={<Input
value={receiver}
onChange={e => setReceiver(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Receiver"
type="text"
className="sm:w-[300px] w-full"
placeholder="Input receiver address"
/>} />
<Row title="Amount" element={<Input
value={amount}
onChange={e => setAmount(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Amount"
type="text"
className="sm:w-[300px] w-full"
placeholder="Input amount to send"
/>} />
{receiverAccount && (<Accordion type="multiple" className="w-full flex flex-col gap-4">
<AccordionItem className="bg-muted rounded text-sm" value="Balance Details">
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
Receiver Details
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pl-8">
<Row title={"Free"} element={<Input
readOnly
aria-label="Fee"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(receiverAccount?.data?.free, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Frozen"} element={<Input
readOnly
aria-label="Frozen"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(receiverAccount?.data?.frozen, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Reserved"} element={<Input
readOnly
aria-label="Reserved"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(receiverAccount?.data?.reserved, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Nonce"} element={<Input
readOnly
aria-label="Nonce"
type="text"
className="sm:w-[300px] w-full"
placeholder={receiverAccount?.nonce ? receiverAccount.nonce.toString() : ""}
/>} />
</AccordionContent>
</AccordionItem>
</Accordion>)}
</>
)
}

View File

@ -12,10 +12,7 @@ import {
import { Input } from "../components/ui/input"
import { Button } from "../components/ui/button"
type AddressBookRecord = {
name: string
address: string
}
import { AddressBookRecord } from "../types"
interface AddressRecordProps {
name: string

View File

@ -7,6 +7,7 @@ import { DEFAULT_CHAIN_ID } from "../settings"
const HealthCheck = lazy(() => import("./HealthCheck").then(module => ({ default: module.HealthCheck })))
const Transactions = lazy(() => import("./Transactions").then(module => ({ default: module.Transactions })))
const Nominations = lazy(() => import("./Nominations").then(module => ({ default: module.Nominations })))
const AddressBook = lazy(() => import("./AddressBook").then(module => ({ default: module.AddressBook })))
export const App = () => {
@ -23,6 +24,7 @@ export const App = () => {
<Route path="/health" element={<HealthCheck />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/book" element={<AddressBook />} />
<Route path="/nominations" element={<Nominations />} />
<Route path="*" element={<Navigate to="/health" replace />} />
</Routes>
</div>

View File

@ -0,0 +1,470 @@
import React, { useEffect, useState, useMemo, useCallback } from "react"
import { Nut, NutOff, Users } from "lucide-react"
import { ss58Decode } from "@polkadot-labs/hdkd-helpers"
import { toHex } from "@polkadot-api/utils"
import { lastValueFrom, tap } from "rxjs"
import { submitTransaction$ } from "../api"
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,
useMetadata,
useEraIndex,
useNominations,
useEraRewardPoints,
useCurrentValidators,
useValidatorsOverview,
useBondedAddress,
useNominateCalldata,
useSystemAccount,
useBondCalldata,
useUnstableProvider,
RewardPoints,
} from "../hooks"
import {
AddressBookRecord,
FollowTransaction,
TransactionError,
} 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 power = Math.pow(10, decimals)
const number = Number(value / BigInt(power))
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}</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 [isSubmittingTransaction, setIsSubmittingTransaction] = useState<boolean>(false)
const [transactionStatus, setTransactionStatus] = useState<FollowTransaction | undefined>()
const [error, setError] = useState<TransactionError | undefined>()
const [interestingValidator, setInterestingValidator] = useState<string | undefined>(undefined)
const [amount, setAmount] = useState<string>("")
const {
provider,
clientFull,
chainId,
account,
accounts,
connectAccount
} = useUnstableProvider()
const metadata = useMetadata()
const chainSpecV1 = useChainSpecV1()
const eraIndex = useEraIndex()
const nominations = useNominations({ address: account?.address })
const eraRewardPoints = useEraRewardPoints({ eraIndex: eraIndex?.index })
const currentValidators = useCurrentValidators({ address: interestingValidator })
const validatorOverview = useValidatorsOverview({ eraIndex: eraIndex?.index, address: interestingValidator })
const tokenDecimals: number = chainSpecV1?.properties?.tokenDecimals ?? 0
const tokenSymbol: string = chainSpecV1?.properties?.tokenSymbol ?? ""
const senderAccount = useSystemAccount({
account: account
? account.address
: undefined
})
const convertedAmount = useMemo(() => {
try {
return BigInt(Number(amount) * Math.pow(10, tokenDecimals))
} catch {
return 0n
}
}, [amount, tokenDecimals])
const bondedAddress = useBondedAddress({ address: account?.address })
const nominateCalldata = useNominateCalldata(checkedAddresses)
const bondCalldata = useBondCalldata(
convertedAmount > 0n ? convertedAmount : undefined
)
useEffect(() => {
if (checkedAddresses.length === 0 && nominations) {
setIsCheckedAddresses(nominations?.targets ?? [])
}
}, [checkedAddresses, nominations])
useEffect(() => {
setError(undefined)
setTransactionStatus(undefined)
}, [amount])
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 = useCallback(async () => {
setIsSubmittingTransaction(true)
setTransactionStatus(undefined)
setError(undefined)
try {
const tx = await provider!.createTx(
chainId ?? "",
account ? toHex(ss58Decode(account.address)[0]) : "",
bondCalldata ?? ""
)
await lastValueFrom(
submitTransaction$(clientFull, tx)
.pipe(
tap(({ txEvent }) => {
let status: string = ""
switch (txEvent.type) {
case "broadcasted":
status = "broadcasted to available peers"
break
case "txBestBlocksState":
status = `included in block #${txEvent.block.number}`
break
case "finalized":
status = `finalized at block #${txEvent.block.number}`
break
case "throttled":
status = "throttling to detect chain head..."
break
}
setTransactionStatus({
status,
hash: txEvent.block?.hash,
})
}),
),
)
} catch (err) {
if (err instanceof Error) {
const currentError = { type: "error", error: err.message }
setError(currentError)
}
console.error(err)
} finally {
setAmount("")
setIsSubmittingTransaction(false)
}
}, [account, bondCalldata, chainId, clientFull, provider])
const handleOnNominate = useCallback(async () => {
setIsSubmittingTransaction(true)
setTransactionStatus(undefined)
setError(undefined)
try {
const tx = await provider!.createTx(
chainId ?? "",
account ? toHex(ss58Decode(account.address)[0]) : "",
nominateCalldata ?? ""
)
await lastValueFrom(
submitTransaction$(clientFull, tx)
.pipe(
tap(({ txEvent }) => {
let status: string = ""
switch (txEvent.type) {
case "broadcasted":
status = "broadcasted to available peers"
break
case "txBestBlocksState":
status = `included in block #${txEvent.block.number}`
break
case "finalized":
status = `finalized at block #${txEvent.block.number}`
break
case "throttled":
status = "throttling to detect chain head..."
break
}
setTransactionStatus({
status,
hash: txEvent.block?.hash,
})
}),
),
)
} catch (err) {
if (err instanceof Error) {
const currentError = { type: "error", error: err.message }
setError(currentError)
}
console.error(err)
} finally {
setAmount("")
setIsSubmittingTransaction(false)
}
}, [account, chainId, clientFull, nominateCalldata, provider])
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}
/>
<Row title="Bonded Amount" element={<Input
readOnly
aria-label="Bonded Amount"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(senderAccount?.data.frozen, tokenDecimals, tokenSymbol)}
/>} />
<hr />
<Row title="Amount" element={<Input
value={amount}
onChange={e => setAmount(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Amount"
type="text"
className="sm:w-[300px] w-full"
placeholder="Input amount to bond or unbond"
/>} />
<div className="flex justify-between gap-2">
{bondedAddress && (
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={handleOnNominate}
>
<Users className="w-4 h-4 inline-block mr-2" />
Nominate
</Button>
)}
{!bondedAddress && (
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={handleOnBond}
>
<Nut className="w-4 h-4 inline-block mr-2" />
Bond
</Button>
)}
<Button
type="button"
variant="secondary"
disabled={!bondedAddress}
className="text-sm p-4 w-full"
>
<NutOff className="w-4 h-4 inline-block mr-2" />
Unbond
</Button>
</div>
{!error && transactionStatus && (
<div className="flex flex-col">
<p className="text-xs text-accent overflow-hidden whitespace-nowrap text-ellipsis">
Transaction status: {`${transactionStatus.status}`}
</p>
{transactionStatus.hash && (<p className="text-xs text-accent overflow-hidden whitespace-nowrap text-ellipsis">
{transactionStatus.hash}
</p>)}
</div>
)}
{!error && !metadata && (
<p className="text-xs text-accent overflow-hidden whitespace-nowrap text-ellipsis">
Downloading chain metadata...
</p>
)}
{error && (
<p className="text-xs text-destructive overflow-hidden whitespace-nowrap text-ellipsis">
Error: {error.error}
</p>
)}
</div>
{eraRewardPoints && (
<Accordion 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>
)
}

15
src/containers/Row.tsx Normal file
View File

@ -0,0 +1,15 @@
import React, { ReactNode } from "react"
interface RowProps {
title: string
element: ReactNode
}
export const Row: React.FC<RowProps> = ({ title, element }) => {
return (
<div className="flex sm:flex-row flex-col gap-2 justify-between sm:items-center items-left">
<div className="text-sm">{title}</div>
{element}
</div>
)
}

View File

@ -6,13 +6,12 @@ import {
ArrowBigRightDash,
ArrowBigDownDash
} from "lucide-react"
import React, { useState, useEffect, useCallback, useMemo, ReactNode } from "react"
import React, { useState, useEffect, useCallback, useMemo } from "react"
import { useLocation } from "react-router-dom"
import { lastValueFrom, tap } from "rxjs"
import { ss58Decode } from "@polkadot-labs/hdkd-helpers"
import { toHex } from "@polkadot-api/utils"
import { Unstable } from "@substrate/connect-discovery"
import {
useChainSpecV1,
@ -22,18 +21,14 @@ import {
useTransferCalldata,
useExistentialDeposit
} from "../hooks"
import type { SystemAccountStorage } from "../hooks"
import { submitTransaction$ } from "../api"
import {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectGroup,
SelectItem,
} from "../components/ui/select"
FollowTransaction,
TransactionError,
TransactionHistory,
} from "../types"
import {
Accordion,
AccordionContent,
@ -43,232 +38,7 @@ import {
import { Input } from "../components/ui/input"
import { Button } from "../components/ui/button"
type TransactionHistory = {
sender: string
receiver: string
status: string
calldata: string
tokenSymbol: string
timestamp: number
amount: string
txHash?: string
blockHash?: string
blockNumber?: number
error?: TransactionError
}
type FollowTransaction = {
hash: string
status: string
}
type TransactionError = {
type: string
error: string
}
interface RowProps {
title: string
element: ReactNode
}
const Row: React.FC<RowProps> = ({ title, element }) => {
return (
<div className="flex sm:flex-row flex-col gap-2 justify-between sm:items-center items-left">
<div className="text-sm">{title}</div>
{element}
</div>
)
}
interface SenderProps {
account: string
accounts: string[]
senderAccount: SystemAccountStorage | undefined
senderBalance: string
tokenDecimals: number
tokenSymbol: string
connectAccount: (account: Unstable.Account) => void
applyDecimals: (value: bigint, decimals: number, tokenSymbol: string) => string
}
const Sender: React.FC<SenderProps> = ({
account,
accounts,
senderAccount,
senderBalance,
tokenDecimals,
tokenSymbol,
connectAccount,
applyDecimals
}) => {
return (
<>
<Row title="Account" element={<Select
value={account}
disabled={!accounts || accounts.length === 0}
onValueChange={(address) => {
try {
const unstableAccount: Unstable.Account = { address }
connectAccount(unstableAccount)
} catch (e) {
console.log(e)
}
}}
>
<SelectTrigger
className={"text-muted-foreground sm:w-[300px] w-full"}
data-testid="chain-select"
>
<SelectValue placeholder="Select Account" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{accounts?.map((address, index) => (
<SelectItem
key={index}
data-testid={`address-${address}`}
value={address}>{address.slice(0, 10)}...{address.slice(-10)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>} />
<Row title="Balance" element={<Input
readOnly
aria-label="Account Balance"
type="text"
className="sm:w-[300px] w-full"
placeholder={senderBalance}
/>} />
{senderAccount && (<Accordion type="multiple" className="w-full flex flex-col gap-4 mb-4">
<AccordionItem className="bg-muted rounded text-sm" value="Balance Details">
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
Balance Details
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pl-8">
<Row title={"Free"} element={<Input
readOnly
aria-label="Fee"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(senderAccount?.data?.free, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Frozen"} element={<Input
readOnly
aria-label="Frozen"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(senderAccount?.data?.frozen, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Reserved"} element={<Input
readOnly
aria-label="Reserved"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(senderAccount?.data?.reserved, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Nonce"} element={<Input
readOnly
aria-label="Nonce"
type="text"
className="sm:w-[300px] w-full"
placeholder={senderAccount?.nonce ? senderAccount.nonce.toString() : ""}
/>} />
</AccordionContent>
</AccordionItem>
</Accordion>)}
</>
)
}
interface ReceiverProps {
receiver: string
receiverAccount: SystemAccountStorage | undefined
amount: string
tokenDecimals: number
tokenSymbol: string
isSubmittingTransaction: boolean
setReceiver: (receiver: string) => void
setAmount: (amount: string) => void
applyDecimals: (value: bigint, decimals: number, tokenSymbol: string) => string
}
const Receiver: React.FC<ReceiverProps> = ({
receiver,
receiverAccount,
amount,
tokenDecimals,
tokenSymbol,
isSubmittingTransaction,
setReceiver,
setAmount,
applyDecimals
}) => {
return (
<>
<Row title="Receiver" element={<Input
value={receiver}
onChange={e => setReceiver(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Receiver"
type="text"
className="sm:w-[300px] w-full"
placeholder="Input receiver address"
/>} />
<Row title="Amount" element={<Input
value={amount}
onChange={e => setAmount(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Amount"
type="text"
className="sm:w-[300px] w-full"
placeholder="Input amount to send"
/>} />
{receiverAccount && (<Accordion type="multiple" className="w-full flex flex-col gap-4">
<AccordionItem className="bg-muted rounded text-sm" value="Balance Details">
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
Receiver Details
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pl-8">
<Row title={"Free"} element={<Input
readOnly
aria-label="Fee"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(receiverAccount?.data?.free, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Frozen"} element={<Input
readOnly
aria-label="Frozen"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(receiverAccount?.data?.frozen, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Reserved"} element={<Input
readOnly
aria-label="Reserved"
type="text"
className="sm:w-[300px] w-full"
placeholder={applyDecimals(receiverAccount?.data?.reserved, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Nonce"} element={<Input
readOnly
aria-label="Nonce"
type="text"
className="sm:w-[300px] w-full"
placeholder={receiverAccount?.nonce ? receiverAccount.nonce.toString() : ""}
/>} />
</AccordionContent>
</AccordionItem>
</Accordion>)}
</>
)
}
import { Sender, Receiver } from "./Accounts"
export const Transactions = () => {
const location = useLocation()
@ -359,11 +129,6 @@ export const Transactions = () => {
? receiverObject.address
: undefined
})
const senderBalance = !account ? undefined : (
senderAccount?.data.free +
senderAccount?.data.frozen +
senderAccount?.data.reserved
)
const applyDecimals = (value = 0n, decimals = 0, tokenSymbol = "CSPR") => {
if (!value) return `0 ${tokenSymbol}`
@ -519,7 +284,7 @@ export const Transactions = () => {
account={account?.address ?? ""}
accounts={accounts?.map(acc => acc?.address ?? "") ?? []}
senderAccount={senderAccount}
senderBalance={applyDecimals(senderBalance, tokenDecimals, tokenSymbol)}
senderBalance={applyDecimals(senderAccount?.data.free ?? 0n, tokenDecimals, tokenSymbol)}
tokenDecimals={tokenDecimals}
tokenSymbol={tokenSymbol}
connectAccount={connectAccount}

View File

@ -8,3 +8,9 @@ export * from "./useBlocks"
export * from "./useSystemHealth"
export * from "./useCalldata"
export * from "./useConstants"
export * from "./useEraIndex"
export * from "./useEraRewardPoints"
export * from "./useCurrentValidators"
export * from "./useValidatorsOverview"
export * from "./useBondedAddress"
export * from "./useNominations"

View File

@ -0,0 +1,44 @@
import useSWRSubscription from "swr/subscription"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import type { BlockInfo } from "@polkadot-api/observable-client"
import { distinct, filter, map, mergeMap } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
export type BondedAddress = string | undefined
export const useBondedAddress = ({ address }: { address: string | undefined }) => {
const { chainHead$, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: bondedAddress } = useSWRSubscription(
chainHead$ && address && chainId && metadata
? ["bonded", chainHead$, address, chainId, metadata]
: null,
([_, chainHead$, address, chainId, metadata], { next }) => {
const { finalized$, storage$ } = chainHead$
const subscription = finalized$.pipe(
filter(Boolean),
mergeMap((blockInfo: BlockInfo) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const bondedAddress = builder.buildStorage("Staking", "Bonded")
return storage$(blockInfo?.hash, "value", () =>
bondedAddress?.keys.enc(address)
).pipe(
filter(Boolean),
distinct(),
map((value: string) => bondedAddress?.value.dec(value) as BondedAddress)
)
}),
)
.subscribe({
next(bondedAddress: BondedAddress) {
next(null, bondedAddress)
},
error: next,
})
return () => subscription.unsubscribe()
}
)
return bondedAddress
}

View File

@ -15,6 +15,14 @@ const AccountId = (value: SS58String) => Enum<
"Id"
>("Id", value)
const RewardDestination = () => Enum<
{
type: "Staked"
value: []
},
"Staked"
>("Staked", [])
export const useTransferCalldata = (destination: SS58String | undefined, amount: bigint | undefined) => {
const { client, chainId } = useUnstableProvider()
const metadata = useMetadata()
@ -39,3 +47,51 @@ export const useTransferCalldata = (destination: SS58String | undefined, amount:
)
return calldata
}
export const useBondCalldata = (amount: bigint | undefined) => {
const { client, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: calldata } = useSWR(
client && chainId && amount && metadata
? ["metadata", client, chainId, metadata, amount]
: null,
([_, client, _chainId, metadata, amount]) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const { codec, location } = builder.buildCall("Staking", "bond")
return toHex(
mergeUint8(
new Uint8Array(location),
codec.enc({
value: amount,
payee: RewardDestination(),
}),
),
)
}
)
return calldata
}
export const useNominateCalldata = (addresses: string[]) => {
const { client, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: calldata } = useSWR(
client && chainId && addresses && metadata
? ["metadata", client, chainId, metadata, addresses]
: null,
([_, client, _chainId, metadata, addresses]) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const { codec, location } = builder.buildCall("Staking", "nominate")
const targets = addresses.map(address => AccountId(address))
return toHex(
mergeUint8(
new Uint8Array(location),
codec.enc({ targets }),
),
)
}
)
return calldata
}

View File

@ -0,0 +1,47 @@
import useSWRSubscription from "swr/subscription"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import type { BlockInfo } from "@polkadot-api/observable-client"
import { distinct, filter, map, mergeMap } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
export type ValidatorDetails = {
commission: number
blocked: boolean
}
export const useCurrentValidators = ({ address }: { address: string | undefined }) => {
const { chainHead$, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: currentValidators } = useSWRSubscription(
chainHead$ && address && chainId && metadata
? ["validators", chainHead$, address, chainId, metadata]
: null,
([_, chainHead$, address, chainId, metadata], { next }) => {
const { finalized$, storage$ } = chainHead$
const subscription = finalized$.pipe(
filter(Boolean),
mergeMap((blockInfo: BlockInfo) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const currentValidators = builder.buildStorage("Staking", "Validators")
return storage$(blockInfo?.hash, "value", () =>
currentValidators?.keys.enc(address)
).pipe(
filter(Boolean),
distinct(),
map((value: string) => currentValidators?.value.dec(value) as ValidatorDetails)
)
}),
)
.subscribe({
next(currentValidators: ValidatorDetails) {
next(null, currentValidators)
},
error: next,
})
return () => subscription.unsubscribe()
}
)
return currentValidators
}

47
src/hooks/useEraIndex.tsx Normal file
View File

@ -0,0 +1,47 @@
import useSWRSubscription from "swr/subscription"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import type { BlockInfo } from "@polkadot-api/observable-client"
import { distinct, filter, map, mergeMap } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
export type EraIndexStorage = {
index: number
start: bigint
}
export const useEraIndex = () => {
const { chainHead$, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: eraIndex } = useSWRSubscription(
chainHead$ && chainId && metadata
? ["eraIndex", chainHead$, chainId, metadata]
: null,
([_, chainHead$, chainId, metadata], { next }) => {
const { finalized$, storage$ } = chainHead$
const subscription = finalized$.pipe(
filter(Boolean),
mergeMap((blockInfo: BlockInfo) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const eraIndex = builder.buildStorage("Staking", "ActiveEra")
return storage$(blockInfo?.hash, "value", () =>
eraIndex?.keys.enc()
).pipe(
filter(Boolean),
distinct(),
map((value: string) => eraIndex?.value.dec(value) as EraIndexStorage)
)
}),
)
.subscribe({
next(eraIndex: EraIndexStorage) {
next(null, eraIndex)
},
error: next,
})
return () => subscription.unsubscribe()
}
)
return eraIndex
}

View File

@ -0,0 +1,49 @@
import useSWRSubscription from "swr/subscription"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import type { BlockInfo } from "@polkadot-api/observable-client"
import { distinct, filter, map, mergeMap } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
export type RewardPoints = [string, number]
export type EraRewardPoints = {
total: number
indivial: RewardPoints[]
}
export const useEraRewardPoints = ({ eraIndex }: { eraIndex: number | undefined }) => {
const { chainHead$, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: eraRewardPoints } = useSWRSubscription(
chainHead$ && eraIndex && chainId && metadata
? ["eraRewardPoints", chainHead$, eraIndex, chainId, metadata]
: null,
([_, chainHead$, eraIndex, chainId, metadata], { next }) => {
const { finalized$, storage$ } = chainHead$
const subscription = finalized$.pipe(
filter(Boolean),
mergeMap((blockInfo: BlockInfo) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const eraRewardPoints = builder.buildStorage("Staking", "ErasRewardPoints")
return storage$(blockInfo?.hash, "value", () =>
eraRewardPoints?.keys.enc(eraIndex)
).pipe(
filter(Boolean),
distinct(),
map((value: string) => eraRewardPoints?.value.dec(value) as EraRewardPoints)
)
}),
)
.subscribe({
next(eraRewardPoints: EraRewardPoints) {
next(null, eraRewardPoints)
},
error: next,
})
return () => subscription.unsubscribe()
}
)
return eraRewardPoints
}

View File

@ -0,0 +1,48 @@
import useSWRSubscription from "swr/subscription"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import type { BlockInfo } from "@polkadot-api/observable-client"
import { distinct, filter, map, mergeMap } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
export type NominationDetails = {
targets: string[]
submittedIn: number
suppressed: boolean
}
export const useNominations = ({ address }: { address: string | undefined }) => {
const { chainHead$, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: nominations } = useSWRSubscription(
chainHead$ && address && chainId && metadata
? ["validators", chainHead$, address, chainId, metadata]
: null,
([_, chainHead$, address, chainId, metadata], { next }) => {
const { finalized$, storage$ } = chainHead$
const subscription = finalized$.pipe(
filter(Boolean),
mergeMap((blockInfo: BlockInfo) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const nominations = builder.buildStorage("Staking", "Nominators")
return storage$(blockInfo?.hash, "value", () =>
nominations?.keys.enc(address)
).pipe(
filter(Boolean),
distinct(),
map((value: string) => nominations?.value.dec(value) as NominationDetails)
)
}),
)
.subscribe({
next(nominations: NominationDetails) {
next(null, nominations)
},
error: next,
})
return () => subscription.unsubscribe()
}
)
return nominations
}

View File

@ -0,0 +1,49 @@
import useSWRSubscription from "swr/subscription"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import type { BlockInfo } from "@polkadot-api/observable-client"
import { distinct, filter, map, mergeMap } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
export type ValidatorOverview = {
page_count: number
nominator_count: number
own: bigint
total: bigint
}
export const useValidatorsOverview = ({ eraIndex, address }: { eraIndex: number | undefined, address: string | undefined }) => {
const { chainHead$, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: validatorOverview } = useSWRSubscription(
chainHead$ && eraIndex && address && chainId && metadata
? ["validatorOverview", chainHead$, eraIndex, address, chainId, metadata]
: null,
([_, chainHead$, eraIndex, address, chainId, metadata], { next }) => {
const { finalized$, storage$ } = chainHead$
const subscription = finalized$.pipe(
filter(Boolean),
mergeMap((blockInfo: BlockInfo) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const validatorOverview = builder.buildStorage("Staking", "ErasStakersOverview")
return storage$(blockInfo?.hash, "value", () =>
validatorOverview?.keys.enc(eraIndex, address)
).pipe(
filter(Boolean),
distinct(),
map((value: string) => validatorOverview?.value.dec(value) as ValidatorOverview)
)
}),
)
.subscribe({
next(validatorOverview: ValidatorOverview) {
next(null, validatorOverview)
},
error: next,
})
return () => subscription.unsubscribe()
}
)
return validatorOverview
}

View File

@ -0,0 +1,4 @@
export type AddressBookRecord = {
name: string
address: string
}

2
src/types/index.tsx Normal file
View File

@ -0,0 +1,2 @@
export * from "./addressBook"
export * from "./transaction"

23
src/types/transaction.tsx Normal file
View File

@ -0,0 +1,23 @@
export type FollowTransaction = {
hash: string
status: string
}
export type TransactionError = {
type: string
error: string
}
export type TransactionHistory = {
sender: string
receiver: string
status: string
calldata: string
tokenSymbol: string
timestamp: number
amount: string
txHash?: string
blockHash?: string
blockNumber?: number
error?: TransactionError
}