Merge pull request #78 from polkadot-api/vo/account-management

feat: account management, fake chopsticks signature
This commit is contained in:
Victor Oliva
2025-08-31 08:17:37 +08:00
committed by GitHub
30 changed files with 992 additions and 319 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "0.1.0-autogenerated.1071370278631255394",
"version": "0.1.0-autogenerated.9612188710233485064",
"name": "@polkadot-api/descriptors",
"files": [
"dist"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -1,11 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 350 350" width="24" height="24" >
<circle
cx="175"
cy="175"
r="175"
fill="#0888f0"
transform="matrix(-1 0 0 1 350 0)"
></circle>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 350 350" width="24" height="24" >
<rect width="350" height="350" fill="#0888f0" />
<path
fill="#fff"
d="m229.916 160.179 20.601-20.474c-46.561-46.274-104.416-46.274-150.977 0l20.601 20.474c35.411-35.193 74.388-35.193 109.799 0h-.024Z"

Before

Width:  |  Height:  |  Size: 596 B

After

Width:  |  Height:  |  Size: 516 B

+2
View File
@@ -1,4 +1,5 @@
import { Navigate, Route, Routes } from "react-router-dom"
import { Accounts } from "./pages/Accounts/Accounts"
import { Constants } from "./pages/Constants"
import { Explorer } from "./pages/Explorer"
import { Extrinsics } from "./pages/Extrinsics"
@@ -23,6 +24,7 @@ export default function App() {
<Route path="runtimeCalls/*" element={<RuntimeCalls />} />
<Route path="rpcCalls/*" element={<RpcCalls />} />
<Route path="metadata/*" element={<Metadata />} />
<Route path="accounts/*" element={<Accounts />} />
<Route path="*" element={<Navigate to="/explorer" replace />} />
</Routes>
</div>
+22 -6
View File
@@ -1,4 +1,5 @@
import { state, useStateObservable } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import {
createContext,
FC,
@@ -6,7 +7,9 @@ import {
useContext,
useLayoutEffect,
} from "react"
import { fromEvent, map } from "rxjs"
import { fromEvent, map, merge } from "rxjs"
export const [changeTheme$, changeTheme] = createSignal<"light" | "dark">()
const getCurrentMedia = (): "light" | "dark" =>
window.matchMedia
@@ -15,17 +18,30 @@ const getCurrentMedia = (): "light" | "dark" =>
: "light"
: "dark"
const defaultTheme = getCurrentMedia()
const prefLSKey = "theme"
changeTheme$.subscribe((theme) => {
if (getCurrentMedia() === theme) {
localStorage.removeItem(prefLSKey)
} else {
localStorage.setItem(prefLSKey, theme)
}
})
const defaultTheme =
(localStorage.getItem(prefLSKey) as "light" | "dark") || getCurrentMedia()
const ThemeContext = createContext<"light" | "dark">(defaultTheme)
export const useTheme = () => useContext(ThemeContext)
const theme$ = state(
fromEvent<MediaQueryListEvent>(
window.matchMedia("(prefers-color-scheme: dark)"),
"change",
).pipe(map((evt) => (evt.matches ? "dark" : "light"))),
merge(
fromEvent<MediaQueryListEvent>(
window.matchMedia("(prefers-color-scheme: dark)"),
"change",
).pipe(map((evt) => (evt.matches ? "dark" : "light"))),
changeTheme$,
),
defaultTheme,
)
+6 -187
View File
@@ -1,190 +1,9 @@
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
accountDetail$,
accounts$,
getPublicKey,
} from "@/state/extension-accounts.state"
import { identity$, isVerified } from "@/state/identity.state"
import { cn } from "@/utils/cn"
import { AccountIdInput } from "@/components/AccountIdInput"
import { EditAccountId, NOTIN } from "@polkadot-api/react-builder"
import { getSs58AddressInfo } from "@polkadot-api/substrate-bindings"
import { toHex } from "@polkadot-api/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { Check, ChevronsUpDown } from "lucide-react"
import { FC, useState } from "react"
import { map, switchMap, take } from "rxjs"
const hintedAccounts$ = state(
combineKeys(
accounts$.pipe(
map((accounts) =>
[...accounts.entries()]
.filter(([, { address }]) => !address.startsWith("0x"))
.map(([key]) => key),
),
),
(address) =>
accounts$.pipe(
map((v) => v.get(address)!),
take(1),
switchMap((details) =>
identity$(details.address).pipe(
map((identity) => ({
address: details.address,
name: identity?.displayName ?? details.name,
isVerified: isVerified(identity),
})),
),
),
),
).pipe(map((v) => new Map(v))),
new Map<
string,
{
address: string
name: string | undefined
isVerified: boolean | undefined
}
>(),
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => (
<AccountIdInput
value={value === NOTIN ? null : value}
onValueChanged={(v) => onValueChanged(v === null ? NOTIN : v)}
/>
)
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => {
const accounts = useStateObservable(hintedAccounts$)
const [query, setQuery] = useState("")
const queryInfo = getSs58AddressInfo(query)
const [open, _setOpen] = useState(false)
const setOpen = (value: boolean) => {
_setOpen(value)
setQuery("")
}
const valueIsNew =
value !== NOTIN &&
!accounts.has(toHex(getPublicKey(value) ?? new Uint8Array()))
const accountList = Array.from(accounts.entries())
if (value !== NOTIN) {
accountList.sort(([, a], [, b]) =>
a.address === value ? -1 : b.address === value ? 1 : 0,
)
}
const onTriggerKeyDown = (evt: React.KeyboardEvent) => {
if (evt.key.length === 1) {
setOpen(true)
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onKeyDown={onTriggerKeyDown}>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex w-64 justify-between overflow-hidden px-2 border border-border bg-input"
forceSvgSize={false}
>
{value !== NOTIN ? (
<AccountIdDisplay value={value} className="overflow-hidden" />
) : (
<span className="opacity-80">Select…</span>
)}
<ChevronsUpDown size={14} className="opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0">
<Command>
<CommandInput
placeholder="Filter…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
<div className="text-foreground/50">
The value is not a valid Account ID
</div>
</CommandEmpty>
<CommandGroup>
{valueIsNew && (
<AccountOption
account={value}
selected={true}
onSelect={() => setOpen(false)}
/>
)}
{accountList.map(([key, account]) => (
<AccountOption
key={key}
account={account.address}
selected={value === account.address}
onSelect={() => {
onValueChanged(account.address)
setOpen(false)
}}
/>
))}
{queryInfo.isValid && (
<AccountOption
account={query}
selected={value === query}
onSelect={() => {
onValueChanged(query)
setOpen(false)
}}
/>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
const AccountOption: FC<{
account: string
selected: boolean
onSelect: () => void
}> = ({ account, selected, onSelect }) => {
const details = useStateObservable(accountDetail$(account))
const identity = useStateObservable(identity$(account))
const name = identity?.displayName ?? details?.name
return (
<CommandItem
value={account + "_" + name}
onSelect={onSelect}
className="flex flex-row items-center gap-2 p-1"
forceSvgSize={false}
>
<AccountIdDisplay value={account} className="overflow-hidden" />
<Check
size={12}
className={cn(
"ml-auto shrink-0",
selected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
)
}
+208
View File
@@ -0,0 +1,208 @@
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { groupBy } from "@/lib/groupBy"
import { accounts$ } from "@/state/accounts.state"
import { getPublicKey } from "@/state/extension-accounts.state"
import { identity$, isVerified } from "@/state/identity.state"
import { cn } from "@/utils/cn"
import {
getSs58AddressInfo,
SS58String,
} from "@polkadot-api/substrate-bindings"
import { toHex } from "@polkadot-api/utils"
import { state, useStateObservable } from "@react-rxjs/core"
import { combineKeys } from "@react-rxjs/utils"
import { Check, ChevronsUpDown } from "lucide-react"
import { FC, useState } from "react"
import { filter, map, switchMap, take } from "rxjs"
const acconutMap$ = accounts$.pipeState(
map((accounts) => groupBy(accounts, (acc) => acc.accountId)),
map((groups) =>
Object.fromEntries(
Object.entries(groups)
.filter(([accountId]) => !accountId.startsWith("0x"))
.map(([accountId, [groupRep]]) => [accountId, groupRep]),
),
),
)
const hintedAccounts$ = state(
combineKeys(
acconutMap$.pipe(map((groups) => Object.keys(groups))),
(address) =>
acconutMap$.pipe(
map((v) => v[address]),
filter((v) => !!v),
take(1),
switchMap((details) =>
identity$(details.accountId).pipe(
map((identity) => ({
address: details.accountId,
name: identity?.displayName ?? details.name,
isVerified: isVerified(identity),
})),
),
),
),
).pipe(map((v) => new Map(v))),
new Map<
string,
{
address: string
name: string | undefined
isVerified: boolean | undefined
}
>(),
)
export const AccountIdInput: FC<{
value: SS58String | null
onValueChanged: (value: SS58String | null) => void
className?: string
}> = ({ value, onValueChanged, className }) => {
const accounts = useStateObservable(hintedAccounts$)
const [query, setQuery] = useState("")
const queryInfo = getSs58AddressInfo(query)
const [open, _setOpen] = useState(false)
const setOpen = (value: boolean) => {
_setOpen(value)
setQuery("")
}
const valueIsNew =
value !== null &&
!accounts.has(toHex(getPublicKey(value) ?? new Uint8Array()))
const accountList = Array.from(accounts.entries())
if (value !== null) {
accountList.sort(([, a], [, b]) =>
a.address === value ? -1 : b.address === value ? 1 : 0,
)
}
const onTriggerKeyDown = (evt: React.KeyboardEvent) => {
if (evt.key.length === 1) {
setOpen(true)
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild onKeyDown={onTriggerKeyDown}>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"flex w-64 justify-between overflow-hidden px-2 border border-border bg-input",
className,
)}
forceSvgSize={false}
>
{value !== null ? (
<AccountIdDisplay value={value} className="overflow-hidden" />
) : (
<span className="opacity-80">Select…</span>
)}
<ChevronsUpDown size={14} className="opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0">
<Command>
<CommandInput
placeholder="Filter or insert…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>
<div className="text-foreground/50">
The value is not a valid Account ID
</div>
</CommandEmpty>
<CommandGroup>
{valueIsNew && (
<AccountOption
account={value}
selected={true}
onSelect={() => setOpen(false)}
/>
)}
{accountList.map(([key, account]) => (
<AccountOption
key={key}
account={account.address}
selected={value === account.address}
onSelect={() => {
onValueChanged(account.address)
setOpen(false)
}}
/>
))}
{queryInfo.isValid && (
<AccountOption
account={query}
selected={value === query}
onSelect={() => {
onValueChanged(query)
setOpen(false)
}}
/>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
const accountDetail$ = state(
(accountId: SS58String) => acconutMap$.pipe(map((v) => v[accountId] ?? null)),
null,
)
const AccountOption: FC<{
account: string
selected: boolean
onSelect: () => void
}> = ({ account, selected, onSelect }) => {
const details = useStateObservable(accountDetail$(account))
const identity = useStateObservable(identity$(account))
const name = identity?.displayName ?? details?.name
return (
<CommandItem
value={account + "_" + name}
onSelect={onSelect}
className="flex flex-row items-center gap-2 p-1"
forceSvgSize={false}
>
<AccountIdDisplay value={account} className="overflow-hidden" />
<Check
size={12}
className={cn(
"ml-auto shrink-0",
selected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
)
}
-2
View File
@@ -21,7 +21,6 @@ import chopsticksLogoLight from "./icons/chopsticks_light.svg"
import enumSvg from "./icons/enum.svg"
import focusSvg from "./icons/focus.svg"
import switchBinarySvg from "./icons/switch_binary.svg"
import walletConnectSvg from "./icons/walletConnect.svg"
type CustomIconProps = Omit<Props, "ref" | "src"> & { size?: number }
const CustomIcon: FC<
@@ -62,7 +61,6 @@ const themeIcon = (light: string, dark: string) => (props: CustomIconProps) => {
export const Focus = customIcon(focusSvg)
export const Enum = customIcon(enumSvg)
export const BinaryEdit = customIcon(binarySvg)
export const WalletConnect = customIcon(walletConnectSvg)
export const SwitchBinary = customIcon(switchBinarySvg)
export const Chopsticks = themeIcon(chopsticksLogoLight, chopsticksLogoDark)
+28
View File
@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
import { chainProperties$ } from "@/state/chain-props.state"
import { useStateObservable } from "@react-rxjs/core"
import { FC } from "react"
export const TokenAmount: FC<{
children: bigint
className?: string
}> = ({ children, className }) => {
const properties = useStateObservable(chainProperties$)
if (!properties) return null
if (properties?.tokenDecimals == null) {
return children.toLocaleString()
}
const formattedValue = (
Number(children) /
10 ** properties.tokenDecimals
).toLocaleString(undefined)
return (
<span
className={cn("tabular-nums", className)}
>{`${formattedValue} ${properties.tokenSymbol}`}</span>
)
}
+31
View File
@@ -1,4 +1,7 @@
import { state } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { useState } from "react"
import { map } from "rxjs"
import { getHashParams, setHashParams } from "../hashParams"
interface Parser<T> {
@@ -89,3 +92,31 @@ const sessionHashState: ExternalState = {
},
}
export const useHashSessionState = createExternalStateHook(sessionHashState)
export const createLocalStorageState = <T>(
key: string,
defaultValue: T,
serializer: {
stringify: (value: Exclude<T, null>) => string
parse: (value: string) => T | null
} = JSON,
) => {
const [valueChange$, setValue] = createSignal<T | null>()
valueChange$.subscribe((v) =>
v === null
? localStorage.removeItem(key)
: localStorage.setItem(key, serializer.stringify(v as Exclude<T, null>)),
)
const state$ = state(
() => valueChange$.pipe(map((v) => (v === null ? defaultValue : v))),
() => {
const initialValueStr = localStorage.getItem(key)
return initialValueStr != null
? (serializer.parse(initialValueStr) ?? defaultValue)
: defaultValue
},
)
return [state$, setValue] as const
}
+190
View File
@@ -0,0 +1,190 @@
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { TokenAmount } from "@/components/TokenAmount"
import { Button } from "@/components/ui/button"
import {
Account,
accounts$,
AccountSource,
accountSourceTypeToName,
} from "@/state/accounts.state"
import { chainProperties$ } from "@/state/chain-props.state"
import { client$ } from "@/state/chains/chain.state"
import { MultiAddress, polkadot_people } from "@polkadot-api/descriptors"
import {
liftSuspense,
sinkSuspense,
state,
useStateObservable,
} from "@react-rxjs/core"
import { Send } from "lucide-react"
import { CompatibilityLevel, SS58String } from "polkadot-api"
import { FC } from "react"
import { Link } from "react-router-dom"
import { catchError, combineLatest, map, switchMap } from "rxjs"
import { knownExtensions } from "./Providers"
export const AccountList = () => {
const accounts = useStateObservable(accounts$)
return (
<div className="bg-card p-4 rounded-xl space-y-4">
<h3 className="text-xl font-bold">Accounts</h3>
<ul className="space-y-4">
{accounts.map((account, i) => (
<AccountCard key={i} account={account} />
))}
</ul>
</div>
)
}
const AccountCard: FC<{
account: Account
}> = ({ account }) => {
return (
<li className="shadow border rounded-xl p-4 space-y-4">
<div className="flex justify-between items-start overflow-hidden">
<AccountIdDisplay
value={account.accountId}
className="shrink-1 overflow-hidden"
/>
<SourceTag account={account} />
</div>
{account.accountId.startsWith("0x") ? null : (
<Balances accountId={account.accountId} />
)}
</li>
)
}
const sourceColors: Record<AccountSource, string> = {
extension: "var(--color-lime-700)",
walletconnect: "var(--color-blue-700)",
readonly: "var(--color-orange-700)",
}
const getExtensionName = (id: string) => knownExtensions[id]?.name ?? id
const SourceTag: FC<{
account: Account
}> = ({ account }) => {
return (
<div
className="rounded px-2 shrink-0"
style={{
color: sourceColors[account.type],
backgroundColor: `color-mix(in srgb, ${sourceColors[account.type]}, transparent 90%)`,
}}
>
{account.type === "extension"
? getExtensionName(account.extensionId)
: accountSourceTypeToName[account.type]}
</div>
)
}
const typedApi$ = client$.pipeState(map((v) => v.getTypedApi(polkadot_people)))
const balance$ = state(
(accountId: SS58String) =>
typedApi$.pipe(
switchMap((typedApi) =>
typedApi.query.System.Account.watchValue(accountId),
),
map((account) => {
const { reserved, free, frozen } = account.data
const total = reserved + free
// TODO ED
const untouchable = total == 0n ? 0n : maxBigInt(frozen - reserved, 0n)
return {
total,
spendable: free - untouchable,
frozen,
reserved,
}
}),
liftSuspense(),
catchError((ex) => {
console.error(ex)
return [null]
}),
sinkSuspense(),
),
null,
)
const getTransferCallData$ = state(
combineLatest([typedApi$, chainProperties$]).pipe(
switchMap(async ([typedApi, chainProperties]) => {
const tokenDecimals = chainProperties?.tokenDecimals
if (tokenDecimals == null) return null
const token = await typedApi.compatibilityToken
if (
!typedApi.tx.Balances.transfer_keep_alive.isCompatible(
CompatibilityLevel.BackwardsCompatible,
token,
)
) {
return null
}
return (dest: SS58String) => {
const value = 10n ** BigInt(tokenDecimals)
return typedApi.tx.Balances.transfer_keep_alive({
dest: MultiAddress.Id(dest),
value,
})
.getEncodedData(token)
.asHex()
}
}),
),
null,
)
const Balances: FC<{
accountId: SS58String
}> = ({ accountId }) => {
const balance = useStateObservable(balance$(accountId))
const getTransferCallData = useStateObservable(getTransferCallData$)
if (!balance) return null
return (
<div className="flex flex-row">
<div className="flex flex-col sm:flex-row justify-center gap-x-8 grow">
<div className="flex flex-row sm:flex-col gap-2">
<div className="text-muted-foreground">Balance</div>
<div>
<TokenAmount>{balance.total}</TokenAmount>
</div>
</div>
<div className="flex flex-row sm:flex-col gap-2">
<div className="text-muted-foreground">Transferable</div>
<TokenAmount className="text-green-800 font-bold">
{balance.spendable}
</TokenAmount>
</div>
<div className="flex flex-row sm:flex-col gap-2">
<div className="text-muted-foreground">Locked</div>
<TokenAmount className="text-orange-800">
{balance.total - balance.spendable}
</TokenAmount>
</div>
</div>
{getTransferCallData ? (
<Button variant="secondary" asChild>
<Link to={`/extrinsics#data=${getTransferCallData(accountId)}`}>
<Send /> Transfer
</Link>
</Button>
) : null}
</div>
)
}
const maxBigInt = (a: bigint, b: bigint) => (a > b ? a : b)
+11
View File
@@ -0,0 +1,11 @@
import { AccountList } from "./AccountList"
import { Providers } from "./Providers"
export const Accounts = () => {
return (
<div className="p-4 space-y-4">
<Providers />
<AccountList />
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { AccountIdInput } from "@/components/AccountIdInput"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"
import { readOnlyAccounts$, setAddresses } from "@/state/accounts.state"
import { useStateObservable } from "@react-rxjs/core"
import { Eye, Trash2 } from "lucide-react"
import { SS58String } from "polkadot-api"
import { useState } from "react"
import { SourceButton } from "./SourceButton"
export const AddressProvider = () => (
<Dialog>
<DialogTrigger asChild>
<SourceButton label="Address">
<div>
<Eye className="size-10" />
</div>
</SourceButton>
</DialogTrigger>
<DialogContent>
<ManageAddresses />
</DialogContent>
</Dialog>
)
const ManageAddresses = () => {
const [addressInput, setAddressInput] = useState<SS58String | null>(null)
const accounts = useStateObservable(readOnlyAccounts$)
return (
<div className="space-y-4 overflow-hidden">
<form
onSubmit={(evt) => {
evt.preventDefault()
if (!addressInput) return
setAddresses([
...accounts
.map((v) => v.accountId)
.filter((v) => v !== addressInput),
addressInput,
])
setAddressInput(null)
}}
>
<h3 className="font-medium text-muted-foreground">
Add read-only address
</h3>
<div className="flex gap-2 items-center">
<AccountIdInput
className="grow"
value={addressInput}
onValueChanged={setAddressInput}
/>
<Button variant="secondary" disabled={!addressInput}>
Add
</Button>
</div>
</form>
{accounts.length ? (
<div>
<h3 className="font-medium text-muted-foreground">Added addresses</h3>
<ul className="space-y-3">
{accounts.map((account) => (
<li key={account.accountId} className="flex gap-2 items-center">
<Button
variant="outline"
className="text-destructive border-destructive"
type="button"
onClick={() =>
setAddresses(
accounts
.map((v) => v.accountId)
.filter((v) => account.accountId !== v),
)
}
>
<Trash2 />
</Button>
<AccountIdDisplay value={account.accountId} />
</li>
))}
</ul>
</div>
) : null}
</div>
)
}
+131
View File
@@ -0,0 +1,131 @@
import { Spinner } from "@/components/Icons"
import {
availableExtensions$,
onToggleExtension,
selectedExtensions$,
} from "@/state/extension-accounts.state"
import { state, useStateObservable } from "@react-rxjs/core"
import { CircleQuestionMark } from "lucide-react"
import { FC } from "react"
import { defer, from, switchMap } from "rxjs"
import { AddressProvider } from "./AddressProvider"
import { SourceButton } from "./SourceButton"
const lazyWalletConnectStatus$ = state(
defer(() =>
from(import("@/state/walletconnect.state")).pipe(
switchMap(({ walletConnectStatus$ }) => walletConnectStatus$),
),
),
{
type: "disconnected",
},
)
export const knownExtensions: Record<string, { name: string; logo: string }> = {
"polkadot-js": {
name: "Polkadot JS",
logo: import.meta.env.BASE_URL + "providers/polkadotjs.webp",
},
"nova-wallet": {
name: "Nova Wallet",
logo: import.meta.env.BASE_URL + "providers/novawallet.webp",
},
talisman: {
name: "Talisman",
logo: import.meta.env.BASE_URL + "providers/talisman.webp",
},
"subwallet-js": {
name: "Subwallet",
logo: import.meta.env.BASE_URL + "providers/subwallet.webp",
},
}
export const Providers = () => {
const availableExtensions = useStateObservable(availableExtensions$).sort(
(a, b) => (b in knownExtensions ? 1 : 0) - (a in knownExtensions ? 1 : 0),
)
const walletConnectStatus = useStateObservable(lazyWalletConnectStatus$)
return (
<div className="bg-card p-4 rounded-xl">
<h3 className="text-xl font-bold">Account Providers</h3>
<ul className="flex gap-2 flex-wrap items-center justify-center">
{availableExtensions.map((id) => (
<li key={id}>
<ExtensionButton id={id} />
</li>
))}
<AddressProvider />
{/* <SourceButton label="Ledger" disabled>
<img
src={import.meta.env.BASE_URL + "providers/ledger.webp"}
alt="Ledger"
className="h-10 rounded"
/>
</SourceButton> */}
{/* <SourceButton label="Vault" disabled>
<img
src={import.meta.env.BASE_URL + "providers/vault.webp"}
alt="Vault"
className="h-10 rounded"
/>
</SourceButton> */}
<SourceButton
label="Wallet Connect"
isSelected={walletConnectStatus.type === "connected"}
onClick={async () => {
const { toggleWalletConnect } = await import(
"@/state/walletconnect.state"
)
toggleWalletConnect()
}}
>
{walletConnectStatus.type === "connecting" ? (
<Spinner className="size-4 m-3 text-sky-500" />
) : (
<img
src={import.meta.env.BASE_URL + "providers/walletConnect.svg"}
alt="Wallet Connect"
className="h-10 rounded"
/>
)}
</SourceButton>
</ul>
</div>
)
}
const ExtensionButton: FC<{
id: string
}> = ({ id }) => {
const knownExtension = knownExtensions[id]
const connectedExtensions = Array.from(
useStateObservable(selectedExtensions$).keys(),
)
const isSelected = connectedExtensions.includes(id)
return (
<SourceButton
isSelected={isSelected}
label={knownExtension?.name ?? id}
onClick={() => onToggleExtension(id)}
>
{knownExtension ? (
<img
src={knownExtension.logo}
alt={knownExtension.name}
className="h-10 rounded"
/>
) : (
<div>
<CircleQuestionMark
className="size-10 text-muted-foreground"
strokeWidth={1}
/>
</div>
)}
</SourceButton>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { Button } from "@/components/ui/button"
import { cn } from "@/utils"
import { FC, MouseEvent, PropsWithChildren } from "react"
export const SourceButton: FC<
PropsWithChildren<{
label: string
isSelected?: boolean
className?: string
onClick?: (evt: MouseEvent) => void
disabled?: boolean
}>
> = ({ label, isSelected, onClick, className, children, disabled }) => (
<Button
variant="outline"
className={cn("h-auto min-w-40", isSelected ? "bg-accent" : "")}
onClick={onClick}
disabled={disabled}
forceSvgSize={false}
>
{children}
<div className="text-left">
<span className={cn("font-bold", className)}>{label}</span>
</div>
</Button>
)
+1
View File
@@ -0,0 +1 @@
export * from "./Accounts"
@@ -1,6 +1,5 @@
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
import { WalletConnect } from "@/components/Icons"
import {
Select,
SelectContent,
@@ -10,18 +9,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { groupBy } from "@/lib/groupBy"
import { knownExtensions } from "@/pages/Accounts/Providers"
import {
accountsByExtension$,
extensionAccounts$,
selectedExtensions$,
} from "@/state/extension-accounts.state"
import {
walletConnectAccounts$,
walletConnectStatus$,
} from "@/state/walletconnect.state"
import { state, useStateObservable } from "@react-rxjs/core"
Account,
accounts$,
AccountSource,
accountSourceTypeToName,
} from "@/state/accounts.state"
import { selectedExtensions$ } from "@/state/extension-accounts.state"
import { walletConnectStatus$ } from "@/state/walletconnect.state"
import { state, useStateObservable, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { InjectedExtension } from "polkadot-api/pjs-signer"
import React from "react"
import {
combineLatest,
@@ -33,53 +32,6 @@ import {
tap,
} from "rxjs"
const Accounts: React.FC<{ extension: InjectedExtension }> = ({
extension,
}) => {
const accounts = useStateObservable(extensionAccounts$(extension.name))
return (
<SelectGroup>
<SelectLabel>{extension.name}</SelectLabel>
{accounts.map((account) => (
<SelectItem
key={account.address}
value={account.address + "-" + extension.name}
>
{account.address.startsWith("0x") ? (
<EthAccountDisplay value={account.address} />
) : (
<AccountIdDisplay value={account.address} />
)}
</SelectItem>
))}
</SelectGroup>
)
}
const WalletConnectAccounts = () => {
const accounts = useStateObservable(walletConnectAccounts$)
if (!Object.keys(accounts).length) return null
return (
<SelectGroup>
<SelectLabel className="flex gap-1">
<WalletConnect /> Wallet Connect
</SelectLabel>
{Object.keys(accounts).map((address) => (
<SelectItem key={address} value={address + "-" + "wallet_connect"}>
{address.startsWith("0x") ? (
<EthAccountDisplay value={address} />
) : (
<AccountIdDisplay value={address} />
)}
</SelectItem>
))}
</SelectGroup>
)
}
const [valueSelected$, selectValue] = createSignal<string>()
const LS_KEY = "selected-signer"
@@ -91,54 +43,55 @@ const selectedValue$ = state(
null,
)
const allAccounts$ = accounts$.pipeState(
map((v) =>
v.map((acc) => ({
...acc,
id: `${acc.accountId}-${acc.type === "extension" ? acc.extensionId : acc.type}`,
})),
),
withDefault([]),
)
export const selectedAccount$ = state(
combineLatest([
selectedValue$,
accountsByExtension$,
walletConnectAccounts$,
]).pipe(
map(([selectedAccount, accountsByExtension, walletConnectAccounts]) => {
combineLatest([selectedValue$, allAccounts$]).pipe(
map(([selectedAccount, accounts]): Account | null => {
if (!selectedAccount) return null
const [address, ...rest] = selectedAccount.split("-")
const signer = rest.join("-")
const account = accounts.find((account) => account.id === selectedAccount)
if (signer === "wallet_connect") {
return address in walletConnectAccounts
? {
polkadotSigner: walletConnectAccounts[address],
}
: null
}
const accounts = accountsByExtension.get(signer)
if (!accounts) return null
return accounts.find((account) => account.address === address) ?? null
return account ?? null
}),
distinctUntilChanged(),
),
null,
)
const allAccounts$ = state(
combineLatest([walletConnectAccounts$, accountsByExtension$]).pipe(
map(([wcAccounts, accounts]) => [
...Object.keys(wcAccounts).map((account) => `${account}-wallet_connect`),
...[...accounts.entries()].flatMap(([extension, accounts]) =>
accounts.map((account) => `${account.address}-${extension}`),
),
]),
const groupedAccounts$ = allAccounts$.pipeState(
map((v) =>
groupBy(
// Accept only signer accounts for submitting transactions
v.filter((v) => v.signer != null),
(acc) => (acc.type === "extension" ? acc.extensionId : acc.type),
),
),
[],
)
const groupIdToName = (groupId: string) => {
if (groupId in knownExtensions) {
return knownExtensions[groupId].name
}
return accountSourceTypeToName[groupId as AccountSource] ?? groupId
}
export const AccountProvider: React.FC = () => {
const value = useStateObservable(selectedValue$)
const extensions = useStateObservable(selectedExtensions$)
const walletConnect = useStateObservable(walletConnectStatus$)
const allAccounts = useStateObservable(allAccounts$)
const groupedAccounts = useStateObservable(groupedAccounts$)
const activeExtensions = [...extensions.values()].filter((v) => !!v)
const valueExists = value && allAccounts.includes(value)
const valueExists = value && allAccounts.some((acc) => acc.id === value)
if (!activeExtensions.length && walletConnect.type !== "connected")
return null
@@ -152,10 +105,22 @@ export const AccountProvider: React.FC = () => {
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{activeExtensions.map((extension) => (
<Accounts key={extension.name} extension={extension} />
{Object.entries(groupedAccounts).map(([groupId, accounts]) => (
<SelectGroup key={groupId}>
<SelectLabel className="flex gap-1">
{groupIdToName(groupId)}
</SelectLabel>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.accountId.startsWith("0x") ? (
<EthAccountDisplay value={account.accountId} />
) : (
<AccountIdDisplay value={account.accountId} />
)}
</SelectItem>
))}
</SelectGroup>
))}
<WalletConnectAccounts />
</SelectContent>
</Select>
)
@@ -1,4 +1,3 @@
import { Spinner, WalletConnect } from "@/components/Icons"
import { Label } from "@/components/ui/label"
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
@@ -6,16 +5,11 @@ import {
onToggleExtension,
selectedExtensions$,
} from "@/state/extension-accounts.state"
import {
toggleWalletConnect,
walletConnectStatus$,
} from "@/state/walletconnect.state"
import { useStateObservable } from "@react-rxjs/core"
export const ExtensionProvider: React.FC = () => {
const availableExtensions = useStateObservable(availableExtensions$)
const selectedExtensions = useStateObservable(selectedExtensions$)
const walletConnectStatus = useStateObservable(walletConnectStatus$)
return (
<>
@@ -31,18 +25,6 @@ export const ExtensionProvider: React.FC = () => {
{extensionName}
</TabsTrigger>
))}
<TabsTrigger
className="mx-1 flex gap-1"
onClick={() => toggleWalletConnect()}
active={walletConnectStatus.type === "connected"}
>
{walletConnectStatus.type === "connecting" ? (
<Spinner size={16} className="text-sky-500" />
) : (
<WalletConnect />
)}{" "}
Wallet Connect
</TabsTrigger>
</TabsList>
</>
)
@@ -22,11 +22,13 @@ const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
return (
<ActionButton
onClick={async () => {
if (!account?.signer) return
setIsSigning(true)
try {
const unsafeApi = await firstValueFrom(unsafeApi$)
const tx = await unsafeApi.txFromCallData(Binary.fromHex(callData))
const signedExtrinsic = await tx.sign(account!.polkadotSigner)
const signedExtrinsic = await tx.sign(account.signer)
trackSignedTx(signedExtrinsic)
onClose()
} catch (ex) {
+36 -4
View File
@@ -6,6 +6,8 @@ import { FC, PropsWithChildren, useState } from "react"
import { useLocation } from "react-router-dom"
import { twMerge } from "tailwind-merge"
import { NetworkSwitcher } from "./Network/Network"
import SliderToggle from "@/components/Toggle"
import { changeTheme, useTheme } from "@/ThemeProvider"
const navigationItems = [
{ path: "/explorer", label: "Explorer", important: true },
@@ -13,8 +15,7 @@ const navigationItems = [
{ path: "/extrinsics", label: "Extrinsics", important: true },
{ path: "/constants", label: "Constants", important: false },
{ path: "/runtimeCalls", label: "Runtime Calls", important: true },
{ path: "/rpcCalls", label: "RPC Calls", important: false },
{ path: "/metadata", label: "Metadata", important: false },
{ path: "/accounts", label: "Accounts", important: false },
]
export const Header = () => {
@@ -61,7 +62,7 @@ export const Header = () => {
<Button
variant="ghost"
size="icon"
className="lg:hidden text-foreground hover:bg-accent"
className="text-foreground hover:bg-accent"
>
<Menu className="h-5 w-5" />
</Button>
@@ -71,7 +72,7 @@ export const Header = () => {
className="bg-background border-accent w-64 pt-10"
>
<NetworkSwitcher forSmallScreen />
<nav className="flex flex-col gap-4">
<nav className="flex flex-col gap-4 lg:hidden">
{navigationItems
.filter(({ important }) => !important)
.map(({ path, label }) => (
@@ -84,6 +85,23 @@ export const Header = () => {
</NavLink>
))}
</nav>
<NavLink to="/metadata" onClick={() => setIsOpen(false)}>
Metadata
</NavLink>
<NavLink to="/rpcCalls" onClick={() => setIsOpen(false)}>
RPC Calls
</NavLink>
<hr />
<ThemeToggle />
<div className="grow" />
<div className="border-t p-2 text-right">
<a
href="https://github.com/polkadot-api/papi-console"
target="_blank"
>
github
</a>
</div>
</SheetContent>
</Sheet>
</div>
@@ -112,3 +130,17 @@ const NavLink: FC<
</Link>
)
}
const ThemeToggle = () => {
const theme = useTheme()
return (
<label className="flex items-center justify-between px-4">
<div>Dark mode</div>
<SliderToggle
isToggled={theme === "dark"}
toggle={() => changeTheme(theme === "dark" ? "light" : "dark")}
/>
</label>
)
}
+1 -1
View File
@@ -39,7 +39,7 @@ export const Lookup: FC = () => {
<label>
Id:{" "}
<input
className="text-sm border rounded p-2 border-polkadot-200 leading-tight text-white"
className="text-sm border rounded p-2 border-polkadot-200 leading-tight dark:text-white"
value={id}
onChange={(evt) => {
setSearch("")
+117
View File
@@ -0,0 +1,117 @@
import { createLocalStorageState } from "@/lib/externalState"
import { state } from "@react-rxjs/core"
import type { HexString, PolkadotSigner, SS58String } from "polkadot-api"
import type { InjectedPolkadotAccount } from "polkadot-api/pjs-signer"
import { combineLatest, defer, from, map, switchMap } from "rxjs"
import { accountsByExtension$, getPublicKey } from "./extension-accounts.state"
import { getPolkadotSigner } from "polkadot-api/signer"
import { canSetStorage$ } from "./chains/chain.state"
export type AccountSource = "extension" | "walletconnect" | "readonly"
export const accountSourceTypeToName: Record<AccountSource, string> = {
extension: "Extension",
walletconnect: "Wallet Connect",
readonly: "Read-only",
}
const lazyWalletConnectAccounts$ = defer(() =>
from(import("@/state/walletconnect.state")).pipe(
switchMap(({ walletConnectAccounts$ }) => walletConnectAccounts$),
),
)
interface BaseAccount {
type: AccountSource
accountId: SS58String | HexString
name?: string
}
interface SignerAccount extends BaseAccount {
signer: PolkadotSigner
}
export interface ExtensionAccount extends SignerAccount {
type: "extension"
extensionId: string
injected: InjectedPolkadotAccount
}
export const extensionAccounts$ = state(
accountsByExtension$.pipe(
map((extensionAccounts) =>
Array.from(extensionAccounts.entries()).flatMap(([extension, accounts]) =>
accounts.map(
(account): ExtensionAccount => ({
type: "extension",
accountId: account.address,
name: account.name,
extensionId: extension,
injected: account,
signer: account.polkadotSigner,
}),
),
),
),
),
[],
)
export interface WalletConnectAccount extends SignerAccount {
type: "walletconnect"
}
export const walletConnectAccounts$ = state(
lazyWalletConnectAccounts$.pipe(
map((accounts) =>
Object.entries(accounts).map(
([accountId, signer]): WalletConnectAccount => ({
type: "walletconnect",
accountId,
signer,
}),
),
),
),
[],
)
export interface ReadOnlyAccount extends BaseAccount {
type: "readonly"
// Chopsticks can fake a signer
signer?: PolkadotSigner
}
const [readOnlyAddresses$, setAddresses] = createLocalStorageState(
"read-only-addr",
[] as string[],
)
export { setAddresses }
export const readOnlyAccounts$ = state(
combineLatest([defer(readOnlyAddresses$), canSetStorage$]).pipe(
map(([v, isChopsticks]) =>
v.map(
(address): ReadOnlyAccount => ({
type: "readonly",
accountId: address,
signer: isChopsticks
? getPolkadotSigner(getPublicKey(address)!, "Sr25519", () => {
// From https://wiki.acala.network/build/sdks/homa
const signature = new Uint8Array(64)
signature.fill(0xcd)
signature.set([0xde, 0xad, 0xbe, 0xef])
return signature
})
: undefined,
}),
),
),
),
[],
)
export type Account = ExtensionAccount | WalletConnectAccount | ReadOnlyAccount
export const accounts$ = state(
combineLatest([
extensionAccounts$,
walletConnectAccounts$,
readOnlyAccounts$,
]).pipe(map((v): Account[] => v.flat())),
[],
)
+29
View File
@@ -0,0 +1,29 @@
import { withDefault } from "@react-rxjs/core"
import { map, switchMap } from "rxjs"
import { client$ } from "./chains/chain.state"
export const chainProperties$ = client$.pipeState(
switchMap((v) => v.getChainSpecData()),
map((v) => v.properties),
map(
(
v,
): {
ss58Format?: number
tokenDecimals?: number
tokenSymbol?: string
} => {
if (v && typeof v === "object") {
const { ss58Format, tokenDecimals, tokenSymbol } = v
return {
ss58Format,
tokenDecimals,
tokenSymbol,
}
}
return {}
},
),
withDefault(null),
)
+1 -1
View File
@@ -157,7 +157,7 @@ export const selectedExtensions$ = extensions$.pipeState(
withDefault(new Map<string, InjectedExtension | null>()),
)
export const extensionAccounts$ = state(
const extensionAccounts$ = state(
(name: string) =>
extension$(name).pipe(
switchMap((x) => {
+2
View File
@@ -3,4 +3,6 @@ import { Polkadot_peopleWhitelistEntry } from "@polkadot-api/descriptors"
export const whitelist: Polkadot_peopleWhitelistEntry[] = [
"query.Identity.IdentityOf",
"query.Identity.SuperOf",
"query.System.Account",
"tx.Balances.transfer_keep_alive",
]