From 83eb5bc20cb1abd079493b94bceea16837b94688 Mon Sep 17 00:00:00 2001 From: sirpy Date: Thu, 8 Oct 2026 16:30:58 +0300 Subject: [PATCH 1/2] feat: add Reown existing wallet login --- .env.example | 1 + config.ts | 3 + package.json | 2 + src/login/context/SessionContext/storage.ts | 23 +++ src/login/hooks/login.ts | 4 + src/login/reown.ts | 160 ++++++++++++++++++ src/login/types.ts | 7 +- .../GoodDollar/components/Claim/ClaimView.tsx | 6 +- src/sections/Login/components/Goodwallet.tsx | 14 +- src/sections/Login/components/LoginButton.tsx | 20 +++ src/sections/Login/useDefaultLoginMethod.ts | 6 + src/translations/translations/da.ts | 1 + src/translations/translations/en.ts | 1 + src/translations/types.ts | 1 + yarn.lock | 22 +++ 15 files changed, 265 insertions(+), 6 deletions(-) create mode 100644 src/login/reown.ts diff --git a/.env.example b/.env.example index 64a1486..9f80935 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,7 @@ NEXT_PUBLIC_GOODDOLLAR_FEED_URL=https://feed-dev.gooddollar.org/api/ceramic-feed NEXT_PUBLIC_LOGIN_PWLESS_ENABLED=true NEXT_PUBLIC_LOGIN_GOOGLE_ENABLED=true NEXT_PUBLIC_LOGIN_FACEBOOK_ENABLED=false +NEXT_PUBLIC_LOGIN_EXISTING_WALLET_ENABLED=true # Test login (dev only — never enable in production). NEXT_PUBLIC_TEST_LOGIN_ENABLED=false diff --git a/config.ts b/config.ts index 07f586c..a4fd6df 100644 --- a/config.ts +++ b/config.ts @@ -54,6 +54,7 @@ type EnabledLoginOptions = { google: boolean facebook: boolean pwless: boolean + existingWallet: boolean testLogin: { enabled: boolean masterSeed: string @@ -66,6 +67,8 @@ const enabledLoginOptions: EnabledLoginOptions = { google: process.env.NEXT_PUBLIC_LOGIN_GOOGLE_ENABLED === "true", facebook: process.env.NEXT_PUBLIC_LOGIN_FACEBOOK_ENABLED === "true", pwless: process.env.NEXT_PUBLIC_LOGIN_PWLESS_ENABLED === "true", + existingWallet: + process.env.NEXT_PUBLIC_LOGIN_EXISTING_WALLET_ENABLED === "true", testLogin: { masterSeed: process.env.NEXT_PUBLIC_TEST_LOGIN_MASTER_SEED as string, userName: process.env.NEXT_PUBLIC_TEST_LOGIN_USER_NAME as string, diff --git a/package.json b/package.json index 22ac58a..33f1951 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "@lottiefiles/react-lottie-player": "^3.6.0", "@neondatabase/serverless": "^1.1.0", "@react-hookz/web": "^25.2.0", + "@reown/appkit": "1.8.23", + "@reown/appkit-adapter-ethers": "1.8.23", "@reown/walletkit": "^1.5.6", "@sentry/nextjs": "^10.56.0", "@solana-name-service/sns-sdk-kit": "^0.10.0", diff --git a/src/login/context/SessionContext/storage.ts b/src/login/context/SessionContext/storage.ts index 9a0db9e..e753390 100644 --- a/src/login/context/SessionContext/storage.ts +++ b/src/login/context/SessionContext/storage.ts @@ -2,6 +2,7 @@ import type { Jsonify } from "type-fest" import { proxy, ref } from "valtio" import { getPrivateKeySession } from "@/login/adapters/privatekey" +import { disconnectReown, getReownSession } from "@/login/reown" import type { Addresses, ISigner, ISignerSession } from "@/login/types" import { resetWalletConnectDialogs } from "@/sections/WalletConnect/store/walletConnectDialogStore" @@ -37,6 +38,21 @@ const fromJSON = async ( profileImage, ) } + case "REOWN": { + const { authProvider, address } = sessionJSON + if (!address) { + return null + } + + return await getReownSession( + "localStorage", + authProvider ?? "existing_wallet", + { + interactive: false, + expectedAddress: address, + }, + ) + } } return null } @@ -79,7 +95,14 @@ export const setSession = (session: ISignerSession | null) => { export const logout = () => { resetWalletConnectDialogs() + const previousSession = sessionState.session setSession(null) + + if (previousSession?.type === "REOWN") { + disconnectReown().catch((error) => { + console.error("Failed to disconnect Reown session", error) + }) + } } if (typeof window !== "undefined") { diff --git a/src/login/hooks/login.ts b/src/login/hooks/login.ts index d6f453d..60bdbd3 100644 --- a/src/login/hooks/login.ts +++ b/src/login/hooks/login.ts @@ -5,6 +5,7 @@ import type { } from "@toruslabs/customauth" import { getPrivateKeySession } from "../adapters/privatekey" +import { getReownSession } from "../reown" import { getTorusSession, type TorusNetwork, @@ -54,6 +55,9 @@ export const useTorusLogin = ( web3authClientId: string, ) => useLogin(async () => getTorusSession(network, web3authClientId)) +export const useReownLogin = () => + useLogin(async () => getReownSession("direct", "existing_wallet")) + const useLogin = ( connectSession: () => Promise, ): (() => Promise) => { diff --git a/src/login/reown.ts b/src/login/reown.ts new file mode 100644 index 0000000..1bd30b2 --- /dev/null +++ b/src/login/reown.ts @@ -0,0 +1,160 @@ +import { createAppKit } from "@reown/appkit" +import { base, celo, fuse, mainnet, xdc } from "@reown/appkit/networks" +import { EthersAdapter } from "@reown/appkit-adapter-ethers" +import { BrowserProvider, type Eip1193Provider } from "ethers" + +import manifest from "@/app/manifest" +import { config } from "@/config" + +import type { ISigner, ISignerSession } from "./types" + +type GetReownSessionOptions = { + interactive?: boolean + expectedAddress?: string +} + +let appKitInstance: ReturnType | undefined + +const getMetadata = () => { + const { name, description, icons } = manifest() + const url = + typeof window !== "undefined" ? window.location.origin : "http://localhost" + + return { + name: name ?? "GoodWallet", + description: + description ?? "Claim UBI and exchange cryptocurrencies with GoodWallet", + url, + icons: (icons ?? []).map((icon) => `${url}${icon.src}`), + } +} + +const getAppKit = async () => { + if (typeof window === "undefined") { + throw new Error("Reown login is only available in browser") + } + + if (!config.walletConnectEnabled) { + throw new Error("WalletConnect is not configured") + } + + if (!appKitInstance) { + appKitInstance = createAppKit({ + adapters: [new EthersAdapter()], + projectId: config.walletConnectProjectId, + metadata: getMetadata(), + networks: [celo, fuse, mainnet, base, xdc], + defaultNetwork: celo, + }) + } + + await appKitInstance.ready() + return appKitInstance +} + +const waitForConnectedAddress = async ( + appKit: ReturnType, + timeoutMs = 120000, +): Promise => { + const currentAddress = appKit.getAddress("eip155") + if (currentAddress) { + return currentAddress + } + + return await new Promise((resolve) => { + const timeout = setTimeout(() => { + unsubscribe() + resolve(null) + }, timeoutMs) + + const unsubscribe = appKit.subscribeAccount((state) => { + if (state.isConnected && state.address) { + clearTimeout(timeout) + unsubscribe() + resolve(state.address) + } + }, "eip155") + }) +} + +const connectAddress = async ( + appKit: ReturnType, + interactive: boolean, +) => { + const connectedAddress = await waitForConnectedAddress( + appKit, + interactive ? 1000 : 15000, + ) + if (connectedAddress) { + return connectedAddress + } + + if (!interactive) { + return null + } + + const connected = waitForConnectedAddress(appKit) + await appKit.open({ view: "Connect", namespace: "eip155" }) + return await connected +} + +export const getReownSession = async ( + sessionOrigin: string, + authProvider: string, + options: GetReownSessionOptions = {}, +): Promise => { + const { interactive = true, expectedAddress } = options + const appKit = await getAppKit() + const connectedAddress = await connectAddress(appKit, interactive) + + if (!connectedAddress) { + throw new Error("Wallet connection was not completed") + } + + if ( + expectedAddress && + connectedAddress.toLowerCase() !== expectedAddress.toLowerCase() + ) { + throw new Error("Connected wallet does not match the previous session") + } + + const walletProvider = appKit.getProvider("eip155") + if (!walletProvider) { + throw new Error("Connected provider not available") + } + + const provider = new BrowserProvider(walletProvider) + const signer = await provider.getSigner(connectedAddress) + + const session: ISignerSession = { + type: "REOWN", + sessionOrigin, + authProvider, + address: connectedAddress, + userName: connectedAddress, + signer: { + EVM: { + address: connectedAddress, + signMessage: (message: Parameters[0]) => + signer.signMessage(message), + signTransaction: (tx: Parameters[0]) => + signer.signTransaction(tx), + signTypedData: ( + domain: Parameters[0], + types: Parameters[1], + value: Parameters[2], + ) => signer.signTypedData(domain, types, value), + }, + } as unknown as ISigner, + } + + return session +} + +export const disconnectReown = async (): Promise => { + if (!appKitInstance) { + return + } + + await appKitInstance.disconnect("eip155") +} diff --git a/src/login/types.ts b/src/login/types.ts index aec5ea2..f6eb438 100644 --- a/src/login/types.ts +++ b/src/login/types.ts @@ -61,7 +61,7 @@ export type ISignerSession = { authProvider?: string userName?: string profileImage?: string -} & (IBrowserSession | IPrivateKeySession) +} & (IBrowserSession | IPrivateKeySession | IReownSession) type IBrowserSession = { type: "BROWSER" @@ -71,3 +71,8 @@ type IPrivateKeySession = { type: "PRIVATE_KEY" masterSeed: string } + +type IReownSession = { + type: "REOWN" + address: string +} diff --git a/src/sections/GoodDollar/components/Claim/ClaimView.tsx b/src/sections/GoodDollar/components/Claim/ClaimView.tsx index c38d9ec..b8e6472 100644 --- a/src/sections/GoodDollar/components/Claim/ClaimView.tsx +++ b/src/sections/GoodDollar/components/Claim/ClaimView.tsx @@ -552,12 +552,10 @@ function ClaimViewContent() { if (!signer) { throw new Error("No signer provided") } - if (!userName) { - throw new Error("No userName provided") - } + const link = await generateGoodIDLink( signer.EVM, - userName, + userName ?? "", invitedChainId, ) window.open(link, "_self") diff --git a/src/sections/Login/components/Goodwallet.tsx b/src/sections/Login/components/Goodwallet.tsx index 7ff121b..bdb3ed8 100644 --- a/src/sections/Login/components/Goodwallet.tsx +++ b/src/sections/Login/components/Goodwallet.tsx @@ -12,6 +12,7 @@ import { postMessageToReactNative } from "@/utils/messageReactNative" import { useDefaultLoginMethod } from "../useDefaultLoginMethod" import { + ExistingWalletLoginButton, FacebookLoginButton, GoogleLoginButton, PrivateKeyLoginButton, @@ -29,11 +30,14 @@ export default function LoginDefault() { const loginTranslations = translations.login const btns = useMemo(() => { - const { google, facebook, pwless, testLogin } = config.enabledLoginOptions + const { google, facebook, pwless, existingWallet, testLogin } = + config.enabledLoginOptions const isFacebookDefault = defaultLoginMethod.value === "facebook" const isGoogleDefault = defaultLoginMethod.value === "google" const isPwlessDefault = defaultLoginMethod.value === "pwless" + const isExistingWalletDefault = + defaultLoginMethod.value === "existingwallet" const isTestLoginDefault = defaultLoginMethod.value === "testlogin" return [ @@ -55,6 +59,14 @@ export default function LoginDefault() { isDefault: isPwlessDefault, isEnabled: pwless, }, + { + method: "existingWallet", + element: ( + + ), + isDefault: isExistingWalletDefault, + isEnabled: existingWallet, + }, { method: "testLogin", element: ( diff --git a/src/sections/Login/components/LoginButton.tsx b/src/sections/Login/components/LoginButton.tsx index 4204ae6..20343ea 100644 --- a/src/sections/Login/components/LoginButton.tsx +++ b/src/sections/Login/components/LoginButton.tsx @@ -8,6 +8,7 @@ import { config } from "@/config" import { Captcha, usePKeyLogin, + useReownLogin, useTriggerTorusAggregateLogin, useTriggerTorusLogin, } from "@/login" @@ -116,3 +117,22 @@ export const PrivateKeyLoginButton: FC<{ /> ) } + +export const ExistingWalletLoginButton: FC<{ isDefault?: boolean }> = ({ + isDefault, +}) => { + const { locale, translations } = useTranslation() + const onClick = useReownLogin() + + return ( +