feat: wallet connect integration

This commit is contained in:
Victor Oliva
2024-11-05 18:11:08 +01:00
parent 479bf1226d
commit ee87ebe44b
8 changed files with 2298 additions and 55 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,

View 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

View File

@@ -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>
)

View File

@@ -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>
</>
)

View File

@@ -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
View 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)
},
}),
)
})