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