Merge pull request #96 from polkadot-api/vo/polkahub

feat: migrate accounts to polkahub
This commit is contained in:
Victor Oliva
2025-11-07 18:01:13 +01:00
committed by GitHub
39 changed files with 2220 additions and 2480 deletions
+15 -18
View File
@@ -14,45 +14,43 @@
"semi": false
},
"dependencies": {
"@acala-network/chopsticks-core": "^1.2.3",
"@noble/hashes": "^2.0.1",
"@acala-network/chopsticks-core": "^1.2.4",
"@ledgerhq/hw-transport-webusb": "^6.29.13",
"@polkadot-api/descriptors": "file:.papi/descriptors",
"@polkadot-api/json-rpc-provider-proxy": "^0.2.5",
"@polkadot-api/metadata-builders": "^0.13.6",
"@polkadot-api/observable-client": "^0.15.2",
"@polkadot-api/react-builder": "0.3.8",
"@polkadot-api/substrate-bindings": "^0.16.4",
"@polkadot-api/tx-utils": "^0.1.7",
"@polkadot-api/metadata-builders": "^0.13.7",
"@polkadot-api/observable-client": "^0.16.0",
"@polkadot-api/react-builder": "0.3.9",
"@polkadot-api/react-components": "^0.4.1",
"@polkadot-api/substrate-bindings": "^0.16.5",
"@polkadot-api/tx-utils": "^0.1.8",
"@polkadot-api/utils": "^0.2.0",
"@polkahub/ui-components": "0.2.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-rxjs/core": "^0.10.8",
"@react-rxjs/utils": "^0.9.7",
"@walletconnect/modal": "^2.7.0",
"@walletconnect/universal-provider": "^2.22.3",
"@walletconnect/utils": "^2.22.3",
"blo": "^2.0.0",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.545.0",
"polkadot-api": "^1.20.0",
"lucide-react": "^0.553.0",
"polkadot-api": "^1.20.1",
"polkahub": "0.2.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-portal": "^4.3.0",
"react-router-dom": "^7.9.4",
"react-router-dom": "^7.9.5",
"react-svg": "^16.3.0",
"react-virtuoso": "^4.14.1",
"react18-json-view": "^0.2.9",
@@ -70,7 +68,6 @@
"@types/react-dom": "^19.2.2",
"@types/react-portal": "^4.0.7",
"@vitejs/plugin-react": "^5.0.4",
"@walletconnect/types": "^2.22.3",
"eslint": "^9.37.0",
"eslint-plugin-react-hooks": "7.0.0",
"eslint-plugin-react-refresh": "^0.4.23",
+1638 -564
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

-11
View File
@@ -1,11 +0,0 @@
<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"
></path>
<path
fill="#fff"
d="m223.045 207.88-48.044-47.748-48.045 47.748-48.045-47.748-20.577 20.45 68.622 68.222 48.045-47.748 48.044 47.748 68.622-68.222-20.577-20.45-48.045 47.748Z"
></path>
</svg>

Before

Width:  |  Height:  |  Size: 516 B

+2
View File
@@ -9,6 +9,7 @@ import { RpcCalls } from "./pages/RpcCalls"
import { RuntimeCalls } from "./pages/RuntimeCalls"
import { Storage } from "./pages/Storage"
import { Transactions } from "./pages/Transactions"
import { VaultTxModal } from "polkahub"
export default function App() {
return (
@@ -30,6 +31,7 @@ export default function App() {
</div>
</div>
<Transactions />
<VaultTxModal />
</div>
)
}
@@ -1,9 +1,12 @@
import { AccountIdInput } from "@/components/AccountIdInput"
import { EditAccountId, NOTIN } from "@polkadot-api/react-builder"
import { AddressInput } from "polkahub"
export const CAccountId: EditAccountId = ({ value, onValueChanged }) => (
<AccountIdInput
<AddressInput
className="w-64"
triggerClassName="h-9 bg-input"
value={value === NOTIN ? null : value}
onValueChanged={(v) => onValueChanged(v === null ? NOTIN : v)}
onChange={(v) => onValueChanged(v === null ? NOTIN : v)}
disableClear
/>
)
@@ -13,7 +13,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { accountDetail$, accounts$ } from "@/state/extension-accounts.state"
import { accountDetail$, addrToAccount$ } from "@/state/polkahub"
import { cn } from "@/utils/cn"
import { EditEthAccount, NOTIN } from "@polkadot-api/react-builder"
import { ethAccount, HexString } from "@polkadot-api/substrate-bindings"
@@ -25,17 +25,15 @@ import { map, take } from "rxjs"
const hintedAccounts$ = state(
combineKeys(
accounts$.pipe(
addrToAccount$.pipe(
map((accounts) =>
[...accounts.entries()]
.filter(([, { address }]) => address.startsWith("0x"))
.map(([key]) => key),
Object.keys(accounts).filter((address) => address.startsWith("0x")),
),
),
(account) =>
accounts$.pipe(
addrToAccount$.pipe(
map((v) => {
const details = v.get(account)!
const details = v[account]!
return {
address: details.address,
name: details.name,
@@ -103,7 +101,6 @@ export const CEthAccount: EditEthAccount = ({ value, onValueChanged }) => {
role="combobox"
aria-expanded={open}
className="flex w-64 justify-between overflow-hidden px-2 border border-border bg-input"
forceSvgSize={false}
>
{value !== NOTIN ? (
<EthAccountDisplay value={value} className="overflow-hidden" />
@@ -177,7 +174,6 @@ const AccountOption: FC<{
value={account + "_" + name}
onSelect={onSelect}
className="flex flex-row items-center gap-2 p-1"
forceSvgSize={false}
>
<EthAccountDisplay value={account} className="overflow-hidden" />
<Check
+18 -9
View File
@@ -1,14 +1,15 @@
import { PolkadotIdenticon } from "@/components/PolkadotIdenticon"
import { accountDetail$, getPublicKey } from "@/state/extension-accounts.state"
import { identity$, isVerified } from "@/state/identity.state"
import { accountDetail$ } from "@/state/polkahub"
import { EthIdenticon, PolkadotIdenticon } from "@polkadot-api/react-components"
import { useStateObservable } from "@react-rxjs/core"
import { CheckCircle } from "lucide-react"
import { SS58String } from "polkadot-api"
import { getSs58AddressInfo } from "polkadot-api"
import { AccountAddress } from "polkahub"
import { FC } from "react"
import { twMerge } from "tailwind-merge"
export const AccountIdDisplay: FC<{
value: SS58String
value: AccountAddress
className?: string
}> = ({ value, className }) => {
const details = useStateObservable(accountDetail$(value))
@@ -18,11 +19,15 @@ export const AccountIdDisplay: FC<{
return (
<div className={twMerge("flex items-center gap-2", className)}>
<PolkadotIdenticon
className="shrink-0"
publicKey={getPublicKey(value)}
size={28}
/>
{value.startsWith("0x") ? (
<EthIdenticon address={value} size={28} className="rounded" />
) : (
<PolkadotIdenticon
className="shrink-0"
publicKey={getPublicKey(value)}
size={28}
/>
)}
<div className="flex flex-col justify-center text-foreground leading-tight overflow-hidden">
{name && (
<span className="inline-flex items-center gap-1">
@@ -42,3 +47,7 @@ export const AccountIdDisplay: FC<{
</div>
)
}
const getPublicKey = (address: string) => {
const info = getSs58AddressInfo(address)
return info.isValid ? info.publicKey : null
}
-208
View File
@@ -1,208 +0,0 @@
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>
)
}
+1
View File
@@ -78,6 +78,7 @@ export function CommandPopover({
onClick={() => setOpen(true)}
placeholder={placeholder}
className="border-none focus:ring-0"
autoFocus
/>
{open && (
+2 -2
View File
@@ -1,9 +1,9 @@
import { accountDetail$ } from "@/state/extension-accounts.state"
import { accountDetail$ } from "@/state/polkahub"
import { EthIdenticon } from "@polkadot-api/react-components"
import { useStateObservable } from "@react-rxjs/core"
import { HexString } from "polkadot-api"
import { FC } from "react"
import { twMerge } from "tailwind-merge"
import { EthIdenticon } from "./EthIdenticon"
export const EthAccountDisplay: FC<{
value: HexString
-10
View File
@@ -1,10 +0,0 @@
import { HexString } from "polkadot-api"
import { FC } from "react"
import { blo } from "blo"
export const EthIdenticon: FC<{ address: HexString; size?: number }> = ({
address,
size,
}) => {
return <img className="rounded-full" src={blo(address as "0x", size)} />
}
-154
View File
@@ -1,154 +0,0 @@
// Based from https://github.com/paritytech/oo7/blob/251ba2b7c45503b68eab4320c270b5afa9bccb60/packages/polkadot-identicon/src/index.jsx
import { blake2b } from "@noble/hashes/blake2.js"
import { FC, SVGAttributes } from "react"
export const PolkadotIdenticon: FC<
Omit<SVGAttributes<SVGElement>, "viewBox"> & {
sixPoint?: boolean
publicKey: Uint8Array | null
size?: number
}
> = ({ sixPoint, publicKey, ...props }) => {
const svgProps = {
...props,
width: props.size ?? props.width ?? SIZE,
height: props.size ?? props.height ?? SIZE,
viewBox: `0 0 ${SIZE} ${SIZE}`,
}
if (!publicKey) {
return (
<svg {...svgProps}>
<circle cx={SIZE / 2} cy={SIZE / 2} r={SIZE / 2} fill="#eee" />
</svg>
)
}
const colors = getColors(publicKey)
const circles = getPositions(sixPoint)
return (
<svg {...svgProps}>
<circle cx={SIZE / 2} cy={SIZE / 2} r={SIZE / 2} fill="#eee" />
{circles.map(({ x, y }, i) => (
<circle key={i} cx={x} cy={y} r={z} fill={colors[i]} />
))}
</svg>
)
}
const SIZE = 64
const CENTER = SIZE / 2
const z = (SIZE / 64) * 5
type Schema = { freq: number; colors: number[] }
const schema: Record<string, Schema> = {
target: {
freq: 1,
colors: [0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 0, 28, 0, 1],
},
cube: {
freq: 20,
colors: [0, 1, 3, 2, 4, 3, 0, 1, 3, 2, 4, 3, 0, 1, 3, 2, 4, 3, 5],
},
quazar: {
freq: 16,
colors: [1, 2, 3, 1, 2, 4, 5, 5, 4, 1, 2, 3, 1, 2, 4, 5, 5, 4, 0],
},
flower: {
freq: 32,
colors: [0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 3],
},
cyclic: {
freq: 32,
colors: [0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 6],
},
vmirror: {
freq: 128,
colors: [0, 1, 2, 3, 4, 5, 3, 4, 2, 0, 1, 6, 7, 8, 9, 7, 8, 6, 10],
},
hmirror: {
freq: 128,
colors: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 8, 6, 7, 5, 3, 4, 2, 11],
},
}
const totalFreq = Object.keys(schema)
.map((k) => schema[k].freq)
.reduce((a, b) => a + b)
const findScheme = (d: number) => {
let cum = 0
for (const scheme of Object.values(schema)) {
cum += scheme.freq
if (d < cum) {
return scheme
}
}
throw "Unreachable"
}
const blake2512 = (input: Uint8Array) => blake2b(input, { dkLen: 64 })
const zero = blake2512(
new Uint8Array([
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0,
]),
)
const getColors = (publicKey: Uint8Array) => {
const id = Array.from(blake2512(publicKey)).map(
(x, i) => (x + 256 - zero[i]) % 256,
)
const sat = (Math.floor((id[29] * 70) / 256 + 26) % 80) + 30
const d = Math.floor((id[30] + id[31] * 256) % totalFreq)
const scheme = findScheme(d)
const palette = Array.from(id).map((x, i) => {
const b = (x + (i % 28) * 58) % 256
if (b == 0) {
return "#444"
}
if (b == 255) {
return "transparent"
}
const h = Math.floor(((b % 64) * 360) / 64)
const l = [53, 15, 35, 75][Math.floor(b / 64)]
return `hsl(${h}, ${sat}%, ${l}%)`
})
const rot = (id[28] % 6) * 3
return scheme.colors.map(
(_, i) => palette[scheme.colors[i < 18 ? (i + rot) % 18 : 18]],
)
}
const getPositions = (sixPoint = false) => {
const r = sixPoint ? (SIZE / 2 / 8) * 5 : (SIZE / 2 / 4) * 3
const rroot3o2 = (r * Math.sqrt(3)) / 2
const ro2 = r / 2
const rroot3o4 = (r * Math.sqrt(3)) / 4
const ro4 = r / 4
const r3o4 = (r * 3) / 4
return [
{ x: CENTER, y: CENTER - r },
{ x: CENTER, y: CENTER - ro2 },
{ x: CENTER - rroot3o4, y: CENTER - r3o4 },
{ x: CENTER - rroot3o2, y: CENTER - ro2 },
{ x: CENTER - rroot3o4, y: CENTER - ro4 },
{ x: CENTER - rroot3o2, y: CENTER },
{ x: CENTER - rroot3o2, y: CENTER + ro2 },
{ x: CENTER - rroot3o4, y: CENTER + ro4 },
{ x: CENTER - rroot3o4, y: CENTER + r3o4 },
{ x: CENTER, y: CENTER + r },
{ x: CENTER, y: CENTER + ro2 },
{ x: CENTER + rroot3o4, y: CENTER + r3o4 },
{ x: CENTER + rroot3o2, y: CENTER + ro2 },
{ x: CENTER + rroot3o4, y: CENTER + ro4 },
{ x: CENTER + rroot3o2, y: CENTER },
{ x: CENTER + rroot3o2, y: CENTER - ro2 },
{ x: CENTER + rroot3o4, y: CENTER - ro4 },
{ x: CENTER + rroot3o4, y: CENTER - r3o4 },
{ x: CENTER, y: CENTER },
]
}
+1 -71
View File
@@ -1,71 +1 @@
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(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden 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-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"border bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "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",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
forceSvgSize?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant,
size,
asChild = false,
forceSvgSize = true,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(
buttonVariants({ variant, size, className }),
forceSvgSize &&
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
)}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }
export { Button } from "@polkahub/ui-components"
+3 -152
View File
@@ -1,157 +1,8 @@
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> & {
forceSvgSize?: boolean
}
>(({ className, forceSvgSize = true, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
forceSvgSize &&
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
} from "@polkahub/ui-components"
+13 -113
View File
@@ -1,120 +1,20 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-top-[20%] data-[state=open]:slide-in-from-top-[20%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogTrigger,
} from "@polkahub/ui-components"
export function DialogBody({
children,
id,
className,
}: React.PropsWithChildren<{ id?: string; className?: string }>) {
return (
<div className={cn("overflow-auto px-6 py-2 last:pb-6", className)} id={id}>
{children}
</div>
)
}
+5 -29
View File
@@ -1,29 +1,5 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-hidden 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 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
export {
Popover,
PopoverContent,
PopoverTrigger,
} from "@polkahub/ui-components"
+4 -3
View File
@@ -1,4 +1,5 @@
@import 'tailwindcss';
@import "polkahub";
@custom-variant dark (&:is(.dark *));
@@ -73,7 +74,7 @@
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0 / 50%);
--input: oklch(0.922 0 0 / 70%);
--ring: oklch(0.708 0 0 / 50%);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
@@ -106,8 +107,8 @@
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 5%);
--border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 10%);
--ring: oklch(0.556 0 0 / 50%);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
+11 -5
View File
@@ -1,5 +1,8 @@
import { Buffer } from "buffer"
globalThis.Buffer = Buffer
import { dynamicBuilder$ } from "@/state/chains/chain.state"
import { RemoveSubscribe, Subscribe } from "@react-rxjs/core"
import { PolkaHubProvider } from "polkahub"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { BrowserRouter } from "react-router-dom"
@@ -10,6 +13,7 @@ import "./index.css"
import { explorer$ } from "./pages/Explorer"
import { storage$ } from "./pages/Storage/storage.state.ts"
import { transactions$ } from "./pages/Transactions"
import { polkaHub } from "./state/polkahub.ts"
import { ThemeProvider } from "./ThemeProvider.tsx"
createRoot(document.getElementById("root")!).render(
@@ -19,11 +23,13 @@ createRoot(document.getElementById("root")!).render(
<RemoveSubscribe>
<StrictMode>
<ThemeProvider>
<BrowserRouter>
<TooltipProvider delayDuration={500}>
<App />
</TooltipProvider>
</BrowserRouter>
<PolkaHubProvider polkaHub={polkaHub}>
<BrowserRouter>
<TooltipProvider delayDuration={500}>
<App />
</TooltipProvider>
</BrowserRouter>
</PolkaHubProvider>
</ThemeProvider>
</StrictMode>
</RemoveSubscribe>
+60 -24
View File
@@ -1,12 +1,6 @@
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"
@@ -18,17 +12,54 @@ import {
} from "@react-rxjs/core"
import { Send } from "lucide-react"
import { CompatibilityLevel, SS58String } from "polkadot-api"
import {
Account,
ledgerProviderId,
PjsWalletAccount,
polkadotVaultProviderId,
readOnlyProviderId,
useAvailableAccounts,
walletConnectProviderId,
} from "polkahub"
import { FC } from "react"
import { Link } from "react-router-dom"
import { catchError, combineLatest, map, switchMap } from "rxjs"
import { knownExtensions } from "./Providers"
const knownGroupsNames: Record<string, string> = {
"pjs-wallet": "Browser Extensions",
[ledgerProviderId]: "Ledger",
[readOnlyProviderId]: "Read Only",
[polkadotVaultProviderId]: "Polkadot Vault",
[walletConnectProviderId]: "Wallet Connect",
}
const knownGroupsColors: Record<string, string> = {
"pjs-wallet": "var(--color-lime-700)",
[readOnlyProviderId]: "var(--color-orange-700)",
[walletConnectProviderId]: "var(--color-blue-700)",
}
export const AccountList = () => {
const accounts = useStateObservable(accounts$)
const accountGroups = useAvailableAccounts()
return (
<div className="bg-card p-4 rounded-xl space-y-4">
<h3 className="text-xl font-bold">Accounts</h3>
{Object.entries(accountGroups)
.filter(([_, accounts]) => accounts.length > 0)
.map(([id, accounts]) => (
<AccountGroup key={id} id={id} accounts={accounts} />
))}
</div>
)
}
const AccountGroup: FC<{ id: string; accounts: Account[] }> = ({
id,
accounts,
}) => {
return (
<div>
<h4>{getGroupName(id)}</h4>
<ul className="space-y-4">
{accounts.map((account, i) => (
<AccountCard key={i} account={account} />
@@ -45,24 +76,25 @@ const AccountCard: FC<{
<li className="shadow border rounded-xl p-4 space-y-4">
<div className="flex justify-between items-start overflow-hidden">
<AccountIdDisplay
value={account.accountId}
value={account.address}
className="shrink-1 overflow-hidden"
/>
<SourceTag account={account} />
</div>
{account.accountId.startsWith("0x") ? null : (
<Balances accountId={account.accountId} />
{account.address.startsWith("0x") ? null : (
<Balances accountId={account.address} />
)}
</li>
)
}
const sourceColors: Record<AccountSource, string> = {
extension: "var(--color-lime-700)",
walletconnect: "var(--color-blue-700)",
readonly: "var(--color-orange-700)",
export const knownExtensions: Record<string, string> = {
"polkadot-js": "Polkadot JS",
"nova-wallet": "Nova Wallet",
talisman: "Talisman",
"subwallet-js": "Subwallet",
}
const getExtensionName = (id: string) => knownExtensions[id]?.name ?? id
const getExtensionName = (id: string) => knownExtensions[id] ?? id
const getGroupName = (id: string) => knownGroupsNames[id] ?? id
const SourceTag: FC<{
account: Account
@@ -70,14 +102,18 @@ const SourceTag: FC<{
return (
<div
className="rounded px-2 shrink-0"
style={{
color: sourceColors[account.type],
backgroundColor: `color-mix(in srgb, ${sourceColors[account.type]}, transparent 90%)`,
}}
style={
account.provider in knownGroupsColors
? {
color: knownGroupsColors[account.provider],
backgroundColor: `color-mix(in srgb, ${knownGroupsColors[account.provider]}, transparent 90%)`,
}
: undefined
}
>
{account.type === "extension"
? getExtensionName(account.extensionId)
: accountSourceTypeToName[account.type]}
{account.provider === "pjs-wallet"
? getExtensionName((account as PjsWalletAccount).extensionId)
: getGroupName(account.provider)}
</div>
)
}
-89
View File
@@ -1,89 +0,0 @@
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>
)
}
+76 -124
View File
@@ -1,131 +1,83 @@
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"
Button,
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
} from "@polkahub/ui-components"
import { ChevronLeft } from "lucide-react"
import {
ManageLedger,
ManageReadOnly,
ManageVault,
ModalContext,
PjsWalletButtons,
usePolkaHubModalState,
WalletConnectButton,
} from "polkahub"
import { FC, PropsWithChildren } from "react"
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$)
const ManageToDialog: FC<PropsWithChildren> = ({ children }) => {
const { contentStack, contextValue, setContentStack } =
usePolkaHubModalState()
const activeContent = contentStack.length
? contentStack[contentStack.length - 1]
: null
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()
<>
<ModalContext value={contextValue}>{children}</ModalContext>
<Dialog
open={activeContent != null}
onOpenChange={() => setContentStack([])}
>
<DialogContent
onEscapeKeyDown={(evt) => {
if (
evt.target instanceof HTMLElement &&
(evt.target.tagName === "INPUT" ||
evt.target.attributes.getNamedItem("cmdk-list"))
) {
evt.preventDefault()
}
}}
>
{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>
<DialogHeader className="flex-row items-center">
{contentStack.length > 1 ? (
<Button
className="has-[>svg]:px-1"
type="button"
variant="ghost"
onClick={() => contextValue.popContent()}
>
<ChevronLeft />
</Button>
) : null}
<DialogTitle>{activeContent?.title}</DialogTitle>
</DialogHeader>
<DialogBody>
<ModalContext value={contextValue}>
{activeContent?.element}
</ModalContext>
</DialogBody>
</DialogContent>
</Dialog>
</>
)
}
export const Providers = () => (
<div className="bg-card p-4 rounded-xl space-y-2">
<h3 className="text-xl font-bold">Account Providers</h3>
<PjsWalletButtons />
<ManageToDialog>
<ul className="flex gap-2 flex-wrap items-center justify-center">
<ManageReadOnly />
<ManageLedger />
<ManageVault />
<WalletConnectButton />
</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>
)
}
</ManageToDialog>
</div>
)
-26
View File
@@ -1,26 +0,0 @@
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,127 +0,0 @@
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
import { EthAccountDisplay } from "@/components/EthAccountDisplay"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { groupBy } from "@/lib/groupBy"
import { knownExtensions } from "@/pages/Accounts/Providers"
import {
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 React from "react"
import {
combineLatest,
defer,
distinctUntilChanged,
map,
merge,
of,
tap,
} from "rxjs"
const [valueSelected$, selectValue] = createSignal<string>()
const LS_KEY = "selected-signer"
const selectedValue$ = state(
merge(
defer(() => of(localStorage.getItem(LS_KEY))),
valueSelected$.pipe(tap((v) => localStorage.setItem(LS_KEY, v))),
),
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$, allAccounts$]).pipe(
map(([selectedAccount, accounts]): Account | null => {
if (!selectedAccount) return null
const account = accounts.find((account) => account.id === selectedAccount)
return account ?? null
}),
distinctUntilChanged(),
),
null,
)
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.some((acc) => acc.id === value)
if (!activeExtensions.length && walletConnect.type !== "connected")
return null
return (
<Select
value={valueExists ? (value ?? "") : ""}
onValueChange={selectValue}
>
<SelectTrigger className="h-auto border-foreground/30">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{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>
))}
</SelectContent>
</Select>
)
}
@@ -2,14 +2,14 @@ import { Label } from "@/components/ui/label"
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
availableExtensions$,
onToggleExtension,
selectedExtensions$,
} from "@/state/extension-accounts.state"
toggleExtension,
} from "@/state/polkahub"
import { useStateObservable } from "@react-rxjs/core"
export const ExtensionProvider: React.FC = () => {
const availableExtensions = useStateObservable(availableExtensions$)
const selectedExtensions = useStateObservable(selectedExtensions$)
const selectedExtensions = new Set(useStateObservable(selectedExtensions$))
return (
<>
@@ -18,7 +18,7 @@ export const ExtensionProvider: React.FC = () => {
{availableExtensions.map((extensionName) => (
<TabsTrigger
className="mx-1"
onClick={() => onToggleExtension(extensionName)}
onClick={() => toggleExtension(extensionName)}
active={selectedExtensions.has(extensionName)}
key={extensionName}
>
+16 -10
View File
@@ -1,7 +1,9 @@
import { ActionButton } from "@/components/ActionButton"
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
@@ -30,17 +32,21 @@ export const ExtrinsicModal: React.FC<{
)
evt.preventDefault()
}}
className="flex flex-col overflow-hidden"
className="flex flex-col overflow-hidden max-w-lg sm:max-w-lg md:max-w-lg"
>
<DialogTitle>Create TX</DialogTitle>
<Suspense fallback="Loading…">
<SendTransactionForm
callData={
callData instanceof Uint8Array ? toHex(callData) : callData!
}
onClose={() => setOpen(false)}
/>
</Suspense>
<DialogHeader>
<DialogTitle>Create TX</DialogTitle>
</DialogHeader>
<DialogBody className="flex flex-col gap-2">
<Suspense fallback="Loading…">
<SendTransactionForm
callData={
callData instanceof Uint8Array ? toHex(callData) : callData!
}
onClose={() => setOpen(false)}
/>
</Suspense>
</DialogBody>
</DialogContent>
</Dialog>
)
+53 -2
View File
@@ -1,15 +1,22 @@
import { ActionButton } from "@/components/ActionButton"
import { Spinner } from "@/components/Icons"
import { cn } from "@/lib/utils"
import { trackSignedTx, trackUnsignedTx } from "@/pages/Transactions"
import { unsafeApi$ } from "@/state/chains/chain.state"
import { selectedAccount$ } from "@/state/polkahub"
import { compactNumber } from "@polkadot-api/substrate-bindings"
import { fromHex, mergeUint8, toHex } from "@polkadot-api/utils"
import { AccountPicker } from "@polkahub/ui-components"
import { useStateObservable } from "@react-rxjs/core"
import { Binary } from "polkadot-api"
import {
AddressIdentity,
useAvailableAccounts,
useSelectedAccount,
} from "polkahub"
import { FC, useState } from "react"
import { firstValueFrom } from "rxjs"
import { twMerge } from "tailwind-merge"
import { AccountProvider, selectedAccount$ } from "./AccountProvider"
import { ExtensionProvider } from "./ExtensionProvider"
const SignAndSubmit: FC<{ callData: string; onClose: () => void }> = ({
@@ -77,7 +84,7 @@ export default function SubmitTxF(props: {
return (
<>
<ExtensionProvider />
<AccountProvider />
<SelectAccount />
<div className="flex flex-col gap-2">
<SignAndSubmit {...props} />
<SubmitUnsigned {...props} />
@@ -85,3 +92,47 @@ export default function SubmitTxF(props: {
</>
)
}
const groupLabels: Record<string, string> = {
ledger: "Ledger",
readonly: "Read Only",
"polkadot-vault": "Vault",
walletconnect: "Wallet Connect",
}
const SelectAccount: FC<{
className?: string
}> = ({ className }) => {
const availableAccounts = useAvailableAccounts()
const [account, setAccount] = useSelectedAccount()
const groups = Object.entries(availableAccounts)
.map(
([group, accounts]) =>
[group, accounts.filter((acc) => acc.signer)] as const,
)
.filter(([, accounts]) => accounts.length > 0)
.map(([key, accounts]) => ({
name: groupLabels[key] ?? key,
accounts,
}))
if (!groups.length && !account) return null
return (
<AccountPicker
value={account}
onChange={setAccount}
groups={groups}
className={cn(className, "max-w-auto w-full")}
renderAddress={(account) => (
<AddressIdentity
addr={account.address}
name={account?.name}
copyable={false}
/>
)}
disableClear
/>
)
}
+129 -122
View File
@@ -12,6 +12,7 @@ import {
} from "@/components/ui/command"
import {
Dialog,
DialogBody,
DialogContent,
DialogHeader,
DialogTitle,
@@ -119,7 +120,7 @@ const NetworkSwitchDialogContent: FC<{
return (
<DialogContent
className="sm:max-w-[425px] min-h-[450px] max-h-full flex flex-col"
className="sm:max-w-[425px] min-h-[450px] max-h-full flex flex-col w-auto"
onEscapeKeyDown={(evt) => {
if (
evt.target instanceof HTMLElement &&
@@ -133,131 +134,137 @@ const NetworkSwitchDialogContent: FC<{
<DialogHeader>
<DialogTitle>Switch Network</DialogTitle>
</DialogHeader>
<div className="h-full grow flex flex-col">
<CommandPopover
placeholder="Search or enter a custom URI"
value={enteredText}
onValueChange={setEnteredText}
selectedValue={selectedNetwork.id}
>
<CommandList>
<CommandEmpty>
<div className="text-foreground/50">No networks found.</div>
</CommandEmpty>
<ScrollArea className="h-[260px]">
{networkCategories.map((category) => (
<CommandGroup key={category.name} heading={category.name}>
{category.networks.map((network) => (
<CommandItem
key={network.id}
onSelect={() => handleNetworkSelect(network)}
value={
network.display.includes(category.name)
? network.display
: `${category.name} ${network.display}`
}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedNetwork.id === network.id
? "opacity-100"
: "opacity-0"
}`}
/>
{network.display}
</CommandItem>
))}
{category.name === "Custom" && isValidUri(enteredText) ? (
<CommandItem
value={enteredText}
onSelect={() => {
handleNetworkSelect({
id: "custom-network",
lightclient: false,
endpoints: { custom: enteredText },
display: enteredText,
})
}}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedNetwork.id === "custom-network"
? "opacity-100"
: "opacity-0"
}`}
/>
{enteredText}
</CommandItem>
) : null}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</CommandPopover>
<div className="h-[50vh] flex flex-col gap-2">
{selectedNetwork ? (
<div className="grow-1 overflow-hidden flex flex-col">
<p className="py-2">Network: {selectedNetwork.display}</p>
<div className="overflow-auto">
<RadioGroup value={selectedRpc} onValueChange={setSelectedRpc}>
{selectedNetwork.lightclient ? (
<ConnectionOption
value="light-client"
isSelected={selectedRpc === "light-client"}
name="Smoldot"
type="light"
/>
) : null}
{Object.entries(selectedNetwork.endpoints).map(
([rpcName, url]) => (
<ConnectionOption
value={url}
isSelected={selectedRpc === url}
name={rpcName}
type="rpc"
url={url}
/>
),
)}
</RadioGroup>
</div>
</div>
) : null}
{selectedRpc && selectedRpc !== "light-client" && (
<div className="mt-4 p-3 border rounded-md bg-muted/30">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Chopsticks size={20} />
<Label
htmlFor="use-chopsticks"
className="font-medium cursor-pointer"
<DialogBody className="flex flex-col overflow-hidden gap-2">
<div className="h-full grow flex flex-col">
<CommandPopover
placeholder="Search or enter a custom URI"
value={enteredText}
onValueChange={setEnteredText}
selectedValue={selectedNetwork.id}
>
<CommandList>
<CommandEmpty>
<div className="text-foreground/50">No networks found.</div>
</CommandEmpty>
<ScrollArea className="h-[260px]">
{networkCategories.map((category) => (
<CommandGroup key={category.name} heading={category.name}>
{category.networks.map((network) => (
<CommandItem
key={network.id}
onSelect={() => handleNetworkSelect(network)}
value={
network.display.includes(category.name)
? network.display
: `${category.name} ${network.display}`
}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedNetwork.id === network.id
? "opacity-100"
: "opacity-0"
}`}
/>
{network.display}
</CommandItem>
))}
{category.name === "Custom" && isValidUri(enteredText) ? (
<CommandItem
value={enteredText}
onSelect={() => {
handleNetworkSelect({
id: "custom-network",
lightclient: false,
endpoints: { custom: enteredText },
display: enteredText,
})
}}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedNetwork.id === "custom-network"
? "opacity-100"
: "opacity-0"
}`}
/>
{enteredText}
</CommandItem>
) : null}
</CommandGroup>
))}
</ScrollArea>
</CommandList>
</CommandPopover>
<div className="h-[50vh] flex flex-col gap-2">
{selectedNetwork ? (
<div className="grow-1 overflow-hidden flex flex-col">
<p className="py-2">Network: {selectedNetwork.display}</p>
<div className="overflow-auto">
<RadioGroup
value={selectedRpc}
onValueChange={setSelectedRpc}
>
Fork with Chopsticks
</Label>
{selectedNetwork.lightclient ? (
<ConnectionOption
value="light-client"
isSelected={selectedRpc === "light-client"}
name="Smoldot"
type="light"
/>
) : null}
{Object.entries(selectedNetwork.endpoints).map(
([rpcName, url]) => (
<ConnectionOption
key={rpcName}
value={url}
isSelected={selectedRpc === url}
name={rpcName}
type="rpc"
url={url}
/>
),
)}
</RadioGroup>
</div>
<SliderToggle
id="use-chopsticks"
isToggled={withChopsticks}
toggle={() => setWithChopsticks(!withChopsticks)}
/>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">
Create a local fork of this chain
</p>
</div>
)}
) : null}
{selectedRpc && selectedRpc !== "light-client" && (
<div className="mt-4 p-3 border rounded-md bg-muted/30">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Chopsticks size={20} />
<Label
htmlFor="use-chopsticks"
className="font-medium cursor-pointer"
>
Fork with Chopsticks
</Label>
</div>
<SliderToggle
id="use-chopsticks"
isToggled={withChopsticks}
toggle={() => setWithChopsticks(!withChopsticks)}
/>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">
Create a local fork of this chain
</p>
</div>
)}
</div>
</div>
</div>
<Button
onClick={handleConfirm}
disabled={
!selectedNetwork ||
!hasChanged ||
(selectedNetwork.id === "custom-network" && !selectedRpc)
}
>
Confirm Selection
</Button>
<Button
onClick={handleConfirm}
disabled={
!selectedNetwork ||
!hasChanged ||
(selectedNetwork.id === "custom-network" && !selectedRpc)
}
>
Confirm Selection
</Button>
</DialogBody>
</DialogContent>
)
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function Transactions() {
return !nTxs ? null : (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Dialog.Trigger asChild>
<button className="fixed bottom-4 right-4 bg-polkadot/90 text-polkadot-foreground px-4 py-2 rounded-full shadow-lg hover:bg-primary hover:font-bold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-opacity-50 flex items-center space-x-2">
<button className="fixed bottom-4 right-4 bg-polkadot/90 text-polkadot-foreground px-4 py-2 rounded-full shadow-lg hover:bg-polkadot hover:font-bold focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-opacity-50 flex items-center space-x-2">
{nOngoing > 0 && (
<>
<Loader2 className="animate-spin" size={20} />
-117
View File
@@ -1,117 +0,0 @@
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())),
[],
)
-214
View File
@@ -1,214 +0,0 @@
import { state, withDefault } from "@react-rxjs/core"
import { combineKeys, createKeyedSignal } from "@react-rxjs/utils"
import { Binary, getSs58AddressInfo } from "polkadot-api"
import {
connectInjectedExtension,
getInjectedExtensions,
InjectedExtension,
InjectedPolkadotAccount,
} from "polkadot-api/pjs-signer"
import {
catchError,
concat,
defer,
filter,
interval,
map,
NEVER,
Observable,
of,
retry,
scan,
startWith,
switchMap,
take,
tap,
timer,
} from "rxjs"
export const availableExtensions$ = state(
concat(
timer(0, 100).pipe(
map(getInjectedExtensions),
filter((v) => v.length > 0),
take(1),
),
interval(2000).pipe(map(getInjectedExtensions)),
),
[],
)
const [toggleExtension$, onToggleExtension] = createKeyedSignal<string>()
export { onToggleExtension }
export const enum ConnectStatus {
Connecting,
Disconnected,
Connected,
}
export type ExtensionState =
| {
type: ConnectStatus.Disconnected
}
| { type: ConnectStatus.Connecting }
| { type: ConnectStatus.Connected; value: InjectedExtension }
const SELECTED_EXTENSIONS_KEY = "selected-extensions"
const getPreselectedExtensions = () => {
try {
const res = JSON.parse(localStorage.getItem(SELECTED_EXTENSIONS_KEY)!)
if (Array.isArray(res)) return res
// eslint-disable-next-line no-empty
} catch (_) {}
return null
}
const extensionIsPreselected = (extension: string) =>
getPreselectedExtensions()?.includes(extension) ?? false
const setPreselectedExtension = (extension: string, selected: boolean) => {
const preselectedExtensions = getPreselectedExtensions() ?? []
const result = selected
? [...new Set([...preselectedExtensions, extension])]
: preselectedExtensions.filter((v) => v !== extension)
localStorage.setItem(SELECTED_EXTENSIONS_KEY, JSON.stringify(result))
}
const extension$ = (name: string) => {
const connect$ = defer(() => connectInjectedExtension(name)).pipe(
// PolkadotJS rejects the promise straight away instead of waiting for user input
retry({
delay(error) {
if (error?.message.includes("pending authorization request")) {
return timer(1000)
}
throw error
},
}),
map((extension) => ({
type: ConnectStatus.Connected as const,
extension,
})),
catchError((ex) => {
console.error(ex)
return of({ type: ConnectStatus.Disconnected as const })
}),
startWith({ type: ConnectStatus.Connecting as const }),
)
const connectWithCleanup$ = defer(() => {
let disconnected = false
let extension: InjectedExtension | null = null
return concat(connect$, NEVER).pipe(
tap({
next(value) {
if (value.type === ConnectStatus.Connected) {
if (disconnected) {
value.extension.disconnect()
} else {
extension = value.extension
}
}
},
unsubscribe() {
if (extension) {
extension.disconnect()
} else {
disconnected = true
}
},
}),
)
})
const initialSelected = extensionIsPreselected(name)
return toggleExtension$(name).pipe(
scan((acc) => !acc, initialSelected),
startWith(initialSelected),
switchMap((selected) =>
selected
? connectWithCleanup$
: of({
type: ConnectStatus.Disconnected as const,
}),
),
tap((v) => {
if (v.type === ConnectStatus.Connected) {
setPreselectedExtension(name, true)
} else if (v.type === ConnectStatus.Disconnected) {
setPreselectedExtension(name, false)
}
}),
)
}
export const extensions$ = state(combineKeys(availableExtensions$, extension$))
export const selectedExtensions$ = extensions$.pipeState(
map(
(extensions) =>
new Map(
[...extensions.entries()]
.filter(([, v]) => v.type !== ConnectStatus.Disconnected)
.map(([k, v]) => [
k,
v.type === ConnectStatus.Connected ? v.extension : null,
]),
),
),
withDefault(new Map<string, InjectedExtension | null>()),
)
const extensionAccounts$ = state(
(name: string) =>
extension$(name).pipe(
switchMap((x) => {
if (x.type !== ConnectStatus.Connected) return of([])
return new Observable<InjectedPolkadotAccount[]>((observer) =>
x.extension.subscribe((accounts) => {
observer.next(accounts)
}),
)
}),
),
[],
)
export const accountsByExtension$ = state(
combineKeys(availableExtensions$, extensionAccounts$),
new Map<string, InjectedPolkadotAccount[]>(),
)
export const getPublicKey = (address: string) => {
const info = getSs58AddressInfo(address)
return info.isValid ? info.publicKey : null
}
export const getAccountMapKey = (address: string) => {
const pk = getPublicKey(address)
return pk ? Binary.fromBytes(pk).asHex() : address
}
export const accounts$ = state(
accountsByExtension$.pipe(
map(
(extensionAccounts) =>
new Map(
Array.from(extensionAccounts.entries()).flatMap(
([extension, accounts]) =>
accounts.map((account) => [
getAccountMapKey(account.address),
{
...account,
extension,
},
]),
),
),
),
),
new Map<string, InjectedPolkadotAccount & { extension: string }>(),
)
export const accountDetail$ = state(
(account: string) =>
accounts$.pipe(map((v) => v.get(getAccountMapKey(account)) ?? null)),
null,
)
+1 -1
View File
@@ -21,7 +21,7 @@ export const isVerified = (identity: Identity | null) =>
identity?.judgments.some((j) => j.judgement === "Reasonable")
const cache = localStorageSubject<Record<string, Identity>>(
"identity-cache",
"console-identity-cache",
JSON,
{},
)
+156
View File
@@ -0,0 +1,156 @@
import { polkadot_people } from "@polkadot-api/descriptors"
import { liftSuspense, state, SUSPENSE, withDefault } from "@react-rxjs/core"
import { AccountId, SS58String } from "polkadot-api"
import {
Account,
createLedgerProvider,
createPjsWalletProvider,
createPolkadotVaultProvider,
createPolkaHub,
createReadOnlyProvider,
createSelectedAccountPlugin,
createWalletConnectProvider,
knownChains,
Plugin,
} from "polkahub"
import {
combineLatest,
filter,
firstValueFrom,
map,
pipe,
switchMap,
} from "rxjs"
import { chainProperties$ } from "./chain-props.state"
import { canSetStorage$, client$ } from "./chains/chain.state"
import { identity$, isVerified } from "./identity.state"
const removeSuspense = <T>() =>
pipe(
liftSuspense<T>(),
filter<T | SUSPENSE, T>((v): v is T => v !== SUSPENSE),
)
const selectedAccountPlugin = createSelectedAccountPlugin()
const pjsWalletProvider = createPjsWalletProvider()
const polkadotVaultProvider = createPolkadotVaultProvider()
const readOnlyProvider$ = canSetStorage$.pipe(
map((fakeSigner) =>
createReadOnlyProvider({
fakeSigner,
}),
),
)
const ledgerAccountProvider = createLedgerProvider(
async () => {
const module = await import("@ledgerhq/hw-transport-webusb")
return module.default.create()
},
() =>
firstValueFrom(
chainProperties$.pipe(
removeSuspense(),
filter((v) => v != null),
map(({ tokenDecimals, tokenSymbol }) => ({
decimals: tokenDecimals!,
tokenSymbol: tokenSymbol!,
})),
),
),
)
const walletConnectProvider = createWalletConnectProvider(
import.meta.env.VITE_REOWN_PROJECT_ID,
[
knownChains.polkadot,
knownChains.polkadotAh,
knownChains.kusama,
knownChains.kusamaAh,
knownChains.paseo,
knownChains.paseoAh,
],
)
export const polkaHub = createPolkaHub(
combineLatest([
[selectedAccountPlugin],
[pjsWalletProvider],
[polkadotVaultProvider],
readOnlyProvider$,
[ledgerAccountProvider],
[walletConnectProvider],
]).pipe(map((v: (Plugin<any> | null)[]) => v.filter((v) => v != null))),
{
getIdentity: (address) =>
identity$(address).pipe(
removeSuspense(),
map((res) =>
res
? {
name: res.displayName,
verified: isVerified(res) ?? false,
}
: null,
),
),
getBalance: (address) =>
client$.pipe(
removeSuspense(),
switchMap((client) =>
combineLatest([
chainProperties$,
client
.getTypedApi(polkadot_people)
.query.System.Account.getValue(address),
]),
),
map(([chainProps, balance]) => {
if (chainProps?.tokenDecimals == null) return null
return {
value: balance.data.reserved + balance.data.free,
decimals: chainProps.tokenDecimals,
symbol: chainProps.tokenSymbol,
}
}),
),
ss58Format: chainProperties$.pipe(
removeSuspense(),
map((v) => v?.ss58Format ?? 42),
),
},
)
export const addrToAccount$ = polkaHub.availableAccounts$.pipeState(
map((groups) =>
Object.fromEntries(
Object.values(groups).flatMap((group) =>
group.map((account) => [account.address, account]),
),
),
),
withDefault({} as Record<string, Account>),
)
export const accountDetail$ = state(
(addr: SS58String) =>
addrToAccount$.pipe(map((addrToAccount) => addrToAccount[addr] ?? null)),
null,
)
export const getPublicKey = AccountId().enc
export const selectedAccount$ =
selectedAccountPlugin.selectedAccount$.pipeState(withDefault(null))
export const availableExtensions$ = pjsWalletProvider.availableExtensions$
export const selectedExtensions$ = pjsWalletProvider.connectedExtensions$
export const toggleExtension = async (id: string) => {
const extensions = await firstValueFrom(
pjsWalletProvider.connectedExtensions$,
)
pjsWalletProvider.setConnectedExtensions(
extensions.includes(id)
? extensions.filter((v) => v != id)
: [...extensions, id],
)
}
-258
View File
@@ -1,258 +0,0 @@
import { localStorageSubject } from "@/utils/localStorageSubject"
import { state, withDefault } from "@react-rxjs/core"
import { createSignal } from "@react-rxjs/utils"
import { WalletConnectModal } from "@walletconnect/modal"
import { SessionTypes } from "@walletconnect/types"
import UniversalProvider from "@walletconnect/universal-provider"
import { getSdkError } from "@walletconnect/utils"
import {
getPolkadotSignerFromPjs,
PolkadotSigner,
} from "polkadot-api/pjs-signer"
import {
catchError,
defer,
EMPTY,
filter,
finalize,
firstValueFrom,
from,
fromEventPattern,
ignoreElements,
map,
Observable,
of,
scan,
startWith,
switchMap,
take,
takeUntil,
tap,
} from "rxjs"
// https://docs.reown.com/advanced/multichain/polkadot/dapp-integration-guide
const chains = [
"polkadot:91b171bb158e2d3848fa23a9f1c25182", // Polkadot
"polkadot:e143f23803ac50e8f6f8e62695d1ce9e", // Westend
]
const projectId = import.meta.env.VITE_REOWN_PROJECT_ID
const walletConnectModal = new WalletConnectModal({
projectId,
chains,
})
const provider$ = state(
defer(() =>
UniversalProvider.init({
projectId,
relayUrl: "wss://relay.walletconnect.com",
}),
),
)
interface InitializedSession {
uri?: string
approval: () => Promise<SessionTypes.Struct>
}
const initializeSession$ = () =>
provider$.pipe(
take(1),
switchMap(
(provider): Promise<InitializedSession> =>
provider.client.connect({
requiredNamespaces: {
polkadot: {
methods: ["polkadot_signTransaction", "polkadot_signMessage"],
chains,
events: ["chainChanged", "accountsChanged"],
},
},
}),
),
)
const sessionSubject = localStorageSubject<SessionTypes.Struct>(
"wallet-connect",
JSON,
)
type WalletConnectStatus =
| {
type: "disconnected"
}
| {
type: "connecting"
}
| {
type: "connected"
session: SessionTypes.Struct
}
const connect$ = defer(initializeSession$).pipe(
switchMap(({ uri, approval }) => {
if (!uri) return approval()
walletConnectModal.openModal({ uri })
const modal$ = fromEventPattern<{ open: boolean }>(
(handler) => walletConnectModal.subscribeModal(handler),
(_, fn) => fn(),
)
const closed$ = modal$.pipe(
tap((v) => console.log("modal event", v)),
filter(({ open }) => !open),
)
return from(approval()).pipe(
takeUntil(closed$),
finalize(() => walletConnectModal.closeModal()),
)
}),
map((session): WalletConnectStatus => ({ type: "connected", session })),
catchError((err) => {
console.log("connect WalletConnect error", err)
return of(EMPTY) as any as Observable<WalletConnectStatus>
}),
startWith({ type: "connecting" } satisfies WalletConnectStatus),
)
const disconnect$ = provider$.pipe(
take(1),
switchMap((provider) =>
provider.session
? provider.client.disconnect({
topic: provider.session.topic,
reason: getSdkError("USER_DISCONNECTED"),
})
: EMPTY,
),
ignoreElements(),
startWith({
type: "disconnected",
} satisfies WalletConnectStatus),
)
export const [toggleConnect$, toggleWalletConnect] = createSignal<void>()
export const walletConnectStatus$ = state(
sessionSubject.stream$.pipe(
take(1),
switchMap((session): Observable<WalletConnectStatus> => {
const connectState$ = toggleConnect$.pipe(
scan((acc) => !acc, !!session),
switchMap((connect) =>
connect
? connect$.pipe(
tapOnLast((v) => {
// hack! if connect$ didn't actually complete, toggle it off
if (v?.type !== "connected") {
toggleWalletConnect()
}
}),
)
: disconnect$,
),
tap((v) => {
if (v.type === "connected") {
sessionSubject.setValue(v.session)
} else {
sessionSubject.clear()
}
}),
)
return connectState$.pipe(
startWith(
(session
? {
type: "connected",
session,
}
: {
type: "disconnected",
}) satisfies WalletConnectStatus,
),
)
}),
),
{
type: "disconnected",
},
)
const getAccounts = (session: SessionTypes.Struct) =>
Object.values(session.namespaces)
.map((namespace) => namespace.accounts)
.flat()
.map((wcAccount) => wcAccount.split(":")[2])
const getSigner = (session: SessionTypes.Struct, address: string) =>
getPolkadotSignerFromPjs(
address,
async (transactionPayload) => {
const provider = await firstValueFrom(provider$)
console.log("Topic to check chainId below", session.topic)
return provider.client.request({
topic: session.topic,
chainId: `polkadot:${transactionPayload.genesisHash.substring(2, 34)}`,
request: {
method: "polkadot_signTransaction",
params: {
address,
transactionPayload,
},
},
})
},
async ({ address, data }) => {
const provider = await firstValueFrom(provider$)
// const chainId = provider.session.topic.split(":")[1];
const chainId = session.topic.split(":")[1]
return provider.client.request({
topic: session.topic,
chainId: `polkadot:${chainId}`,
request: {
method: "polkadot_signMessage",
params: {
address,
message: data,
},
},
})
},
)
const getSignersFromSession = (
session: SessionTypes.Struct,
): Record<string, PolkadotSigner> => {
const accounts = getAccounts(session)
return Object.fromEntries(
accounts.map((address) => [address, getSigner(session, address)]),
)
}
export const walletConnectAccounts$ = walletConnectStatus$.pipeState(
map((status) =>
status.type === "connected" ? getSignersFromSession(status.session) : {},
),
withDefault({} as Record<string, PolkadotSigner>),
)
const tapOnLast =
<T>(onLast: (value: T | null) => void) =>
(source$: Observable<T>) =>
defer(() => {
let value: T | null = null
return source$.pipe(
tap({
next(v) {
value = v
},
complete() {
onLast(value)
},
}),
)
})