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/chain/chains.ts b/src/chain/chains.ts index 1df2f74..c9cb4a9 100644 --- a/src/chain/chains.ts +++ b/src/chain/chains.ts @@ -13,6 +13,7 @@ import { } from "viem/chains" import { config } from "@/config" +import type { Addresses } from "@/login/types" import { BASE_CHAIN_ID, @@ -448,6 +449,20 @@ export const AVAILABLE_CHAINS_IDS = Array.from(AVAILABLE_CHAINS.values()) .sort((a, b) => b.tvl - a.tvl) .map((c) => c.id) +// Chains the session can use, i.e. it holds an address for the chain family. +export const isChainAvailableForAddresses = ( + chainId: number, + addresses: Addresses | undefined, +) => { + const family = AVAILABLE_CHAINS.get(chainId)?.family + return !!family && !!addresses?.has(family) +} + +export const getAvailableChainIds = (addresses: Addresses | undefined) => + AVAILABLE_CHAINS_IDS.filter((chainId) => + isChainAvailableForAddresses(chainId, addresses), + ) + export const getChainName = (chainId: number): string => { const chain = AVAILABLE_CHAINS.get(chainId) diff --git a/src/ethers-utils/utils.test.ts b/src/ethers-utils/utils.test.ts index 86c12f6..3f4225a 100644 --- a/src/ethers-utils/utils.test.ts +++ b/src/ethers-utils/utils.test.ts @@ -8,6 +8,7 @@ const { mockAcquire, mockRelease, mockBroadcastTransaction, + mockGetTransaction, mockGetTransactionCount, mockSubmitReferral, mockSetDivviMarked, @@ -15,6 +16,7 @@ const { mockAcquire: vi.fn(), mockRelease: vi.fn(), mockBroadcastTransaction: vi.fn(), + mockGetTransaction: vi.fn(), mockGetTransactionCount: vi.fn(), mockSubmitReferral: vi.fn(), mockSetDivviMarked: vi.fn(), @@ -32,6 +34,7 @@ vi.mock("async-await-mutex-lock", () => ({ vi.mock("./ethersProviders", () => ({ getEthersProvider: vi.fn(() => ({ broadcastTransaction: mockBroadcastTransaction, + getTransaction: mockGetTransaction, getTransactionCount: mockGetTransactionCount, })), })) @@ -366,4 +369,88 @@ describe("ethers-utils", () => { expect(mockRelease).toHaveBeenCalledWith(request.chainId) }) }) + + // ── external wallet (submitTransaction) ─────────────────────────────── + + describe("external wallet", () => { + const createWalletSigner = () => ({ + ...createSigner(), + submitTransaction: vi.fn().mockResolvedValue(TX_HASH), + }) + + let walletSigner: ReturnType + + beforeEach(() => { + walletSigner = createWalletSigner() + mockGetTransaction.mockResolvedValue(txResponse) + }) + + it("sendTx submits through the wallet without signing, locking, or broadcasting", async () => { + request = createRequest({ data: "0xcalldata" }) + + const result = await sendTx( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + expect(result.hash).toBe(TX_HASH) + expect(walletSigner.submitTransaction).toHaveBeenCalledWith({ + ...request, + from: walletSigner.address, + gasLimit: DEFAULT_GAS_LIMIT, + }) + expect(walletSigner.signTransaction).not.toHaveBeenCalled() + expect(mockAcquire).not.toHaveBeenCalled() + expect(mockBroadcastTransaction).not.toHaveBeenCalled() + expect(mockGetTransactionCount).not.toHaveBeenCalled() + await expect(result.trxMinedPromise).resolves.toEqual({ hash: TX_HASH }) + expect(mockGetTransaction).toHaveBeenCalledWith(TX_HASH) + }) + + it("sendTx does not pass wallet-managed fees or nonce", async () => { + await sendTx(walletSigner as never, DEFAULT_GAS_LIMIT, request, options) + + const submitted = walletSigner.submitTransaction.mock.calls[0][0] + expect(submitted).not.toHaveProperty("nonce") + expect(submitted).not.toHaveProperty("gasPrice") + expect(submitted).not.toHaveProperty("maxFeePerGas") + }) + + it("sendTxWaitForMining returns the response and receipt and keeps divvi tracking", async () => { + request = createRequest({ data: "0xcalldata_DIVVI_SUFFIX" }) + + const result = await sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ) + + expect(result.txResponse.hash).toBe(TX_HASH) + expect(result.txReceipt).toEqual({ hash: TX_HASH }) + expect(mockBroadcastTransaction).not.toHaveBeenCalled() + expect(mockSubmitReferral).toHaveBeenCalledWith({ + txHash: TX_HASH, + chainId: request.chainId, + }) + }) + + it("propagates wallet rejection", async () => { + walletSigner.submitTransaction.mockRejectedValue( + new Error("User rejected"), + ) + + await expect( + sendTxWaitForMining( + walletSigner as never, + DEFAULT_GAS_LIMIT, + request, + options, + ), + ).rejects.toThrow("User rejected") + expect(mockBroadcastTransaction).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/ethers-utils/utils.ts b/src/ethers-utils/utils.ts index cb49dfd..10cad07 100644 --- a/src/ethers-utils/utils.ts +++ b/src/ethers-utils/utils.ts @@ -48,6 +48,37 @@ export const encodeMethodCall = async ( return { contractAddress, data } } const replacementFeeMultipler = BigInt(2) +const SUBMITTED_TX_POLL_MS = 1500 +const SUBMITTED_TX_MAX_ATTEMPTS = 40 + +const getSubmittedTransaction = async ( + provider: AbstractProvider, + hash: string, +): Promise => { + for (let attempt = 0; attempt < SUBMITTED_TX_MAX_ATTEMPTS; attempt++) { + const txResponse = await provider.getTransaction(hash) + if (txResponse) return txResponse + await new Promise((resolve) => setTimeout(resolve, SUBMITTED_TX_POLL_MS)) + } + throw new Error(`Transaction ${hash} was not found on chain`) +} + +// The wallet picks nonce and fees and broadcasts once; we only track the hash. +const submitWithWallet = async ( + submit: NonNullable, + signer: EVMSigner, + gasLimit: bigint, + broadcastRequest: BroadcastRequest, +) => { + const hash = await submit({ + ...broadcastRequest, + from: signer.address, + gasLimit, + }) + const provider = getEthersProvider(broadcastRequest.chainId) + return { hash, txResponse: getSubmittedTransaction(provider, hash) } +} + export const sendTx = async ( signer: EVMSigner, gasLimit: bigint, @@ -58,6 +89,19 @@ export const sendTx = async ( hash: string trxMinedPromise: Promise }> => { + if (signer.submitTransaction) { + const { hash, txResponse } = await submitWithWallet( + signer.submitTransaction, + signer, + gasLimit, + broadcastRequest, + ) + const trxMinedPromise = txResponse.then((trx) => + waitForMinedStatus(trx, broadcastRequest, signer), + ) + return { hash, trxMinedPromise } + } + const from = signer.address const provider = getEthersProvider(broadcastRequest.chainId) await lock.acquire(broadcastRequest.chainId) @@ -85,6 +129,22 @@ export const sendTxWaitForMining = async ( broadcastOptions: BroadcastOptions, usePendingNonce: boolean = false, ): Promise => { + if (signer.submitTransaction) { + const submitted = await submitWithWallet( + signer.submitTransaction, + signer, + gasLimit, + broadcastRequest, + ) + const txResponse = await submitted.txResponse + const txReceipt = await waitForMinedStatus( + txResponse, + broadcastRequest, + signer, + ) + return { txResponse, txReceipt } + } + const from = signer.address const provider = getEthersProvider(broadcastRequest.chainId) await lock.acquire(broadcastRequest.chainId) diff --git a/src/hooks/useTokenBalances/index.ts b/src/hooks/useTokenBalances/index.ts index c5c8007..06fe7ca 100644 --- a/src/hooks/useTokenBalances/index.ts +++ b/src/hooks/useTokenBalances/index.ts @@ -1,8 +1,9 @@ "use client" -import { useCallback } from "react" +import { useCallback, useMemo } from "react" import { useSnapshot } from "valtio" +import { isChainAvailableForAddresses } from "@/chain/chains" import { sessionState } from "@/login/context/SessionContext/storage" import type { Prices, Tokens } from "@/tokens/types" @@ -57,8 +58,23 @@ export function useTokenBalances(): { [tokens, addresses], ) + // Hide chains the session has no address for (e.g. non-EVM on connected wallets). + const availableTokens = useMemo(() => { + const source = tokensStore.tokens + if (!tokens || !source || !addresses) return tokens + const entries = Array.from(source).filter(([chainId]) => + isChainAvailableForAddresses(chainId, addresses), + ) + if (entries.length === source.size) return tokens + const filtered: Tokens = Object.assign(new Map(entries), { + getBy: source.getBy, + getNativeBy: source.getNativeBy, + }) + return filtered + }, [tokens, addresses]) + return { - tokens, + tokens: availableTokens, prices, balances, isLoading, diff --git a/src/hooks/useTokenBalances/stores/amountsStore.ts b/src/hooks/useTokenBalances/stores/amountsStore.ts index dda7471..30f3594 100644 --- a/src/hooks/useTokenBalances/stores/amountsStore.ts +++ b/src/hooks/useTokenBalances/stores/amountsStore.ts @@ -3,6 +3,7 @@ import { imap } from "itertools" import { proxy } from "valtio" import { proxyMap, subscribeKey } from "valtio/utils" +import { isChainAvailableForAddresses } from "@/chain/chains" import { getChainProvider } from "@/chain/provider/provider" import { sessionState } from "@/login/context/SessionContext/storage" import type { Addresses } from "@/login/types" @@ -59,12 +60,15 @@ export const refreshAmounts = async ( if (chainId === "all") { const promises = imap( tokens.entries(), - async ([chainId, tokensOfChain]) => - getAmountsForChain(tokensOfChain, addresses, chainId).then( - (amounts) => { - amountsStore.amounts.set(chainId, amounts) - }, - ), + async ([chainId, tokensOfChain]) => { + if (!isChainAvailableForAddresses(chainId, addresses)) return + const amounts = await getAmountsForChain( + tokensOfChain, + addresses, + chainId, + ) + amountsStore.amounts.set(chainId, amounts) + }, ) const r = await Promise.allSettled(promises) //Log errors, if any @@ -73,7 +77,7 @@ export const refreshAmounts = async ( ) } else { const tokensOfChain = tokens.get(chainId) - if (tokensOfChain) { + if (tokensOfChain && isChainAvailableForAddresses(chainId, addresses)) { const amountsForChain = await getAmountsForChain( tokensOfChain, addresses, diff --git a/src/login/context/SessionContext/storage.ts b/src/login/context/SessionContext/storage.ts index 9a0db9e..fa855b0 100644 --- a/src/login/context/SessionContext/storage.ts +++ b/src/login/context/SessionContext/storage.ts @@ -2,6 +2,11 @@ import type { Jsonify } from "type-fest" import { proxy, ref } from "valtio" import { getPrivateKeySession } from "@/login/adapters/privatekey" +import { + disconnectReown, + getReownSession, + watchReownAccount, +} from "@/login/reown" import type { Addresses, ISigner, ISignerSession } from "@/login/types" import { resetWalletConnectDialogs } from "@/sections/WalletConnect/store/walletConnectDialogStore" @@ -37,30 +42,58 @@ 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 } +export type ReconnectReason = "accountChanged" | "restoreFailed" + type SessionState = { session: null | ISignerSession addresses?: Addresses isLoading: boolean + reconnectReason?: ReconnectReason } export const sessionState = proxy({ session: null, addresses: undefined, isLoading: true, + reconnectReason: undefined, }) +// Bumped on every session change so stale async restores are dropped. +let sessionVersion = 0 +let stopAccountWatch: (() => void) | undefined + export const setSession = (session: ISignerSession | null) => { + sessionVersion++ + stopAccountWatch?.() + stopAccountWatch = undefined + sessionState.session = session && ref(session) if (session) { const addressMap = new Map() for (const [key, value] of Object.entries(session.signer)) { - addressMap.set(key as keyof ISigner, value.address) + if (value) addressMap.set(key as keyof ISigner, value.address) } sessionState.addresses = addressMap + sessionState.reconnectReason = undefined } else { sessionState.addresses = undefined } @@ -75,18 +108,62 @@ export const setSession = (session: ISignerSession | null) => { } sessionState.isLoading = false + + if (session?.type === "REOWN") { + stopAccountWatch = watchReownAccount(session.address, () => + endSession("accountChanged"), + ) + } +} + +const disconnectDappSessions = async () => { + const { disconnectAllWalletConnectSessions } = await import( + "@/sections/WalletConnect/store/walletConnectStore" + ) + await disconnectAllWalletConnectSessions() } -export const logout = () => { +const endSession = (reconnectReason?: ReconnectReason) => { resetWalletConnectDialogs() + const previousSession = sessionState.session setSession(null) + sessionState.reconnectReason = reconnectReason + + if (previousSession?.type === "REOWN") { + disconnectDappSessions().catch((error) => { + console.error("Failed to disconnect WalletConnect sessions", error) + }) + disconnectReown().catch((error) => { + console.error("Failed to disconnect Reown session", error) + }) + } +} + +export const logout = () => endSession() + +const getStoredSessionType = () => { + try { + const stored = localStorage.getItem(SIGNER_SESSION_KEY) + return stored ? (JSON.parse(stored) as { type?: string }).type : undefined + } catch { + return undefined + } +} + +const restoreSession = async () => { + const version = sessionVersion + try { + const session = await getSessionFromLocalStorage() + if (version === sessionVersion) setSession(session) + } catch (e) { + console.error("error getting persisted session", e) + if (version !== sessionVersion) return + const wasReown = getStoredSessionType() === "REOWN" + setSession(null) + if (wasReown) sessionState.reconnectReason = "restoreFailed" + } } if (typeof window !== "undefined") { - getSessionFromLocalStorage() - .then(setSession) - .catch((e) => { - console.error("error getting persisted session", e) - setSession(null) - }) + restoreSession() } diff --git a/src/login/hooks/context.ts b/src/login/hooks/context.ts index db71aa9..d86657f 100644 --- a/src/login/hooks/context.ts +++ b/src/login/hooks/context.ts @@ -2,6 +2,7 @@ import { useSnapshot } from "valtio" import { logout, + type ReconnectReason, sessionState, setSession, } from "../context/SessionContext/storage" @@ -10,11 +11,20 @@ import type { Addresses, ISignerSession } from "../types" export type ISessionContext = Partial & { addresses?: Addresses isLoading: boolean + reconnectReason?: ReconnectReason logout?: () => void setSession(session: ISignerSession): void } export const useSessionContext = (): ISessionContext => { - const { session, addresses, isLoading } = useSnapshot(sessionState) - return { ...session, addresses, isLoading, setSession, logout } + const { session, addresses, isLoading, reconnectReason } = + useSnapshot(sessionState) + return { + ...session, + addresses, + isLoading, + reconnectReason, + setSession, + logout, + } } 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..4b1b887 --- /dev/null +++ b/src/login/reown.ts @@ -0,0 +1,344 @@ +import { type CreateAppKit, createAppKit } from "@reown/appkit" +import { EthersAdapter } from "@reown/appkit-adapter-ethers" +import { + BrowserProvider, + type Eip1193Provider, + type TransactionRequest, +} from "ethers" + +import manifest from "@/app/manifest" +import { CELO_CHAIN_ID } from "@/chain/chain-ids" +import { AVAILABLE_EVM_CHAINS } from "@/chain/chains" +import type { Chain } from "@/chain/types" +import { config } from "@/config" + +import type { ISignerSession } from "./types" + +type GetReownSessionOptions = { + interactive?: boolean + expectedAddress?: string +} + +type AppKit = ReturnType + +export class ReownConnectionCancelledError extends Error { + constructor() { + super("Wallet connection was cancelled") + this.name = "ReownConnectionCancelledError" + } +} + +let appKitPromise: Promise | undefined +let appKitInstance: AppKit | 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}`), + } +} + +type AppKitNetwork = CreateAppKit["networks"][number] + +// Plain fields only: AppKit bundles its own viem, so fee/formatter types differ. +const toAppKitNetwork = ({ + id, + name, + nativeCurrency, + rpcUrls, + blockExplorers, + testnet, +}: Chain): AppKitNetwork => ({ + id, + name, + nativeCurrency, + rpcUrls, + blockExplorers, + testnet, +}) + +const getNetworks = () => { + const [first, ...rest] = Array.from(AVAILABLE_EVM_CHAINS.values()).map( + toAppKitNetwork, + ) + if (!first) { + throw new Error("No EVM networks available") + } + const networks: CreateAppKit["networks"] = [first, ...rest] + return { + networks, + defaultNetwork: + networks.find((network) => network.id === CELO_CHAIN_ID) ?? first, + } +} + +const getAppKit = async (): Promise => { + if (typeof window === "undefined") { + throw new Error("Reown login is only available in browser") + } + + if (!config.walletConnectEnabled) { + throw new Error("WalletConnect is not configured") + } + + appKitPromise ??= (async () => { + appKitInstance ??= createAppKit({ + adapters: [new EthersAdapter()], + projectId: config.walletConnectProjectId, + metadata: getMetadata(), + ...getNetworks(), + }) + await appKitInstance.ready() + return appKitInstance + })().catch((error) => { + // Allow a later attempt to retry initialization. + appKitPromise = undefined + throw error + }) + + return appKitPromise +} + +type WaitOptions = { + timeoutMs: number + cancelOnModalClose?: boolean + signal?: AbortSignal +} + +const MODAL_CLOSE_GRACE_MS = 1500 + +const waitForConnectedAddress = ( + appKit: AppKit, + { timeoutMs, cancelOnModalClose = false, signal }: WaitOptions, +): Promise => { + const currentAddress = appKit.getAddress("eip155") + if (currentAddress) { + return Promise.resolve(currentAddress) + } + + return new Promise((resolve, reject) => { + let settled = false + const cleanups: Array<() => void> = [] + const track = (cleanup: () => void) => { + if (settled) cleanup() + else cleanups.push(cleanup) + } + const finish = (settle: () => void) => { + if (settled) return + settled = true + for (const cleanup of cleanups) cleanup() + settle() + } + + const timeout = setTimeout(() => finish(() => resolve(null)), timeoutMs) + track(() => clearTimeout(timeout)) + + track( + appKit.subscribeAccount((state) => { + const address = state.address + if (state.isConnected && address) { + finish(() => resolve(address)) + } + }, "eip155"), + ) + + if (cancelOnModalClose) { + let wasOpen = false + track( + appKit.subscribeState((state) => { + if (state.open) { + wasOpen = true + return + } + if (!wasOpen) return + // The account update can land shortly after the modal closes. + const grace = setTimeout(() => { + const address = appKit.getAddress("eip155") + finish(() => + address + ? resolve(address) + : reject(new ReownConnectionCancelledError()), + ) + }, MODAL_CLOSE_GRACE_MS) + track(() => clearTimeout(grace)) + }), + ) + } + + if (signal) { + const onAbort = () => finish(() => reject(signal.reason)) + signal.addEventListener("abort", onAbort, { once: true }) + track(() => signal.removeEventListener("abort", onAbort)) + } + }) +} + +const connectAddress = async (appKit: AppKit, interactive: boolean) => { + const restoredAddress = await waitForConnectedAddress(appKit, { + timeoutMs: interactive ? 1000 : 15000, + }) + if (restoredAddress || !interactive) { + return restoredAddress + } + + const controller = new AbortController() + const connected = waitForConnectedAddress(appKit, { + timeoutMs: 120000, + cancelOnModalClose: true, + signal: controller.signal, + }) + try { + await appKit.open({ view: "Connect", namespace: "eip155" }) + } catch (error) { + connected.catch(() => undefined) + controller.abort(error) + throw error + } + return await connected +} + +const isSameAddress = (a?: string, b?: string) => + !!a && !!b && a.toLowerCase() === b.toLowerCase() + +const getWalletSigner = async (appKit: AppKit, address: string) => { + if (!isSameAddress(appKit.getAddress("eip155"), address)) { + throw new Error("Connected wallet account changed. Please reconnect.") + } + const walletProvider = appKit.getProvider("eip155") + if (!walletProvider) { + throw new Error("Connected provider not available") + } + // A fresh provider avoids ethers "network changed" errors after switching. + return await new BrowserProvider(walletProvider, "any").getSigner(address) +} + +const ensureChain = async ( + appKit: AppKit, + chainId: bigint | number | string | null | undefined, +) => { + if (chainId === null || chainId === undefined) return + const targetChainId = Number(chainId) + if (Number(appKit.getChainId()) === targetChainId) return + + const chain = AVAILABLE_EVM_CHAINS.get(targetChainId) + if (!chain) { + throw new Error(`Chain ${targetChainId} is not supported`) + } + await appKit.switchNetwork(toAppKitNetwork(chain), { throwOnFailure: true }) + if (Number(appKit.getChainId()) !== targetChainId) { + throw new Error(`Please switch your wallet to ${chain.name}`) + } +} + +const submitTransaction = async ( + appKit: AppKit, + address: string, + tx: TransactionRequest, +): Promise => { + await ensureChain(appKit, tx.chainId) + const signer = await getWalletSigner(appKit, address) + // The wallet owns nonce and fee selection for transactions it submits. + const { nonce: _nonce, ...request } = tx + return await signer.sendUncheckedTransaction(request) +} + +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 && !isSameAddress(connectedAddress, expectedAddress)) { + throw new Error("Connected wallet does not match the previous session") + } + + if (!appKit.getProvider("eip155")) { + throw new Error("Connected provider not available") + } + + return { + type: "REOWN", + sessionOrigin, + authProvider, + address: connectedAddress, + userName: connectedAddress, + signer: { + EVM: { + address: connectedAddress, + signMessage: async (message) => + (await getWalletSigner(appKit, connectedAddress)).signMessage( + message, + ), + signTypedData: async (domain, types, value) => { + await ensureChain(appKit, domain.chainId) + return ( + await getWalletSigner(appKit, connectedAddress) + ).signTypedData(domain, types, value) + }, + signTransaction: async () => { + throw new Error("Connected wallets cannot sign raw transactions") + }, + submitTransaction: (tx) => + submitTransaction(appKit, connectedAddress, tx), + }, + }, + } +} + +// EIP-1193 provider of the connected wallet, switched to chainId when given. +export const getReownEip1193Provider = async ( + address: string, + chainId?: number, +) => { + const appKit = await getAppKit() + await ensureChain(appKit, chainId) + if (!isSameAddress(appKit.getAddress("eip155"), address)) { + throw new Error("Connected wallet account changed. Please reconnect.") + } + const provider = appKit.getProvider("eip155") + if (!provider) { + throw new Error("Connected provider not available") + } + return { provider, chainId: Number(appKit.getChainId()) } +} + +// Calls onChange when the wallet switches account or disconnects. +export const watchReownAccount = ( + address: string, + onChange: () => void, +): (() => void) => { + if (!appKitInstance) { + return () => undefined + } + return appKitInstance.subscribeAccount((state) => { + if (state.status === "connecting" || state.status === "reconnecting") { + return + } + const changed = state.isConnected + ? !isSameAddress(state.address, address) + : state.status === "disconnected" + if (changed) onChange() + }, "eip155") +} + +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..18c7df8 100644 --- a/src/login/types.ts +++ b/src/login/types.ts @@ -1,5 +1,5 @@ import type * as bitcoin from "bitcoinjs-lib" -import type { Signer } from "ethers/providers" +import type { Signer, TransactionRequest } from "ethers/providers" import type { KeyPairSigner, Transaction as SolanaTransaction } from "gill" import type { Transaction } from "xrpl" @@ -13,7 +13,10 @@ export type EVMSigner = Pick< Signer, "signTransaction" | "signMessage" | "signTypedData" > & - Address + Address & { + // Set for external wallets that broadcast themselves; returns the tx hash. + submitTransaction?: (tx: TransactionRequest) => Promise + } export type BTCSigner = { signPsbt: (psbt: bitcoin.Psbt) => Promise @@ -40,10 +43,10 @@ export type XRPSigner = { export type ISigner = { EVM: EVMSigner - BTC: BTCSigner + BTC?: BTCSigner BTC_TESTNET?: BTCSigner - DOGE: BTCSigner + DOGE?: BTCSigner DOGE_TESTNET?: BTCSigner SOLANA?: SVMSigner @@ -61,7 +64,7 @@ export type ISignerSession = { authProvider?: string userName?: string profileImage?: string -} & (IBrowserSession | IPrivateKeySession) +} & (IBrowserSession | IPrivateKeySession | IReownSession) type IBrowserSession = { type: "BROWSER" @@ -71,3 +74,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..f9825f4 100644 --- a/src/sections/GoodDollar/components/Claim/ClaimView.tsx +++ b/src/sections/GoodDollar/components/Claim/ClaimView.tsx @@ -552,12 +552,11 @@ 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, + // Connected-wallet sessions only have an address, not a name. + type === "REOWN" ? "" : (userName ?? ""), invitedChainId, ) window.open(link, "_self") diff --git a/src/sections/Home/components/ProfileCard.tsx b/src/sections/Home/components/ProfileCard.tsx index 939792f..ab8b870 100644 --- a/src/sections/Home/components/ProfileCard.tsx +++ b/src/sections/Home/components/ProfileCard.tsx @@ -7,7 +7,7 @@ import { Box, Button, createToast, Drawer, Icon } from "ui" import { AnalyticsEventTypes } from "@/analytics/types" import { useAnalytics } from "@/analytics/useAnalytics" -import { AVAILABLE_CHAINS, AVAILABLE_CHAINS_IDS } from "@/chain/chains" +import { AVAILABLE_CHAINS, getAvailableChainIds } from "@/chain/chains" import { formatTokenValue } from "@/components/Utils/tokenFormat" import { useSessionContext } from "@/login" import { @@ -149,7 +149,7 @@ export const ProfileCard = ({ onClose={() => setIsBottomSliderOpen(false)} > - {AVAILABLE_CHAINS_IDS.map((chainId) => { + {getAvailableChainIds(addresses).map((chainId) => { return ( ("ShowWelcomeDialog", { defaultValue: true, }) const loginTranslations = translations.login + const isExistingWalletEnabled = + config.enabledLoginOptions.existingWallet && config.walletConnectEnabled + const showReconnect = !!reconnectReason && isExistingWalletEnabled + const defaultMethod = showReconnect + ? "existingwallet" + : defaultLoginMethod.value const btns = useMemo(() => { const { google, facebook, pwless, testLogin } = config.enabledLoginOptions - const isFacebookDefault = defaultLoginMethod.value === "facebook" - const isGoogleDefault = defaultLoginMethod.value === "google" - const isPwlessDefault = defaultLoginMethod.value === "pwless" - const isTestLoginDefault = defaultLoginMethod.value === "testlogin" + const isFacebookDefault = defaultMethod === "facebook" + const isGoogleDefault = defaultMethod === "google" + const isPwlessDefault = defaultMethod === "pwless" + const isExistingWalletDefault = defaultMethod === "existingwallet" + const isTestLoginDefault = defaultMethod === "testlogin" return [ { @@ -55,6 +65,14 @@ export default function LoginDefault() { isDefault: isPwlessDefault, isEnabled: pwless, }, + { + method: "existingWallet", + element: ( + + ), + isDefault: isExistingWalletDefault, + isEnabled: isExistingWalletEnabled, + }, { method: "testLogin", element: ( @@ -70,7 +88,7 @@ export default function LoginDefault() { testLogin.enabled && testLogin.masterSeed && testLogin.userName, }, ] - }, [defaultLoginMethod.value]) + }, [defaultMethod, isExistingWalletEnabled]) if (showWelcomeDialog.value) return ( @@ -109,6 +127,14 @@ export default function LoginDefault() { {loginTranslations.signinText} + {showReconnect && ( +

+ {reconnectReason === "accountChanged" + ? loginTranslations.existingWalletAccountChanged + : loginTranslations.existingWalletRestoreFailed} +

+ )} + {/* Default method */} {btns.find((btn) => btn.isEnabled && btn.isDefault)?.element} diff --git a/src/sections/Login/components/LoginButton.tsx b/src/sections/Login/components/LoginButton.tsx index 4204ae6..48fa9bc 100644 --- a/src/sections/Login/components/LoginButton.tsx +++ b/src/sections/Login/components/LoginButton.tsx @@ -1,16 +1,18 @@ "use client" -import type { FC } from "react" -import { Button } from "ui" +import { type FC, useState } from "react" +import { Button, useToast } from "ui" import { localeKey, useTranslation } from "translations" import { config } from "@/config" import { Captcha, usePKeyLogin, + useReownLogin, useTriggerTorusAggregateLogin, useTriggerTorusLogin, } from "@/login" +import { ReownConnectionCancelledError } from "@/login/reown" const { env, torus } = config const { network, web3AuthClientId, providers, captchaKey } = torus @@ -116,3 +118,48 @@ export const PrivateKeyLoginButton: FC<{ /> ) } + +export const ExistingWalletLoginButton: FC<{ isDefault?: boolean }> = ({ + isDefault, +}) => { + const { locale, translations } = useTranslation() + const { createToast } = useToast() + const connect = useReownLogin() + const [isConnecting, setIsConnecting] = useState(false) + + const onClick = async () => { + if (isConnecting) return + saveLocaleToSession(locale) + setIsConnecting(true) + try { + await connect() + } catch (error) { + const cancelled = error instanceof ReownConnectionCancelledError + if (!cancelled) console.error("Existing wallet login failed", error) + createToast({ + message: cancelled + ? translations.login.existingWalletCancelled + : translations.login.existingWalletFailed, + status: "error", + autoClose: true, + }) + } finally { + setIsConnecting(false) + } + } + + return ( +
+
+ ) +} diff --git a/src/sections/Login/useDefaultLoginMethod.ts b/src/sections/Login/useDefaultLoginMethod.ts index de8781e..6dc97c0 100644 --- a/src/sections/Login/useDefaultLoginMethod.ts +++ b/src/sections/Login/useDefaultLoginMethod.ts @@ -30,6 +30,12 @@ export const useDefaultLoginMethod = () => { if (transformedValue === "master_seed") { transformedValue = "testlogin" } + if ( + transformedValue === "existing_wallet" || + transformedValue === "existing-wallet" + ) { + transformedValue = "existingwallet" + } if (transformedValue && acceptableLoginMethods.includes(transformedValue)) { defaultLoginMethod.set(transformedValue) } diff --git a/src/sections/Options/OptionsView.tsx b/src/sections/Options/OptionsView.tsx index b7dac64..582f3dd 100644 --- a/src/sections/Options/OptionsView.tsx +++ b/src/sections/Options/OptionsView.tsx @@ -26,7 +26,7 @@ export default function OptionsView() { const [expanded, setExpanded] = useState(false) const [selectedChainType, setSelectedChainType] = useState() const handleCopyPrivateKey = async () => { - if (!selectedChainType) return + if (!selectedChainType || type !== "PRIVATE_KEY") return const session = await getSessionFromLocalStorage() if (!session || session.type !== "PRIVATE_KEY") return diff --git a/src/sections/Receive/ReceiveView.tsx b/src/sections/Receive/ReceiveView.tsx index 13e47d6..b6310e2 100644 --- a/src/sections/Receive/ReceiveView.tsx +++ b/src/sections/Receive/ReceiveView.tsx @@ -6,31 +6,30 @@ import { Box, Button, Drawer, Icon, Text } from "ui" import { useTranslation } from "translations" import { AVAILABLE_CHAINS, - AVAILABLE_CHAINS_IDS, + getAvailableChainIds, getChainName, } from "@/chain/chains" import { setBottomSheetProps } from "@/components/Snippet/BottomSheet/bottomSheetStore" import { ChainIcon } from "@/components/Typo/ChainIcon" import { truncateString } from "@/components/Utils/format" import { useSessionContext } from "@/login" +import { canShare } from "@/utils/share" import { ChainSelectorSliderRow } from "../Home/components/ActivityHistory/ChainSelectorSliderRow" import { QRCodeWithLogo } from "./components/QRCodeWithLogo" import styles from "./ReceiveView.module.css" -const chainIds = AVAILABLE_CHAINS_IDS - -import { canShare } from "@/utils/share" - export default function ReceiveView() { const { translations } = useTranslation() const receiveTranslations = translations.receive setBottomSheetProps({ title: receiveTranslations.title }) const [isBottomSliderOpen, setIsBottomSliderOpen] = useState(false) - const { addresses } = useSessionContext() + const { addresses, type } = useSessionContext() + const chainIds = getAvailableChainIds(addresses) - const [currentChain, setCurrentChain] = useState(chainIds[0]) + const [selectedChain, setCurrentChain] = useState() + const currentChain = selectedChain ?? chainIds[0] const chain = AVAILABLE_CHAINS.get(currentChain) const currentSignerAddress = chain && addresses?.get(chain.family) @@ -62,6 +61,11 @@ export default function ReceiveView() { /> {receiveTranslations.scan} + {type === "REOWN" && ( + + {translations.login.evmOnlyNotice} + + )}
diff --git a/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx b/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx index a579c76..fe9c4fb 100644 --- a/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx +++ b/src/sections/Send/components/SendConfirmDialog/BTCDialogDetails.tsx @@ -65,7 +65,8 @@ export const BTCDialogDetails = ({ const signers = sessionSnap.signer const signer = signers[chainProvider.family] if (!signer) { - throw new Error("Signer not found") + // e.g. connected EVM-only wallets; leave the form invalid. + return } const amountSats = parseUnits(amountInCrypto, selectedToken.decimals) diff --git a/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx b/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx index 01bec16..8c3ab1f 100644 --- a/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx +++ b/src/sections/Send/components/SendConfirmDialog/SVMDialogDetails.tsx @@ -57,7 +57,8 @@ export const SVMDialogDetails = ({ const signers = sessionSnap.signer const signer = signers[chainProvider.family] if (!signer) { - throw new Error("Signer not found") + // e.g. connected EVM-only wallets; leave the form invalid. + return } const createTransaction = async () => { diff --git a/src/sections/Swap/lifiStore.ts b/src/sections/Swap/lifiStore.ts index 397633c..baa0543 100644 --- a/src/sections/Swap/lifiStore.ts +++ b/src/sections/Swap/lifiStore.ts @@ -13,7 +13,7 @@ import { import type { SDKClient, SDKProvider } from "@lifi/sdk" import { proxy, ref } from "valtio" import { subscribeKey } from "valtio/utils" -import { createWalletClient, http } from "viem" +import { createWalletClient, custom, type Hex, http } from "viem" import { base, bsc, @@ -27,7 +27,8 @@ import { import { config } from "@/config" import { sessionState } from "@/login/context/SessionContext/storage" -import type { ISigner } from "@/login/types" +import { getReownEip1193Provider } from "@/login/reown" +import type { EVMSigner, ISigner } from "@/login/types" import { bitcoinAdapter } from "./adapters/bitcoinWalletAdapter" import { createSolanaWallet } from "./adapters/solanaWalletAdapter" @@ -65,6 +66,45 @@ const getLifiRpcUrls = () => { return rpcUrls } +// External wallets sign and broadcast through their own provider. +const getExternalWalletClient = async (signer: EVMSigner, chainId?: number) => { + const { provider, chainId: activeChainId } = await getReownEip1193Provider( + signer.address, + chainId, + ) + return createWalletClient({ + account: signer.address as Hex, + chain: chains.find((chain) => chain.id === activeChainId), + transport: custom(provider), + }) +} + +const getEvmProviderOptions = (signer: EVMSigner) => { + if (signer.submitTransaction) { + return { + getWalletClient: () => getExternalWalletClient(signer), + switchChain: (chainId: number) => + getExternalWalletClient(signer, chainId), + } + } + + const account = getViemAccount(signer) + const walletClient = createWalletClient({ + account, + chain: mainnet, + transport: http(config.rpcUrls[mainnet.id]), + }) + return { + getWalletClient: async () => walletClient, + switchChain: async (chainId: number) => + createWalletClient({ + account, + chain: chains.find((chain) => chain.id == chainId), + transport: http(config.rpcUrls[chainId]), + }), + } +} + const initializeLifi = async (signer: ISigner | undefined) => { if (!signer) { lifiState.isInitialized = false @@ -86,23 +126,8 @@ const initializeLifi = async (signer: ISigner | undefined) => { import("@lifi/sdk-provider-bitcoin"), ]) - const account = getViemAccount(signer.EVM) - const walletClient = createWalletClient({ - account, - chain: mainnet, - transport: http(config.rpcUrls[mainnet.id]), - }) - const providers: SDKProvider[] = [ - EthereumProvider({ - getWalletClient: async () => walletClient, - switchChain: async (chainId) => - createWalletClient({ - account, - chain: chains.find((chain) => chain.id == chainId), - transport: http(config.rpcUrls[chainId]), - }), - }), + EthereumProvider(getEvmProviderOptions(signer.EVM)), ] if (signer.SOLANA) { const wallet = createSolanaWallet(signer.SOLANA) diff --git a/src/sections/WalletConnect/components/WalletConnectDialog/SessionProposalDialog.tsx b/src/sections/WalletConnect/components/WalletConnectDialog/SessionProposalDialog.tsx index d060816..4200a3c 100644 --- a/src/sections/WalletConnect/components/WalletConnectDialog/SessionProposalDialog.tsx +++ b/src/sections/WalletConnect/components/WalletConnectDialog/SessionProposalDialog.tsx @@ -2,7 +2,7 @@ import type { ProposalTypes, SignClientTypes } from "@walletconnect/types" import { Icon, Text } from "ui" import { useTranslation } from "translations" -import { AVAILABLE_CHAINS, AVAILABLE_CHAINS_IDS } from "@/chain/chains" +import { AVAILABLE_CHAINS, isChainAvailableForAddresses } from "@/chain/chains" import { ChainIcon } from "@/components/Typo/ChainIcon" import { truncateString } from "@/components/Utils/format" import { type Addresses, useSessionContext } from "@/login" @@ -56,7 +56,7 @@ export const SessionProposalDialog = ({ sessionProposal }: Props) => { ) const optionalChainIds: number[] = extractChainIds( sessionProposal.params.optionalNamespaces, - ).filter((chainId) => AVAILABLE_CHAINS_IDS.includes(chainId)) + ).filter((chainId) => isChainAvailableForAddresses(chainId, addresses)) const listOfChainIds = [...requiredChainIds, ...optionalChainIds] diff --git a/src/sections/WalletConnect/requests/EIP155RequestHandlerUtil.ts b/src/sections/WalletConnect/requests/EIP155RequestHandlerUtil.ts index c61ed48..17af1a1 100644 --- a/src/sections/WalletConnect/requests/EIP155RequestHandlerUtil.ts +++ b/src/sections/WalletConnect/requests/EIP155RequestHandlerUtil.ts @@ -4,7 +4,6 @@ import { } from "@walletconnect/jsonrpc-utils" import type { SignClientTypes } from "@walletconnect/types" import { getSdkError } from "@walletconnect/utils" -import type { AbstractSigner } from "ethers" import { getEthersProvider } from "ethers-utils" @@ -14,6 +13,7 @@ import { getSignParamsMessage, getSignTypedDataParamsData, } from "../utils/HelperUtils" +import type { WalletWrapper } from "../utils/WalletWrapper" type RequestEventArgs = Omit< SignClientTypes.EventArguments["session_request"], @@ -21,7 +21,7 @@ type RequestEventArgs = Omit< > export async function approveEIP155Request( - wallet: AbstractSigner, + wallet: WalletWrapper, requestEvent: RequestEventArgs, ) { const { params, id } = requestEvent @@ -74,15 +74,16 @@ export async function approveEIP155Request( case EIP155_SIGNING_METHODS.ETH_SEND_TRANSACTION: try { //ChainId of format e.g. eip155:1 for Ethereum Mainnet - const provider = getEthersProvider(Number(chainId.split(":")[1])) + const internalChainId = Number(chainId.split(":")[1]) + const provider = getEthersProvider(internalChainId) const sendTransaction = request.params[0] if (sendTransaction.gas && !sendTransaction.gasLimit) { sendTransaction.gasLimit = sendTransaction.gas } - const connectedWallet = wallet.connect(provider) - const hash = await connectedWallet.sendTransaction(sendTransaction) - const receipt = typeof hash === "string" ? hash : hash?.hash // TODO improve interface - return formatJsonRpcResult(id, receipt) + const hash = await wallet + .connect(provider) + .sendTransactionHash({ ...sendTransaction, chainId: internalChainId }) + return formatJsonRpcResult(id, hash) } catch (error) { console.error(error) const message = diff --git a/src/sections/WalletConnect/requests/RequestHandler.ts b/src/sections/WalletConnect/requests/RequestHandler.ts index 6133bf2..3707213 100644 --- a/src/sections/WalletConnect/requests/RequestHandler.ts +++ b/src/sections/WalletConnect/requests/RequestHandler.ts @@ -10,7 +10,7 @@ import { import { AnalyticsEventTypes } from "@/analytics/types" import { captureEvent } from "@/analytics/useAnalytics" -import { AVAILABLE_CHAINS, AVAILABLE_CHAINS_IDS } from "@/chain/chains" +import { AVAILABLE_CHAINS } from "@/chain/chains" import { getChainProvider } from "@/chain/provider/provider" import type { ISigner } from "@/login/types" import { getCloseAfterWCAction } from "@/utils/reownAppKitWrapper" @@ -32,64 +32,81 @@ import { approveEIP155Request } from "./EIP155RequestHandlerUtil" import { approveEIP5792Request } from "./EIP5792RequestHandlerUtil" import { approveSolanaRequest } from "./SolanaRequestHandlerUtil" -const getSupportedNamespaces = (signer: ISigner) => { - const solanaChains: string[] = [] - const solanaAccounts: string[] = [] +// External wallets broadcast themselves, so raw-signing methods are not offered. +const RAW_SIGNING_METHODS = new Set([ + EIP155_SIGNING_METHODS.ETH_SIGN_TRANSACTION, + EIP155_SIGNING_METHODS.ETH_SEND_RAW_TRANSACTION, +]) - for (const [internalChainId, walletConnectChainId] of SOLANA_CHAINS) { - solanaChains.push(walletConnectChainId) - const chain = AVAILABLE_CHAINS.get(internalChainId) - if (!chain) { - throw new Error( - `Chain with id ${internalChainId} not found in AVAILABLE_CHAINS`, - ) - } - const address = signer[chain.family]?.address - if (!address) { - throw new Error(`Address for chain ${chain.name} not found in addresses`) - } - solanaAccounts.push(`${walletConnectChainId}:${address}`) - } +const isExternalWallet = (signer: ISigner) => !!signer.EVM.submitTransaction - const bitcoinAccounts: string[] = [] - bitcoinAccounts.push(`${BIP122_MAINNET_CAIP2}:${signer.BTC.address}`) - if (signer.BTC_TESTNET) { - bitcoinAccounts.push( - `${BIP122_TESTNET_CAIP2}:${signer.BTC_TESTNET.address}`, - ) - } +const getEip155Methods = (signer: ISigner) => + Object.values(EIP155_SIGNING_METHODS).filter( + (method) => !isExternalWallet(signer) || !RAW_SIGNING_METHODS.has(method), + ) + +type SupportedNamespace = { + chains: string[] + methods: string[] + events: string[] + accounts: string[] +} - return { +const getSupportedNamespaces = (signer: ISigner) => { + const namespaces: Record = { eip155: { chains: EIP155_CHAINS, - methods: Object.values(EIP155_SIGNING_METHODS), + methods: getEip155Methods(signer), events: ["accountsChanged", "chainChanged"], accounts: EIP155_CHAINS.map((chain) => `${chain}:${signer.EVM.address}`), }, - solana: { + } + + const solanaChains: string[] = [] + const solanaAccounts: string[] = [] + for (const [internalChainId, walletConnectChainId] of SOLANA_CHAINS) { + const chain = AVAILABLE_CHAINS.get(internalChainId) + const address = chain && signer[chain.family]?.address + if (!address) continue + solanaChains.push(walletConnectChainId) + solanaAccounts.push(`${walletConnectChainId}:${address}`) + } + if (solanaAccounts.length > 0) { + namespaces.solana = { chains: solanaChains, methods: Object.values(SOLANA_SIGNING_METHODS), events: [], accounts: solanaAccounts, - }, - bip122: { + } + } + + if (signer.BTC) { + const bitcoinAccounts = [`${BIP122_MAINNET_CAIP2}:${signer.BTC.address}`] + if (signer.BTC_TESTNET) { + bitcoinAccounts.push( + `${BIP122_TESTNET_CAIP2}:${signer.BTC_TESTNET.address}`, + ) + } + namespaces.bip122 = { chains: Array.from(BIP122_CHAINS.values()), methods: Object.values(BIP122_SIGNING_METHODS), events: [], accounts: bitcoinAccounts, - }, + } } + + return namespaces } export const setupListeners = ( - signer: ISigner, + getSigner: () => ISigner, walletConnectInstance: WalletKit, refreshSessions: () => void, ) => { - const evmWalletWrapper = new WalletWrapper(signer.EVM) const onSessionProposal = async ( proposal: SignClientTypes.EventArguments["session_proposal"], ) => { + const signer = getSigner() const requiredChainIds: string[] = [] const optionalChainIds: string[] = [] const methods: string[] = [] @@ -105,7 +122,8 @@ export const setupListeners = ( const safeIsChainSupported = (chainId: string) => { try { const internalChainId = getInternalChainId(chainId) - return AVAILABLE_CHAINS_IDS.includes(internalChainId) + const family = AVAILABLE_CHAINS.get(internalChainId)?.family + return !!family && !!signer[family] } catch { return false } @@ -118,12 +136,15 @@ export const setupListeners = ( ) if (nonSupportedChains.length > 0 || supportedChains.length === 0) { + const walletNote = isExternalWallet(signer) + ? "Connected wallets support EVM networks only. " + : "" openWalletConnectDialog({ type: "error", errorText: supportedChains.length === 0 - ? `No supported chain found in request` - : `Chains not supported: ${nonSupportedChains}`, + ? `${walletNote}No supported chain found in request` + : `${walletNote}Chains not supported: ${nonSupportedChains}`, acceptBtnText: "Ok", }) captureEvent({ @@ -188,21 +209,26 @@ export const setupListeners = ( closeWindowAfterWCRequestFullfilled() } - const supportedMethods = new Map< - string, - "eip155" | "solana" | "bip122" | "eip5792" - >() - for (const method of Object.values(EIP155_SIGNING_METHODS)) { - supportedMethods.set(method, "eip155") - } - for (const method of Object.values(SOLANA_SIGNING_METHODS)) { - supportedMethods.set(method, "solana") - } - for (const method of Object.values(BIP122_SIGNING_METHODS)) { - supportedMethods.set(method, "bip122") - } - for (const method of Object.values(EIP5792_METHODS)) { - supportedMethods.set(method, "eip5792") + const getSupportedMethods = (signer: ISigner) => { + const supportedMethods = new Map< + string, + "eip155" | "solana" | "bip122" | "eip5792" + >() + for (const method of getEip155Methods(signer)) { + supportedMethods.set(method, "eip155") + } + for (const method of Object.values(SOLANA_SIGNING_METHODS)) { + supportedMethods.set(method, "solana") + } + for (const method of Object.values(BIP122_SIGNING_METHODS)) { + supportedMethods.set(method, "bip122") + } + if (!isExternalWallet(signer)) { + for (const method of Object.values(EIP5792_METHODS)) { + supportedMethods.set(method, "eip5792") + } + } + return supportedMethods } const onSessionRequest = async ( @@ -215,7 +241,8 @@ export const setupListeners = ( let responseStatus: "accepted" | "rejected" | "error" = "accepted" try { - const type = supportedMethods.get(request.method) + const signer = getSigner() + const type = getSupportedMethods(signer).get(request.method) if (!type) { throw new Error(`The method ${request.method} is not supported`) } @@ -239,7 +266,10 @@ export const setupListeners = ( response = await approveEIP5792Request(requestEvent) break case "eip155": - response = await approveEIP155Request(evmWalletWrapper, requestEvent) + response = await approveEIP155Request( + new WalletWrapper(signer.EVM), + requestEvent, + ) break case "solana": { const internalChainId = getInternalChainId(chainId) @@ -258,7 +288,7 @@ export const setupListeners = ( } case "bip122": { response = await approveBIP122Request( - getSignerForBip122ChainId(chainId), + getSignerForBip122ChainId(signer, chainId), requestEvent, ) break @@ -292,10 +322,13 @@ export const setupListeners = ( } } - const getSignerForBip122ChainId = (chainId: string) => { + const getSignerForBip122ChainId = (signer: ISigner, chainId: string) => { switch (chainId) { case BIP122_MAINNET_CAIP2: - return signer.BTC + if (signer.BTC) { + return signer.BTC + } + throw new Error("BTC signer not available") case BIP122_TESTNET_CAIP2: if (signer.BTC_TESTNET) { return signer.BTC_TESTNET @@ -309,10 +342,11 @@ export const setupListeners = ( const onSessionAuthenticate = async ( authRequest: SignClientTypes.EventArguments["session_authenticate"], ) => { + const evmWalletWrapper = new WalletWrapper(getSigner().EVM) const authPayload = populateAuthPayload({ authPayload: authRequest?.params?.authPayload, chains: EIP155_CHAINS, - methods: Object.values(EIP155_SIGNING_METHODS), + methods: getEip155Methods(getSigner()), }) const address = evmWalletWrapper.address diff --git a/src/sections/WalletConnect/store/walletConnectStore.ts b/src/sections/WalletConnect/store/walletConnectStore.ts index 5226d3b..d5138f4 100644 --- a/src/sections/WalletConnect/store/walletConnectStore.ts +++ b/src/sections/WalletConnect/store/walletConnectStore.ts @@ -1,6 +1,7 @@ import WalletKit from "@reown/walletkit" import { Core } from "@walletconnect/core" import type { SessionTypes } from "@walletconnect/types" +import { getSdkError } from "@walletconnect/utils" import { proxy, ref } from "valtio" import manifest from "@/app/manifest" @@ -19,10 +20,18 @@ type WalletConnectState = { refreshSessions: () => void } +// Listeners resolve the signer per request so a reconnected session is used. +let activeSigner: ISigner | undefined +const getActiveSigner = () => { + if (!activeSigner) throw new Error("No active wallet session") + return activeSigner +} + const initializeWalletConnect = async (signer: ISigner) => { if (!config.walletConnectEnabled) { return } + activeSigner = signer if (!walletConnectState.walletConnectInstance) { walletConnectState.walletConnectInstance = "initializing" if (typeof window !== "undefined") { @@ -50,7 +59,7 @@ const initializeWalletConnect = async (signer: ISigner) => { core, metadata, }) - setupListeners(signer, wallet, refreshSessions) + setupListeners(getActiveSigner, wallet, refreshSessions) const wcUri = getWCUri() if (wcUri) { @@ -78,3 +87,17 @@ export const walletConnectState = proxy({ initializeWalletConnect, refreshSessions, }) + +export const disconnectAllWalletConnectSessions = async () => { + const wallet = walletConnectState.walletConnectInstance + if (!wallet || wallet === "initializing") return + await Promise.allSettled( + Object.values(wallet.getActiveSessions()).map((session) => + wallet.disconnectSession({ + topic: session.topic, + reason: getSdkError("USER_DISCONNECTED"), + }), + ), + ) + refreshSessions() +} diff --git a/src/sections/WalletConnect/utils/WalletWrapper.test.ts b/src/sections/WalletConnect/utils/WalletWrapper.test.ts new file mode 100644 index 0000000..553457f --- /dev/null +++ b/src/sections/WalletConnect/utils/WalletWrapper.test.ts @@ -0,0 +1,38 @@ +import type { Provider, TransactionResponse } from "ethers" +import { describe, expect, it, vi } from "vitest" + +import { WalletWrapper } from "./WalletWrapper" + +const address = "0x1111111111111111111111111111111111111111" +const tx = { to: address, value: BigInt(1), chainId: 1 } + +const createSigner = () => ({ + address, + signMessage: vi.fn(), + signTypedData: vi.fn(), + signTransaction: vi.fn(async () => "0xraw"), +}) + +describe("WalletWrapper.sendTransactionHash", () => { + it("submits through external wallets and returns the wallet hash", async () => { + const submitTransaction = vi.fn(async () => "0xwallethash") + const signer = { ...createSigner(), submitTransaction } + + const hash = await new WalletWrapper(signer).sendTransactionHash(tx) + + expect(hash).toBe("0xwallethash") + expect(submitTransaction).toHaveBeenCalledWith({ ...tx, from: address }) + expect(signer.signTransaction).not.toHaveBeenCalled() + }) + + it("signs and broadcasts locally for local-key signers", async () => { + const signer = createSigner() + const wrapper = new WalletWrapper(signer, {} as Provider) + const sendTransaction = vi + .spyOn(wrapper, "sendTransaction") + .mockResolvedValue({ hash: "0xlocalhash" } as TransactionResponse) + + await expect(wrapper.sendTransactionHash(tx)).resolves.toBe("0xlocalhash") + expect(sendTransaction).toHaveBeenCalledWith(tx) + }) +}) diff --git a/src/sections/WalletConnect/utils/WalletWrapper.ts b/src/sections/WalletConnect/utils/WalletWrapper.ts index 9fcaec5..39bf8d2 100644 --- a/src/sections/WalletConnect/utils/WalletWrapper.ts +++ b/src/sections/WalletConnect/utils/WalletWrapper.ts @@ -34,6 +34,14 @@ export class WalletWrapper extends AbstractSigner { return this.#signer.signTransaction(tx) } + // External wallets broadcast themselves; local keys sign and broadcast here. + async sendTransactionHash(tx: TransactionRequest): Promise { + if (this.#signer.submitTransaction) { + return await this.#signer.submitTransaction({ ...tx, from: this.address }) + } + return (await this.sendTransaction(tx)).hash + } + signMessage(message: string | Uint8Array): Promise { return this.#signer.signMessage(message) } diff --git a/src/stores/transactionHistoryStore.ts b/src/stores/transactionHistoryStore.ts index b47ecc0..be1de42 100644 --- a/src/stores/transactionHistoryStore.ts +++ b/src/stores/transactionHistoryStore.ts @@ -1,7 +1,7 @@ import { proxy } from "valtio" import { proxyMap } from "valtio/utils" -import { AVAILABLE_CHAINS_IDS } from "@/chain/chains" +import { getAvailableChainIds } from "@/chain/chains" import { getChainProvider } from "@/chain/provider/provider" import type { Tx } from "@/hooks/useLedger/types" import type { Addresses } from "@/login" @@ -61,7 +61,7 @@ const refreshHistory = async (addresses: Addresses | undefined) => { } const refreshAllChains = async (addresses: Addresses) => { - const promises = AVAILABLE_CHAINS_IDS.map((chainId) => + const promises = getAvailableChainIds(addresses).map((chainId) => refreshHistorySingleChain(addresses, chainId), ) await Promise.allSettled(promises) diff --git a/src/translations/translations/da.ts b/src/translations/translations/da.ts index 281586c..fb15986 100644 --- a/src/translations/translations/da.ts +++ b/src/translations/translations/da.ts @@ -32,6 +32,15 @@ const da: TypedTranslations = { google: "Fortsæt ved Google", facebook: "Fortsæt ved Facebook", pwdless: "Fortsæt via SMS-kode", + existingWallet: "Forbind wallet", + existingWalletCancelled: "Forbindelsen til din wallet blev annulleret", + existingWalletFailed: "Kunne ikke forbinde din wallet. Prøv igen", + existingWalletAccountChanged: + "Din wallet-konto er ændret. Forbind din wallet igen", + existingWalletRestoreFailed: + "Vi kunne ikke genoprette forbindelsen til din wallet. Forbind din wallet igen", + evmOnlyNotice: + "Forbundne wallets understøtter kun EVM-netværk. Bitcoin, Dogecoin, Solana og XRP er ikke tilgængelige.", legalText: { preText: "Ved at logge ind og fortsætte, accepterer du vores", terms: "Vilkår for brug", diff --git a/src/translations/translations/en.ts b/src/translations/translations/en.ts index 42bc059..7dba58c 100644 --- a/src/translations/translations/en.ts +++ b/src/translations/translations/en.ts @@ -33,6 +33,15 @@ const en: TypedTranslations = { google: "Continue with Google", facebook: "Continue with Facebook", pwdless: "Continue via SMS code", + existingWallet: "Connect Wallet", + existingWalletCancelled: "Wallet connection was cancelled", + existingWalletFailed: "Could not connect your wallet. Please try again", + existingWalletAccountChanged: + "Your wallet account changed. Please connect your wallet again", + existingWalletRestoreFailed: + "We couldn't reconnect your wallet. Please connect your wallet again", + evmOnlyNotice: + "Connected wallets support EVM networks only. Bitcoin, Dogecoin, Solana and XRP are not available.", legalText: { preText: "By signing in and entering, you're accepting our", terms: "Terms of use", diff --git a/src/translations/types.ts b/src/translations/types.ts index e14760b..00da2b3 100644 --- a/src/translations/types.ts +++ b/src/translations/types.ts @@ -6,6 +6,12 @@ export type LoginTranslations = { google: string facebook: string pwdless: string + existingWallet: string + existingWalletCancelled: string + existingWalletFailed: string + existingWalletAccountChanged: string + existingWalletRestoreFailed: string + evmOnlyNotice: string legalText: { preText: string terms: string diff --git a/src/widgets/provider/RestrictedEip1193Provider.test.ts b/src/widgets/provider/RestrictedEip1193Provider.test.ts index 3800dba..06764c8 100644 --- a/src/widgets/provider/RestrictedEip1193Provider.test.ts +++ b/src/widgets/provider/RestrictedEip1193Provider.test.ts @@ -178,6 +178,28 @@ describe("RestrictedEip1193Provider", () => { }) }) + it("submits approved transactions through external wallets instead of raw broadcast", async () => { + const submitTransaction = vi.fn(async () => "0xwallethash") + const { provider, rpcRequest, requestWalletApproval } = createProvider({ + signer: { ...signer, submitTransaction }, + }) + await expect( + provider.request({ + method: "eth_sendTransaction", + params: [{ from: address, chainId: 1, to: address, value: BigInt(1) }], + }), + ).resolves.toBe("0xwallethash") + expect(requestWalletApproval).toHaveBeenCalled() + expect(submitTransaction).toHaveBeenCalledWith( + expect.objectContaining({ from: address, to: address }), + ) + expect(signer.signTransaction).not.toHaveBeenCalled() + expect(rpcRequest).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ method: "eth_sendRawTransaction" }), + ) + }) + it("keeps signing disabled until the Wallet explicitly approves it", async () => { const { provider } = createProvider({ requestWalletApproval: undefined }) await expect( diff --git a/src/widgets/provider/RestrictedEip1193Provider.ts b/src/widgets/provider/RestrictedEip1193Provider.ts index dce1439..54a2392 100644 --- a/src/widgets/provider/RestrictedEip1193Provider.ts +++ b/src/widgets/provider/RestrictedEip1193Provider.ts @@ -372,6 +372,12 @@ export class RestrictedEip1193Provider { approvalParams, context, ) + if (context.signer.submitTransaction) { + return await context.signer.submitTransaction({ + ...populatedRequest, + from: context.account, + }) + } const rawTransaction = await context.signer.signTransaction(populatedRequest) this.#assertSigningContext(context) diff --git a/yarn.lock b/yarn.lock index 3ca3fe7..83debba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2056,6 +2056,26 @@ __metadata: languageName: node linkType: hard +"@reown/appkit-adapter-ethers@npm:1.8.23": + version: 1.8.23 + resolution: "@reown/appkit-adapter-ethers@npm:1.8.23" + dependencies: + "@reown/appkit": "npm:1.8.23" + "@reown/appkit-common": "npm:1.8.23" + "@reown/appkit-controllers": "npm:1.8.23" + "@reown/appkit-polyfills": "npm:1.8.23" + "@reown/appkit-scaffold-ui": "npm:1.8.23" + "@reown/appkit-utils": "npm:1.8.23" + "@reown/appkit-wallet": "npm:1.8.23" + "@walletconnect/universal-provider": "npm:2.23.7" + valtio: "npm:2.1.7" + peerDependencies: + "@ethersproject/sha2": 5.8.0 + ethers: ">=6" + checksum: 10c0/91846406c98fec02d68796df650f8929635032b6197b1a7fbdf1fb58519fc225e33de134b4d8f1f37405ab20c9da8c227ebe77de684a88f088cb16ad1887ddd6 + languageName: node + linkType: hard + "@reown/appkit-adapter-wagmi@npm:^1.8.22": version: 1.8.23 resolution: "@reown/appkit-adapter-wagmi@npm:1.8.23" @@ -10062,6 +10082,8 @@ __metadata: "@neondatabase/serverless": "npm:^1.1.0" "@playwright/test": "npm:1.61.1" "@react-hookz/web": "npm:^25.2.0" + "@reown/appkit": "npm:1.8.23" + "@reown/appkit-adapter-ethers": "npm:1.8.23" "@reown/walletkit": "npm:^1.5.6" "@sentry/nextjs": "npm:^10.56.0" "@solana-name-service/sns-sdk-kit": "npm:^0.10.0"