diff --git a/package.json b/package.json index c9e9815..410d676 100644 --- a/package.json +++ b/package.json @@ -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 ", "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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d4acba..e78501d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ab74074..b0437d0 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -12,6 +12,8 @@ export const Header = () => { return "Transactions" case "book": return "Address Book" + case "nominations": + return "Nominations" default: return "Health Check"; } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a2ba09b..86491a6 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 = () => { + +
+ + +
+
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..8198ea5 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/containers/Accounts.tsx b/src/containers/Accounts.tsx new file mode 100644 index 0000000..26ee1f3 --- /dev/null +++ b/src/containers/Accounts.tsx @@ -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 = ({ + account, + accounts, + senderAccount, + senderBalance, + tokenDecimals, + tokenSymbol, + connectAccount, + applyDecimals +}) => { + return ( + <> + { + try { + const unstableAccount: Unstable.Account = { address } + connectAccount(unstableAccount) + } catch (e) { + console.log(e) + } + }} + > + + + + + + {accounts?.map((address, index) => ( + {address.slice(0, 10)}...{address.slice(-10)} + + ))} + + + } /> + } /> + {senderAccount && ( + + +
+ Balance Details +
+
+ + } /> + } /> + } /> + } /> + +
+
)} + + ) +} + +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 = ({ + receiver, + receiverAccount, + amount, + tokenDecimals, + tokenSymbol, + isSubmittingTransaction, + setReceiver, + setAmount, + applyDecimals +}) => { + return ( + <> + setReceiver(e.target.value)} + disabled={isSubmittingTransaction} + aria-label="Transfer Receiver" + type="text" + className="sm:w-[300px] w-full" + placeholder="Input receiver address" + />} /> + setAmount(e.target.value)} + disabled={isSubmittingTransaction} + aria-label="Transfer Amount" + type="text" + className="sm:w-[300px] w-full" + placeholder="Input amount to send" + />} /> + {receiverAccount && ( + + +
+ Receiver Details +
+
+ + } /> + } /> + } /> + } /> + +
+
)} + + ) +} diff --git a/src/containers/AddressBook.tsx b/src/containers/AddressBook.tsx index 25b6af0..15fdea3 100644 --- a/src/containers/AddressBook.tsx +++ b/src/containers/AddressBook.tsx @@ -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 diff --git a/src/containers/App.tsx b/src/containers/App.tsx index dbe9dfa..51d87bb 100644 --- a/src/containers/App.tsx +++ b/src/containers/App.tsx @@ -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 = () => { } /> } /> } /> + } /> } />
diff --git a/src/containers/Nominations.tsx b/src/containers/Nominations.tsx new file mode 100644 index 0000000..d0a8057 --- /dev/null +++ b/src/containers/Nominations.tsx @@ -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> + +} + +const Item: React.FC = (props) => { + const { + name, + address, + points, + commission, + blocked, + nominatorCount, + totalStake, + ownStake, + decimals, + symbol, + nominated, + checkedAddresses, + setIsCheckedAddresses, + } = props + + const [copied, setCopied] = useState(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 ( + + +
+ +
+
{name ?? address}
+
{points}
+
+
+ +
+ } /> + } /> + } /> +
+ } /> + } /> +
+
handleCopy(address)} + className="flex justify-center items-center cursor-pointer hover:text-foreground" + > + {copied ? "Address copid to clipboard" : address} +
+
+
+
+ ) +} + +const HeaderInfo = ({ text, value }: { text: string, value: string }) => { + return ( +
+ {text} + {value} +
+ ) +} + +export const Nominations = () => { + const addressBook: AddressBookRecord[] = JSON.parse(localStorage.getItem('addressBook') ?? '[]') + + const [checkedAddresses, setIsCheckedAddresses] = useState([]) + const [isSubmittingTransaction, setIsSubmittingTransaction] = useState(false) + const [transactionStatus, setTransactionStatus] = useState() + const [error, setError] = useState() + + const [interestingValidator, setInterestingValidator] = useState(undefined) + const [amount, setAmount] = useState("") + + 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 ( +
+
+
+ + + {nominations && ( + + )} +
+
+ acc?.address ?? "") ?? []} + senderAccount={senderAccount} + senderBalance={applyDecimals(senderAccount?.data.free ?? 0n, tokenDecimals, tokenSymbol)} + tokenDecimals={tokenDecimals} + tokenSymbol={tokenSymbol} + connectAccount={connectAccount} + applyDecimals={applyDecimals} + /> + } /> +
+ 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" + />} /> +
+ {bondedAddress && ( + + )} + {!bondedAddress && ( + + )} + +
+ {!error && transactionStatus && ( +
+

+ Transaction status: {`${transactionStatus.status}`} +

+ {transactionStatus.hash && (

+ {transactionStatus.hash} +

)} +
+ + )} + {!error && !metadata && ( +

+ Downloading chain metadata... +

+ )} + {error && ( +

+ Error: {error.error} +

+ )} +
+ {eraRewardPoints && ( + + {eraRewardPoints?.individual.map((indivial: RewardPoints, idx: number) => ( + 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} + /> + ))} + + )} + {!eraRewardPoints && ( +
+ Waiting for validators list... +
+ )} +
+ ) +} diff --git a/src/containers/Row.tsx b/src/containers/Row.tsx new file mode 100644 index 0000000..60ca9fc --- /dev/null +++ b/src/containers/Row.tsx @@ -0,0 +1,15 @@ +import React, { ReactNode } from "react" + +interface RowProps { + title: string + element: ReactNode +} + +export const Row: React.FC = ({ title, element }) => { + return ( +
+
{title}
+ {element} +
+ ) +} diff --git a/src/containers/Transactions.tsx b/src/containers/Transactions.tsx index 34c5b53..450a290 100644 --- a/src/containers/Transactions.tsx +++ b/src/containers/Transactions.tsx @@ -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 = ({ title, element }) => { - return ( -
-
{title}
- {element} -
- ) -} - -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 = ({ - account, - accounts, - senderAccount, - senderBalance, - tokenDecimals, - tokenSymbol, - connectAccount, - applyDecimals -}) => { - return ( - <> - { - try { - const unstableAccount: Unstable.Account = { address } - connectAccount(unstableAccount) - } catch (e) { - console.log(e) - } - }} - > - - - - - - {accounts?.map((address, index) => ( - {address.slice(0, 10)}...{address.slice(-10)} - - ))} - - - } /> - } /> - {senderAccount && ( - - -
- Balance Details -
-
- - } /> - } /> - } /> - } /> - -
-
)} - - ) -} - -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 = ({ - receiver, - receiverAccount, - amount, - tokenDecimals, - tokenSymbol, - isSubmittingTransaction, - setReceiver, - setAmount, - applyDecimals -}) => { - return ( - <> - setReceiver(e.target.value)} - disabled={isSubmittingTransaction} - aria-label="Transfer Receiver" - type="text" - className="sm:w-[300px] w-full" - placeholder="Input receiver address" - />} /> - setAmount(e.target.value)} - disabled={isSubmittingTransaction} - aria-label="Transfer Amount" - type="text" - className="sm:w-[300px] w-full" - placeholder="Input amount to send" - />} /> - {receiverAccount && ( - - -
- Receiver Details -
-
- - } /> - } /> - } /> - } /> - -
-
)} - - ) -} +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} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index dc9887a..a9aa82b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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" diff --git a/src/hooks/useBondedAddress.tsx b/src/hooks/useBondedAddress.tsx new file mode 100644 index 0000000..97fcd20 --- /dev/null +++ b/src/hooks/useBondedAddress.tsx @@ -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 +} diff --git a/src/hooks/useCalldata.tsx b/src/hooks/useCalldata.tsx index 4017394..bbf9f8a 100644 --- a/src/hooks/useCalldata.tsx +++ b/src/hooks/useCalldata.tsx @@ -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 +} diff --git a/src/hooks/useCurrentValidators.tsx b/src/hooks/useCurrentValidators.tsx new file mode 100644 index 0000000..0116935 --- /dev/null +++ b/src/hooks/useCurrentValidators.tsx @@ -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 +} diff --git a/src/hooks/useEraIndex.tsx b/src/hooks/useEraIndex.tsx new file mode 100644 index 0000000..c793acc --- /dev/null +++ b/src/hooks/useEraIndex.tsx @@ -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 +} diff --git a/src/hooks/useEraRewardPoints.tsx b/src/hooks/useEraRewardPoints.tsx new file mode 100644 index 0000000..ddf5291 --- /dev/null +++ b/src/hooks/useEraRewardPoints.tsx @@ -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 +} diff --git a/src/hooks/useNominations.tsx b/src/hooks/useNominations.tsx new file mode 100644 index 0000000..da7e844 --- /dev/null +++ b/src/hooks/useNominations.tsx @@ -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 +} diff --git a/src/hooks/useValidatorsOverview.tsx b/src/hooks/useValidatorsOverview.tsx new file mode 100644 index 0000000..461ae9f --- /dev/null +++ b/src/hooks/useValidatorsOverview.tsx @@ -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 +} diff --git a/src/types/addressBook.tsx b/src/types/addressBook.tsx new file mode 100644 index 0000000..a4f1d2f --- /dev/null +++ b/src/types/addressBook.tsx @@ -0,0 +1,4 @@ +export type AddressBookRecord = { + name: string + address: string +} diff --git a/src/types/index.tsx b/src/types/index.tsx new file mode 100644 index 0000000..01c6af5 --- /dev/null +++ b/src/types/index.tsx @@ -0,0 +1,2 @@ +export * from "./addressBook" +export * from "./transaction" diff --git a/src/types/transaction.tsx b/src/types/transaction.tsx new file mode 100644 index 0000000..62e087c --- /dev/null +++ b/src/types/transaction.tsx @@ -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 +}