feat: wallet connect integration
This commit is contained in:
@@ -34,6 +34,9 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@react-rxjs/core": "^0.10.7",
|
||||
"@react-rxjs/utils": "^0.9.7",
|
||||
"@walletconnect/modal": "^2.7.0",
|
||||
"@walletconnect/universal-provider": "^2.17.2",
|
||||
"@walletconnect/utils": "^2.17.2",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -59,6 +62,7 @@
|
||||
"@types/react-portal": "^4.0.7",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@walletconnect/types": "^2.17.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
|
||||
1967
pnpm-lock.yaml
generated
1967
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { ReactSVG, Props } from "react-svg"
|
||||
import focusSvg from "./icons/focus.svg"
|
||||
import enumSvg from "./icons/enum.svg"
|
||||
import binarySvg from "./icons/binary.svg"
|
||||
import walletConnectSvg from "./icons/walletConnect.svg"
|
||||
import { useEffect, useRef } from "react"
|
||||
import {
|
||||
Ban,
|
||||
@@ -11,9 +12,12 @@ import {
|
||||
Copy,
|
||||
Hash,
|
||||
List,
|
||||
LoaderCircle,
|
||||
LucideProps,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { LookupEntry } from "@polkadot-api/metadata-builders"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
type CustomIconProps = Omit<Props, "ref" | "src"> & { size?: number }
|
||||
const customIcon =
|
||||
@@ -43,6 +47,14 @@ const customIcon =
|
||||
export const Focus = customIcon(focusSvg)
|
||||
export const Enum = customIcon(enumSvg)
|
||||
export const BinaryEdit = customIcon(binarySvg)
|
||||
export const WalletConnect = customIcon(walletConnectSvg)
|
||||
|
||||
export const Spinner = (props: LucideProps) => (
|
||||
<LoaderCircle
|
||||
{...props}
|
||||
className={twMerge("animate-spin", props.className)}
|
||||
/>
|
||||
)
|
||||
|
||||
export const TypeIcons = {
|
||||
list: List,
|
||||
|
||||
17
src/components/icons/walletConnect.svg
Normal file
17
src/components/icons/walletConnect.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<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>
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
accountsByExtension$,
|
||||
extensionAccounts$,
|
||||
selectedExtensions$,
|
||||
} from "@/extension-accounts.state"
|
||||
import { AccountIdDisplay } from "@/components/AccountIdDisplay"
|
||||
import { WalletConnect } from "@/components/Icons"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -12,6 +9,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
accountsByExtension$,
|
||||
extensionAccounts$,
|
||||
selectedExtensions$,
|
||||
} from "@/extension-accounts.state"
|
||||
import {
|
||||
walletConnectAccounts$,
|
||||
walletConnectStatus$,
|
||||
} from "@/walletconnect.state"
|
||||
import { state, useStateObservable } from "@react-rxjs/core"
|
||||
import { createSignal } from "@react-rxjs/utils"
|
||||
import { InjectedExtension } from "polkadot-api/pjs-signer"
|
||||
@@ -31,7 +37,26 @@ const Accounts: React.FC<{ extension: InjectedExtension }> = ({
|
||||
key={account.address}
|
||||
value={account.address + "-" + extension.name}
|
||||
>
|
||||
{account.name ?? 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"}>
|
||||
<AccountIdDisplay value={address} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -50,12 +75,24 @@ const selectedValue$ = state(
|
||||
)
|
||||
|
||||
export const selectedAccount$ = state(
|
||||
combineLatest([selectedValue$, accountsByExtension$]).pipe(
|
||||
map(([selectedAccount, accountsByExtension]) => {
|
||||
combineLatest([
|
||||
selectedValue$,
|
||||
accountsByExtension$,
|
||||
walletConnectAccounts$,
|
||||
]).pipe(
|
||||
map(([selectedAccount, accountsByExtension, walletConnectAccounts]) => {
|
||||
if (!selectedAccount) return null
|
||||
const [address, ...rest] = selectedAccount.split("-")
|
||||
const signer = rest.join("-")
|
||||
|
||||
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
|
||||
@@ -68,20 +105,23 @@ export const selectedAccount$ = state(
|
||||
export const AccountProvider: React.FC = () => {
|
||||
const value = useStateObservable(selectedValue$)
|
||||
const extensions = useStateObservable(selectedExtensions$)
|
||||
const walletConnect = useStateObservable(walletConnectStatus$)
|
||||
|
||||
const activeExtensions = [...extensions.values()].filter((v) => !!v)
|
||||
|
||||
if (!activeExtensions.length) return null
|
||||
if (!activeExtensions.length && walletConnect.type !== "connected")
|
||||
return null
|
||||
|
||||
return (
|
||||
<Select value={value ?? ""} onValueChange={selectValue}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-auto">
|
||||
<SelectValue placeholder="Select an account" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeExtensions.map((extension) => (
|
||||
<Accounts key={extension.name} extension={extension} />
|
||||
))}
|
||||
<WalletConnectAccounts />
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { Spinner, WalletConnect } from "@/components/Icons"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
availableExtensions$,
|
||||
onToggleExtension,
|
||||
selectedExtensions$,
|
||||
} from "@/extension-accounts.state"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
toggleWalletConnect,
|
||||
walletConnectStatus$,
|
||||
} from "@/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$)
|
||||
|
||||
if (availableExtensions.length === 0)
|
||||
return <div>No extension provider detected</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label>Click on the extension name to toggle it:</Label>
|
||||
<Label>Click on the provider name to toggle it:</Label>
|
||||
<TabsList>
|
||||
{availableExtensions.map((extensionName) => (
|
||||
<TabsTrigger
|
||||
@@ -28,6 +34,18 @@ 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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -73,7 +73,16 @@ export const ExtrinsicModal: React.FC<{
|
||||
Submit extrinsic
|
||||
</ActionButton>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent
|
||||
onInteractOutside={(evt) => {
|
||||
if (
|
||||
evt.target instanceof HTMLElement &&
|
||||
evt.target.tagName === "WCM-MODAL"
|
||||
)
|
||||
evt.preventDefault()
|
||||
}}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
<DialogTitle>Create TX</DialogTitle>
|
||||
<CallDataCtx.Provider
|
||||
value={callData instanceof Uint8Array ? toHex(callData) : callData!}
|
||||
|
||||
258
src/walletconnect.state.ts
Normal file
258
src/walletconnect.state.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
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"
|
||||
import { localStorageSubject } from "./utils/localStorageSubject"
|
||||
|
||||
// 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)
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user