From 0adee842fd6faf63aa27f19e41340a8ddb2a1058 Mon Sep 17 00:00:00 2001 From: akbarsaputrait Date: Wed, 15 Jul 2026 12:01:09 +0700 Subject: [PATCH 1/4] fix(sdk): mobile wallet UX fixes and connection stability - RozoPayModal: force EVM connect on deeplink open when Solana-only auto-connected; matches injected connector by Solana wallet name (works for Phantom, Backpack, any dual-chain wallet) - RozoPayModal: fix dualChainConnect flag not cleared after Phantom connect, causing subsequent MetaMask connect to loop back to SELECT_METHOD instead of SELECT_TOKEN - RozoPayProvider: combine route+routeMeta into single setState to prevent two-render race on 'Pay with another wallet' click - RozoPayProvider: reuse consumer QueryClient if present - SelectMethod: extract disconnectAll() with Promise.allSettled and isSolanaConnected/isStellarConnected guards to prevent TypeError when disconnect is undefined on uninitialized adapter - OptionsList: replace 'Loading...' subtitle with spinner on loading option item (Pay to Address tile) - ConnectorSolana: add error state + Retry/Cancel buttons on WalletConnect failure (was a silent hang) - ConnectorStellar: same error handling as ConnectorSolana - rozoPay: add sourceAmountUnits/sourceTokenSymbol optional fields to zRozoPayOrderMetadata schema --- packages/connectkit/package.json | 4 +- .../components/Common/OptionsList/index.tsx | 17 ++++- .../components/Pages/SelectMethod/index.tsx | 22 +++--- .../Pages/Solana/ConnectorSolana/index.tsx | 57 ++++++++++++++- .../Pages/Stellar/ConnectorStellar/index.tsx | 29 ++++++++ .../src/components/RozoPayModal/index.tsx | 57 +++++++++++---- .../src/provider/RozoPayProvider.tsx | 73 +++++++++++++------ packages/pay-common/src/rozoPay.ts | 9 +++ 8 files changed, 215 insertions(+), 53 deletions(-) diff --git a/packages/connectkit/package.json b/packages/connectkit/package.json index 5733df029..b8c8eccab 100644 --- a/packages/connectkit/package.json +++ b/packages/connectkit/package.json @@ -1,6 +1,6 @@ { "name": "@rozoai/intent-pay", - "version": "0.1.36", + "version": "0.1.37-beta.2", "private": false, "description": "Seamless crypto payments. Onboard users from any chain, any coin into your app with one click.", "keywords": [ @@ -48,7 +48,7 @@ "@reown/appkit": "^1.7.0", "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-typescript": "^12.1.2", - "@rozoai/intent-common": "0.1.23", + "@rozoai/intent-common": "0.1.25-beta.1", "@solana/spl-memo": "^0.2.5", "@solana/spl-token": "^0.4.14", "@solana/wallet-adapter-base": "^0.9.27", diff --git a/packages/connectkit/src/components/Common/OptionsList/index.tsx b/packages/connectkit/src/components/Common/OptionsList/index.tsx index 703f93729..8e3852274 100644 --- a/packages/connectkit/src/components/Common/OptionsList/index.tsx +++ b/packages/connectkit/src/components/Common/OptionsList/index.tsx @@ -1,6 +1,8 @@ import { motion } from "framer-motion"; import { useEffect } from "react"; import { keyframes } from "styled-components"; +import { Spinner } from "../Spinner"; +import { SpinnerContainer } from "../Spinner/styles"; import { usePayContext } from "../../../hooks/usePayContext"; import styled from "../../../styles/styled"; import { OrDivider } from "../Modal"; @@ -18,6 +20,7 @@ export interface Option { sortValue?: number; title: string; subtitle?: string; + loading?: boolean; icons: (React.ReactNode | string)[]; rightIcons?: (React.ReactNode | string)[]; iconsPosition?: "left" | "right"; @@ -180,7 +183,6 @@ const OptionItem = ({ option }: { option: Option }) => { onClick={option.onClick} disabled={option.disabled} > - {iconContent} 0} $iconsPosition={iconsPosition} @@ -188,7 +190,18 @@ const OptionItem = ({ option }: { option: Option }) => { {option.title} {option.subtitle && {option.subtitle}} - {rightIconContent} + {option.loading ? ( + + + + + + ) : ( + <> + {iconContent} + {rightIconContent} + + )} ); }; diff --git a/packages/connectkit/src/components/Pages/SelectMethod/index.tsx b/packages/connectkit/src/components/Pages/SelectMethod/index.tsx index 9df8cb4e6..c65d58cce 100644 --- a/packages/connectkit/src/components/Pages/SelectMethod/index.tsx +++ b/packages/connectkit/src/components/Pages/SelectMethod/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { ROUTES } from "../../../constants/routes"; import { usePayContext } from "../../../hooks/usePayContext"; import { useAnalytics } from "../../../provider/AnalyticsProvider"; @@ -60,6 +60,15 @@ export default function SelectMethod() { const { disconnectAsync } = useDisconnect(); const autoConnectGate = useAutoConnectGate(); + const disconnectAll = useCallback(async () => { + await Promise.allSettled([ + disconnectAsync(), + isSolanaConnected ? disconnectSolana() : Promise.resolve(), + isStellarConnected && !isStellarExternalKit ? disconnectStellar() : Promise.resolve(), + ]); + }, [disconnectAsync, isSolanaConnected, disconnectSolana, isStellarConnected, isStellarExternalKit, disconnectStellar]); + + // Eagerly hydrate on mobile so deeplink wallets have a payId ready // before the user taps "Pay with wallet". const { hydrateOrder, order, paymentState: payState } = useRozoPay(); @@ -371,9 +380,7 @@ export default function SelectMethod() { chain_type: "stellar", }); } - await disconnectAsync(); - await disconnectSolana(); - if (!isStellarExternalKit) await disconnectStellar(); + await disconnectAll(); setRoute(isMobile ? ROUTES.MOBILECONNECTORS : ROUTES.CONNECTORS); }, }; @@ -395,9 +402,8 @@ export default function SelectMethod() { options.push({ ...base, disabled: depositAddressOptions.loading, - subtitle: depositAddressOptions.loading ? "Loading..." : undefined, + loading: depositAddressOptions.loading, onClick: () => { - if (depositAddressOptions.loading) return; capture(ROZO_EVENTS.PAYMENT_METHOD_SELECTED, { field: "chain", value: "deposit_address", @@ -424,9 +430,7 @@ export default function SelectMethod() { field: "chain", value: "stellar", }); - await disconnectAsync(); - await disconnectSolana(); - if (!isStellarExternalKit) await disconnectStellar(); + await disconnectAll(); setRoute(ROUTES.STELLAR_CONNECT); }, }); diff --git a/packages/connectkit/src/components/Pages/Solana/ConnectorSolana/index.tsx b/packages/connectkit/src/components/Pages/Solana/ConnectorSolana/index.tsx index d347101f5..021bc805c 100644 --- a/packages/connectkit/src/components/Pages/Solana/ConnectorSolana/index.tsx +++ b/packages/connectkit/src/components/Pages/Solana/ConnectorSolana/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { ModalBody, @@ -12,12 +12,14 @@ import { AnimatePresence, motion } from "framer-motion"; import { ROUTES } from "../../../../constants/routes"; import { usePayContext } from "../../../../hooks/usePayContext"; import styled from "../../../../styles/styled"; +import Button from "../../../Common/Button"; import { walletConfigs } from "../../../../wallets/walletConfigs"; import SquircleSpinner from "../../../Spinners/SquircleSpinner"; const ConnectSolana: React.FC = () => { const solanaWallets = useWallet(); const isConnected = solanaWallets.connected; + const [connectionError, setConnectionError] = useState(null); const { solanaConnector, setRoute, paymentState, routeMeta } = usePayContext(); @@ -54,6 +56,20 @@ const ConnectSolana: React.FC = () => { ); }; + // Listen for adapter-level errors (e.g. WalletConnect proposal expired) + useEffect(() => { + const adapter = solanaWallets.wallet?.adapter; + if (!adapter) return; + const handleError = (error: unknown) => { + const msg = error instanceof Error ? error.message : String(error); + setConnectionError(msg || "Connection failed"); + }; + adapter.on("error", handleError); + return () => { + adapter.off("error", handleError); + }; + }, [solanaWallets.wallet]); + useEffect(() => { if (!solanaConnector) return; solanaWallets.select(solanaConnector); @@ -89,13 +105,41 @@ const ConnectSolana: React.FC = () => { {getWalletIcon()} } - loading={true} + loading={!isConnected && !connectionError} /> - {isConnected ? ( + {connectionError ? ( + <> + Connection Failed + {connectionError} + + + + + + ) : isConnected ? ( Connected ) : ( <> @@ -130,4 +174,11 @@ const AnimationContainer = styled(motion.div)` } `; +const ButtonRow = styled.div` + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: center; +`; + export default ConnectSolana; diff --git a/packages/connectkit/src/components/Pages/Stellar/ConnectorStellar/index.tsx b/packages/connectkit/src/components/Pages/Stellar/ConnectorStellar/index.tsx index 0b7ba0e4e..934aac85b 100644 --- a/packages/connectkit/src/components/Pages/Stellar/ConnectorStellar/index.tsx +++ b/packages/connectkit/src/components/Pages/Stellar/ConnectorStellar/index.tsx @@ -12,6 +12,7 @@ import { ROUTES } from "../../../../constants/routes"; import { usePayContext } from "../../../../hooks/usePayContext"; import { useStellar } from "../../../../provider/StellarContextProvider"; import styled from "../../../../styles/styled"; +import Button from "../../../Common/Button"; import SquircleSpinner from "../../../Spinners/SquircleSpinner"; const ConnectorStellar: React.FC = () => { @@ -104,6 +105,27 @@ const ConnectorStellar: React.FC = () => { <> Connection Failed {connectionError} + + + + ) : isConnected ? ( Connected @@ -138,4 +160,11 @@ const AnimationContainer = styled(motion.div)` } `; +const ButtonRow = styled.div` + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: center; +`; + export default ConnectorStellar; diff --git a/packages/connectkit/src/components/RozoPayModal/index.tsx b/packages/connectkit/src/components/RozoPayModal/index.tsx index 076093d8b..b1a691272 100644 --- a/packages/connectkit/src/components/RozoPayModal/index.tsx +++ b/packages/connectkit/src/components/RozoPayModal/index.tsx @@ -1,6 +1,6 @@ import { useWallet } from "@solana/wallet-adapter-react"; import { useEffect } from "react"; -import { useAccount } from "wagmi"; +import { useAccount, useConnect, useConnectors } from "wagmi"; import { ROUTES } from "../../constants/routes"; import { getAppName } from "../../defaultConfig"; @@ -87,7 +87,7 @@ export const RozoPayModal: React.FC<{ const { isConnected: isEthConnected, connector, chain, address } = useAccount(); // Solana - const { connected: isSolanaConnected } = useWallet(); + const { connected: isSolanaConnected, wallet: solanaWallet } = useWallet(); // Stellar const { isConnected: isStellarConnected } = useStellar(); @@ -140,6 +140,9 @@ export const RozoPayModal: React.FC<{ generatePreviewOrder(); context.setRoute(ROUTES.SELECT_AMOUNT, meta); } else { + if (paymentState.payParams) { + generatePreviewOrder(); + } setSelectedTokenOption(undefined); context.setRoute(ROUTES.SELECT_TOKEN, meta); } @@ -173,6 +176,9 @@ export const RozoPayModal: React.FC<{ generatePreviewOrder(); context.setRoute(ROUTES.SOLANA_SELECT_AMOUNT, meta); } else { + if (paymentState.payParams) { + generatePreviewOrder(); + } setSelectedSolanaTokenOption(undefined); context.setRoute(ROUTES.SELECT_TOKEN, meta); } @@ -184,6 +190,9 @@ export const RozoPayModal: React.FC<{ generatePreviewOrder(); context.setRoute(ROUTES.STELLAR_SELECT_AMOUNT, meta); } else { + if (paymentState.payParams) { + generatePreviewOrder(); + } setSelectedStellarTokenOption(undefined); context.setRoute(ROUTES.SELECT_TOKEN, meta); } @@ -237,6 +246,27 @@ export const RozoPayModal: React.FC<{ context.setOpen(false, { event: "click-close" }); } const { isMobile } = useIsMobile(); + const { connect } = useConnect(); + const connectors = useConnectors(); + + // On deeplink open, only Solana auto-connects via wallet-standard. + // EVM needs an explicit connect() — the wallet silently approves it inside + // its own in-app browser (works for Phantom, Backpack, and any wallet that + // injects both EVM and Solana providers). + useEffect(() => { + if (!isMobile) return; + if (!context.open) return; + if (isEthConnected) return; + if (!isSolanaConnected) return; + const solanaName = solanaWallet?.adapter.name.toLowerCase(); + if (!solanaName) return; + // Find the injected EVM connector whose name matches the connected Solana wallet + const injected = connectors.find( + (c) => c.type === "injected" && c.name.toLowerCase().includes(solanaName), + ); + if (!injected) return; + connect({ connector: injected }); + }, [context.open, isEthConnected, isSolanaConnected, isMobile, solanaWallet, connectors, connect]); // If the user has a wallet already connected upon opening the modal, go // straight to the select token screen. @@ -269,7 +299,7 @@ export const RozoPayModal: React.FC<{ // gateState === "ready": pick the token screen for the connected wallet. // Dual-chain connect (Phantom mobile) just linked EVM + Solana: don't // auto-jump to a single-chain SELECT_TOKEN. Clear the flag and stay on - // SELECT_METHOD so both connected wallet tiles are shown. + // the pay page so SELECT_METHOD shows both connected wallet tiles. if (context.dualChainConnect) { context.setDualChainConnect(false); return; @@ -330,23 +360,22 @@ export const RozoPayModal: React.FC<{ // If we're on the connect page and the user successfully connects their // wallet, go to the select token page useEffect(() => { - // Dual-chain connect (e.g. Phantom mobile): EVM connected directly without - // routing through CONNECT page. Route to SELECT_METHOD so both wallet - // tiles are shown. - if (isEthConnected && context.dualChainConnect) { - context.setDualChainConnect(false); - context.setRoute(ROUTES.SELECT_METHOD, { - event: "dual_chain_connected", - }); - return; - } - if ( context.route === ROUTES.CONNECT || context.route === ROUTES.CONNECTORS || context.route === ROUTES.MOBILECONNECTORS ) { if (isEthConnected) { + // Dual-chain connect (e.g. Phantom mobile) just linked both EVM and + // Solana. Return to the pay page so the user picks which connected + // wallet to pay with, instead of auto-jumping into EVM SELECT_TOKEN. + if (context.dualChainConnect) { + context.setDualChainConnect(false); + context.setRoute(ROUTES.SELECT_METHOD, { + event: "dual_chain_connected", + }); + return; + } paymentState.setTokenMode("evm"); context.setRoute(ROUTES.SELECT_TOKEN, { event: "connected", diff --git a/packages/connectkit/src/provider/RozoPayProvider.tsx b/packages/connectkit/src/provider/RozoPayProvider.tsx index e01d9d7a5..4212427be 100644 --- a/packages/connectkit/src/provider/RozoPayProvider.tsx +++ b/packages/connectkit/src/provider/RozoPayProvider.tsx @@ -7,7 +7,16 @@ import { RozoPayOrderStatusSource, } from "@rozoai/intent-common"; import { Buffer } from "buffer"; -import React, { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { QueryClient, QueryClientContext, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "styled-components"; import { useChainId, WagmiContext } from "wagmi"; @@ -153,8 +162,12 @@ const RozoPayUIProvider = ({ const [open, setOpenState] = useState(false); const [lockPayParams, setLockPayParams] = useState(false); const [paymentCompleted, setPaymentCompleted] = useState(false); - const [route, setRouteState] = useState(ROUTES.SELECT_METHOD); - const [routeMeta, setRouteMeta] = useState | undefined>(undefined); + const [routeState, setRouteStateInner] = useState<{ + route: ROUTES; + meta: Record | undefined; + }>({ route: ROUTES.SELECT_METHOD, meta: undefined }); + const route = routeState.route; + const routeMeta = routeState.meta; const [modalOptions, setModalOptions] = useState(); // Rozo Pay context @@ -252,8 +265,7 @@ const RozoPayUIProvider = ({ (route: ROUTES, data?: Record) => { const action = route.replace("rozoPay", ""); log(`[SET ROUTE] ${action} ${pay.order?.id} ${debugJson(data ?? {})}`); - setRouteState(route); - setRouteMeta(data); + setRouteStateInner({ route, meta: data }); }, [trpc, pay.order?.id, log], ); @@ -511,27 +523,42 @@ export const RozoPayProvider = (props: RozoPayProviderProps) => { [props.debugMode], ); + // Don't force consumers to wire QueryClientProvider — reuse theirs if present. + const parentClient = useContext(QueryClientContext); + const ownClient = useMemo( + () => + new QueryClient({ + defaultOptions: { + queries: { retry: 1, staleTime: 30_000, refetchOnWindowFocus: false }, + }, + }), + [], + ); + const queryClient = parentClient ?? ownClient; + return ( - - - - - + + + + - - - - + > + + + + + + ); }; diff --git a/packages/pay-common/src/rozoPay.ts b/packages/pay-common/src/rozoPay.ts index b4bffb22c..4e02b5019 100644 --- a/packages/pay-common/src/rozoPay.ts +++ b/packages/pay-common/src/rozoPay.ts @@ -151,6 +151,15 @@ export const zRozoPayOrderMetadata = z.object({ .string() .optional() .describe("Base builder code attribution suffix (0x-prefixed hex)."), + // Source-side amount in source-token units (e.g. "0.016885" for 0.016885 SOL). + // Written by `formatPaymentResponseToHydratedOrder` for deposit-address flows + // where the source token can be native and its amount differs from the USD / + // destination payout. Consumers must NOT fall back to `usdValue` when this is + // missing for a native source — the value would be semantically wrong. + sourceAmountUnits: z.string().nullish(), + // Source-side token symbol (e.g. "SOL", "ETH", "USDC"). Paired with + // `sourceAmountUnits` above. + sourceTokenSymbol: z.string().nullish(), }); export type RozoPayOrderMetadata = z.infer; From 9f33b0abcb308b54eea20f9ce7aeb563e0ee0d30 Mon Sep 17 00:00:00 2001 From: akbarsaputrait Date: Wed, 15 Jul 2026 23:17:35 +0700 Subject: [PATCH 2/4] fix(sdk): userDisconnected flag prevents reconnect loop after Pay-with-another-wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a session-lifetime userDisconnected flag to PayContext. Problem: On Phantom mobile deeplink, after user clicks 'Pay with another wallet' and disconnectAll runs, Phantom's in-app browser silently re-authorizes the EVM injected provider (persistent origin trust). This flipped isEthConnected back to true, triggering auto-navigate effects that routed the user to SELECT_TOKEN or back to SELECT_METHOD with only EVM connected — never reaching MOBILECONNECTORS. Fix: userDisconnected flag set to true right before disconnectAll, guarding three auto-navigate effects in RozoPayModal: - Force-connect (Phantom deeplink EVM auto-connect) - SELECT_METHOD → SELECT_TOKEN auto-navigate - CONNECT/CONNECTORS/MOBILECONNECTORS → SELECT_TOKEN auto-route Flag resets on: - Modal open/close (fresh session) - Explicit wallet pick in MobileConnectors handleInjectedWallet / handleDeeplinkWallet - ConnectorList onClick (desktop CONNECTORS page) Safe under repeat cycles: each 'Pay with another wallet' click sets the flag, disconnect + route works cleanly, wallet pick resets flag, connect + auto-route resumes. No state leaks between cycles. --- .../components/Common/ConnectorList/index.tsx | 1 + .../Pages/MobileConnectors/index.tsx | 2 ++ .../components/Pages/SelectMethod/index.tsx | 8 ++++-- .../src/components/RozoPayModal/index.tsx | 26 ++++++++++++++++--- .../connectkit/src/provider/PayContext.tsx | 2 ++ .../src/provider/RozoPayProvider.tsx | 7 +++++ 6 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/connectkit/src/components/Common/ConnectorList/index.tsx b/packages/connectkit/src/components/Common/ConnectorList/index.tsx index 08438e50f..d0093264d 100644 --- a/packages/connectkit/src/components/Common/ConnectorList/index.tsx +++ b/packages/connectkit/src/components/Common/ConnectorList/index.tsx @@ -137,6 +137,7 @@ const ConnectorItem = ({ const onClick = () => { const meta = { event: "connector-list-click", walletId: wallet.id }; + context.setUserDisconnected(false); // Desktop multi-chain wallet flow: prompt for chain selection. if (!isMobile) { diff --git a/packages/connectkit/src/components/Pages/MobileConnectors/index.tsx b/packages/connectkit/src/components/Pages/MobileConnectors/index.tsx index 283b065b3..57c4814f3 100644 --- a/packages/connectkit/src/components/Pages/MobileConnectors/index.tsx +++ b/packages/connectkit/src/components/Pages/MobileConnectors/index.tsx @@ -88,6 +88,7 @@ const MobileConnectors: React.FC = () => { ); const handleInjectedWallet = (wallet: WalletProps) => { + context.setUserDisconnected(false); if (wallet.connector && wallet.solanaConnectorName) { // Dual-chain (e.g. Phantom in-app browser) context.setDualChainConnect(true); @@ -101,6 +102,7 @@ const MobileConnectors: React.FC = () => { }; const handleDeeplinkWallet = (wallet: WalletConfigProps) => { + context.setUserDisconnected(false); if (!orderReady) return; // still hydrating — ignore tap if (wallet.getRozoPayDeeplink == null) { console.error(`wallet ${wallet.name} has no deeplink`); diff --git a/packages/connectkit/src/components/Pages/SelectMethod/index.tsx b/packages/connectkit/src/components/Pages/SelectMethod/index.tsx index c65d58cce..043cbbef9 100644 --- a/packages/connectkit/src/components/Pages/SelectMethod/index.tsx +++ b/packages/connectkit/src/components/Pages/SelectMethod/index.tsx @@ -54,7 +54,7 @@ export default function SelectMethod() { isExternalKit: isStellarExternalKit, } = useStellar(); - const { setRoute, paymentState, log, disableMobileInjector, open: modalOpen } = usePayContext(); + const { setRoute, paymentState, log, disableMobileInjector, open: modalOpen, setUserDisconnected } = usePayContext(); const { capture } = useAnalytics(); const { showSolanaPaymentMethod } = paymentState; const { disconnectAsync } = useDisconnect(); @@ -380,8 +380,11 @@ export default function SelectMethod() { chain_type: "stellar", }); } + setUserDisconnected(true); await disconnectAll(); - setRoute(isMobile ? ROUTES.MOBILECONNECTORS : ROUTES.CONNECTORS); + setRoute(isMobile ? ROUTES.MOBILECONNECTORS : ROUTES.CONNECTORS, { + event: "click-select-another-method", + }); }, }; options.push(unconnectedWalletOption); @@ -430,6 +433,7 @@ export default function SelectMethod() { field: "chain", value: "stellar", }); + setUserDisconnected(true); await disconnectAll(); setRoute(ROUTES.STELLAR_CONNECT); }, diff --git a/packages/connectkit/src/components/RozoPayModal/index.tsx b/packages/connectkit/src/components/RozoPayModal/index.tsx index b1a691272..5280089c4 100644 --- a/packages/connectkit/src/components/RozoPayModal/index.tsx +++ b/packages/connectkit/src/components/RozoPayModal/index.tsx @@ -1,5 +1,5 @@ import { useWallet } from "@solana/wallet-adapter-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useAccount, useConnect, useConnectors } from "wagmi"; import { ROUTES } from "../../constants/routes"; @@ -248,25 +248,35 @@ export const RozoPayModal: React.FC<{ const { isMobile } = useIsMobile(); const { connect } = useConnect(); const connectors = useConnectors(); + const didForceEvmConnectRef = useRef(false); // On deeplink open, only Solana auto-connects via wallet-standard. // EVM needs an explicit connect() — the wallet silently approves it inside // its own in-app browser (works for Phantom, Backpack, and any wallet that // injects both EVM and Solana providers). + // Guard: only fire once per modal open, only on SELECT_METHOD (not after + // user explicitly disconnected and navigated away). useEffect(() => { + if (!context.open) { + // Reset when modal closes so next open can trigger again + didForceEvmConnectRef.current = false; + return; + } if (!isMobile) return; - if (!context.open) return; + if (context.userDisconnected) return; + if (context.route !== ROUTES.SELECT_METHOD) return; if (isEthConnected) return; if (!isSolanaConnected) return; + if (didForceEvmConnectRef.current) return; const solanaName = solanaWallet?.adapter.name.toLowerCase(); if (!solanaName) return; - // Find the injected EVM connector whose name matches the connected Solana wallet const injected = connectors.find( (c) => c.type === "injected" && c.name.toLowerCase().includes(solanaName), ); if (!injected) return; + didForceEvmConnectRef.current = true; connect({ connector: injected }); - }, [context.open, isEthConnected, isSolanaConnected, isMobile, solanaWallet, connectors, connect]); + }, [context.open, context.route, context.userDisconnected, isEthConnected, isSolanaConnected, isMobile, solanaWallet, connectors, connect]); // If the user has a wallet already connected upon opening the modal, go // straight to the select token screen. @@ -277,6 +287,10 @@ export const RozoPayModal: React.FC<{ if (!context.open) return; if (context.route !== ROUTES.SELECT_METHOD) return; + // User explicitly clicked 'Pay with another wallet' — don't auto-route + // even if a wallet silently reconnects. + if (context.userDisconnected) return; + // Only auto-navigate on initial open, not when the user explicitly // navigated back to SELECT_METHOD from SELECT_TOKEN. const isExplicitBackNavigation = context.routeMeta?.event === "click-select-another-method"; @@ -360,6 +374,10 @@ export const RozoPayModal: React.FC<{ // If we're on the connect page and the user successfully connects their // wallet, go to the select token page useEffect(() => { + // User explicitly disconnected — don't auto-route even if wallet + // silently reconnects (Phantom/Backpack in-app browser). + if (context.userDisconnected) return; + if ( context.route === ROUTES.CONNECT || context.route === ROUTES.CONNECTORS || diff --git a/packages/connectkit/src/provider/PayContext.tsx b/packages/connectkit/src/provider/PayContext.tsx index 31c1f4b19..3bfa2f1ff 100644 --- a/packages/connectkit/src/provider/PayContext.tsx +++ b/packages/connectkit/src/provider/PayContext.tsx @@ -59,6 +59,8 @@ export type PayContextValue = { * SELECT_METHOD instead of jumping straight to SELECT_TOKEN. */ dualChainConnect: boolean; setDualChainConnect: (v: boolean) => void; + userDisconnected: boolean; + setUserDisconnected: (v: boolean) => void; /** Chosen Solana wallet, eg Phantom.*/ solanaConnector: SolanaWalletName | undefined; setSolanaConnector: React.Dispatch>; diff --git a/packages/connectkit/src/provider/RozoPayProvider.tsx b/packages/connectkit/src/provider/RozoPayProvider.tsx index 4212427be..f2efc8520 100644 --- a/packages/connectkit/src/provider/RozoPayProvider.tsx +++ b/packages/connectkit/src/provider/RozoPayProvider.tsx @@ -173,6 +173,7 @@ const RozoPayUIProvider = ({ // Rozo Pay context const [pendingConnectorId, setPendingConnectorId] = useState(undefined); const [dualChainConnect, setDualChainConnect] = useState(false); + const [userDisconnected, setUserDisconnected] = useState(false); // Track sessions. Each generates separate intent IDs unless using externalId. const [sessionId] = useState(() => crypto.randomUUID().replaceAll("-", "")); const [solanaConnector, setSolanaConnector] = useState(); @@ -197,6 +198,10 @@ const RozoPayUIProvider = ({ (open: boolean, meta?: Record) => { setOpenState(open); + // Reset user-explicit-disconnect flag on any modal state change so the + // next session starts fresh (auto-connect flows re-enabled). + setUserDisconnected(false); + // Lock pay params starting from the first time the modal is opened to // prevent the rozopay order from changing from under the user if (open) { @@ -425,6 +430,8 @@ const RozoPayUIProvider = ({ setPendingConnectorId, dualChainConnect, setDualChainConnect, + userDisconnected, + setUserDisconnected, sessionId, apiVersion, solanaConnector, From dfe917182115cc4fa27d3644b8367768f9f1c36a Mon Sep 17 00:00:00 2001 From: akbarsaputrait Date: Wed, 15 Jul 2026 23:40:07 +0700 Subject: [PATCH 3/4] fix(sdk): show all supported wallets in MobileConnectors - walletConfigs: enable showInMobileConnectors for MetaMask (was false). Missing entry caused MetaMask to never appear as a deeplink option, even when not injected in the current browser (Safari, Phantom in-app, etc.). - MobileConnectors: dedup logic now cross-matches both name and shortName between injected and deeplink configs so any injected wallet correctly hides its deeplink counterpart. - bundle-analysis, package.json version bumps, pnpm-lock refresh. --- packages/connectkit/bundle-analysis.html | 2 +- packages/connectkit/package.json | 2 +- .../Pages/MobileConnectors/index.tsx | 12 ++++++---- .../connectkit/src/wallets/walletConfigs.tsx | 2 +- packages/pay-common/package.json | 2 +- pnpm-lock.yaml | 22 +++++++++++++++++-- 6 files changed, 32 insertions(+), 10 deletions(-) diff --git a/packages/connectkit/bundle-analysis.html b/packages/connectkit/bundle-analysis.html index 23cc872b0..c3b7b55a6 100644 --- a/packages/connectkit/bundle-analysis.html +++ b/packages/connectkit/bundle-analysis.html @@ -4929,7 +4929,7 @@