From 048e63cdc86c3fb2b11a34f99667bbf31f39ffab Mon Sep 17 00:00:00 2001 From: Benjamin A <97291322+Baoufa@users.noreply.github.com> Date: Fri, 17 Nov 2023 16:18:58 +0100 Subject: [PATCH] feat: wallet connect qr-reader done (#42) --- front/package.json | 1 + front/pnpm-lock.yaml | 21 +++- front/src/components/HomePage/index.tsx | 5 +- front/src/components/NavBar/index.tsx | 8 +- front/src/components/QrReaderModal/index.tsx | 108 ++++++++++++++++++ front/src/components/SettingsPage/index.tsx | 7 +- front/src/components/WCInput/index.tsx | 30 ----- .../hook/useWalletConnectHook.tsx | 71 ++++++------ .../wallet-connect/service/wallet-connect.ts | 60 ++++------ front/src/providers/MeProvider/index.tsx | 3 + front/src/providers/ModalProvider/index.tsx | 50 ++++++-- 11 files changed, 243 insertions(+), 121 deletions(-) create mode 100644 front/src/components/QrReaderModal/index.tsx delete mode 100644 front/src/components/WCInput/index.tsx diff --git a/front/package.json b/front/package.json index 5da60e7..8ce38fa 100644 --- a/front/package.json +++ b/front/package.json @@ -28,6 +28,7 @@ "next-themes": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-qr-reader-es6": "2.2.1-2", "viem": "^1.18.0", "wagmi": "^1.0.6" }, diff --git a/front/pnpm-lock.yaml b/front/pnpm-lock.yaml index cb7dc12..f8448f9 100644 --- a/front/pnpm-lock.yaml +++ b/front/pnpm-lock.yaml @@ -62,6 +62,9 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-qr-reader-es6: + specifier: 2.2.1-2 + version: 2.2.1-2(react-dom@18.2.0)(react@18.2.0) viem: specifier: ^1.18.0 version: 1.18.0(typescript@5.2.2) @@ -4796,6 +4799,10 @@ packages: engines: {'0': node >= 0.2.0} dev: false + /jsqr-es6@1.4.0-1: + resolution: {integrity: sha512-LPWZJLI+3LLOy9k3/s/MeXlkfNOs3bYBX5O+fp4N0XuxbgO8H7Uc/nYZeNwo13nSZXRW9xWFKmZdy9591+PyAg==} + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -5070,7 +5077,6 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - dev: true /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} @@ -5314,7 +5320,6 @@ packages: loose-envify: 1.4.0 object-assign: 4.1.1 react-is: 16.13.1 - dev: true /proxy-compare@2.5.1: resolution: {integrity: sha512-oyfc0Tx87Cpwva5ZXezSp5V9vht1c7dZBhvuV/y3ctkgMVUmiAGDVeeB0dKhGSyT0v1ZTEQYpe/RXlBVBNuCLA==} @@ -5412,6 +5417,18 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + /react-qr-reader-es6@2.2.1-2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-pDNH8FoR3fOBBCgh4ImKHlX+pv/D3P8JmE+vjjcw3+YTEUgBqUAZbIkD/WUE3HzhVhN2zx7ZLBhO9vJngnjJxw==} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + jsqr-es6: 1.4.0-1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-remove-scroll-bar@2.3.4(@types/react@18.2.31)(react@18.2.0): resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} engines: {node: '>=10'} diff --git a/front/src/components/HomePage/index.tsx b/front/src/components/HomePage/index.tsx index e7217b3..0fb8c59 100644 --- a/front/src/components/HomePage/index.tsx +++ b/front/src/components/HomePage/index.tsx @@ -2,14 +2,14 @@ import OnBoarding from "@/components/OnBoarding"; import { useMe } from "@/providers/MeProvider"; -import { Button, Flex } from "@radix-ui/themes"; +import { Flex } from "@radix-ui/themes"; import Balance from "../Balance"; import NavBar from "../NavBar"; import History from "../History"; import TopBar from "../TopBar"; export default function Home() { - const { me, disconnect, isMounted } = useMe(); + const { me, isMounted } = useMe(); if (!isMounted) return null; @@ -20,7 +20,6 @@ export default function Home() { - ); } else { diff --git a/front/src/components/NavBar/index.tsx b/front/src/components/NavBar/index.tsx index 81b6d26..74d427e 100644 --- a/front/src/components/NavBar/index.tsx +++ b/front/src/components/NavBar/index.tsx @@ -5,6 +5,7 @@ import { useModal } from "@/providers/ModalProvider"; import { PaperPlaneIcon, CornersIcon } from "@radix-ui/react-icons"; import { SendTransaction } from "@/components/SendTransaction"; import { useEffect } from "react"; +import QrReaderModal from "../QrReaderModal"; export default function NavBar() { const { open } = useModal(); @@ -19,7 +20,12 @@ export default function NavBar() { > - diff --git a/front/src/components/QrReaderModal/index.tsx b/front/src/components/QrReaderModal/index.tsx new file mode 100644 index 0000000..98f247b --- /dev/null +++ b/front/src/components/QrReaderModal/index.tsx @@ -0,0 +1,108 @@ +import ReactQrReader from "react-qr-reader-es6"; +import { Text, TextField, Button, Flex } from "@radix-ui/themes"; +import { useState } from "react"; +import { CheckCircledIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { useWalletConnect } from "@/libs/wallet-connect"; +import Spinner from "../Spinner"; +import { useModal } from "@/providers/ModalProvider"; + +export default function QrReaderModal() { + const [input, setInput] = useState(""); + const [success, setSuccess] = useState(false); + const { close } = useModal(); + + const { pairSession, pairingState, sessions } = useWalletConnect(); + function handleScan(data: string | null) { + if (data) { + if (data.startsWith("wc:")) { + pairSession({ + uri: data, + onSuccess: (pairingTopic) => { + setSuccess(true); + setTimeout(() => { + close(); + }, 3000); + }, + onError: () => {}, + }); + } + if (data.startsWith("0x")) { + console.log("TODO: handle ethereum address"); + } + } + } + + const isLoading = Object.values(pairingState).some((element) => element.isLoading); + + if (success) { + return ( + + + + ); + } + + if (isLoading) { + return ( + + + + ); + } + + return ( + + console.error(err)} + onScan={handleScan} + /> + or + + + + + + + setInput(e.target.value)} + /> + + + + + ); +} diff --git a/front/src/components/SettingsPage/index.tsx b/front/src/components/SettingsPage/index.tsx index c209951..aac138e 100644 --- a/front/src/components/SettingsPage/index.tsx +++ b/front/src/components/SettingsPage/index.tsx @@ -10,11 +10,13 @@ import Link from "next/link"; import { useWalletConnect } from "@/libs/wallet-connect"; import SessionCard from "../SessionCard"; import { useState } from "react"; +import { useRouter } from "next/navigation"; export default function SettingsPage() { const { me, disconnect, isMounted } = useMe(); const { sessions } = useWalletConnect(); const [isCopied, setIsCopied] = useState(false); + const router = useRouter(); if (!isMounted) return null; @@ -53,7 +55,10 @@ export default function SettingsPage() { - - ); -} diff --git a/front/src/libs/wallet-connect/hook/useWalletConnectHook.tsx b/front/src/libs/wallet-connect/hook/useWalletConnectHook.tsx index c3567e9..0cff6f5 100644 --- a/front/src/libs/wallet-connect/hook/useWalletConnectHook.tsx +++ b/front/src/libs/wallet-connect/hook/useWalletConnectHook.tsx @@ -7,6 +7,8 @@ import { } from "../service/wallet-connect"; import { SessionTypes } from "@walletconnect/types"; import { WC_CONFIG } from "../config"; +import { on } from "events"; +import { useMe } from "@/providers/MeProvider"; interface ISessions { [topic: string]: SessionTypes.Struct; @@ -54,7 +56,7 @@ function formatSessionsToReactSessions(sessions: ISessions): IWCReactSessions { error: null, }, }), - {} + {}, ); } @@ -70,8 +72,7 @@ const reducer = (state: IWCReactSessions, action: Action): IWCReactSessions => { [topic]: { ...state[topic], disconnectIsLoading: action.type === "SET_DISCONNECT_LOADING", - disconnectError: - action.type === "SET_DISCONNECT_ERROR" ? action.error : null, + disconnectError: action.type === "SET_DISCONNECT_ERROR" ? action.error : null, }, }; case "SET_EXTEND_LOADING": @@ -83,8 +84,7 @@ const reducer = (state: IWCReactSessions, action: Action): IWCReactSessions => { [topic]: { ...state[topic], disconnectIsLoading: action.type === "SET_EXTEND_LOADING", - disconnectError: - action.type === "SET_EXTEND_ERROR" ? action.error : null, + disconnectError: action.type === "SET_EXTEND_ERROR" ? action.error : null, }, }; case "SET_UPDATE_LOADING": @@ -96,14 +96,11 @@ const reducer = (state: IWCReactSessions, action: Action): IWCReactSessions => { [topic]: { ...state[topic], disconnectIsLoading: action.type === "SET_UPDATE_LOADING", - disconnectError: - action.type === "SET_UPDATE_ERROR" ? action.error : null, + disconnectError: action.type === "SET_UPDATE_ERROR" ? action.error : null, }, }; case "SET_SESSIONS": - const newFormattedSessions = formatSessionsToReactSessions( - action.sessions - ); + const newFormattedSessions = formatSessionsToReactSessions(action.sessions); return { ...Object.keys(newFormattedSessions).reduce((acc, topic) => { if (!state[topic]) { @@ -128,16 +125,20 @@ export function useWalletConnectHook() { const [isInitLoading, setIsInitLoading] = useState(false); const [isInitReady, setIsInitReady] = useState(false); const [initError, setInitError] = useState(null); - const [pairingState, setPairingState] = useState< - Record - >({}); + const [pairingState, setPairingState] = useState>({}); const [sessions, dispatch] = useReducer(reducer, {}); + const { me } = useMe(); useEffect(() => { - async function init() { + if (!me?.account) return; + + async function init(account: string) { try { setIsInitLoading(true); - await walletConnect.init(WC_CONFIG); + await walletConnect.init({ + walletConnectConfig: WC_CONFIG, + smartWalletAddress: account, + }); setIsInitReady(true); } catch (error: any) { setInitError(error); @@ -145,19 +146,17 @@ export function useWalletConnectHook() { setIsInitLoading(false); } } - init(); + init(me.account); return () => { walletConnect.unsubscribe(); }; - }, []); + }, [me]); useEffect(() => { const handleSessionsChanged = (newSessions: ISessions) => { dispatch({ type: "SET_SESSIONS", sessions: newSessions }); }; - const handlePairingApproved = ({ - pairingTopic, - }: IPairingApprovedEventPayload) => { + const handlePairingApproved = ({ pairingTopic }: IPairingApprovedEventPayload) => { setPairingState((prev) => ({ ...prev, [pairingTopic]: { @@ -167,10 +166,7 @@ export function useWalletConnectHook() { }, })); }; - const handlePairingRejected = ({ - pairingTopic, - msg, - }: IPairingRejectedEventPayload) => { + const handlePairingRejected = ({ pairingTopic, msg }: IPairingRejectedEventPayload) => { setPairingState((prev) => ({ ...prev, [pairingTopic]: { @@ -184,22 +180,21 @@ export function useWalletConnectHook() { walletConnect.on(WCEvent.pairingApproved, handlePairingApproved); walletConnect.on(WCEvent.pairingRejected, handlePairingRejected); return () => { - walletConnect.removeListener( - WCEvent.sessionChanged, - handleSessionsChanged - ); - walletConnect.removeListener( - WCEvent.pairingApproved, - handlePairingApproved - ); - walletConnect.removeListener( - WCEvent.pairingRejected, - handlePairingRejected - ); + walletConnect.removeListener(WCEvent.sessionChanged, handleSessionsChanged); + walletConnect.removeListener(WCEvent.pairingApproved, handlePairingApproved); + walletConnect.removeListener(WCEvent.pairingRejected, handlePairingRejected); }; }, []); - async function pairSession(uri: string) { + async function pairSession({ + uri, + onSuccess, + onError, + }: { + uri: string; + onSuccess?: (pairingTopic: string) => void; + onError?: (error: any) => void; + }) { let pairingTopic = ""; try { pairingTopic = uri.split("@")[0].split(":")[1]; @@ -223,6 +218,7 @@ export function useWalletConnectHook() { }, })); }, 5000); + onSuccess && onSuccess(pairingTopic); } catch (error: any) { setPairingState((prev) => ({ ...prev, @@ -232,6 +228,7 @@ export function useWalletConnectHook() { error: error, }, })); + onError && onError(error); } } diff --git a/front/src/libs/wallet-connect/service/wallet-connect.ts b/front/src/libs/wallet-connect/service/wallet-connect.ts index 84c5aab..133c84d 100644 --- a/front/src/libs/wallet-connect/service/wallet-connect.ts +++ b/front/src/libs/wallet-connect/service/wallet-connect.ts @@ -1,16 +1,9 @@ import { Core } from "@walletconnect/core"; import { EventEmitter } from "events"; -import { - Web3Wallet, - Web3WalletTypes, - IWeb3Wallet, -} from "@walletconnect/web3wallet"; +import { Web3Wallet, Web3WalletTypes, IWeb3Wallet } from "@walletconnect/web3wallet"; import { buildApprovedNamespaces, getSdkError } from "@walletconnect/utils"; import { SessionTypes } from "@walletconnect/types"; -import { - EIP155_CHAINS, - EIP155Method, -} from "@/libs/wallet-connect/config/EIP155"; +import { EIP155_CHAINS, EIP155Method } from "@/libs/wallet-connect/config/EIP155"; import { EthEvent, WCChains } from "@/libs/wallet-connect/config/common"; import { smartWallet } from "@/libs/smart-wallet/service/smart-wallet"; @@ -44,25 +37,27 @@ export interface IPairingRejectedEventPayload { * */ class WalletConnect extends EventEmitter { public sessions: Record = {}; - private _smartWalletAddress: string; + private _smartWalletAddress: string = ""; private _web3wallet: IWeb3Wallet | null; - private static _instance: WalletConnect; constructor() { super(); this.sessions = {}; - this._smartWalletAddress = "0x938f169352008d35e065F153be53b3D3C07Bcd90"; this._web3wallet = null; } - - public static getInstance(): WalletConnect { - if (!this._instance) { - this._instance = new this(); - } - return this._instance; + public set smartWalletAddress(address: string) { + this._smartWalletAddress = address; } - public async init(walletConnectConfig: IWalletConnectConfig) { + public async init({ + walletConnectConfig, + smartWalletAddress, + }: { + walletConnectConfig: IWalletConnectConfig; + smartWalletAddress: string; + }): Promise { + this._smartWalletAddress = smartWalletAddress; + const core = new Core({ projectId: walletConnectConfig.projectId, // TODO: optimize relayerRegionURL base on user's location @@ -75,24 +70,16 @@ class WalletConnect extends EventEmitter { }); if (!this._web3wallet) throw new Error("Web3Wallet is not initialized"); - this._web3wallet.on("session_proposal", (event) => - this._onSessionProposal(event) - ); - this._web3wallet.on("session_request", (event) => - this._onSessionRequest(event) - ); + this._web3wallet.on("session_proposal", (event) => this._onSessionProposal(event)); + this._web3wallet.on("session_request", (event) => this._onSessionRequest(event)); this._web3wallet.on("session_delete", () => this._onSessionDelete()); this._setSessions(); } public unsubscribe(): void { if (!this._web3wallet) return; - this._web3wallet.off("session_proposal", (event) => - this._onSessionProposal(event) - ); - this._web3wallet.off("session_request", (event) => - this._onSessionRequest(event) - ); + this._web3wallet.off("session_proposal", (event) => this._onSessionProposal(event)); + this._web3wallet.off("session_request", (event) => this._onSessionRequest(event)); this._web3wallet.off("session_delete", () => this._onSessionDelete()); } @@ -144,10 +131,7 @@ class WalletConnect extends EventEmitter { this._setSessions(); } - private async _onSessionProposal({ - id, - params, - }: Web3WalletTypes.SessionProposal) { + private async _onSessionProposal({ id, params }: Web3WalletTypes.SessionProposal) { if (!this._web3wallet) return; try { @@ -182,9 +166,7 @@ class WalletConnect extends EventEmitter { } } - private async _onSessionRequest( - event: Web3WalletTypes.SessionRequest - ): Promise { + private async _onSessionRequest(event: Web3WalletTypes.SessionRequest): Promise { if (!this._web3wallet) return; const { topic, params, id } = event; const { request } = params; @@ -242,4 +224,4 @@ class WalletConnect extends EventEmitter { } } -export const walletConnect = WalletConnect.getInstance(); +export const walletConnect = new WalletConnect(); diff --git a/front/src/providers/MeProvider/index.tsx b/front/src/providers/MeProvider/index.tsx index 12adb6f..cc3901e 100644 --- a/front/src/providers/MeProvider/index.tsx +++ b/front/src/providers/MeProvider/index.tsx @@ -5,6 +5,7 @@ import { Address, Hex } from "viem"; import { WebAuthn } from "@/libs/web-authn/service/web-authn"; import { saveUser } from "@/libs/factory"; import { getUser } from "@/libs/factory/getUser"; +import { walletConnect } from "@/libs/wallet-connect/service/wallet-connect"; export type Me = { account: Address; @@ -51,6 +52,7 @@ function useMeHook() { } localStorage.setItem("hocuspocus.me", JSON.stringify(me)); localStorage.setItem("hocuspocus.returning", "true"); + walletConnect.smartWalletAddress = me.account; setIsReturning(true); setMe(me); } catch (e) { @@ -76,6 +78,7 @@ function useMeHook() { localStorage.setItem("hocuspocus.me", JSON.stringify(me)); localStorage.setItem("hocuspocus.returning", "true"); + walletConnect.smartWalletAddress = me.account; setIsReturning(true); setMe(me); } catch (e) { diff --git a/front/src/providers/ModalProvider/index.tsx b/front/src/providers/ModalProvider/index.tsx index a3429a3..f7e8df0 100644 --- a/front/src/providers/ModalProvider/index.tsx +++ b/front/src/providers/ModalProvider/index.tsx @@ -1,8 +1,9 @@ "use client"; import React, { useContext, useEffect, useState } from "react"; -import { Portal } from "@radix-ui/themes"; +import { Portal, Box } from "@radix-ui/themes"; import styled from "@emotion/styled"; +import { useTheme } from "next-themes"; const PortalContainer = styled(Portal)<{ $isOpen: Boolean }>` position: absolute; @@ -11,24 +12,25 @@ const PortalContainer = styled(Portal)<{ $isOpen: Boolean }>` width: 100%; height: 100svh; @media (min-width: 391px) { - height: calc(100vh - 40px); + height: calc(100vh - 4rem); } pointer-events: ${({ $isOpen }) => ($isOpen ? "auto" : "none")}; `; const Modal = styled.div<{ $isOpen: Boolean }>` display: flex; - direction: column; + flex-direction: column; + align-items: center; position: absolute; bottom: 0; left: 0; width: 100%; - height: 80svh; + height: 70svh; padding: 1rem; background-color: var(--color-background); border-radius: 10px 10px 0 0; transform: ${({ $isOpen }) => ($isOpen ? "translate3d(0, 0, 0)" : "translate3d(0, 100svh, 0)")}; - transition: transform 0.2s ease-in-out; + transition: transform 0.3s ease-in-out; z-index: 100; `; @@ -37,21 +39,36 @@ const Overlay = styled.div<{ $isOpen: Boolean }>` top: 0; left: 0; opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)}; - backdrop-filter: blur(3px); + backdrop-filter: blur(4px); width: 100%; height: 100svh; @media (min-width: 391px) { height: calc(100vh - 40px); } visibility: ${({ $isOpen }) => ($isOpen ? "visible" : "hidden")}; - transition: opacity 0.1s ease-in-out; + transition: opacity 0.3s ease-in-out; z-index: 99; + + ::before { + background: var(--gray-8); + opacity: 0.5; + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100svh; + @media (min-width: 391px) { + height: calc(100vh - 40px); + } + } `; function useModalHook() { const [content, setContent] = useState(null); const [isOpen, setIsOpen] = useState(false); const [isBackdrop, setIsBackdrop] = useState(false); + const { theme } = useTheme(); function open(content: React.ReactNode) { if (isOpen) { @@ -114,7 +131,24 @@ export function ModalProvider({ children }: { children: React.ReactNode }) { {isPortalMounted && radixElement && ( modalValue.close()} /> - {modalValue.content} + { + e.preventDefault(); + }} + > + modalValue.close()} + /> + {modalValue.content} + )}