ghost-lite/src/containers/Transactions.tsx
Uncle Fatso 5b32bd5500
nominators tab added with bond and nominate functionality
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
2025-08-16 20:03:52 +03:00

482 lines
24 KiB
TypeScript

import {
NotepadText,
Send,
Trash,
Settings2,
ArrowBigRightDash,
ArrowBigDownDash
} from "lucide-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 {
useChainSpecV1,
useSystemAccount,
useUnstableProvider,
useMetadata,
useTransferCalldata,
useExistentialDeposit
} from "../hooks"
import { submitTransaction$ } from "../api"
import {
FollowTransaction,
TransactionError,
TransactionHistory,
} from "../types"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../components/ui/accordion"
import { Input } from "../components/ui/input"
import { Button } from "../components/ui/button"
import { Sender, Receiver } from "./Accounts"
export const Transactions = () => {
const location = useLocation()
const queryParams = new URLSearchParams(location.search)
const initialReceiver = queryParams.get("address") ?? ""
const [transactionHistory, setTransactionHistory] = useState<TransactionHistory[]>(
JSON.parse(localStorage.getItem('transactionHistory') ?? '[]') || []
)
const [historyLifetimeDuration, setHistoryLifetimeDuration] = useState<number>(
Number(localStorage.getItem("historyLifetimeDuration") ?? 259200) // default is 3 days
)
const [historyMaxRecords, setHistoryMaxRecords] = useState<number>(
Number(localStorage.getItem("historyMaxRecords") ?? 5)
)
const [defaultTransactAmount, setDefaultTransactAmount] = useState<string>(
localStorage.getItem("defaultTransactAmount") ?? ""
)
const [transactionStatus, setTransactionStatus] = useState<FollowTransaction | undefined>()
const [error, setError] = useState<TransactionError | undefined>()
const [isSubmittingTransaction, setIsSubmittingTransaction] = useState<boolean>(false)
const [activeTab, onActiveTabChanged] = useState<string>("transact")
const [receiver, setReceiver] = useState<string>(initialReceiver)
const [amount, setAmount] = useState<string>(defaultTransactAmount)
const metadata = useMetadata()
const existentialDeposit = useExistentialDeposit()
const chainSpecV1 = useChainSpecV1()
const tokenDecimals: number = chainSpecV1?.properties?.tokenDecimals ?? 0
const tokenSymbol: string = chainSpecV1?.properties?.tokenSymbol ?? ""
const convertedAmount = useMemo(() => {
try {
return BigInt(Number(amount) * Math.pow(10, tokenDecimals))
} catch {
return 0n
}
}, [amount, tokenDecimals])
const convertedTimestamp = ({ timestamp }: { timestamp: number }) => {
const secondsInMinute = 60;
const secondsInHour = secondsInMinute * 60;
const secondsInDay = secondsInHour * 24;
const days = Math.floor(timestamp / secondsInDay);
const hours = Math.floor((timestamp % secondsInDay) / secondsInHour);
const minutes = Math.floor((timestamp % secondsInHour) / secondsInMinute);
const seconds = timestamp % secondsInMinute;
return `${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds`;
}
const {
provider,
clientFull,
chainId,
account,
accounts,
connectAccount
} = useUnstableProvider()
const receiverObject = useMemo(() => {
try {
const [, prefix] = ss58Decode(receiver)
if (prefix !== 1995 && prefix !== 1996) {
throw new Error("bad prefix")
}
return { isValid: true, address: receiver }
} catch (e) {
return { isValid: false, address: receiver }
}
}, [receiver])
const calldata = useTransferCalldata(
receiverObject.isValid ? receiverObject.address : undefined,
convertedAmount > 0n ? convertedAmount : undefined
)
const senderAccount = useSystemAccount({
account: account
? account.address
: undefined
})
const receiverAccount = useSystemAccount({
account: receiverObject.isValid
? receiverObject.address
: undefined
})
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}`
}
useEffect(() => {
const deadline = Math.floor(Date.now() / 1000) + historyLifetimeDuration
const cleanedTransactionHistory = transactionHistory.slice(-historyMaxRecords).filter(transaction =>
(transaction.timestamp ?? 0) < deadline && transaction.txHash
)
localStorage.setItem("transactionHistory", JSON.stringify(cleanedTransactionHistory))
}, [transactionHistory, historyLifetimeDuration, historyMaxRecords])
useEffect(() => {
localStorage.setItem("historyLifetimeDuration", historyLifetimeDuration.toString())
}, [historyLifetimeDuration])
useEffect(() => {
localStorage.setItem("historyMaxRecords", historyMaxRecords.toString())
}, [historyMaxRecords])
useEffect(() => {
localStorage.setItem("defaultTransactAmount", defaultTransactAmount)
}, [defaultTransactAmount])
const handleOnTransfer = useCallback(async () => {
setIsSubmittingTransaction(true)
setTransactionStatus(undefined)
setError(undefined)
const transactStory: TransactionHistory = {
sender: account?.address ?? "",
receiver: receiverObject?.address ?? "",
amount,
timestamp: Math.floor(Date.now() / 1000),
calldata: calldata ?? "",
tokenSymbol: tokenSymbol ?? "",
status: "Initiated"
}
try {
const tx = await provider!.createTx(
chainId ?? "",
account ? toHex(ss58Decode(account.address)[0]) : "",
calldata ?? ""
)
await lastValueFrom(
submitTransaction$(clientFull, tx)
.pipe(
tap(({ txEvent }) => {
let status: string = ""
switch (txEvent.type) {
case "broadcasted":
status = "broadcasted to available peers"
transactStory.status = "Broadcasted"
break
case "txBestBlocksState":
status = `included in block #${txEvent.block.number}`
transactStory.blockNumber = txEvent.block.number
transactStory.status = "Mined"
break
case "finalized":
status = `finalized at block #${txEvent.block.number}`
transactStory.blockNumber = txEvent.block.number
transactStory.status = "Finalized"
break
case "throttled":
status = "throttling to detect chain head..."
transactStory.status = "Throttled"
break
}
transactStory.txHash = txEvent.txHash
transactStory.blockHash = txEvent.block?.hash
setTransactionStatus({
status,
hash: txEvent.block?.hash,
})
}),
),
)
} catch (err) {
if (err instanceof Error) {
const currentError = { type: "error", error: err.message }
transactStory.error = currentError
setError(currentError)
}
console.error(err)
} finally {
setAmount(defaultTransactAmount ?? "")
setIsSubmittingTransaction(false)
setTransactionHistory([...transactionHistory, transactStory])
}
}, [
provider,
chainId,
account,
amount,
calldata,
clientFull,
defaultTransactAmount,
receiverObject?.address,
tokenSymbol,
transactionHistory
])
return (
<div className="sm:w-[500px] w-[85%] h-fit flex flex-col flex-1 gap-2 justify-center self-center rounded py-2">
<div className="bg-muted p-4 rounded flex flex-col gap-2">
<div className="w-full flex flex-row justify-between">
<div className="flex sm:flex-row flex-col gap-2">
<Button
type="button"
variant="ghost"
disabled={isSubmittingTransaction}
onClick={() => onActiveTabChanged("transact")}
className={`text-sm p-4 ${activeTab === "transact" ? "font-semibold underline" : ""}`}
>
<Send className="w-4 h-4 inline-block mr-2" />
Transact
</Button>
<Button
type="button"
variant="ghost"
disabled={isSubmittingTransaction}
onClick={() => onActiveTabChanged("history")}
className={`text-sm p-4 ${activeTab === "history" ? "font-semibold underline" : ""}`}
>
<NotepadText className="w-4 h-4 inline-block mr-2" />
History
</Button>
</div>
<Button
type="button"
variant="ghost"
disabled={isSubmittingTransaction}
onClick={() => onActiveTabChanged("settings")}
className="sm:block hidden"
>
<Settings2 className="w-4 h-4 inline-block" />
</Button>
</div>
<hr className="w-full my-2" />
<div className="flex flex-col gap-2">
{activeTab === "transact" && (
<>
<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}
/>
<Receiver
receiver={receiver}
receiverAccount={receiverAccount}
amount={amount}
tokenDecimals={tokenDecimals}
tokenSymbol={tokenSymbol}
isSubmittingTransaction={isSubmittingTransaction}
setReceiver={setReceiver}
setAmount={setAmount}
applyDecimals={applyDecimals}
/>
<Button
onClick={handleOnTransfer}
disabled={
isSubmittingTransaction || !provider || !senderAccount ||
!receiverObject.isValid || !tokenDecimals || !calldata ||
!existentialDeposit || convertedAmount < existentialDeposit ||
!metadata || senderAccount.data.free < convertedAmount
}
className="mt-4"
variant="secondary"
size="full"
>
{isSubmittingTransaction ? "Submitting" : "Transfer"}
</Button>
{!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>
)}
</>
)}
{activeTab === "history" && (
<div className="min-h-[248px]">
{transactionHistory.length === 0
? (
<div className="text-sm">
There are currently no stored transactions.
</div>
)
: (
<Accordion type="multiple" className="w-full h-fit flex flex-col flex-1 gap-4 justify-center self-center py-2">
{transactionHistory.map((props: TransactionHistory) => (
<AccordionItem key={props.txHash} className="bg-muted rounded px-4" value={props.txHash ?? ""}>
<AccordionTrigger className="w-full hover:no-underline">
<div className="flex sm:flex-row flex-col gap-2 items-center w-[90%] justify-between cursor-pointer">
<Input
readOnly
className="overflow-hidden whitespace-nowrap text-ellipsis sm:w-[45%] w-full"
value={props.sender}
placeholder="Sender not found"
/>
<ArrowBigRightDash className="w-6 h-6 sm:block hidden" />
<ArrowBigDownDash className="w-6 h-6 sm:hidden block" />
<Input
readOnly
className="overflow-hidden whitespace-nowrap text-ellipsis sm:w-[45%] w-full"
value={props.receiver}
placeholder="Receiver not found"
/>
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4">
<div className="text-sm text-accent flex flex-col">
{props.error && (
<span className="overflow-hidden whitespace-nowrap text-ellipsis text-destructive">Error: {props.error?.error ?? ""}</span>
)}
{!props.error && (
<>
<span className="overflow-hidden whitespace-nowrap text-ellipsis">{props.status} at block #{props.blockNumber}</span>
<span className="overflow-hidden whitespace-nowrap text-ellipsis">Transfered amount: {props.amount} {props.tokenSymbol}</span>
</>
)}
<span className="overflow-hidden whitespace-nowrap text-ellipsis">
Execution datetime: {new Date(props.timestamp * 1000).toLocaleString("en-US")}
</span>
<hr className="my-4" />
<div className="flex flex-col gap-2">
<div className="flex sm:flex-row flex-col justify-between sm:items-center items-left gap-2">
Tx hash:
<Input
readOnly
value={props.txHash}
className="text-accent sm:w-[350px] w-full h-[30px]"
/>
</div>
<div className="flex sm:flex-row flex-col justify-between sm:items-center items-left gap-2">
Block hash:
<Input
readOnly
value={props.blockHash}
className="text-accent sm:w-[350px] w-full h-[30px]"
/>
</div>
<div className="flex sm:flex-row flex-col justify-between sm:items-center items-left gap-2">
Calldata:
<Input
readOnly
value={props.calldata}
className="text-accent sm:w-[350px] w-full h-[30px]"
/>
</div>
</div>
</div>
<Button
variant="destructive"
size="full"
className="h-[35px] text-accent"
onClick={() => {
setTransactionHistory(transactionHistory.filter((obj: TransactionHistory) =>
obj.txHash !== props.txHash
))
}}
>
<Trash className="w-4 h-4 mr-2" />
Remove
</Button>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
}
</div>
)}
{activeTab === "settings" && (
<div className="min-h-[248px] text-sm flex flex-col gap-2">
<div className="flex sm:flex-row flex-col gap-2 justify-between sm:items-center items-left">
<span className="sm:pb-4 pb-0">Max lifetime:</span>
<div className="sm:w-[350px] w-full flex flex-col">
<Input
value={historyLifetimeDuration}
onChange={(e) => {
const newValue = +e.target.value
if (newValue) setHistoryLifetimeDuration(newValue)
}}
className="sm:w-[350px] w-full"
/>
<span className="text-xs text-accent my-2">
{convertedTimestamp({ timestamp: historyLifetimeDuration})}
</span>
</div>
</div>
<div className="flex sm:flex-row flex-col gap-2 justify-between sm:items-center items-left">
<span>Max records:</span>
<Input
value={historyMaxRecords}
onChange={(e) => {
const newValue = +e.target.value
if (newValue) setHistoryMaxRecords(newValue)
}}
className="sm:w-[350px] w-full"
/>
</div>
<div className="flex sm:flex-row flex-col gap-2 justify-between sm:items-center items-left">
<span>Default amount:</span>
<Input
value={defaultTransactAmount}
onChange={(e) => {
setDefaultTransactAmount(e.target.value)
setAmount(e.target.value)
}}
className="sm:w-[350px] w-full"
placeholder="Amount will be empty"
/>
</div>
</div>
)}
</div>
</div>
</div>
)
}