move transaction handler to context and add ability to change payee

Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
This commit is contained in:
Uncle Fatso 2025-08-26 16:48:59 +03:00
parent 7822556988
commit b5ab8a6b81
Signed by: f4ts0
GPG Key ID: 565F4F2860226EBB
10 changed files with 389 additions and 432 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "ghost-lite", "name": "ghost-lite",
"version": "0.1.6", "version": "0.1.7",
"description": "Web application for Ghost and Casper chain.", "description": "Web application for Ghost and Casper chain.",
"author": "Uncle f4ts0 <f4ts0@ghostchain.io>", "author": "Uncle f4ts0 <f4ts0@ghostchain.io>",
"maintainers": [ "maintainers": [

View File

@ -1 +0,0 @@
export * from "./submitTransaction$"

View File

@ -1,7 +0,0 @@
import { map } from "rxjs"
export const submitTransaction$ = (clientFull: any, tx: string) => {
return clientFull?.submitAndWatch(tx).pipe(
map((txEvent) => ({ tx, txEvent }))
)
}

View File

@ -2,7 +2,11 @@ import { lazy, Suspense } from "react"
import { HashRouter, Routes, Route, Navigate } from "react-router-dom" import { HashRouter, Routes, Route, Navigate } from "react-router-dom"
import { Layout, Sidebar, Header } from "../components" import { Layout, Sidebar, Header } from "../components"
import { UnstableProviderProvider, MetadataProviderProvider } from "../hooks" import {
UnstableProviderProvider,
MetadataProviderProvider,
TransactionStatusProviderProvider,
} from "../hooks"
import { DEFAULT_CHAIN_ID } from "../settings" import { DEFAULT_CHAIN_ID } from "../settings"
const HealthCheck = lazy(() => import("./HealthCheck").then(module => ({ default: module.HealthCheck }))) const HealthCheck = lazy(() => import("./HealthCheck").then(module => ({ default: module.HealthCheck })))
@ -16,19 +20,21 @@ export const App = () => {
<Suspense fallback={<div></div>}> <Suspense fallback={<div></div>}>
<UnstableProviderProvider defaultChainId={DEFAULT_CHAIN_ID}> <UnstableProviderProvider defaultChainId={DEFAULT_CHAIN_ID}>
<MetadataProviderProvider> <MetadataProviderProvider>
<Layout> <TransactionStatusProviderProvider>
<Sidebar /> <Layout>
<div style={{ maxWidth: "calc(100% - 100px)"}} className="w-full h-full flex flex-col sm:px-6 px-2 sm:py-8 py-2"> <Sidebar />
<Header /> <div style={{ maxWidth: "calc(100% - 100px)"}} className="w-full h-full flex flex-col sm:px-6 px-2 sm:py-8 py-2">
<Routes> <Header />
<Route path="/health" element={<HealthCheck />} /> <Routes>
<Route path="/transactions" element={<Transactions />} /> <Route path="/health" element={<HealthCheck />} />
<Route path="/book" element={<AddressBook />} /> <Route path="/transactions" element={<Transactions />} />
<Route path="/nominations" element={<Nominations />} /> <Route path="/book" element={<AddressBook />} />
<Route path="*" element={<Navigate to="/health" replace />} /> <Route path="/nominations" element={<Nominations />} />
</Routes> <Route path="*" element={<Navigate to="/health" replace />} />
</div> </Routes>
</Layout> </div>
</Layout>
</TransactionStatusProviderProvider>
</MetadataProviderProvider> </MetadataProviderProvider>
</UnstableProviderProvider> </UnstableProviderProvider>
</Suspense> </Suspense>

View File

@ -1,11 +1,6 @@
import React, { useEffect, useState, useMemo, useCallback } from "react" import React, { useEffect, useState, useMemo } from "react"
import { Nut, NutOff, Users, PiggyBank, RefreshCcw } from "lucide-react" import { Nut, NutOff, Users, PiggyBank, RefreshCcw } from "lucide-react"
import { ss58Decode } from "@polkadot-labs/hdkd-helpers" import { ss58Decode } from "@polkadot-labs/hdkd-helpers"
import { toHex } from "@polkadot-api/utils"
import { lastValueFrom, tap } from "rxjs"
import { submitTransaction$ } from "../api"
import { import {
Select, Select,
@ -27,7 +22,6 @@ import { Button } from "../components/ui/button"
import { import {
useChainSpecV1, useChainSpecV1,
useMetadata,
useEraIndex, useEraIndex,
useNominations, useNominations,
useLedger, useLedger,
@ -39,21 +33,19 @@ import {
useBondedAddress, useBondedAddress,
useNominateCalldata, useNominateCalldata,
useWithdrawCalldata, useWithdrawCalldata,
usePayeeCalldata,
useSystemAccount, useSystemAccount,
useBondCalldata, useBondCalldata,
useUnbondCalldata, useUnbondCalldata,
useBondExtraCalldata,
useUnstableProvider, useUnstableProvider,
useTransactionStatusProvider,
RewardPoints, RewardPoints,
Unlocking, Unlocking,
RewardDestination, RewardDestination,
} from "../hooks" } from "../hooks"
import { import { AddressBookRecord } from "../types"
AddressBookRecord,
FollowTransaction,
TransactionError,
} from "../types"
import { Sender } from "./Accounts" import { Sender } from "./Accounts"
import { Row } from "./Row" import { Row } from "./Row"
@ -90,7 +82,7 @@ const Item: React.FC<ItemProps> = (props) => {
setIsCheckedAddresses, setIsCheckedAddresses,
} = props } = props
const [copied, setCopied] = useState<boolean>(false); const [copied, setCopied] = useState<boolean>(false)
const convertToFixed = (value: bigint, decimals: number) => { const convertToFixed = (value: bigint, decimals: number) => {
if (!value || !decimals) { if (!value || !decimals) {
return parseFloat("0").toFixed(5) return parseFloat("0").toFixed(5)
@ -102,7 +94,7 @@ const Item: React.FC<ItemProps> = (props) => {
const handleCopy = (textToCopy: string) => { const handleCopy = (textToCopy: string) => {
if (!textToCopy) return if (!textToCopy) return
navigator.clipboard.writeText(textToCopy).then(() => { navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true); setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
}) })
} }
@ -191,27 +183,22 @@ const HeaderInfo = ({ text, value }: { text: string, value: string }) => {
export const Nominations = () => { export const Nominations = () => {
const addressBook: AddressBookRecord[] = JSON.parse(localStorage.getItem('addressBook') ?? '[]') const addressBook: AddressBookRecord[] = JSON.parse(localStorage.getItem('addressBook') ?? '[]')
const [checkedAddresses, setIsCheckedAddresses] = useState<string[]>([]) 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 [interestingValidator, setInterestingValidator] = useState<string | undefined>(undefined)
const [amount, setAmount] = useState<string>("") const [amount, setAmount] = useState<string>("")
const [destinationReceiver, setDestinationReceiver] = useState<string>("") const [destinationReceiver, setDestinationReceiver] = useState<string>("")
const [expectedPayee, setExpectedPayee] = useState<string | undefined>(undefined) const [expectedPayee, setExpectedPayee] = useState<string>("")
const { const {
provider, setTransactionStatus,
clientFull, setError,
chainId, isSubmittingTransaction,
account, handleTransaction,
accounts, renderStatus,
connectAccount } = useTransactionStatusProvider()
} = useUnstableProvider() const { account, accounts, connectAccount } = useUnstableProvider()
const metadata = useMetadata()
const chainSpecV1 = useChainSpecV1() const chainSpecV1 = useChainSpecV1()
const eraIndex = useEraIndex() const eraIndex = useEraIndex()
const nominations = useNominations({ address: account?.address }) const nominations = useNominations({ address: account?.address })
@ -224,6 +211,7 @@ export const Nominations = () => {
const tokenDecimals: number = chainSpecV1?.properties?.tokenDecimals ?? 0 const tokenDecimals: number = chainSpecV1?.properties?.tokenDecimals ?? 0
const tokenSymbol: string = chainSpecV1?.properties?.tokenSymbol ?? "" const tokenSymbol: string = chainSpecV1?.properties?.tokenSymbol ?? ""
const ss58Format: number = chainSpecV1?.properties?.ss58Format ?? 1995
const senderAccount = useSystemAccount({ const senderAccount = useSystemAccount({
account: account account: account
@ -234,15 +222,14 @@ export const Nominations = () => {
const destinationReceiverIsValid = useMemo(() => { const destinationReceiverIsValid = useMemo(() => {
try { try {
const [, prefix] = ss58Decode(destinationReceiver) const [, prefix] = ss58Decode(destinationReceiver)
if (prefix !== 1995 && prefix !== 1996) { if (prefix !== ss58Format) {
throw new Error("bad prefix") throw new Error("bad prefix")
} }
return true return true
} catch { } catch {
return false return false
} }
}, [destinationReceiver, ss58Format])
}, [destinationReceiver])
const convertedAmount = useMemo(() => { const convertedAmount = useMemo(() => {
try { try {
@ -254,8 +241,12 @@ export const Nominations = () => {
const bondedAddress = useBondedAddress({ address: account?.address }) const bondedAddress = useBondedAddress({ address: account?.address })
const withdrawCalldata = useWithdrawCalldata({ numberOfSpans: slashingSpans?.length ?? 0 }) const payeeCalldata = usePayeeCalldata(expectedPayee, destinationReceiver)
const withdrawCalldata = useWithdrawCalldata(slashingSpans?.prior?.length ?? 0)
const nominateCalldata = useNominateCalldata(checkedAddresses) const nominateCalldata = useNominateCalldata(checkedAddresses)
const bondExtraCalldata = useBondExtraCalldata(
convertedAmount > 0n ? convertedAmount : undefined
)
const bondCalldata = useBondCalldata( const bondCalldata = useBondCalldata(
convertedAmount > 0n ? convertedAmount : undefined convertedAmount > 0n ? convertedAmount : undefined
) )
@ -268,16 +259,16 @@ export const Nominations = () => {
switch (destination) { switch (destination) {
case "Staked": case "Staked":
description = "Re-stake upcoming rewards" description = "Re-stake upcoming rewards"
break; break
case "Stash": case "Stash":
description = "Withdraw rewards to free" description = "Withdraw rewards to free"
break; break
case "Account": case "Account":
description = `Rewards to account` description = `Rewards to account`
break; break
case "None": case "None":
description = "Refuse to receive rewards" description = "Refuse to receive rewards"
break; break
} }
return description return description
} }
@ -301,7 +292,11 @@ export const Nominations = () => {
}, [ledger, eraIndex]) }, [ledger, eraIndex])
const latestWithdrawEra = useMemo(() => { const latestWithdrawEra = useMemo(() => {
return Math.max(ledger?.unlocking.map((el: Unlocking) => el.era - (eraIndex?.index ?? 0)), []) if (!ledger || ledger.unlocking.length === 0) {
return 0
}
const index = eraIndex?.index ?? 0
return Math.max(...ledger.unlocking.map((el: Unlocking) => el.era - index), 0)
}, [eraIndex, ledger]) }, [eraIndex, ledger])
useEffect(() => { useEffect(() => {
@ -309,7 +304,7 @@ export const Nominations = () => {
}, [account]) }, [account])
useEffect(() => { useEffect(() => {
if (!expectedPayee) setExpectedPayee(payee?.type) if (expectedPayee === "") setExpectedPayee(payee?.type ?? "")
}, [expectedPayee, setExpectedPayee, payee]) }, [expectedPayee, setExpectedPayee, payee])
useEffect(() => { useEffect(() => {
@ -319,9 +314,21 @@ export const Nominations = () => {
}, [checkedAddresses, nominations]) }, [checkedAddresses, nominations])
useEffect(() => { useEffect(() => {
setError(undefined) if (amount !== "") {
setTransactionStatus(undefined) setError(undefined)
}, [amount]) setTransactionStatus(undefined)
}
}, [amount, setError, setTransactionStatus])
useEffect(() => {
if (!isSubmittingTransaction) {
setAmount("")
}
}, [isSubmittingTransaction])
useEffect(() => {
setDestinationReceiver("")
}, [expectedPayee])
const applyDecimals = (value = 0n, decimals = 0, tokenSymbol = "CSPR") => { const applyDecimals = (value = 0n, decimals = 0, tokenSymbol = "CSPR") => {
if (!value) return `0 ${tokenSymbol}` if (!value) return `0 ${tokenSymbol}`
@ -333,201 +340,12 @@ export const Nominations = () => {
return `${formatter.format(numberValue)} ${tokenSymbol}` return `${formatter.format(numberValue)} ${tokenSymbol}`
} }
const handleOnUnbond = useCallback(async () => { const handleOnBond = () => handleTransaction({ calldata: bondCalldata, txName: "bond" })
setIsSubmittingTransaction(true) const handleOnExtraBond = () => handleTransaction({ calldata: bondExtraCalldata, txName: "bond_extra" })
setTransactionStatus(undefined) const handleOnUnbond = () => handleTransaction({ calldata: unbondCalldata, txName: "unbond" })
setError(undefined) const handleOnNominate = () => handleTransaction({ calldata: nominateCalldata, txName: "nominate" })
const handleOnWithdraw = () => handleTransaction({ calldata: withdrawCalldata, txName: "withdraw" })
try { const handleOnSetPayee = () => handleTransaction({ calldata: payeeCalldata, txName: "set_payee" })
const tx = await provider!.createTx(
chainId ?? "",
account ? toHex(ss58Decode(account.address)[0]) : "",
unbondCalldata ?? ""
)
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, unbondCalldata, chainId, clientFull, provider])
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])
const handleOnWithdraw = useCallback(async () => {
setIsSubmittingTransaction(true)
setTransactionStatus(undefined)
setError(undefined)
try {
const tx = await provider!.createTx(
chainId ?? "",
account ? toHex(ss58Decode(account.address)[0]) : "",
withdrawCalldata ?? ""
)
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, withdrawCalldata, provider])
return ( 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="sm:w-[500px] w-[85%] h-fit flex flex-col flex-1 gap-8 justify-center self-center rounded py-2">
@ -583,6 +401,15 @@ export const Nominations = () => {
</SelectContent> </SelectContent>
</Select> </Select>
} />)} } />)}
{payee?.type === "Account" && (
<Row title="" element={<Input
readOnly
aria-label="Destination Account Address"
type="text"
className="sm:w-[300px] w-full"
placeholder={payee?.value}
/>} />
)}
{expectedPayee !== payee?.type && expectedPayee === "Account" && ( {expectedPayee !== payee?.type && expectedPayee === "Account" && (
<Row title="Receiver" element={<Input <Row title="Receiver" element={<Input
aria-label="Destination Account" aria-label="Destination Account"
@ -598,8 +425,12 @@ export const Nominations = () => {
type="button" type="button"
variant="secondary" variant="secondary"
className="text-sm my-4 w-full" className="text-sm my-4 w-full"
onClick={() => alert("not ready yet")} onClick={handleOnSetPayee}
disabled={isSubmittingTransaction || !destinationReceiverIsValid} disabled={
isSubmittingTransaction
|| !payeeCalldata
|| (expectedPayee === "Account" && !destinationReceiverIsValid)
}
> >
<RefreshCcw className="w-4 h-4 inline-block mr-2" /> <RefreshCcw className="w-4 h-4 inline-block mr-2" />
Change Destination Change Destination
@ -661,6 +492,28 @@ export const Nominations = () => {
placeholder="Input amount to bond or unbond" placeholder="Input amount to bond or unbond"
/> />
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={bondedAddress ? handleOnExtraBond : handleOnBond}
disabled={isSubmittingTransaction || convertedAmount === 0n}
>
<Nut className="w-4 h-4 inline-block mr-2" />
Bond
</Button>
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={handleOnUnbond}
disabled={isSubmittingTransaction || convertedAmount === 0n}
>
<NutOff className="w-4 h-4 inline-block mr-2" />
Unbond
</Button>
</div>
<div>
{bondedAddress && ( {bondedAddress && (
<Button <Button
type="button" type="button"
@ -673,55 +526,13 @@ export const Nominations = () => {
Nominate Nominate
</Button> </Button>
)} )}
{!bondedAddress && (
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={handleOnBond}
disabled={isSubmittingTransaction || convertedAmount === 0n}
>
<Nut className="w-4 h-4 inline-block mr-2" />
Bond
</Button>
)}
<Button
type="button"
variant="secondary"
className="text-sm p-4 w-full"
onClick={handleOnUnbond}
disabled={isSubmittingTransaction || convertedAmount === 0n}
>
<NutOff className="w-4 h-4 inline-block mr-2" />
Unbond
</Button>
</div> </div>
{eraRewardPoints?.individual.some((indivial: RewardPoints) => indivial?.at(0) === account?.address) && ( {eraRewardPoints?.individual.some((indivial: RewardPoints) => indivial?.at(0) === account?.address) && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
You are attempting to use the nomination functionality from the current validator account, which is mutually exclusive. Please switch accounts or proceed at your own risk. You are attempting to use the nomination functionality from the current validator account, which is mutually exclusive. Please switch accounts or proceed at your own risk.
</p> </p>
)} )}
{!error && transactionStatus && ( {renderStatus()}
<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> </div>
{eraRewardPoints && ( {eraRewardPoints && (
<Accordion collapsible onValueChange={setInterestingValidator} type="single" className="sm:w-[500px] w-[85%] h-fit flex flex-col flex-1 gap-4 justify-center self-center sm:text-base text-xs"> <Accordion collapsible onValueChange={setInterestingValidator} type="single" className="sm:w-[500px] w-[85%] h-fit flex flex-col flex-1 gap-4 justify-center self-center sm:text-base text-xs">

View File

@ -6,29 +6,20 @@ import {
ArrowBigRightDash, ArrowBigRightDash,
ArrowBigDownDash ArrowBigDownDash
} from "lucide-react" } from "lucide-react"
import React, { useState, useEffect, useCallback, useMemo } from "react" import React, { useState, useEffect, useMemo } from "react"
import { useLocation } from "react-router-dom" import { useLocation } from "react-router-dom"
import { lastValueFrom, tap } from "rxjs"
import { ss58Decode } from "@polkadot-labs/hdkd-helpers" import { ss58Decode } from "@polkadot-labs/hdkd-helpers"
import { toHex } from "@polkadot-api/utils"
import { import {
useChainSpecV1, useChainSpecV1,
useSystemAccount, useSystemAccount,
useUnstableProvider, useUnstableProvider,
useTransactionStatusProvider,
useMetadata, useMetadata,
useTransferCalldata, useTransferCalldata,
useExistentialDeposit useExistentialDeposit
} from "../hooks" } from "../hooks"
import { submitTransaction$ } from "../api"
import {
FollowTransaction,
TransactionError,
TransactionHistory,
} from "../types"
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@ -39,6 +30,7 @@ import { Input } from "../components/ui/input"
import { Button } from "../components/ui/button" import { Button } from "../components/ui/button"
import { Sender, Receiver } from "./Accounts" import { Sender, Receiver } from "./Accounts"
import { TransactionHistory } from "../types"
export const Transactions = () => { export const Transactions = () => {
const location = useLocation() const location = useLocation()
@ -58,19 +50,24 @@ export const Transactions = () => {
localStorage.getItem("defaultTransactAmount") ?? "" 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 [activeTab, onActiveTabChanged] = useState<string>("transact")
const [receiver, setReceiver] = useState<string>(initialReceiver) const [receiver, setReceiver] = useState<string>(initialReceiver)
const [amount, setAmount] = useState<string>(defaultTransactAmount) const [amount, setAmount] = useState<string>(defaultTransactAmount)
const {
setTransactionStatus,
setError,
isSubmittingTransaction,
handleTransaction,
renderStatus,
} = useTransactionStatusProvider()
const metadata = useMetadata() const metadata = useMetadata()
const existentialDeposit = useExistentialDeposit() const existentialDeposit = useExistentialDeposit()
const chainSpecV1 = useChainSpecV1() const chainSpecV1 = useChainSpecV1()
const tokenDecimals: number = chainSpecV1?.properties?.tokenDecimals ?? 0 const tokenDecimals: number = chainSpecV1?.properties?.tokenDecimals ?? 0
const tokenSymbol: string = chainSpecV1?.properties?.tokenSymbol ?? "" const tokenSymbol: string = chainSpecV1?.properties?.tokenSymbol ?? ""
const ss58Format: number = chainSpecV1?.properties?.ss58Format ?? 1995
const convertedAmount = useMemo(() => { const convertedAmount = useMemo(() => {
try { try {
@ -81,22 +78,19 @@ export const Transactions = () => {
}, [amount, tokenDecimals]) }, [amount, tokenDecimals])
const convertedTimestamp = ({ timestamp }: { timestamp: number }) => { const convertedTimestamp = ({ timestamp }: { timestamp: number }) => {
const secondsInMinute = 60; const secondsInMinute = 60
const secondsInHour = secondsInMinute * 60; const secondsInHour = secondsInMinute * 60
const secondsInDay = secondsInHour * 24; const secondsInDay = secondsInHour * 24
const days = Math.floor(timestamp / secondsInDay); const days = Math.floor(timestamp / secondsInDay)
const hours = Math.floor((timestamp % secondsInDay) / secondsInHour); const hours = Math.floor((timestamp % secondsInDay) / secondsInHour)
const minutes = Math.floor((timestamp % secondsInHour) / secondsInMinute); const minutes = Math.floor((timestamp % secondsInHour) / secondsInMinute)
const seconds = timestamp % secondsInMinute; const seconds = timestamp % secondsInMinute
return `${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds`; return `${days} days, ${hours} hours, ${minutes} minutes, ${seconds} seconds`
} }
const { const {
provider,
clientFull,
chainId,
account, account,
accounts, accounts,
connectAccount connectAccount
@ -105,14 +99,14 @@ export const Transactions = () => {
const receiverObject = useMemo(() => { const receiverObject = useMemo(() => {
try { try {
const [, prefix] = ss58Decode(receiver) const [, prefix] = ss58Decode(receiver)
if (prefix !== 1995 && prefix !== 1996) { if (prefix !== ss58Format) {
throw new Error("bad prefix") throw new Error("bad prefix")
} }
return { isValid: true, address: receiver } return { isValid: true, address: receiver }
} catch (e) { } catch (e) {
return { isValid: false, address: receiver } return { isValid: false, address: receiver }
} }
}, [receiver]) }, [receiver, ss58Format])
const calldata = useTransferCalldata( const calldata = useTransferCalldata(
receiverObject.isValid ? receiverObject.address : undefined, receiverObject.isValid ? receiverObject.address : undefined,
@ -160,85 +154,51 @@ export const Transactions = () => {
localStorage.setItem("defaultTransactAmount", defaultTransactAmount) localStorage.setItem("defaultTransactAmount", defaultTransactAmount)
}, [defaultTransactAmount]) }, [defaultTransactAmount])
const handleOnTransfer = useCallback(async () => { useEffect(() => {
setIsSubmittingTransaction(true) if (!isSubmittingTransaction) {
setTransactionStatus(undefined) setAmount("")
setError(undefined) }
}, [isSubmittingTransaction])
const transactStory: TransactionHistory = { useEffect(() => {
if (amount !== "") {
setError(undefined)
setTransactionStatus(undefined)
}
}, [amount, setError, setTransactionStatus])
const extraLogic = (txEvent: any) => {
const rawStatus = txEvent?.type.replace("txBestBlocksState", "mined") ?? "initiated"
const status = rawStatus.charAt(0).toUpperCase() + rawStatus.slice(1).toLowerCase()
const newTransaction: TransactionHistory = {
sender: account?.address ?? "", sender: account?.address ?? "",
receiver: receiverObject?.address ?? "", receiver: receiverObject?.address ?? "",
amount, amount,
timestamp: Math.floor(Date.now() / 1000), timestamp: Math.floor(Date.now() / 1000),
calldata: calldata ?? "", calldata: calldata ?? "",
tokenSymbol: tokenSymbol ?? "", tokenSymbol: tokenSymbol ?? "",
status: "Initiated" status,
blockHash: txEvent?.block?.hash ?? "",
txHash: txEvent?.txHash ?? "",
blockNumber: txEvent?.block?.number
} }
try { setTransactionHistory((prevHistory) => {
const tx = await provider!.createTx( const existingIndex = prevHistory.findIndex(tx => tx.txHash === newTransaction.txHash)
chainId ?? "", if (existingIndex > -1) {
account ? toHex(ss58Decode(account.address)[0]) : "", const updatedHistory = [...prevHistory]
calldata ?? "" updatedHistory[existingIndex] = {
) ...updatedHistory[existingIndex],
await lastValueFrom( ...newTransaction,
submitTransaction$(clientFull, tx) }
.pipe( return updatedHistory
tap(({ txEvent }) => { } else {
let status: string = "" return [...prevHistory, newTransaction]
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 ?? "") const handleOnTransfer = () => handleTransaction({ calldata, txName: "transfer", extraLogic })
setIsSubmittingTransaction(false)
setTransactionHistory([...transactionHistory, transactStory])
}
}, [
provider,
chainId,
account,
amount,
calldata,
clientFull,
defaultTransactAmount,
receiverObject?.address,
tokenSymbol,
transactionHistory
])
return ( 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="sm:w-[500px] w-[85%] h-fit flex flex-col flex-1 gap-2 justify-center self-center rounded py-2">
@ -304,8 +264,8 @@ export const Transactions = () => {
<Button <Button
onClick={handleOnTransfer} onClick={handleOnTransfer}
disabled={ disabled={
isSubmittingTransaction || !provider || !senderAccount || isSubmittingTransaction || !senderAccount || !calldata ||
!receiverObject.isValid || !tokenDecimals || !calldata || !receiverObject.isValid || !tokenDecimals ||
!existentialDeposit || convertedAmount < existentialDeposit || !existentialDeposit || convertedAmount < existentialDeposit ||
!metadata || senderAccount.data.free < convertedAmount !metadata || senderAccount.data.free < convertedAmount
} }
@ -315,27 +275,7 @@ export const Transactions = () => {
> >
{isSubmittingTransaction ? "Submitting" : "Transfer"} {isSubmittingTransaction ? "Submitting" : "Transfer"}
</Button> </Button>
{!error && transactionStatus && ( {renderStatus()}
<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" && ( {activeTab === "history" && (

View File

@ -1,6 +1,7 @@
export * from "./useSystemAccount" export * from "./useSystemAccount"
export * from "./useUnstableProvider" export * from "./useUnstableProvider"
export * from "./useMetadata" export * from "./useMetadata"
export * from "./useTransactionStatusProvider"
export * from "./useChains" export * from "./useChains"
export * from "./useIsMounted" export * from "./useIsMounted"
export * from "./useChainSpecV1" export * from "./useChainSpecV1"

View File

@ -15,7 +15,7 @@ const AccountId = (value: SS58String) => Enum<
"Id" "Id"
>("Id", value) >("Id", value)
export const RewardDestination = (account: SS58String | undefined) => { export const RewardDestination = () => {
return { return {
Staked: () => Enum< Staked: () => Enum<
{ {
@ -36,7 +36,7 @@ export const RewardDestination = (account: SS58String | undefined) => {
Account: (account: SS58String) => Enum< Account: (account: SS58String) => Enum<
{ {
type: "Account" type: "Account"
value: account value: SS58String
}, },
"Account" "Account"
>("Account", account), >("Account", account),
@ -114,7 +114,7 @@ export const useBondCalldata = (amount: bigint | undefined) => {
new Uint8Array(location), new Uint8Array(location),
codec.enc({ codec.enc({
value: amount, value: amount,
payee: RewardDestination.Staked(), payee: RewardDestination().Staked(),
}), }),
), ),
) )
@ -123,6 +123,28 @@ export const useBondCalldata = (amount: bigint | undefined) => {
return calldata return calldata
} }
export const useBondExtraCalldata = (amount: bigint | undefined) => {
const { client, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: calldata } = useSWR(
client && chainId && amount && metadata
? ["bond_extra", client, chainId, metadata, amount]
: null,
([_, client, _chainId, metadata, amount]) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const { codec, location } = builder.buildCall("Staking", "bond_extra")
return toHex(
mergeUint8(
new Uint8Array(location),
codec.enc({ max_additional: amount }),
),
)
}
)
return calldata
}
export const useNominateCalldata = (addresses: string[]) => { export const useNominateCalldata = (addresses: string[]) => {
const { client, chainId } = useUnstableProvider() const { client, chainId } = useUnstableProvider()
const metadata = useMetadata() const metadata = useMetadata()
@ -167,3 +189,41 @@ export const useWithdrawCalldata = (numberOfSpans: number) => {
) )
return calldata return calldata
} }
export const usePayeeCalldata = (expectedPayee: string | undefined, destinationReceiver: string) => {
const { client, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: calldata } = useSWR(
client && chainId && expectedPayee && metadata
? ["withdraw_unbonded", client, chainId, metadata, expectedPayee]
: null,
([_, client, _chainId, metadata, expectedPayee]) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const { codec, location } = builder.buildCall("Staking", "set_payee")
let destination
switch (expectedPayee) {
case "Stash":
destination = RewardDestination().Stash()
break
case "Account":
destination = RewardDestination().Account(destinationReceiver)
break
case "None":
destination = RewardDestination().None()
break
default:
destination = RewardDestination().Staked()
break
}
return toHex(
mergeUint8(
new Uint8Array(location),
codec.enc({ payee: destination }),
),
)
}
)
return calldata
}

View File

@ -6,9 +6,11 @@ import { distinct, filter, map, mergeMap } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider" import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata" import { useMetadata } from "./useMetadata"
export type Payee = { export type SlashingSpans = {
type: string span_index: number,
value: string last_start: number,
last_nonzero_slash: number,
prior: number[],
} }
export const useSlasingSpans = ({ address }: { address: string | undefined }) => { export const useSlasingSpans = ({ address }: { address: string | undefined }) => {
@ -30,12 +32,12 @@ export const useSlasingSpans = ({ address }: { address: string | undefined }) =>
).pipe( ).pipe(
filter(Boolean), filter(Boolean),
distinct(), distinct(),
map((value: string) => slasingSpans?.value.dec(value)) map((value: string) => slasingSpans?.value.dec(value) as SlashingSpans)
) )
}), }),
) )
.subscribe({ .subscribe({
next(slasingSpans) { next(slasingSpans: SlashingSpans) {
next(null, slasingSpans) next(null, slasingSpans)
}, },
error: next, error: next,

View File

@ -0,0 +1,145 @@
import { type ReactNode, createContext, useContext, useState, useCallback } from "react"
import { toHex } from "@polkadot-api/utils"
import { ss58Decode } from "@polkadot-labs/hdkd-helpers"
import { lastValueFrom, tap, map } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
import { FollowTransaction, TransactionError } from "../types"
type HandleTransactionParams = {
calldata: string | undefined;
txName: string;
extraLogic?: (data: any) => void;
}
type Context = {
isSubmittingTransaction: boolean;
transactionStatus: FollowTransaction | undefined;
error: TransactionError | undefined;
setIsSubmittingTransaction: (isSubmitting: boolean) => void;
setTransactionStatus: (status: FollowTransaction | undefined) => void;
setError: (error: TransactionError | undefined) => void;
handleTransaction: ({ calldata, txName, extraLogic }: HandleTransactionParams) => Promise<void>;
renderStatus: () => JSX.Element;
}
const TransactionStatusProvider = createContext<Context>(null!)
export const useTransactionStatusProvider = () => useContext(TransactionStatusProvider)
export const TransactionStatusProviderProvider = ({ children }: { children: ReactNode }) => {
const [isSubmittingTransaction, setIsSubmittingTransaction] = useState<boolean>(false)
const [transactionStatus, setTransactionStatus] = useState<FollowTransaction | undefined>()
const [error, setError] = useState<TransactionError | undefined>()
const { provider, clientFull, chainId, account } = useUnstableProvider()
const metadata = useMetadata()
const handleTransaction = useCallback(async ({ calldata, txName, extraLogic }: HandleTransactionParams) => {
if (!calldata) {
throw new Error("Calldata is not recognizable");
}
if (!account) {
throw new Error("Account is not recognizable")
}
if (!clientFull) {
throw new Error("Connected client not found")
}
setIsSubmittingTransaction(true);
setTransactionStatus(undefined);
setError(undefined);
try {
const tx = await provider!.createTx(
chainId ?? "",
account ? toHex(ss58Decode(account?.address)[0]) : "",
calldata ?? ""
);
await lastValueFrom(
clientFull.submitAndWatch(tx)
.pipe(
map((txEvent) => ({ tx, txEvent })),
tap(({ txEvent }) => {
let status: string = "";
switch (txEvent.type) {
case "broadcasted":
status = `${txName} is broadcasted to available peers`;
break;
case "txBestBlocksState":
status = `${txName} included in block #${txEvent.block.number}`;
break;
case "finalized":
status = `${txName} finalized at block #${txEvent.block.number}`;
break;
case "throttled":
status = `${txName} throttling to detect chain head...`;
break;
}
setTransactionStatus({
status,
hash: txEvent.block?.hash,
});
extraLogic?.(txEvent)
}),
),
);
} catch (err) {
if (err instanceof Error) {
const currentError = { type: "error", error: err.message };
setError(currentError);
}
console.error(err);
} finally {
setIsSubmittingTransaction(false);
}
}, [account, chainId, clientFull, provider]);
const renderStatus = () => {
return (
<>
{!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>
)}
</>
)
}
return (
<TransactionStatusProvider.Provider
value={{
isSubmittingTransaction,
transactionStatus,
error,
setIsSubmittingTransaction,
setTransactionStatus,
setError,
handleTransaction,
renderStatus
}}
>
{children}
</TransactionStatusProvider.Provider>
)
}