initial version

Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
This commit is contained in:
Uncle Fatso 2025-07-22 12:55:56 +03:00
commit a7833d9a5b
Signed by: f4ts0
GPG Key ID: 565F4F2860226EBB
49 changed files with 11264 additions and 0 deletions

1
.env.template Normal file
View File

@ -0,0 +1 @@
VITE_APP_TRACKING_ID=

8
.eslintignore Normal file
View File

@ -0,0 +1,8 @@
# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
dist
# don't lint .cache
.cache
webpack.*

30
.eslintrc.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
settings: { react: { version: "detect" } },
extends: ["react-app", "prettier"],
rules: {
"import/no-extraneous-dependencies": [
"error",
{
devDependencies: [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.bench.ts",
"vite.config.ts"
],
},
],
"@typescript-eslint/no-redeclare": "off",
},
env: {
browser: true,
},
globals: {
chrome: true,
},
}

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.env
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

37
README.md Normal file
View File

@ -0,0 +1,37 @@
# Ghost Lite
This project is a fully decentralized application that leverages the [Ghost Wallet](https://git.ghostchain.io/ghostchain/ghost-wallet)'s light client extension. It aims to deliver comprehensive on-chain functionality directly from your browser. In the future, all features will align with the capabilities outlined by [ghost-eye](https://git.ghostchain.io/ghostchain/ghost-eye).
## Functionality
* Health check
* Address nook
* Transfers
* Transfer history
* ~~~Nominations~~~
* ~~~Validators info~~~
## Technologies Used
- **React**: A JavaScript library for building user interfaces.
- **TypeScript**: A strongly typed programming language that builds on JavaScript.
- **Vite**: A build tool that aims to provide a faster and leaner development experience for modern web projects.
## Installation
To get started with the project, follow these steps:
```bash
pnpm install
```
Then to run the project
```bash
pnpm dev
```
In order to build project
```bash
pnpm build
```

30
index.html Normal file
View File

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="https://ipfs.io/ipfs/QmRgJ2UbzpM58te1R91bC2neWsS28F2QatBtzerM1JekyC" type="image/png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GHOST Lite</title>
<meta name="description" content="Unlock faster access to GHOST Chain from your browser." />
<meta name="keywords" content="Chain Bridge, Cross-chain arbitrage, Blockchain, Crypto, Ethereum, Bitcoin, freedom, privacy, GHOST, Chain, Lite Node, Light Node, Node, RPC, Wallet, Transaction, Hash" />
<meta property="og:image" content="https://blog.ghostchain.io/wp-content/uploads/2025/07/GHOST-LITE-Featured-Image.png" />
<meta property="og:title" content="GHOST Lite" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:type" content="website" />
<meta property="og:description" content="Unlock faster access to GHOST Chain from your browser." />
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@realGhostChain" />
<meta name="twitter:title" content="GHOST Lite" />
<meta name="twitter:description" content="Unlock faster access to GHOST Chain from your browser." />
<meta name="twitter:image" content="https://blog.ghostchain.io/wp-content/uploads/2025/07/GHOST-LITE-Featured-Image.png" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

71
package.json Normal file
View File

@ -0,0 +1,71 @@
{
"name": "ghost-lite",
"version": "0.0.18",
"description": "Web application for Ghost and Casper chain.",
"author": "Uncle f4ts0 <f4ts0@ghostchain.io>",
"maintainers": [
"Uncle f4ts0 <fatso@ghostchain.io>",
"Uncle 57r3tch <stretch@ghostchain.io>",
"Uncle 5t1nky <stinky@ghostchain.io>"
],
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"check": "tsc --noEmit",
"build": "pnpm lint && pnpm check && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"clean": "rm -rf dist",
"deep-clean": "pnpm clean && rm -rf node_modules",
"preview": "vite preview"
},
"dependencies": {
"@picocss/pico": "^2.0.6",
"@polkadot-api/metadata-builders": "~0.13.0",
"@polkadot-api/observable-client": "~0.8.6",
"@polkadot-api/substrate-bindings": "~0.15.0",
"@polkadot-api/substrate-client": "~0.3.0",
"@polkadot-api/utils": "~0.1.2",
"@polkadot-api/view-builder": "~0.4.3",
"@polkadot-labs/hdkd-helpers": "^0.0.11",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@substrate/connect-discovery": "^0.2.2",
"@substrate/light-client-extension-helpers": "^2.7.6",
"@zag-js/react": "^0.48.0",
"@zag-js/select": "^0.48.0",
"@zag-js/toast": "^0.48.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.468.0",
"polkadot-api": "^1.15.0",
"react": "^18.3.1",
"react-dom": "^18.2.0",
"react-ga4": "^2.1.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.7.0",
"rxjs": "^7.8.1",
"swr": "^2.2.5",
"tailwind-merge": "^3.3.1",
"typescript": "^5.6.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@total-typescript/tsconfig": "^1.0.4",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.2.25",
"@typescript-eslint/parser": "^7.11.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.21",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-import": "^2.29.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"vite": "^5.3.4",
"vite-tsconfig-paths": "^5.1.4"
}
}

8622
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

5
postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {}
},
}

0
public/.gitkeep Normal file
View File

1
src/api/index.ts Normal file
View File

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

View File

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

0
src/assets/.gitkeep Normal file
View File

View File

@ -0,0 +1,101 @@
import { useUnstableProvider } from "../hooks"
import * as select from "@zag-js/select"
import { useMachine, normalizeProps } from "@zag-js/react"
import { useId, useEffect } from "react"
import { useChains } from "../hooks/useChains"
import { chainSpec as casperDevelopment } from "./chainspecs/casper_dev"
import {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectGroup,
SelectItem,
} from "../components/ui/select"
const chainData = [
{
label: "Casper",
value: "0x07074eb5f47a6f4dd70430674e5174d5414bc055292b90392fb6f0a28c7524d1",
chainSpec: casperDevelopment,
}
]
export const ChainSelect = () => {
const {
chainId,
setChainId,
provider,
providerDetail,
providerDetails,
connectProviderDetail
} = useUnstableProvider()
const { chains: connectedChains } = useChains(provider)
const isConnected = !!Object.keys(connectedChains).find(
(connectedChainId) => connectedChainId === chainId,
)
useEffect(() => {
// TODO: make sure we are using correct extension
const maybeProvider = providerDetails?.find(obj => obj.info.rdns === "io.ghostchain.GhostWalletExtension")
if (maybeProvider && !providerDetail) {
try {
connectProviderDetail(maybeProvider)
} catch (e) {
console.log(e)
}
}
}, [providerDetail, providerDetails, connectProviderDetail])
const chains = select.collection({
items: chainData,
itemToString: (item) => item.label,
itemToValue: (item) => item.value,
})
const [state, send] = useMachine(
select.machine({
id: useId(),
collection: chains,
value: [chainId],
onValueChange: (chainId) => setChainId(chainId.value[0] ?? ""),
}),
)
const api = select.connect(state, send, normalizeProps)
return (
<div className="flex flex-col gap-2">
<Select
value={state.context.value[0]}
onValueChange={(chainId) => {
let newValue = chainData.find(obj => obj.value === chainId)
api.selectValue(newValue?.value ? newValue.value : "")
}}
>
<SelectTrigger
className={"text-muted-foreground w-[200px]"}
data-testid="chain-select"
>
<SelectValue placeholder="Select Chain" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[...chainData, { label: "Disconnect", value: "Disconnect"}].map(({ label, value }, index) => (
<SelectItem key={index} data-testid={`chain-${label}`} value={value}>{label}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{state.context.value[0] && (
isConnected
? <div className="text-center text-sm text-secondary font-bold tracking-wide bg-accent rounded">Connected</div>
: <div className="text-center text-sm text-secondary font-bold tracking-wide bg-destructive rounded">Connecting...</div>
)}
</div>
)
}

29
src/components/Header.tsx Normal file
View File

@ -0,0 +1,29 @@
import { useMemo } from "react"
import { useLocation } from "react-router-dom"
import { ChainSelect } from "../components"
export const Header = () => {
const location = useLocation()
const currentPath = useMemo(() => {
switch (location.pathname.replace("/", "").toLowerCase()) {
case "health":
return "Health Check"
case "transactions":
return "Transactions"
case "book":
return "Address Book"
default:
return "Health Check";
}
}, [location])
return (
<div className="flex flex-row w-full justify-between">
<h2 className="text-3xl font-semibold">
{currentPath}
</h2>
<ChainSelect />
</div>
)
}

23
src/components/Layout.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from "react"
import { cn } from "../lib/utils"
export type Props = {
className?: string
children?: React.ReactNode
}
export const Layout: React.FC<Props> = ({ children, className }) => {
return (
<div
className={cn(
"flex flex-grow items-center justify-center",
"w-full h-screen flex overflow-auto",
"font-sans",
className,
)}
>
{children}
</div>
)
}

49
src/components/Logo.tsx Normal file
View File

@ -0,0 +1,49 @@
interface IconProps {
cName?: string
}
const HeaderIcon: React.FC<IconProps> = (props) => (
<svg id="uuid-ae1f2c6d-1513-4375-a811-a4b94d9bb8c4" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 291.02">
<defs>
<style>{`
.uuid-d82384f2-3641-4778-a548-e458fa9b1886 {
font-family: MyriadPro-Regular, 'Myriad Pro';
font-size: 55px;
letter-spacing: 1.2em;
}`}
</style>
</defs>
<g id="uuid-6796ab52-3b89-40e5-8e67-eacb931a5314" data-name="GHOST Lite Node">
<g>
<g>
<path d="m391.84,172.36l-3.32-.76-1.66-.38-1.63-.48c-1.08-.33-2.18-.62-3.25-.98l-3.19-1.15c-4.22-1.62-8.26-3.65-12.08-6.06-7.57-4.87-13.9-11.45-18.47-19.21-4.49-7.75-7.28-16.37-8.18-25.28-.49-4.42-.5-8.87-.04-13.3.36-3.7,1.18-7.34,2.46-10.84.38-1-.09-2.12-1.07-2.56h0c-1.03-.46-2.23,0-2.69,1.03-.02.05-.04.1-.06.16-1.28,3.84-2.07,7.83-2.34,11.87-1.02,14.1,2.4,28.16,9.77,40.23,4.97,7.91,11.72,14.54,19.71,19.36,3.98,2.35,8.18,4.31,12.54,5.84l3.28,1.08c1.1.33,2.22.59,3.33.89l1.67.44,1.69.34,3.38.67s.35.04.42-.4-.25-.52-.25-.52Z"/>
<path d="m391.71,176.97c-.13,0-.27,0-.4-.02l-.36-.05-5.28-1.06-1.67-.44c-.41-.11-.79-.21-1.17-.31-.77-.2-1.53-.4-2.29-.62l-.09-.03-3.37-1.11c-4.59-1.61-9.01-3.67-13.19-6.15-8.49-5.13-15.67-12.18-20.95-20.58-7.79-12.74-11.39-27.59-10.31-42.48.3-4.35,1.15-8.65,2.53-12.78,1.03-2.99,4.29-4.57,7.27-3.54,2.99,1.03,4.57,4.29,3.54,7.27-.02.05-.04.1-.06.16-1.16,3.2-1.91,6.54-2.24,9.93-.43,4.16-.42,8.36.04,12.51.85,8.38,3.48,16.49,7.69,23.79,4.28,7.26,10.21,13.42,17.29,17.98,3.6,2.27,7.41,4.18,11.38,5.7l3.1,1.12c.63.21,1.27.39,1.9.58.4.12.81.23,1.21.36l1.5.44,5.03,1.17c2.2.68,3.44,3.01,2.76,5.22-.53,1.72-2.1,2.9-3.89,2.94h0Z"/>
<path d="m351.35,72.22c3.09-3.64,6.49-7.01,10.15-10.07,3.7-2.99,7.71-5.56,11.97-7.69,4.24-2.1,8.73-3.67,13.36-4.67,4.62-.97,9.34-1.38,14.06-1.23,9.44.34,18.68,2.76,27.08,7.08,4.17,2.12,8.07,4.72,11.64,7.74,3.04,2.54,5.7,5.49,7.91,8.77.6.89,1.79,1.16,2.71.61h0c.97-.58,1.28-1.83.7-2.8-.03-.05-.06-.09-.09-.14-2.5-3.5-5.49-6.63-8.86-9.29-3.87-3.09-8.08-5.73-12.55-7.86-8.9-4.28-18.63-6.54-28.51-6.63-4.91-.03-9.81.53-14.58,1.68-4.76,1.16-9.35,2.91-13.67,5.21-4.3,2.29-8.33,5.04-12.04,8.21-3.63,3.21-6.98,6.73-10.02,10.5,0,0-.15.24.18.52s.54.07.54.07Z"/>
<path d="m449.21,76.73c-1.91,0-3.69-.95-4.74-2.53-2.02-2.99-4.45-5.68-7.22-7.99-3.36-2.84-7.04-5.28-10.95-7.28-7.91-4.07-16.63-6.35-25.52-6.68-4.42-.15-8.84.24-13.17,1.15-4.33.93-8.52,2.4-12.49,4.37-4.02,2-7.8,4.44-11.3,7.26-3.49,2.92-6.72,6.13-9.66,9.6l-.29.3c-1.75,1.51-4.39,1.32-5.9-.43-1.15-1.33-1.35-3.24-.49-4.77l.3-.43c3.17-3.94,6.66-7.61,10.45-10.95,3.92-3.35,8.19-6.26,12.74-8.69,9.23-4.9,19.54-7.42,29.99-7.31,10.42.1,20.69,2.48,30.08,6.99,4.73,2.25,9.18,5.04,13.27,8.31,3.64,2.88,6.85,6.26,9.55,10.03,1.83,2.58,1.23,6.16-1.35,7.99-.13.09-.26.17-.39.25-.88.53-1.89.81-2.92.81Z"/>
<path d="m460.08,92.31c2.39,8.23,3.18,16.84,2.34,25.36-.92,8.45-3.72,16.59-8.19,23.83-4.48,7.18-10.33,13.4-17.23,18.3-6.28,4.53-13.33,7.9-20.81,9.94-1.05.29-1.69,1.37-1.42,2.43h0c.27,1.09,1.38,1.76,2.47,1.49.04,0,.08-.02.11-.03,7.82-2.39,15.14-6.17,21.62-11.15,7.05-5.35,12.97-12.04,17.42-19.68,4.41-7.64,7.05-16.17,7.74-24.96.6-8.72-.47-17.47-3.16-25.79-.11-.2-.35-.28-.56-.2-.22.04-.36.25-.33.46Z"/>
<path d="m416.76,177.4c-3.16.01-5.72-2.54-5.73-5.7,0-2.58,1.72-4.85,4.21-5.53,7.04-1.93,13.68-5.11,19.61-9.37,6.49-4.62,12.01-10.48,16.24-17.24,4.18-6.77,6.8-14.38,7.67-22.29.8-8.07.04-16.21-2.23-23.99l-.07-.29c-.38-2.27,1.15-4.42,3.42-4.8,1.78-.3,3.54.58,4.38,2.17l.15.29.1.31c2.83,8.77,3.96,17.99,3.31,27.18-.74,9.33-3.55,18.38-8.22,26.49-9.18,15.6-23.87,27.21-41.17,32.52-.54.16-1.1.25-1.66.25Z"/>
<path d="m482.16,140.76c-9.88-.1-17.97,7.83-18.06,17.71-.03,3.41.91,6.76,2.71,9.65-4.47,5.24-9.49,9.98-14.97,14.13-10.01,7.62-21.54,12.99-33.82,15.74-12.36,2.68-25.18,2.53-37.47-.44-11.4-2.64-23.15-7.97-34.93-15.85l-.1-.07c-2.21-1.33-5.08-.62-6.41,1.59-1.21,2-.75,4.59,1.07,6.05l.11.08c12.45,9,25.1,15.27,37.58,18.66,8.11,2.26,16.49,3.44,24.91,3.52,5.92.06,11.84-.45,17.66-1.52,14.21-2.65,27.67-8.33,39.48-16.66,7.01-4.93,13.41-10.65,19.09-17.06,9.75,1.64,18.98-4.93,20.63-14.68,1.64-9.75-4.93-18.98-14.68-20.63-.93-.16-1.87-.24-2.81-.25h0Z"/>
<path d="m341.77,158.26c-3.11-7.82-11.26-12.4-19.56-11.01-10.06-20.68-12.08-44.36-5.66-66.44,2.08-6.81,5.05-13.32,8.83-19.36,3.83-6.06,8.41-11.61,13.65-16.51,5.3-4.94,11.12-9.3,17.34-13.02,6.4-3.68,13.08-6.85,19.97-9.47l.59-.23.49-.39c1.58-1.27,2.18-3.41,1.49-5.32-.78-2.2-3.09-3.47-5.36-2.95l-.42.12c-7.49,2.58-14.76,5.75-21.75,9.49-7.01,3.89-13.59,8.5-19.62,13.78-6.11,5.36-11.5,11.47-16.05,18.2-4.56,6.78-8.21,14.13-10.84,21.87-5.18,15.54-6.59,32.08-4.11,48.27,1.52,10.46,4.61,20.64,9.16,30.18-5.2,8.41-2.6,19.44,5.81,24.64,8.41,5.2,19.44,2.6,24.64-5.81,2.98-4.83,3.5-10.78,1.4-16.05h0Z"/>
<path d="m397.52,35.17c1.42.39,2.89.61,4.37.64,6.67.13,12.85-3.45,16.05-9.31,20.58,4.26,39.13,15.29,52.71,31.32,8.59,10.26,14.65,22.38,17.7,35.41,2.93,12.08,3.22,25.66.87,40.35l-.05.52c-.06,2.33,1.61,4.34,3.91,4.7.22.03.44.05.66.06,1.83.05,3.53-.98,4.33-2.63l.24-.48.1-.53c3.11-15.89,3.27-30.81.49-44.31-2.96-14.88-9.37-28.87-18.71-40.82-9.41-11.93-21.26-21.72-34.75-28.72-8.34-4.32-17.21-7.54-26.38-9.58-.34-.95-.76-1.87-1.26-2.76-4.9-8.59-15.83-11.59-24.42-6.69-8.59,4.9-11.59,15.83-6.69,24.42,2.34,4.13,6.24,7.16,10.82,8.4Z"/>
</g>
<path d="m85.98,57.73c-17.43,0-30.74,4.23-39.93,12.69-9.2,8.46-13.8,20.2-13.8,35.21-.08,6.63,1.03,13.24,3.3,19.56,2,5.61,5.33,10.79,9.77,15.17,4.48,4.3,9.98,7.66,16.12,9.83,7.2,2.48,14.87,3.68,22.59,3.53,5.37,0,10.01-.16,13.92-.48,3.12-.21,6.23-.62,9.28-1.24v-47.52h30.78v63.55c-4.24,1.27-11.4,2.67-21.49,4.2-11.55,1.63-23.23,2.39-34.93,2.29-11.36.12-22.67-1.43-33.46-4.58-9.57-2.78-18.36-7.33-25.77-13.36-7.22-6.03-12.85-13.39-16.49-21.57C1.95,126.5,0,116.7,0,105.63,0,94.56,2.16,84.77,6.47,76.24c4.09-8.25,10.13-15.64,17.71-21.66,7.68-6.01,16.65-10.59,26.38-13.46,10.39-3.1,21.28-4.64,32.24-4.58,6.99-.05,13.97.36,20.88,1.24,5.3.69,10.56,1.61,15.75,2.77,3.72.81,7.35,1.9,10.87,3.24,2.85,1.15,4.84,1.97,5.98,2.48l-9.28,19.66c-5.78-2.58-11.89-4.53-18.2-5.82-7.47-1.61-15.14-2.41-22.84-2.39Z"/>
<path d="m256.56,36.54h31.44v138h-31.44v-61.13h-75.11v61.13h-31.44V36.54h31.44v55.16h75.11v-55.16Z"/>
<path d="m575.27,153.76c12.98,0,22.43-1.46,28.34-4.38,5.91-2.92,8.87-7.05,8.88-12.39.08-2.96-.98-5.85-3.01-8.2-2.37-2.55-5.29-4.66-8.59-6.19-4.35-2.08-8.9-3.8-13.6-5.15-5.35-1.59-11.45-3.14-18.32-4.67-6.87-1.65-13.5-3.46-19.9-5.43-5.97-1.78-11.65-4.25-16.89-7.34-4.67-2.75-8.67-6.31-11.74-10.48-3.06-4.4-4.61-9.5-4.44-14.68,0-11.94,6.2-21.31,18.61-28.11,12.41-6.8,29.3-10.19,50.68-10.2,11.1-.11,22.18.81,33.07,2.76,9.64,1.84,17.22,3.84,22.76,6l-11.17,19.44c-6.87-2.51-14.02-4.36-21.33-5.53-7.89-1.28-15.89-1.92-23.91-1.91-9.73,0-17.32,1.33-22.76,4-5.44,2.67-8.16,6.42-8.16,11.24-.08,2.71.88,5.37,2.72,7.53,2.12,2.32,4.75,4.24,7.73,5.62,3.76,1.82,7.69,3.35,11.74,4.57,4.48,1.4,9.4,2.73,14.74,4,9.35,2.29,17.7,4.61,25.05,6.96,6.61,2.02,12.87,4.84,18.61,8.39,4.79,2.98,8.76,6.89,11.59,11.44,2.67,4.38,4.01,9.69,4.01,15.91,0,11.94-6.35,21.19-19.04,27.73-12.69,6.55-31.26,9.82-55.69,9.82-7.51.03-15.01-.32-22.48-1.05-6.78-.7-12.79-1.56-18.04-2.57-5.25-1.02-9.79-2.06-13.6-3.15-3.11-.85-6.17-1.83-9.16-2.95l10.59-19.63c6.8,2.36,13.78,4.27,20.9,5.72,8.78,1.91,19.37,2.86,31.78,2.86Z"/>
<path d="m800,36.54v21.71h-53.15v116.29h-31.69V58.25h-53.15v-21.71h138Z"/>
</g>
<text className="uuid-d82384f2-3641-4778-a548-e458fa9b1886" transform="translate(258.58 277.27)"><tspan x="0" y="0">LITE</tspan></text>
</g>
</svg>
);
interface LogoProps {
cName?: string
}
export const Logo = ({ cName }: LogoProps) => {
return (
<div className={cName}>
<HeaderIcon />
</div>
)
}

143
src/components/Sidebar.tsx Normal file
View File

@ -0,0 +1,143 @@
import { HeartPulse, SendToBack, Book } from "lucide-react"
import { FaGithub } from "react-icons/fa"
import { Link, useLocation } from "react-router-dom"
import { useEffect } from "react"
import ReactGA from "react-ga4";
import { Logo } from "./Logo"
import pckg from "../../package.json"
type MenuItemTypes = "item" | "title" | "icon"
const item = [
"group",
"flex",
"items-center",
"text-base",
"text-primary",
"py-4",
"px-6",
"h-12",
"overflow-hidden",
"text-ellipsis",
"whitespace-nowrap",
"bg-background",
"md:justify-start",
"justify-center",
"cursor-pointer",
]
const itemInactive = [
"bg-transparent",
"transition",
"duration-300",
"ease-in-out",
]
const itemActive = [
"cursor-default",
]
const title = ["ml-4 font-inter font-medium text-primary"]
const titleInactive = ["group-hover:text-accent"]
const titleActive = ["text-accent", "cursor-default"]
const icon = [
"w-6",
"h-6",
"md:w-4",
"md:h-4",
"text-primary",
]
const iconInactive = ["group-hover:text-accent"]
const iconActive = ["cursor-default"]
const cName = (type: MenuItemTypes, menu: string, reqMenu: string) => {
let classes: string[] = []
switch (type) {
case "item":
if (menu === reqMenu) {
classes = [...item, ...itemActive]
} else {
classes = [...item, ...itemInactive]
}
break
case "title":
if (menu === reqMenu) {
classes = [...title, ...titleActive]
} else {
classes = [...title, ...titleInactive]
}
break
case "icon":
if (menu === reqMenu) {
classes = [...icon, ...iconActive]
} else {
classes = [...icon, ...iconInactive]
}
break
}
return classes.join(" ")
}
export const Sidebar = () => {
const location = useLocation()
const currentPath = location.pathname.replace("/", "")
useEffect(() => {
ReactGA.send({ hitType: "pageview", page: `/${currentPath}` });
}, [currentPath])
return (
<header className="h-full sticky top-0 flex">
<div className="h-[full] bg-muted flex flex-col md:w-60 w-[100px]">
<div className="md:px-6 md:pt-5 md:pb-2 p-2 pt-6">
<div className="flex items-center">
<div className="grow">
<div className="flex justify-center items-center">
<Logo cName="md:w-[80%] cursor-pointer fill-primary hover:fill-accent w-full" />
</div>
</div>
</div>
</div>
<ul className="relative pt-6">
<Link to="/health" className="relative">
<div className={cName("item", currentPath, "health")}>
<HeartPulse className={cName("icon", currentPath, "health")} />
<span className={`md:block hidden ${cName("title", currentPath, "health")}`} >Health Check</span>
</div>
</Link>
<Link to="/transactions" className="relative">
<div className={cName("item", currentPath, "transactions")}>
<SendToBack className={cName("icon", currentPath, "transactions")} />
<span className={`md:block hidden ${cName("title", currentPath, "transactions")}`} >Transactions</span>
</div>
</Link>
<Link to="/book" className="relative">
<div className={cName("item", currentPath, "book")}>
<Book className={cName("icon", currentPath, "book")} />
<span className={`md:block hidden ${cName("title", currentPath, "book")}`} >Address Book</span>
</div>
</Link>
</ul>
<div className="w-full text-center flex-grow flex flex-col justify-end">
<hr className="m-0" />
<div className="block float-left px-6 py-4 cursor-pointer">
<a
href="https://git.ghostchain.io/ghostchain/ghost-lite"
target="_blank"
rel="noreferrer"
className="flex justify-center items-center md:flex-row flex-col gap-4"
>
<FaGithub className="w-8 h-8" />
<div className="block float-left text-xs text-left">
<div className="md:block hidden text-primary">Ghost Extension Git</div>
<div className="text-accent">v {pckg.version}</div>
</div>
</a>
</div>
</div>
</div>
</header>
)
}

File diff suppressed because one or more lines are too long

5
src/components/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from "./ChainSelect"
export * from "./Sidebar"
export * from "./Logo"
export * from "./Header"
export * from "./Layout"

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "../../lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,55 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-foreground hover:text-background",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary-foreground hover:text-secondary",
ghost: "text-primary hover:bg-accent hover:text-background",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
full: "h-10 w-full",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,28 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
"file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,168 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "../../lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input",
"bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground",
"text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border",
"bg-background text-primary shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2",
"text-sm outline-none text-primary focus:bg-accent focus:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,158 @@
import React, { useEffect, useState } from "react"
import { CirclePlus, Trash, Send } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { ss58Decode } from "@polkadot-labs/hdkd-helpers"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../components/ui/accordion"
import { Input } from "../components/ui/input"
import { Button } from "../components/ui/button"
type AddressBookRecord = {
name: string
address: string
}
interface AddressRecordProps {
name: string
address: string
removeRecord: ({ name }: { name: string}) => void
openTransfer: ({ address }: { address: string}) => void
}
const AddressRecord: React.FC<AddressRecordProps> = ({ name, address, removeRecord, openTransfer }) => {
return (
<AccordionItem className="bg-muted rounded px-4" value={name}>
<AccordionTrigger className="w-full hover:no-underline">
<div className="flex flex-row items-center gap-2 w-[90%] justify-between">
{name}
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-row gap-4">
<Button
variant="secondary"
size="full"
className="flex-1 text-accent text-xs"
onClick={() => openTransfer({ address })}
>
<Send className="w-4 h-4" />
</Button>
<Input
readOnly
value={address}
aria-label={name}
type="text"
className="w-[300px] flex-6"
/>
<Button
variant="destructive"
size="full"
className="flex-2 text-accent text-xs"
onClick={() => removeRecord({ name })}
>
<Trash className="w-4 h-4 mr-2" />
Remove
</Button>
</AccordionContent>
</AccordionItem>
)
}
export const AddressBook = () => {
const navigate = useNavigate()
const [name, setName] = useState<string>("")
const [address, setAddress] = useState<string>("")
const [error, setError] = useState<string | undefined>(undefined)
const [addressBook, setAddressBook] = useState<AddressBookRecord[]>(
JSON.parse(localStorage.getItem('addressBook') ?? '[]') || []
)
useEffect(() => {
localStorage.setItem('addressBook', JSON.stringify(addressBook))
setAddress("")
setName("")
setError(undefined)
}, [addressBook])
const addRecord = ({ name, address }: AddressBookRecord) => {
if (addressBook.find(record => record.name === name)) {
setError("Name already exist in the address book")
return
}
if (addressBook.find(record => record.address === address)) {
setError("Address already exist in the address book")
return
}
try {
ss58Decode(address)
} catch (e) {
setError("Incorrect Ghost address provided")
return
}
const newRecord = { name, address }
setAddressBook([...addressBook, newRecord])
}
const removeRecord = ({ name }: { name: string }) => {
const updatedAddressBook = addressBook.filter((record: AddressBookRecord) =>
record.name !== name
)
setAddressBook(updatedAddressBook)
}
const openTransfer = ({ address }: { address: string }) => {
const queryString = new URLSearchParams({ address }).toString()
navigate(`/transactions?${queryString}`)
}
return (
<Accordion type="multiple" className="w-[500px] h-fit flex flex-col flex-1 gap-4 justify-center self-center py-8">
{addressBook.map(({ name, address }: AddressBookRecord, idx: number) => (
<AddressRecord
key={idx}
name={name}
address={address}
removeRecord={removeRecord}
openTransfer={openTransfer}
/>
))}
<div className="bg-muted flex flex-col gap-2 w-full rounded px-4 py-6">
<div className="flex flex-row gap-4">
<Input
value={name}
onChange={e => setName(e.target.value)}
aria-label="New Name"
type="text"
className="w-full"
placeholder="Record Name"
/>
<Input
value={address}
onChange={e => setAddress(e.target.value)}
aria-label="New Address"
type="text"
className="w-full"
placeholder="Record Address"
/>
</div>
<Button
variant="secondary"
size="full"
className="flex flex-row justify-center items-center"
onClick={() => addRecord({ name, address })}
>
<CirclePlus className="w-4 h-4 mr-2" />
Add Record
</Button>
{error && (
<div className="text-xs text-destructive">{error}</div>
)}
</div>
</Accordion>
)
}

36
src/containers/App.tsx Normal file
View File

@ -0,0 +1,36 @@
import { lazy, Suspense } from "react"
import { HashRouter, Routes, Route, Navigate } from "react-router-dom"
import { Layout, Sidebar, Header } from "../components"
import { UnstableProviderProvider, MetadataProviderProvider } from "../hooks"
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 AddressBook = lazy(() => import("./AddressBook").then(module => ({ default: module.AddressBook })))
export const App = () => {
return (
<HashRouter>
<Suspense fallback={<div></div>}>
<UnstableProviderProvider defaultChainId={DEFAULT_CHAIN_ID}>
<MetadataProviderProvider>
<Layout>
<Sidebar />
<div className="w-full h-full flex flex-col px-6 py-8">
<Header />
<Routes>
<Route path="/health" element={<HealthCheck />} />
<Route path="/transactions" element={<Transactions />} />
<Route path="/book" element={<AddressBook />} />
<Route path="*" element={<Navigate to="/health" replace />} />
</Routes>
</div>
</Layout>
</MetadataProviderProvider>
</UnstableProviderProvider>
</Suspense>
</HashRouter>
)
}

View File

@ -0,0 +1,215 @@
import React, { ReactNode } from "react"
import { Binary, Info, Cuboid, Cog, ShieldCheck } from "lucide-react"
import { Input } from "../components/ui/input"
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../components/ui/accordion"
import {
useChainSpecV1,
useBlocks,
useSystemHealth
} from "../hooks"
type RowType = {
title: string
element: ReactNode
}
interface ItemProps {
value: string
elements: RowType[]
icon: ReactNode
}
const Item: React.FC<ItemProps> = ({ value, elements, icon }) => {
return (
<AccordionItem className="bg-muted rounded px-4" value={value}>
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
{icon} {value}
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2">
{elements.map(({ title, element }: RowType, i: number) => (
<Row key={title} title={title} element={element} />
))}
</AccordionContent>
</AccordionItem>
)
}
interface RowProps {
title: string
element: ReactNode
}
const Row: React.FC<RowProps> = ({ title, element }) => {
return (
<div className="flex flex-row gap-2 justify-between items-center">
<div className="text-sm">{title}</div>
{element}
</div>
)
}
export const HealthCheck = () => {
const chainSpec = useChainSpecV1()
const blocks = useBlocks()
const health = useSystemHealth()
const metadataElements = [
{
title: "Chain Name",
element: <Input
readOnly
aria-label="Chain Name"
type="text"
className="w-[300px]"
placeholder={chainSpec?.chainName}
/>
},
{
title: "Genesis Hash",
element: <Input
readOnly
aria-label="Genesis Hash"
type="text"
className="w-[300px]"
placeholder={chainSpec?.genesisHash}
/>
}
]
const peersElements = [
{
title: "Sync State",
element: <Input
readOnly
aria-label="Sync State"
type="text"
className="w-[300px]"
placeholder={!health ? "" : health.isSyncing ? "Syncing..." : "Synced"}
/>
},
{
title: "Peers Connected",
element: <Input
readOnly
aria-label="Peers Connected"
type="text"
className="w-[300px]"
placeholder={health?.peers}
/>
}
]
const bestBlockElements = [
{
title: "Block Number",
element: <Input
readOnly
aria-label="Block Number"
type="text"
className="w-[300px]"
placeholder={blocks?.latest?.number}
/>
},
{
title: "Block Hash",
element: <Input
readOnly
aria-label="Block Hash"
type="text"
className="w-[300px]"
placeholder={blocks?.latest?.hash}
/>
},
{
title: "Parent Hash",
element: <Input
readOnly
aria-label="Parent Hash"
type="text"
className="w-[300px]"
placeholder={blocks?.latest?.parent}
/>
}
]
const finalizedBlockElements = [
{
title: "Block Number",
element: <Input
readOnly
aria-label="Block Number"
type="text"
className="w-[300px]"
placeholder={blocks?.finalized?.number}
/>
},
{
title: "Block Hash",
element: <Input
readOnly
aria-label="BlockHash"
type="text"
className="w-[300px]"
placeholder={blocks?.finalized?.hash}
/>
},
{
title: "Parent Hash",
element: <Input
readOnly
aria-label="Parent Hash"
type="text"
className="w-[300px]"
placeholder={blocks?.finalized?.parent}
/>
}
]
const coinElements = [
{
title: "Symbol",
element: <Input
readOnly
aria-label="Coin Symbol"
type="text"
className="w-[300px]"
placeholder={chainSpec?.properties?.tokenSymbol}
/>
},
{
title: "Decimals",
element: <Input
readOnly
aria-label="Block Hash"
type="text"
className="w-[300px]"
placeholder={chainSpec?.properties?.tokenDecimals?.toString() ?? ""}
/>
},
{
title: "Base58 Prefix",
element: <Input
readOnly
aria-label="Base58 Prefix"
type="text"
className="w-[300px]"
placeholder={chainSpec?.properties?.ss58Format?.toString() ?? ""}
/>
}
]
return (
<Accordion type="single" defaultValue="Chain metadata" className="w-[500px] h-fit flex flex-col flex-1 gap-4 justify-center self-center">
<Item value="Chain metadata" elements={metadataElements} icon={<Info className="w-4 h-4" />} />
<Item value="Peers information" elements={peersElements} icon={<Binary className="w-4 h-4" />} />
<Item value="Latest block" elements={bestBlockElements} icon={<Cuboid className="w-4 h-4" />} />
<Item value="Finalized block" elements={finalizedBlockElements} icon={<ShieldCheck className="w-4 h-4" />} />
<Item value="Coin properties" elements={coinElements} icon={<Cog className="w-4 h-4" />} />
</Accordion>
)
}

View File

@ -0,0 +1,710 @@
import {
NotepadText,
Send,
Trash,
Settings2,
ArrowBigRightDash
} from "lucide-react"
import React, { useState, useEffect, useCallback, useMemo, ReactNode } 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,
useSystemAccount,
useUnstableProvider,
useMetadata,
useTransferCalldata,
useExistentialDeposit
} from "../hooks"
import type { SystemAccountStorage } from "../hooks"
import { submitTransaction$ } from "../api"
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 { Button } from "../components/ui/button"
type TransactionHistory = {
sender: string
receiver: string
status: string
calldata: string
tokenSymbol: string
timestamp: number
amount: string
txHash?: string
blockHash?: string
blockNumber?: number
error?: TransactionError
}
type FollowTransaction = {
hash: string
status: string
}
type TransactionError = {
type: string
error: string
}
interface RowProps {
title: string
element: ReactNode
}
const Row: React.FC<RowProps> = ({ title, element }) => {
return (
<div className="flex flex-row gap-2 justify-between items-center">
<div className="text-sm">{title}</div>
{element}
</div>
)
}
interface SenderProps {
account: string
accounts: string[]
senderAccount: SystemAccountStorage | undefined
senderBalance: string
tokenDecimals: number
tokenSymbol: string
connectAccount: (account: Unstable.Account) => void
applyDecimals: (value: bigint, decimals: number, tokenSymbol: string) => string
}
const Sender: React.FC<SenderProps> = ({
account,
accounts,
senderAccount,
senderBalance,
tokenDecimals,
tokenSymbol,
connectAccount,
applyDecimals
}) => {
return (
<>
<Row title="Account" element={<Select
value={account}
disabled={!accounts || accounts.length === 0}
onValueChange={(address) => {
try {
const unstableAccount: Unstable.Account = { address }
connectAccount(unstableAccount)
} catch (e) {
console.log(e)
}
}}
>
<SelectTrigger
className={"text-muted-foreground w-[300px]"}
data-testid="chain-select"
>
<SelectValue placeholder="Select Account" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{accounts?.map((address, index) => (
<SelectItem
key={index}
data-testid={`address-${address}`}
value={address}>{address.slice(0, 10)}...{address.slice(-10)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>} />
<Row title="Balance" element={<Input
readOnly
aria-label="Account Balance"
type="text"
className="w-[300px]"
placeholder={senderBalance}
/>} />
{senderAccount && (<Accordion type="multiple" className="w-full flex flex-col gap-4 mb-4">
<AccordionItem className="bg-muted rounded text-sm" value="Balance Details">
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
Balance Details
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pl-8">
<Row title={"Free"} element={<Input
readOnly
aria-label="Fee"
type="text"
className="w-[300px]"
placeholder={applyDecimals(senderAccount?.data?.free, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Frozen"} element={<Input
readOnly
aria-label="Frozen"
type="text"
className="w-[300px]"
placeholder={applyDecimals(senderAccount?.data?.frozen, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Reserved"} element={<Input
readOnly
aria-label="Reserved"
type="text"
className="w-[300px]"
placeholder={applyDecimals(senderAccount?.data?.reserved, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Nonce"} element={<Input
readOnly
aria-label="Nonce"
type="text"
className="w-[300px]"
placeholder={senderAccount?.nonce ? senderAccount.nonce.toString() : ""}
/>} />
</AccordionContent>
</AccordionItem>
</Accordion>)}
</>
)
}
interface ReceiverProps {
receiver: string
receiverAccount: SystemAccountStorage | undefined
amount: string
tokenDecimals: number
tokenSymbol: string
isSubmittingTransaction: boolean
setReceiver: (receiver: string) => void
setAmount: (amount: string) => void
applyDecimals: (value: bigint, decimals: number, tokenSymbol: string) => string
}
const Receiver: React.FC<ReceiverProps> = ({
receiver,
receiverAccount,
amount,
tokenDecimals,
tokenSymbol,
isSubmittingTransaction,
setReceiver,
setAmount,
applyDecimals
}) => {
return (
<>
<Row title="Receiver" element={<Input
value={receiver}
onChange={e => setReceiver(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Receiver"
type="text"
className="w-[300px]"
placeholder="Input receiver address"
/>} />
<Row title="Amount" element={<Input
value={amount}
onChange={e => setAmount(e.target.value)}
disabled={isSubmittingTransaction}
aria-label="Transfer Amount"
type="text"
className="w-[300px]"
placeholder="Input amount to send"
/>} />
{receiverAccount && (<Accordion type="multiple" className="w-full flex flex-col gap-4">
<AccordionItem className="bg-muted rounded text-sm" value="Balance Details">
<AccordionTrigger>
<div className="flex items-center gap-2 space-x-2 cursor-pointer">
Receiver Details
</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-2 pl-8">
<Row title={"Free"} element={<Input
readOnly
aria-label="Fee"
type="text"
className="w-[300px]"
placeholder={applyDecimals(receiverAccount?.data?.free, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Frozen"} element={<Input
readOnly
aria-label="Frozen"
type="text"
className="w-[300px]"
placeholder={applyDecimals(receiverAccount?.data?.frozen, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Reserved"} element={<Input
readOnly
aria-label="Reserved"
type="text"
className="w-[300px]"
placeholder={applyDecimals(receiverAccount?.data?.reserved, tokenDecimals, tokenSymbol)}
/>} />
<Row title={"Nonce"} element={<Input
readOnly
aria-label="Nonce"
type="text"
className="w-[300px]"
placeholder={receiverAccount?.nonce ? receiverAccount.nonce.toString() : ""}
/>} />
</AccordionContent>
</AccordionItem>
</Accordion>)}
</>
)
}
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 {
ss58Decode(receiver)
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 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}`
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="w-[500px] h-fit flex flex-col flex-1 gap-2 justify-center self-center rounded py-8">
<div className="bg-muted p-4 rounded flex flex-col gap-2">
<div className="w-full flex flex-row justify-between">
<div className="flex flex-row 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")}
>
<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(senderBalance, 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 flex-row gap-2 items-center w-[90%] justify-between cursor-pointer">
<Input
readOnly
className="overflow-hidden whitespace-nowrap text-ellipsis w-[45%]"
value={props.sender}
placeholder="Sender not found"
/>
<ArrowBigRightDash className="w-6 h-6" />
<Input
readOnly
className="overflow-hidden whitespace-nowrap text-ellipsis w-[45%]"
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 flex-row justify-between items-center gap-2">
Tx hash:
<Input
readOnly
value={props.txHash}
className="text-accent w-[350px] h-[30px]"
/>
</div>
<div className="flex flex-row justify-between items-center gap-2">
Block hash:
<Input
readOnly
value={props.blockHash}
className="text-accent w-[350px] h-[30px]"
/>
</div>
<div className="flex flex-row justify-between items-center gap-2">
Calldata:
<Input
readOnly
value={props.calldata}
className="text-accent w-[350px] 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 flex-row gap-2 justify-between items-center">
<span className="pb-4">Max lifetime:</span>
<div className="w-[350px] flex flex-col">
<Input
value={historyLifetimeDuration}
onChange={(e) => {
const newValue = +e.target.value
if (newValue) setHistoryLifetimeDuration(newValue)
}}
className="w-[350px]"
/>
<span className="text-xs text-accent my-2">
{convertedTimestamp({ timestamp: historyLifetimeDuration})}
</span>
</div>
</div>
<div className="flex flex-row gap-2 justify-between items-center">
<span>Max records:</span>
<Input
value={historyMaxRecords}
onChange={(e) => {
const newValue = +e.target.value
if (newValue) setHistoryMaxRecords(newValue)
}}
className="w-[350px]"
/>
</div>
<div className="flex flex-row gap-2 justify-between items-center">
<span>Default amount:</span>
<Input
value={defaultTransactAmount}
onChange={(e) => {
setDefaultTransactAmount(e.target.value)
setAmount(e.target.value)
}}
className="w-[350px]"
placeholder="Amount will be empty"
/>
</div>
</div>
)}
</div>
</div>
</div>
)
}

Binary file not shown.

Binary file not shown.

10
src/hooks/index.ts Normal file
View File

@ -0,0 +1,10 @@
export * from "./useSystemAccount"
export * from "./useUnstableProvider"
export * from "./useMetadata"
export * from "./useChains"
export * from "./useIsMounted"
export * from "./useChainSpecV1"
export * from "./useBlocks"
export * from "./useSystemHealth"
export * from "./useCalldata"
export * from "./useConstants"

23
src/hooks/useBlocks.tsx Normal file
View File

@ -0,0 +1,23 @@
import useSWRSubscription from "swr/subscription"
import { filter } from "rxjs"
import type { BlockInfo } from "@polkadot-api/observable-client"
import { useUnstableProvider } from "./useUnstableProvider"
export const useBlocks = () => {
const { chainHead$, chainId } = useUnstableProvider()
const { data: blocks } = useSWRSubscription(
chainHead$ && chainId && chainId ? ["blocks", chainHead$, chainId] : null,
([_, chainHead$, chainId], { next }) => {
const subscription = chainHead$?.bestBlocks$
.pipe(filter(Boolean))
.subscribe({
next(blocks: BlockInfo[]) {
next(null, blocks)
},
error: next,
})
return () => subscription?.unsubscribe()
},
)
return { latest: blocks?.at(0), finalized: blocks?.at(-1) }
}

41
src/hooks/useCalldata.tsx Normal file
View File

@ -0,0 +1,41 @@
import useSWR from "swr"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import { mergeUint8, toHex } from "@polkadot-api/utils"
import { type SS58String, Enum } from "@polkadot-api/substrate-bindings"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
const AccountId = (value: SS58String) => Enum<
{
type: "Id"
value: SS58String
},
"Id"
>("Id", value)
export const useTransferCalldata = (destination: SS58String | undefined, amount: bigint | undefined) => {
const { client, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: calldata } = useSWR(
client && chainId && destination && amount && metadata
? ["metadata", client, chainId, metadata, destination, amount]
: null,
([_, client, _chainId, metadata, destination, amount]) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const { codec, location } = builder.buildCall("Balances", "transfer_allow_death")
return toHex(
mergeUint8(
new Uint8Array(location),
codec.enc({
dest: AccountId(destination),
value: amount,
}),
),
)
}
)
return calldata
}

View File

@ -0,0 +1,45 @@
import useSWR from "swr"
import { useUnstableProvider } from "./useUnstableProvider"
export type ChainSpecV1Type = {
chainName: string | undefined
genesisHash: string | undefined
properties: {
tokenSymbol: string | undefined
tokenDecimals: number | undefined
ss58Format: number | undefined
} | undefined
}
export const useChainSpecV1 = () => {
const { client } = useUnstableProvider()
const { data: chainSpecV1 } = useSWR(
client ? ["chainSpec_v1_", client] : null,
async ([prefix, client]) => {
const methods = ["chainName", "properties", "genesisHash"]
const responses = await Promise.all(methods.map((method) => {
return new Promise<string | undefined>((resolve, reject) =>
client._request(`${prefix}${method}`, [], {
onSuccess: resolve,
onError: reject,
}),
)
.catch((_) => undefined)
}))
return {
chainName: responses?.at(0) as string | undefined,
properties: responses?.at(1) as {
tokenSymbol: string | undefined
tokenDecimals: number | undefined
ss58Format: number | undefined
} | undefined,
genesisHash: responses?.at(2) as string | undefined
}
},
{
revalidateOnFocus: false,
refreshInterval: 0
}
)
return chainSpecV1
}

20
src/hooks/useChains.ts Normal file
View File

@ -0,0 +1,20 @@
import { useEffect, useState } from "react"
import { useIsMounted } from "./useIsMounted"
import { Unstable } from "@substrate/connect-discovery"
export const useChains = (provider?: Unstable.Provider) => {
const [chains, setChains] = useState<Unstable.RawChains>({})
const isMounted = useIsMounted()
useEffect(() => {
const chains = provider?.getChains()
if (!isMounted()) return
setChains(chains ?? {})
}, [provider, isMounted])
useEffect(() => provider?.addChainsChangeListener((chains) => {
setChains(chains)
}), [provider])
return { chains }
}

View File

@ -0,0 +1,23 @@
import useSWR from "swr"
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
import { useUnstableProvider } from "./useUnstableProvider"
import { useMetadata } from "./useMetadata"
export const useExistentialDeposit = () => {
const { chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: existentialDeposit } = useSWR(
chainId && metadata
? ["existentialDeposit", chainId, metadata]
: null,
([_, chainId, metadata]) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const codec = builder.buildConstant("Balances", "ExistentialDeposit")
const constants = metadata?.pallets?.find(obj => obj.name === "Balances")?.constants
const value = constants?.find(obj => obj.name === "ExistentialDeposit")?.value
return value ? codec.dec(value) : undefined
}
)
return existentialDeposit
}

12
src/hooks/useIsMounted.ts Normal file
View File

@ -0,0 +1,12 @@
import { useCallback, useEffect, useRef } from "react"
export const useIsMounted = () => {
const isMounted = useRef(false)
useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
}
}, [])
return useCallback(() => isMounted.current, [])
}

48
src/hooks/useMetadata.tsx Normal file
View File

@ -0,0 +1,48 @@
import React, { ReactNode } from "react"
import useSWR from "swr"
import { createContext, useContext } from 'react';
import { decAnyMetadata, unifyMetadata, type UnifiedMetadata } from "@polkadot-api/substrate-bindings"
import { useUnstableProvider } from "./useUnstableProvider"
const MetadataProvider = createContext<UnifiedMetadata | undefined>(undefined)
export const useMetadata = () => useContext(MetadataProvider)
interface MetadataProviderProps {
children: ReactNode
}
export const MetadataProviderProvider: React.FC<MetadataProviderProps> = ({ children }) => {
const { client, chainId } = useUnstableProvider()
const { data: metadata } = useSWR(
client && chainId ? ["metadata", client, chainId] : null,
async ([_, client]) => {
const storageKey = `metadata-${chainId}`
const storedMetadata = sessionStorage.getItem(storageKey)
if (storedMetadata) return unifyMetadata(decAnyMetadata(storedMetadata))
const metadata = await new Promise<string | undefined>((resolve, reject) =>
client._request("state_getMetadata", [], {
onSuccess: resolve,
onError: reject,
}),
)
.then(r => r)
.catch(_ => undefined)
if (metadata) {
sessionStorage.setItem(storageKey, metadata)
return unifyMetadata(decAnyMetadata(metadata))
} else {
sessionStorage.removeItem(storageKey)
return undefined
}
}
)
return (
<MetadataProvider.Provider value={metadata}>
{children}
</MetadataProvider.Provider>
)
}

View File

@ -0,0 +1,55 @@
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 SystemAccountStorage = {
consumers: number
data: {
flags: bigint
free: bigint
frozen: bigint
reserved: bigint
}
nonce: number
providers: number
sufficients: number
}
export const useSystemAccount = ({ account }: { account: string | undefined }) => {
const { chainHead$, chainId } = useUnstableProvider()
const metadata = useMetadata()
const { data: systemAccount } = useSWRSubscription(
chainHead$ && chainId && account && metadata
? ["systemAccount", chainHead$, chainId, account, metadata]
: null,
([_, chainHead$, chainId, account, metadata], { next }) => {
const { finalized$, storage$ } = chainHead$
const subscription = finalized$.pipe(
filter(Boolean),
mergeMap((blockInfo: BlockInfo) => {
const builder = getDynamicBuilder(getLookupFn(metadata))
const storageAccount = builder.buildStorage("System", "Account")
return storage$(blockInfo?.hash, "value", () =>
storageAccount?.keys.enc(account)
).pipe(
filter(Boolean),
distinct(),
map((value: string) => storageAccount?.value.dec(value) as SystemAccountStorage)
)
}),
)
.subscribe({
next(systemAccount: SystemAccountStorage) {
next(null, systemAccount)
},
error: next,
})
return () => subscription.unsubscribe()
}
)
return systemAccount
}

View File

@ -0,0 +1,35 @@
import useSWRSubscription from "swr/subscription"
import { switchMap, from } from "rxjs"
import { useUnstableProvider } from "./useUnstableProvider"
type SystemHealth = {
isSyncing: Boolean
shouldHavePeers: Boolean
peers: number
}
export const useSystemHealth = () => {
const { chainId, client, chainHead$ } = useUnstableProvider()
const { data: systemHealth } = useSWRSubscription(
chainId && client && chainHead$ ? ["system_health", chainId, client, chainHead$] : null,
([method, chainId, client, chainHead$], { next }) => {
const { best$ } = chainHead$
const subscription = best$?.pipe(
switchMap(() => from(new Promise((resolve, reject) =>
client._request("system_health", [], {
onSuccess: resolve,
onError: reject,
})
)))
)
.subscribe({
next(data: SystemHealth) {
next(null, data)
},
error: next
})
return () => subscription?.unsubscribe()
}
)
return systemHealth
}

View File

@ -0,0 +1,110 @@
import { type ReactNode, createContext, useContext, useState, useMemo } from "react"
import { Unstable } from "@substrate/connect-discovery"
import { createClient, SubstrateClient } from "@polkadot-api/substrate-client"
import { getObservableClient, ObservableClient } from "@polkadot-api/observable-client"
import { createClient as createFullClient } from "polkadot-api"
import useSWR from "swr"
type Context = {
providerDetails?: Unstable.SubstrateConnectProviderDetail[]
providerDetail?: Unstable.SubstrateConnectProviderDetail
connectProviderDetail(detail: Unstable.SubstrateConnectProviderDetail): void
disconnectProviderDetail(): void
accounts?: Unstable.Account[]
account?: Unstable.Account
connectAccount(account: Unstable.Account): void
disconnectAccount(): void
provider?: Unstable.Provider
chainId: string
setChainId: (chainId: string) => void
client?: SubstrateClient
observableClient?: ObservableClient
chainHead$?: any
clientFull?: any
}
const UnstableProvider = createContext<Context>(null!)
export const useUnstableProvider = () => useContext(UnstableProvider)
export const UnstableProviderProvider = ({
children,
defaultChainId,
}: {
children: ReactNode
defaultChainId: string
}) => {
const { data: providerDetails } = useSWR("getProviders", () =>
Unstable.getSubstrateConnectExtensionProviders(),
)
const [providerDetail, setProviderDetail] =
useState<Unstable.SubstrateConnectProviderDetail>()
const { data: provider } = useSWR(
() => `providerDetail.${providerDetail!.info.uuid}.provider`,
() => providerDetail!.provider,
)
const [chainId, setChainId_] = useState(defaultChainId)
const { data: accounts } = useSWR(
() =>
`providerDetail.${providerDetail!.info.uuid}.provider.getAccounts(${chainId})`,
async () => (await providerDetail!.provider).getAccounts(chainId),
)
const client = useMemo(() => {
if (!provider || !chainId) return undefined
const chain = provider?.getChains()[chainId]
if (!chain) return undefined
return createClient(chain.connect)
}, [provider, chainId])
const clientFull = useMemo(() => {
if (!provider || !chainId) return undefined
const chain = provider?.getChains()[chainId]
if (!chain) return undefined
return createFullClient(chain.connect)
}, [provider, chainId])
const observableClient = useMemo(() => {
return client ? getObservableClient(client) : undefined
}, [client])
const chainHead$ = useMemo(() => {
return observableClient ? observableClient.chainHead$() : undefined
}, [observableClient])
const [account, setAccount] = useState<Unstable.Account>()
const disconnectAccount = () => setAccount(undefined)
const disconnectProviderDetail = () => {
disconnectAccount()
setProviderDetail(undefined)
}
const setChainId = (chainId: string) => {
setChainId_(chainId)
disconnectAccount()
}
return (
<UnstableProvider.Provider
value={{
providerDetails,
providerDetail,
connectProviderDetail: setProviderDetail,
disconnectProviderDetail,
accounts,
account,
connectAccount: setAccount,
disconnectAccount,
provider,
chainId,
setChainId,
client,
clientFull,
observableClient,
chainHead$
}}
>
{children}
</UnstableProvider.Provider>
)
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

15
src/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from "react"
import ReactDOM from "react-dom/client"
import ReactGA from "react-ga4";
import { App } from "./containers/App"
import "./style.css"
const TRACKING_ID = import.meta.env.VITE_APP_TRACKING_ID;
ReactGA.initialize(TRACKING_ID);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

1
src/settings.ts Normal file
View File

@ -0,0 +1 @@
export const DEFAULT_CHAIN_ID = "0x07074eb5f47a6f4dd70430674e5174d5414bc055292b90392fb6f0a28c7524d1"

75
src/style.css Normal file
View File

@ -0,0 +1,75 @@
@import "tailwindcss";
@config "../tailwind.config.js";
@layer base {
:root {
--background: 211 57% 28%;
--foreground: 53 83% 69%;
--muted: 212 44% 37%;
--muted-foreground: 53 100% 89%;
--popover: 150 40% 98.04%;
--popover-foreground: 221 39.3% 11%;
--card: 211 55.4% 25.5%;
--card-foreground: 180 0% 100%;
--border: 221 6% 91%;
--input: 221 6% 91%;
--primary: 180 0% 100%;
--primary-foreground: 0 0% 0%;
--secondary: 211 57% 28%;
--secondary-foreground: 53 83% 69%;
--accent: 53 83.3% 69.4%;
--accent-foreground: 221 7% 20%;
--destructive: 357 96% 58%;
--destructive-foreground: 16 98% 50%;
--ring: 221 69% 32%;
--radius: 0.5rem;
--white: 0 0% 100%;
--black: 0 0% 0%;
}
.dark {
--background: 150 40% 98.04%;
--foreground: 221 39.3% 11%;
--muted: 221 19% 87%;
--muted-foreground: 221 11% 26%;
--popover: 150 40% 98.04%;
--popover-foreground: 221 39.3% 11%;
--card: 150 40% 98.04%;
--card-foreground: 221 39.3% 11%;
--border: 221 6% 91%;
--input: 221 6% 91%;
--primary: 154.64 70% 47.06%;
--primary-foreground: 0 0% 100%;
--secondary: 152 1% 86%;
--secondary-foreground: 221 1% 26%;
--accent: 221 7% 80%;
--accent-foreground: 221 7% 20%;
--destructive: 16 98% 31%;
--destructive-foreground: 16 98% 50%;
--ring: 221 69% 32%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-primary;
}
}
@font-face {
font-family: "Ubuntu-Regular";
src:
url("./fonts/ubuntu-regular-webfont.woff") format("woff"),
url("./fonts/ubuntu-regular-webfont.woff2") format("woff2");
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

84
tailwind.config.js Normal file
View File

@ -0,0 +1,84 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
fontFamily: {
sans: ["Ubuntu-Regular", "sans-serif"],
},
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
width: {
128: "32rem",
132: "39rem",
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@total-typescript/tsconfig/bundler/dom/app",
"compilerOptions": {
"baseUrl": ".",
"jsx": "preserve",
"verbatimModuleSyntax": false,
"noUncheckedIndexedAccess": false,
"lib": ["ESNext", "dom", "dom.iterable"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react-swc"
import tsconfigPaths from "vite-tsconfig-paths"
export default defineConfig({
plugins: [react(), tsconfigPaths()],
})