From 388f04ec72d5dbb24363e9683c4b36fdc9a09721 Mon Sep 17 00:00:00 2001 From: Maxwell Ward Date: Mon, 8 Sep 2025 11:53:08 -0700 Subject: [PATCH 1/5] add customizable RPC url --- frontend/app/src/App.tsx | 36 +- frontend/app/src/api/contracts.ts | 9 +- frontend/app/src/api/queries.ts | 7 +- .../app/src/assets/images/icons/settings.svg | 1 + frontend/app/src/components/header/Header.tsx | 351 +++++++++--------- .../components/settings/SettingsButton.tsx | 22 ++ .../components/settings/SettingsDialog.tsx | 112 ++++++ frontend/app/src/components/settings/index.ts | 2 + frontend/app/src/contracts/client.ts | 2 +- frontend/app/src/utils/rpcManager.ts | 85 +++++ 10 files changed, 438 insertions(+), 189 deletions(-) create mode 100644 frontend/app/src/assets/images/icons/settings.svg create mode 100644 frontend/app/src/components/settings/SettingsButton.tsx create mode 100644 frontend/app/src/components/settings/SettingsDialog.tsx create mode 100644 frontend/app/src/components/settings/index.ts create mode 100644 frontend/app/src/utils/rpcManager.ts diff --git a/frontend/app/src/App.tsx b/frontend/app/src/App.tsx index d76852c3..6c533bcc 100644 --- a/frontend/app/src/App.tsx +++ b/frontend/app/src/App.tsx @@ -4,13 +4,13 @@ import { QueryClient, QueryClientProvider } from 'react-query'; import { ChakraProvider } from '@chakra-ui/react'; import { WalletProvider } from './wallet/walletContext'; import { WalletInterface } from './interfaces'; -import { initTezos, setWalletProvider } from './contracts/client'; -import { APP_NAME, NETWORK, RPC_URL, RPC_PORT, CTEZ_ADDRESS } from './utils/globals'; +import { setWalletProvider } from './contracts/client'; +import { APP_NAME, NETWORK, CTEZ_ADDRESS } from './utils/globals'; import { getBeaconInstance, isWalletConnected } from './wallet'; import { AppRouter } from './router'; import { initCTez } from './contracts/ctez'; import { logger } from './utils/logger'; -import { getNodePort, getNodeURL } from './utils/settingUtils'; +import { initializeRpcUrl } from './utils/rpcManager'; import ModalContainer from './components/modals/ModalContainer'; import theme from './theme/theme'; import ErrorBoundary from './components/ErrorBoundary'; @@ -28,25 +28,37 @@ const App: React.FC = () => { const walletData = await getBeaconInstance(APP_NAME, true, NETWORK); walletData?.wallet && setWalletProvider(walletData.wallet); walletData && setWallet(walletData); + return walletData; } + return null; }; - const nodeUrl = wallet.pkh ? getNodeURL(wallet.pkh) : RPC_URL; - const nodePort = wallet.pkh ? getNodePort(wallet.pkh) : RPC_PORT; - useEffect(() => { const setup = async () => { try { - initTezos(nodeUrl ?? RPC_URL, nodePort ?? RPC_PORT); - await checkWalletConnection(); - CTEZ_ADDRESS && (await initCTez(CTEZ_ADDRESS)); - CFMM_ADDRESS && (await initCfmm(CFMM_ADDRESS)); + const walletData = await checkWalletConnection(); + const walletAddress = walletData?.pkh || wallet.pkh; + const nodeUrl = initializeRpcUrl(walletAddress); + + if (nodeUrl && (nodeUrl.startsWith('http://') || nodeUrl.startsWith('https://'))) { + + if (CTEZ_ADDRESS) { + await initCTez(CTEZ_ADDRESS); + } + + if (CFMM_ADDRESS) { + await initCfmm(CFMM_ADDRESS); + } + } else { + logger.warn('Invalid RPC URL, skipping contract initialization:', nodeUrl); + } } catch (error: any) { - logger.error(error); + logger.error('Failed to initialize contracts:', error); } }; + setup(); - }, [wallet.pkh, nodeUrl, nodePort]); + }, [wallet.pkh]); return ( diff --git a/frontend/app/src/api/contracts.ts b/frontend/app/src/api/contracts.ts index 1237993f..4eeed94f 100644 --- a/frontend/app/src/api/contracts.ts +++ b/frontend/app/src/api/contracts.ts @@ -4,9 +4,10 @@ import { sub, format } from 'date-fns'; import { getActualCtezStorage, getUserHalfDexLqtBalance } from '../contracts/ctez'; import { getCtezFa12TotalSupply } from '../contracts/fa12'; import { BaseStats, CTezStorage, CTezTzktStorage, OvenBalance, UserLQTData } from '../interfaces'; -import { CTEZ_CONTRACT_BIGMAP, RPC_URL } from '../utils/globals'; +import { getCurrentRpcUrl, isRpcInitialized } from '../utils/rpcManager'; import { getOvenCtezOutstandingAndFeeIndex, getUpdatedDexFeeIndex } from '../utils/ovenUtils'; import { getCTezTzktStorage, getLastBlockOfTheDay, getUserOvensAPI } from './tzkt'; +import { CTEZ_CONTRACT_BIGMAP } from '../utils/globals'; export const getPrevCTezStorage = async ( days = 7, @@ -19,7 +20,11 @@ export const getPrevCTezStorage = async ( return storage; }; export const getCurrentBlock = async () => { - const response = await axios.get(`${RPC_URL}/chains/main/blocks/head`); + if (!isRpcInitialized()) { + throw new Error('RPC not initialized. Please wait for initialization to complete.'); + } + const rpcUrl = getCurrentRpcUrl(); + const response = await axios.get(`${rpcUrl}/chains/main/blocks/head`); return response.data.header.level; }; diff --git a/frontend/app/src/api/queries.ts b/frontend/app/src/api/queries.ts index 7a577075..8b8d2f0c 100644 --- a/frontend/app/src/api/queries.ts +++ b/frontend/app/src/api/queries.ts @@ -1,6 +1,7 @@ import { AxiosError } from 'axios'; import { useQueries, useQuery } from 'react-query'; import { UseQueryResult } from 'react-query/types/react/types'; +import { getCurrentRpcUrl } from '../utils/rpcManager'; import { getActualCtezStorage, getAllOvens, @@ -48,8 +49,9 @@ export const useCtezBaseStats = (userAddress?: string) => { }; export const useUserBalance = (userAddress?: string) => { + const rpcUrl = getCurrentRpcUrl(); return useQuery( - [`user-balance-${userAddress}`], + [`user-balance-${userAddress}-${rpcUrl}`], () => { if (userAddress) { return getUserBalance(userAddress); @@ -181,8 +183,9 @@ export const useOvenDelegate = (ovenAddress?: string) => { }; export const useUserLqtData = (userAddress?: string) => { + const rpcUrl = getCurrentRpcUrl(); return useQuery( - ['userLqtData', userAddress], + ['userLqtData', userAddress, rpcUrl], async () => { if (userAddress) { return getUserLQTData(userAddress); diff --git a/frontend/app/src/assets/images/icons/settings.svg b/frontend/app/src/assets/images/icons/settings.svg new file mode 100644 index 00000000..e28dad82 --- /dev/null +++ b/frontend/app/src/assets/images/icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/app/src/components/header/Header.tsx b/frontend/app/src/components/header/Header.tsx index 3ace898c..7d279826 100644 --- a/frontend/app/src/components/header/Header.tsx +++ b/frontend/app/src/components/header/Header.tsx @@ -1,172 +1,179 @@ -import { Flex, Box, useColorMode, Text, useMediaQuery } from '@chakra-ui/react'; -import React, { useEffect, useState } from 'react'; -import { useLocation, matchPath } from 'react-router-dom'; -import { FiMoon, FiSun } from 'react-icons/fi'; -import { GiHamburgerMenu } from 'react-icons/gi'; -import { ReactComponent as AllOvens } from '../../assets/images/sidebar/allovens.svg'; -import { ReactComponent as MyOvens } from '../../assets/images/sidebar/myovens.svg'; -import { ReactComponent as Trade } from '../../assets/images/sidebar/trade.svg'; -import { ReactComponent as AnalyticsIcon } from '../../assets/images/sidebar/analytics-icon.svg'; -import { ReactComponent as Faq } from '../../assets/images/sidebar/faq.svg'; -import { ReactComponent as Arrow } from '../../assets/images/icons/rightArrow.svg'; -import { ReactComponent as ArrowDark } from '../../assets/images/icons/rightArrowDark.svg'; -import { ReactComponent as Close } from '../../assets/images/icons/close.svg'; -import Button from '../button'; -import SignIn from '../SignIn'; -import { useThemeColors } from '../../hooks/utilHooks'; - -export interface IHeaderProps { - handleToggled: ((value: boolean) => void) | undefined; - toggled: boolean; -} - -interface HeaderIconText { - text: string | null; - icon: JSX.Element | null; -} - -const Header: React.FC = ({ handleToggled, toggled }) => { - const [mobileScreen] = useMediaQuery(['(max-width: 600px)']); - const { colorMode, toggleColorMode } = useColorMode(); - const [headerBackground, bannerbg, bannertext, trynow] = useThemeColors([ - 'headerBg', - 'bannerBg', - 'bannerText', - 'tryNow', - ]); - const location = useLocation(); - const [headerIconText, setHeaderIconText] = useState({ text: null, icon: null }); - - const setHeader = (pathName: string) => { - if ( - matchPath(pathName, { - path: '/myovens/:address', - }) != null - ) { - // const ovenAddress = pathName.substr(pathName.lastIndexOf('/') + 1, pathName.length); - setHeaderIconText({ text: `My Oven Details`, icon: null }); - } else if ( - matchPath(pathName, { - path: '/myovens', - exact: true, - }) - ) { - setHeaderIconText({ text: `My Ovens`, icon: }); - } else if ( - matchPath(pathName, { - path: '/myV1Ovens', - exact: true, - }) - ) { - setHeaderIconText({ text: `My V1 Ovens`, icon: }); - } else if ( - matchPath(pathName, { - path: '/ovens', - exact: true, - }) - ) { - setHeaderIconText({ text: `All Ovens`, icon: }); - } else if ( - matchPath(pathName, { - path: '/trade', - exact: true, - }) - ) { - setHeaderIconText({ text: `Trade`, icon: }); - } else if ( - matchPath(pathName, { - path: '/analytics', - exact: true, - }) - ) { - setHeaderIconText({ text: `Analytics`, icon: }); - } else if ( - matchPath(pathName, { - path: '/faq', - exact: true, - }) - ) { - setHeaderIconText({ text: `FAQ`, icon: }); - } else { - setHeaderIconText({ text: null, icon: null }); - } - }; - - const isFrontpage = () => { - return location.pathname === '/'; - }; - - useEffect(() => { - const pathName = location.pathname; - setHeader(pathName); - }, [location]); - const [isBannerOpen, setBannerOpen] = useState(false); - const closeBanner = () => { - setBannerOpen(false); - }; - - return ( - - {isBannerOpen && ( - - - - {mobileScreen - ? 'Plenty V3 is live!' - : 'Plenty V3 is live!'}{' '} - - - Try now - {' '} -{/* {!mobileScreen && ( - - New - - )} - {colorMode === 'light' ? : } */} - - - - - - closeBanner()} width={30} height={30} /> - - - - )} - - - - - {headerIconText.icon} - - - {headerIconText.text} - - - - {colorMode === 'light' ? : } - - - - - ); -}; - -export { Header }; +import { Flex, Box, useColorMode, Text, useMediaQuery, HStack } from '@chakra-ui/react'; +import React, { useEffect, useState } from 'react'; +import { useLocation, matchPath } from 'react-router-dom'; +import { FiMoon, FiSun } from 'react-icons/fi'; +import { GiHamburgerMenu } from 'react-icons/gi'; +import { ReactComponent as AllOvens } from '../../assets/images/sidebar/allovens.svg'; +import { ReactComponent as MyOvens } from '../../assets/images/sidebar/myovens.svg'; +import { ReactComponent as Trade } from '../../assets/images/sidebar/trade.svg'; +import { ReactComponent as AnalyticsIcon } from '../../assets/images/sidebar/analytics-icon.svg'; +import { ReactComponent as Faq } from '../../assets/images/sidebar/faq.svg'; +import { ReactComponent as Close } from '../../assets/images/icons/close.svg'; +import Button from '../button'; +import SignIn from '../SignIn'; +import { SettingsButton, SettingsDialog } from '../settings'; +import { useThemeColors } from '../../hooks/utilHooks'; + +export interface IHeaderProps { + handleToggled: ((value: boolean) => void) | undefined; + toggled: boolean; +} + +interface HeaderIconText { + text: string | null; + icon: JSX.Element | null; +} + +const Header: React.FC = ({ handleToggled, toggled }) => { + const [mobileScreen] = useMediaQuery(['(max-width: 600px)']); + const { colorMode, toggleColorMode } = useColorMode(); + const [headerBackground, bannerbg, bannertext, trynow] = useThemeColors([ + 'headerBg', + 'bannerBg', + 'bannerText', + 'tryNow', + ]); + const location = useLocation(); + const [headerIconText, setHeaderIconText] = useState({ text: null, icon: null }); + + const setHeader = (pathName: string) => { + if ( + matchPath(pathName, { + path: '/myovens/:address', + }) != null + ) { + // const ovenAddress = pathName.substr(pathName.lastIndexOf('/') + 1, pathName.length); + setHeaderIconText({ text: `My Oven Details`, icon: null }); + } else if ( + matchPath(pathName, { + path: '/myovens', + exact: true, + }) + ) { + setHeaderIconText({ text: `My Ovens`, icon: }); + } else if ( + matchPath(pathName, { + path: '/myV1Ovens', + exact: true, + }) + ) { + setHeaderIconText({ text: `My V1 Ovens`, icon: }); + } else if ( + matchPath(pathName, { + path: '/ovens', + exact: true, + }) + ) { + setHeaderIconText({ text: `All Ovens`, icon: }); + } else if ( + matchPath(pathName, { + path: '/trade', + exact: true, + }) + ) { + setHeaderIconText({ text: `Trade`, icon: }); + } else if ( + matchPath(pathName, { + path: '/analytics', + exact: true, + }) + ) { + setHeaderIconText({ text: `Analytics`, icon: }); + } else if ( + matchPath(pathName, { + path: '/faq', + exact: true, + }) + ) { + setHeaderIconText({ text: `FAQ`, icon: }); + } else { + setHeaderIconText({ text: null, icon: null }); + } + }; + + const isFrontpage = () => { + return location.pathname === '/'; + }; + + useEffect(() => { + const pathName = location.pathname; + setHeader(pathName); + }, [location]); + const [isBannerOpen, setBannerOpen] = useState(false); + const [isSettingsOpen, setSettingsOpen] = useState(false); + const closeBanner = () => { + setBannerOpen(false); + }; + + return ( + + {isBannerOpen && ( + + + + {mobileScreen + ? 'Plenty V3 is live!' + : 'Plenty V3 is live!'}{' '} + + + Try now + {' '} +{/* {!mobileScreen && ( + + New + + )} + {colorMode === 'light' ? : } */} + + + + + + closeBanner()} width={30} height={30} /> + + + + )} + + + + + {headerIconText.icon} + + + {headerIconText.text} + + + + {colorMode === 'light' ? : } + + + + setSettingsOpen(true)} /> + + + setSettingsOpen(false)} + /> + + ); +}; + +export { Header }; diff --git a/frontend/app/src/components/settings/SettingsButton.tsx b/frontend/app/src/components/settings/SettingsButton.tsx new file mode 100644 index 00000000..9307ee37 --- /dev/null +++ b/frontend/app/src/components/settings/SettingsButton.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Button from '../button'; +import { ReactComponent as SettingsIcon } from '../../assets/images/icons/settings.svg'; + +interface ISettingsButtonProps { + onClick: () => void; +} + +const SettingsButton: React.FC = ({ onClick }) => { + return ( + + ); +}; + +export { SettingsButton }; diff --git a/frontend/app/src/components/settings/SettingsDialog.tsx b/frontend/app/src/components/settings/SettingsDialog.tsx new file mode 100644 index 00000000..edfe57e0 --- /dev/null +++ b/frontend/app/src/components/settings/SettingsDialog.tsx @@ -0,0 +1,112 @@ +import React, { useEffect, useState } from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + Input, + VStack, + Text, + HStack, +} from '@chakra-ui/react'; +import Button from '../button'; +import { initTezos, setWalletProvider } from '../../contracts/client'; +import { APP_NAME, NETWORK, RPC_URL } from '../../utils/globals'; +import { getBeaconInstance, isWalletConnected } from '../../wallet'; +import { WalletInterface } from '../../interfaces'; +import { getRpcUrlForUser, setCurrentRpcUrl } from '../../utils/rpcManager'; + +interface ISettingsDialogProps { + isOpen: boolean; + onClose: () => void; +} + +const SettingsDialog: React.FC = ({ isOpen, onClose }) => { + const [wallet, setWallet] = useState>({}); + const checkWalletConnection = async () => { + const prevUsedWallet = isWalletConnected(); + if (prevUsedWallet) { + const walletData = await getBeaconInstance(APP_NAME, true, NETWORK); + walletData?.wallet && setWalletProvider(walletData.wallet); + walletData && setWallet(walletData); + } + }; + const [rpcUrl, setRpcUrl] = useState(''); + + useEffect(() => { + checkWalletConnection(); + }, []); + + useEffect(() => { + setRpcUrl(getRpcUrlForUser(wallet.pkh)); + }, [wallet.pkh]); + + + // const [text2] = useThemeColors(['text2']); + + const handleSave = async () => { + // Clean the RPC URL by removing any leading @ symbol and trimming whitespace + const cleanRpcUrl = (rpcUrl ?? RPC_URL).replace(/^@+/, '').trim(); + + // Validate URL format + if (!cleanRpcUrl.startsWith('http://') && !cleanRpcUrl.startsWith('https://')) { + console.error('Invalid RPC URL format. Must start with http:// or https://'); + return; + } + + try { + // Update the global RPC URL + setCurrentRpcUrl(cleanRpcUrl, wallet.pkh); + + // Re-initialize Tezos with the new URL + initTezos(cleanRpcUrl); + + // Force a page reload to ensure all components use the new RPC URL + window.location.reload(); + + onClose(); + } catch (error) { + console.error('Failed to update RPC URL:', error); + } + }; + + const handleCancel = () => { + setRpcUrl(''); + onClose(); + }; + + return ( + + + + Settings + + + + + RPC URL + + setRpcUrl(e.target.value)} + size="md" + /> + + + + + + + + + ); +}; + +export { SettingsDialog }; diff --git a/frontend/app/src/components/settings/index.ts b/frontend/app/src/components/settings/index.ts new file mode 100644 index 00000000..20664bb3 --- /dev/null +++ b/frontend/app/src/components/settings/index.ts @@ -0,0 +1,2 @@ +export { SettingsButton } from './SettingsButton'; +export { SettingsDialog } from './SettingsDialog'; diff --git a/frontend/app/src/contracts/client.ts b/frontend/app/src/contracts/client.ts index cec31666..af7dc460 100644 --- a/frontend/app/src/contracts/client.ts +++ b/frontend/app/src/contracts/client.ts @@ -7,7 +7,7 @@ export const setWalletProvider = (wallet: BeaconWallet): void => { tezos && tezos.setProvider({ wallet }); }; -export const initTezos = (url: string, port: string | number): void => { +export const initTezos = (url: string): void => { tezos = new TezosToolkit(url); tezos.setPackerProvider(new MichelCodecPacker()); }; diff --git a/frontend/app/src/utils/rpcManager.ts b/frontend/app/src/utils/rpcManager.ts new file mode 100644 index 00000000..a51730db --- /dev/null +++ b/frontend/app/src/utils/rpcManager.ts @@ -0,0 +1,85 @@ +import { RPC_URL } from './globals'; +import { getNodeURL } from './settingUtils'; +import { initTezos } from '../contracts/client'; + +// Global RPC URL state +let currentRpcUrl: string = RPC_URL; +let isInitialized = false; + +// Initialize RPC URL and Tezos immediately when module loads +const initializeImmediately = () => { + // Try to get any saved URL from localStorage (without user address) + // This is a fallback for when we don't have a user address yet + const savedUrls = Object.keys(localStorage) + .filter(key => key.startsWith('nodeURL:')) + .map(key => localStorage.getItem(key)) + .filter(url => url && url !== RPC_URL); + + if (savedUrls.length > 0) { + // Use the first saved URL we find + const url = savedUrls[0]!.replace(/^@+/, '').trim(); + currentRpcUrl = url; + } + + isInitialized = true; + + // Initialize Tezos immediately with the RPC URL + try { + initTezos(currentRpcUrl); + } catch (error) { + console.error('Failed to initialize Tezos:', error); + } +}; + +// Initialize immediately when module loads +initializeImmediately(); + +export const getCurrentRpcUrl = (): string => { + if (!isInitialized) { + return RPC_URL; + } + return currentRpcUrl; +}; + +export const setCurrentRpcUrl = (url: string, userAddress?: string): void => { + // Clean the URL + const cleanUrl = url.replace(/^@+/, '').trim(); + currentRpcUrl = cleanUrl; + + // Save to localStorage if user address is provided + if (userAddress) { + localStorage.setItem(`nodeURL:${userAddress}`, cleanUrl); + } +}; + +export const initializeRpcUrl = (userAddress?: string): string => { + // If we have a user address, try to get their saved URL + const savedUrl = userAddress ? getNodeURL(userAddress) : null; + + if (savedUrl) { + // User has a saved URL preference, use it + const cleanUrl = savedUrl.replace(/^@+/, '').trim(); + currentRpcUrl = cleanUrl; + + // Reinitialize Tezos with the new URL + try { + initTezos(cleanUrl); + } catch (error) { + console.error('Failed to reinitialize Tezos:', error); + } + } + + return currentRpcUrl; +}; + +export const isRpcInitialized = (): boolean => { + return isInitialized; +}; + +export const getRpcUrlForUser = (userAddress?: string): string => { + if (userAddress) { + const savedUrl = getNodeURL(userAddress); + return savedUrl ? savedUrl.replace(/^@+/, '').trim() : RPC_URL; + } + return currentRpcUrl; +}; \ No newline at end of file From 41069fdfa744656aa66064606dce44b33f28038c Mon Sep 17 00:00:00 2001 From: Maxwell Ward Date: Mon, 8 Sep 2025 12:40:20 -0700 Subject: [PATCH 2/5] resolve complex union error --- frontend/app/src/components/header/Header.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/app/src/components/header/Header.tsx b/frontend/app/src/components/header/Header.tsx index 7d279826..611d4add 100644 --- a/frontend/app/src/components/header/Header.tsx +++ b/frontend/app/src/components/header/Header.tsx @@ -105,6 +105,8 @@ const Header: React.FC = ({ handleToggled, toggled }) => { setBannerOpen(false); }; + const flexBackground = isFrontpage() ? undefined : headerBackground; + return ( {isBannerOpen && ( @@ -142,7 +144,7 @@ const Header: React.FC = ({ handleToggled, toggled }) => {