From 8b569c6e190c8083e8d6ca5da8f2122cfdebc6d5 Mon Sep 17 00:00:00 2001 From: dan13ram Date: Thu, 23 Jul 2026 22:46:36 +0530 Subject: [PATCH 1/6] feat: add SIWE auth, DAO access control, and rate limiting --- apps/web/.env.local.example | 5 + apps/web/package.json | 1 + apps/web/src/hooks/useWagmiAuthSync.ts | 133 ++++++++++++++++ .../web/src/layouts/BaseLayout/BaseLayout.tsx | 4 +- .../layouts/DefaultLayout/ConnectButton.tsx | 6 +- .../DefaultLayout/NavMenu/ChainMenu.tsx | 10 +- .../layouts/DefaultLayout/NavMenu/NavMenu.tsx | 4 +- .../DefaultLayout/NavMenu/ProfileMenu.tsx | 22 +-- apps/web/src/layouts/LayoutWrapper.tsx | 4 +- .../coin/CoinDetail/CoinCommentForm.tsx | 5 +- .../CreateContentCoinForm.tsx | 8 +- .../NoCreatorCoinWarning.tsx | 10 +- apps/web/src/modules/dashboard/Dashboard.tsx | 14 +- apps/web/src/modules/explore/Explore.tsx | 5 +- .../src/modules/explore/ExploreFavorites.tsx | 4 +- .../web/src/modules/explore/ExploreMyDaos.tsx | 4 +- .../src/modules/explore/ExploreToolbar.tsx | 4 +- apps/web/src/modules/home/GetStarted.tsx | 5 +- .../src/modules/playground/PlaygroundPage.tsx | 4 +- apps/web/src/pages/_app.tsx | 143 ++++++++++++++---- apps/web/src/pages/api/abi.ts | 9 +- .../web/src/pages/api/ai/generateTxSummary.ts | 9 +- .../web/src/pages/api/alchemy/nft-balances.ts | 8 +- .../web/src/pages/api/alchemy/nft-metadata.ts | 8 +- .../src/pages/api/alchemy/pinned-assets.ts | 8 +- .../src/pages/api/alchemy/token-balances.ts | 8 +- .../src/pages/api/alchemy/token-metadata.ts | 8 +- .../web/src/pages/api/alchemy/token-prices.ts | 8 +- .../src/pages/api/auctionHistory/[token].ts | 9 +- apps/web/src/pages/api/daos/[user].tsx | 9 +- apps/web/src/pages/api/dashboard.ts | 23 ++- apps/web/src/pages/api/decode.ts | 9 +- apps/web/src/pages/api/feed.ts | 10 +- .../pages/api/membersList/[collectionId].ts | 9 +- apps/web/src/pages/api/migrate/attributes.ts | 9 +- apps/web/src/pages/api/migrate/snapshot.ts | 9 +- apps/web/src/pages/api/migrated.ts | 9 +- apps/web/src/pages/api/pinata/generate-jwt.ts | 28 ++-- apps/web/src/pages/api/pinata/pin-cid.ts | 28 ++-- apps/web/src/pages/api/pinata/pin-json.ts | 26 +++- apps/web/src/pages/api/pinata/upload-url.ts | 28 ++-- .../src/pages/api/profile-preview/[user].ts | 9 +- .../api/profile/[network]/[user]/tokens.ts | 9 +- apps/web/src/pages/api/property-items.ts | 9 +- apps/web/src/pages/api/simulate.ts | 28 +++- apps/web/src/pages/api/siwe/logout.ts | 25 +++ apps/web/src/pages/api/siwe/me.ts | 23 +++ apps/web/src/pages/api/siwe/nonce.ts | 27 ++++ apps/web/src/pages/api/siwe/verify.ts | 46 ++++++ apps/web/src/pages/bridge.tsx | 5 +- apps/web/src/pages/create.tsx | 5 +- .../pages/dao/[network]/[token]/[tokenId].tsx | 29 ++-- .../[token]/candidate/[candidateId].tsx | 5 +- .../[network]/[token]/candidate/create.tsx | 25 ++- .../src/pages/dao/[network]/[token]/index.tsx | 11 +- .../pages/dao/[network]/[token]/migrate.tsx | 6 +- .../dao/[network]/[token]/proposal/create.tsx | 27 +++- .../dao/[network]/[token]/proposal/review.tsx | 20 ++- apps/web/src/pages/index.tsx | 8 +- apps/web/src/pages/profile/[user].tsx | 5 +- .../src/services/simulationService.test.ts | 5 + apps/web/src/services/simulationService.ts | 4 +- apps/web/src/utils/api/authMiddleware.ts | 56 +++++++ apps/web/src/utils/api/daoAuthMiddleware.ts | 120 +++++++++++++++ apps/web/src/utils/api/rateLimit.ts | 108 +++++++++++++ apps/web/src/utils/iron.ts | 15 ++ .../CurrentAuction/PlaceBid.test.tsx | 5 +- .../components/CurrentAuction/PlaceBid.tsx | 4 +- .../src/components/CurrentAuction/Settle.tsx | 5 +- .../src/components/CandidateCommentForm.tsx | 18 ++- .../src/components/CandidatePromoteButton.tsx | 6 +- .../CandidatePromoteCard.tsx | 11 +- .../components/CandidateSignatureButton.tsx | 15 +- .../CandidateSigners/CandidateSigners.tsx | 6 +- .../src/components/CandidateSubmitForm.tsx | 11 +- .../AllocationForm/AllocationForm.tsx | 6 +- .../ReviewAndDeploy/ReviewAndDeploy.tsx | 6 +- .../ReviewAndDeploy/SuccessfulDeploy.tsx | 6 +- .../ProposalStageIndicator.tsx | 4 +- .../ReviewProposalForm/ReviewProposalForm.tsx | 15 +- .../CrossChainMigration/Step1_LoadConfig.tsx | 5 +- .../Step2_ReviewConfig.tsx | 4 +- .../TransactionForm/Droposal/DroposalForm.tsx | 5 +- .../src/hooks/useMintReservedTokens.tsx | 5 +- .../src/utils/tenderlySimulation.ts | 4 +- .../dao-ui/src/components/About/About.tsx | 6 +- .../src/components/Activity/Activity.tsx | 44 +++--- .../src/components/Admin/AuctionRewards.tsx | 6 +- .../dao-ui/src/components/Cards/CoinCard.tsx | 5 +- .../CustomMinterForm/CustomMinterForm.tsx | 6 +- .../ERC721RedeemMinterForm/MintingForm.tsx | 5 +- .../dao-ui/src/components/Gallery/Gallery.tsx | 10 +- .../MinterManagementModal.tsx | 6 +- .../src/components/PreAuction/PreAuction.tsx | 12 +- .../feed-ui/src/Actions/AuctionActions.tsx | 5 +- packages/feed-ui/src/Actions/CoinActions.tsx | 5 +- packages/feed-ui/src/Feed.tsx | 4 +- .../feed-ui/src/Modals/VoteModalWrapper.tsx | 4 +- .../src/UrgencyAlerts/UrgencyAlerts.test.tsx | 4 +- .../src/UrgencyAlerts/UrgencyAlerts.tsx | 4 +- packages/hooks/src/useDashboardData.ts | 1 - packages/hooks/src/useWalletDisconnect.ts | 11 +- .../ProposalActions/ProposalActions.tsx | 12 +- .../ProposalActions/VoteStatus/VoteStatus.tsx | 6 +- .../VoteStatus/VotingPowerExplainer.test.tsx | 6 +- .../VoteStatus/VotingPowerExplainer.tsx | 5 +- .../AirdropDetails/AirdropItem.tsx | 5 +- .../MilestoneDetails/EscrowInstance.tsx | 11 +- .../MilestoneDetails/MilestoneDetails.tsx | 5 +- .../StreamDetails/SenderDelegation.tsx | 4 +- .../StreamDetails/StreamItem.tsx | 11 +- .../UpdateProposalButton.tsx | 9 +- packages/sdk/src/contract/index.ts | 4 + .../src/contract/requests/getDAOMembership.ts | 86 +++++++++++ packages/stores/src/createAuthStore.ts | 76 ++++++++++ packages/stores/src/hooks/useAuthStore.ts | 24 +++ packages/stores/src/index.ts | 8 + .../src/providers/AuthStoreProvider.tsx | 17 +++ pnpm-lock.yaml | 24 ++- turbo.json | 1 + 120 files changed, 1546 insertions(+), 344 deletions(-) create mode 100644 apps/web/src/hooks/useWagmiAuthSync.ts create mode 100644 apps/web/src/pages/api/siwe/logout.ts create mode 100644 apps/web/src/pages/api/siwe/me.ts create mode 100644 apps/web/src/pages/api/siwe/nonce.ts create mode 100644 apps/web/src/pages/api/siwe/verify.ts create mode 100644 apps/web/src/utils/api/authMiddleware.ts create mode 100644 apps/web/src/utils/api/daoAuthMiddleware.ts create mode 100644 apps/web/src/utils/iron.ts create mode 100644 packages/sdk/src/contract/requests/getDAOMembership.ts create mode 100644 packages/stores/src/createAuthStore.ts create mode 100644 packages/stores/src/hooks/useAuthStore.ts create mode 100644 packages/stores/src/providers/AuthStoreProvider.tsx diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index cac45dee0..dc94dbf5d 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -20,3 +20,8 @@ NEXT_PUBLIC_PINATA_GATEWAY= NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID= NEXT_PUBLIC_WALLET_CONNECT_METADATA= + +# Iron Session Configuration (for SIWE authentication) +# Generate a password with: openssl rand -base64 32 +# Must be at least 32 characters long +IRON_PASSWORD=your-secret-here-at-least-32-chars diff --git a/apps/web/package.json b/apps/web/package.json index 975a4b23a..9a788c742 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "framer-motion": "^12.12.1", "graphql-request": "^7.1.2", "ioredis": "^5.8.1", + "iron-session": "^8.0.4", "next": "^15.5.9", "nextjs-progressbar": "^0.0.16", "ox": "^0.14.0", diff --git a/apps/web/src/hooks/useWagmiAuthSync.ts b/apps/web/src/hooks/useWagmiAuthSync.ts new file mode 100644 index 000000000..73b9ac7fb --- /dev/null +++ b/apps/web/src/hooks/useWagmiAuthSync.ts @@ -0,0 +1,133 @@ +import { useAuthStore } from '@buildeross/stores' +import type { AuthenticationStatus } from '@rainbow-me/rainbowkit' +import { useCallback, useEffect, useRef } from 'react' +import { useAccount } from 'wagmi' + +/** + * Syncs wagmi wallet state with AuthStore and RainbowKit authentication + * + * Handles: + * - Wallet connection/disconnection + * - Account switching (clears auth on account change) + * - SIWE authentication state + * - Session verification on mount and window focus + * + * Bug fixes: + * - Proper event listener cleanup + * - Account switching detection + * - Stale closure prevention + * - Gap state handling + */ +export function useWagmiAuthSync( + rainbowKitAuthStatus: AuthenticationStatus, + setRainbowKitAuthStatus: (status: AuthenticationStatus) => void +) { + const { address, isConnected, connector } = useAccount() + const { setConnected, setConnecting, setAuthenticated, reset } = useAuthStore() + + const prevAddressRef = useRef(undefined) + const fetchingRef = useRef(false) + + // Core sync effect - handles the main state flow + useEffect(() => { + // 1. Handle disconnection + if (!isConnected) { + reset() + if (rainbowKitAuthStatus !== 'unauthenticated') { + setRainbowKitAuthStatus('unauthenticated') + } + prevAddressRef.current = undefined + return + } + + // 2. Update connected state + setConnected(true) + + // 3. Handle account switch (treat as logout - requires re-authentication) + if (prevAddressRef.current && prevAddressRef.current !== address) { + reset() + setRainbowKitAuthStatus('unauthenticated') + prevAddressRef.current = address + + void fetch('/api/siwe/logout', { method: 'POST' }) + return + } + prevAddressRef.current = address + + // 4. Set authenticated state when both conditions met + if (rainbowKitAuthStatus === 'authenticated' && address) { + setAuthenticated(address) + } else if (rainbowKitAuthStatus === 'unauthenticated') { + // Clear auth data if SIWE session expired + reset() + setConnected(true) // But keep connected state + } + }, [ + isConnected, + address, + rainbowKitAuthStatus, + setConnected, + setAuthenticated, + reset, + setRainbowKitAuthStatus, + ]) + + // Connector state - properly cleanup event listeners + useEffect(() => { + if (!connector?.emitter) { + setConnecting(false) + return + } + + // Use named functions so cleanup works correctly + const handleConnect = () => setConnecting(false) + const handleError = () => setConnecting(false) + + setConnecting(true) + connector.emitter.on('connect', handleConnect) + connector.emitter.on('error', handleError) + + return () => { + connector.emitter.off('connect', handleConnect) + connector.emitter.off('error', handleError) + } + }, [connector, setConnecting]) + + // Session verification + const verifySession = useCallback(async () => { + if (fetchingRef.current) return + + fetchingRef.current = true + try { + const response = await fetch('/api/siwe/me') + const json = await response.json() + + const sessionAddress = + typeof json.address === 'string' ? json.address.toLowerCase() : undefined + const connectedAddress = address?.toLowerCase() + + // Only consider the session authenticated when it matches the connected wallet. + const newStatus = + sessionAddress && connectedAddress && sessionAddress === connectedAddress + ? 'authenticated' + : 'unauthenticated' + + if (newStatus !== rainbowKitAuthStatus) { + setRainbowKitAuthStatus(newStatus) + } + } catch { + if (rainbowKitAuthStatus !== 'unauthenticated') { + setRainbowKitAuthStatus('unauthenticated') + } + } finally { + fetchingRef.current = false + } + }, [address, rainbowKitAuthStatus, setRainbowKitAuthStatus]) + + // Initial check and window focus + useEffect(() => { + verifySession() + window.addEventListener('focus', verifySession) + return () => window.removeEventListener('focus', verifySession) + }, [verifySession]) +} diff --git a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx index ff64d4eab..ce5c87c10 100644 --- a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx +++ b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx @@ -9,6 +9,7 @@ import { getCandidateStore, getProposalStore, ProposalStoreProvider, + useAuthStore, useChainStore, useDaoStore, } from '@buildeross/stores' @@ -18,7 +19,6 @@ import { Box } from '@buildeross/zord' import { useConnectModal } from '@rainbow-me/rainbowkit' import React, { ReactNode, useMemo } from 'react' import { zeroAddress as ZERO_ADDRESS } from 'viem' -import { useAccount } from 'wagmi' import { Nav as DefaultLayoutNav } from '../DefaultLayout/Nav' @@ -69,7 +69,7 @@ export function BaseLayout({ function DraftStoreProviders({ children }: { children: ReactNode }) { const chain = useChainStore((state) => state.chain) const addresses = useDaoStore((state) => state.addresses) - const { address } = useAccount() + const { address } = useAuthStore() const walletAddress = address ?? ZERO_ADDRESS const tokenAddress = addresses.token ?? ZERO_ADDRESS diff --git a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx index 94352e6b5..a9e475d26 100644 --- a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx +++ b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx @@ -1,14 +1,14 @@ +import { useAuthStore } from '@buildeross/stores' import { Flex } from '@buildeross/zord' import { ConnectButton as RKConnectButton } from '@rainbow-me/rainbowkit' import React from 'react' -import { useAccount } from 'wagmi' import { connectButtonWrapper } from './Nav.styles.css' export const ConnectButton = () => { - const { address, chain: wagmiChain } = useAccount() + const { address } = useAuthStore() - if (address || wagmiChain) { + if (address) { return null } diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx index 11c641a71..18e34d02a 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx @@ -30,7 +30,7 @@ export const ChainMenu: React.FC = ({ }) => { const [isChainInitialized, setIsChainInitialized] = React.useState(false) const router = useRouter() - const { address, chain: wagmiChain, connector } = useAccount() + const { isConnected, chain: wagmiChain, connector } = useAccount() const { switchChain } = useSwitchChain() const onDisconnect = useWalletDisconnect() @@ -61,11 +61,11 @@ export const ChainMenu: React.FC = ({ onSetActiveDropdown(undefined) const selected = PUBLIC_DEFAULT_CHAINS.find((x) => x.id === chainId) if (selected) setChain(selected) - if (address) { + if (isConnected) { onSwitchChain(chainId) } }, - [onSetActiveDropdown, setChain, hasNetwork, onSwitchChain, address] + [onSetActiveDropdown, setChain, hasNetwork, onSwitchChain, isConnected] ) const isSelectedChain = useCallback( @@ -74,8 +74,8 @@ export const ChainMenu: React.FC = ({ ) const isWrongNetwork = useMemo( - () => hasNetwork && !!address && wagmiChain?.id !== selectedChain.id, - [address, wagmiChain?.id, selectedChain.id, hasNetwork] + () => hasNetwork && !!isConnected && wagmiChain?.id !== selectedChain.id, + [isConnected, wagmiChain?.id, selectedChain.id, hasNetwork] ) // Handle route change start events diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx index bed0a1c2d..d12d7f46e 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/NavMenu.tsx @@ -1,6 +1,6 @@ +import { useAuthStore } from '@buildeross/stores' import { Box, Flex } from '@buildeross/zord' import React from 'react' -import { useAccount } from 'wagmi' import { ConnectButton } from '../ConnectButton' import { ChainMenu } from './ChainMenu' @@ -15,7 +15,7 @@ interface NavMenuProps { export const NavMenu = ({ hideChainMenu = false }: NavMenuProps) => { const [activeDropdown, setActiveDropdown] = React.useState() - const { address } = useAccount() + const { address } = useAuthStore() const onOpenMenu = React.useCallback( (open: boolean, menuType: MenuType) => { diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx index e37307952..3c44a5abe 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx @@ -3,7 +3,7 @@ import { MOBILE_PROFILE_MENU_LAYER, NAV_BUTTON_LAYER } from '@buildeross/constan import { useEnsData } from '@buildeross/hooks/useEnsData' import { useUserDaos } from '@buildeross/hooks/useUserDaos' import { useWalletDisconnect } from '@buildeross/hooks/useWalletDisconnect' -import { useChainStore } from '@buildeross/stores' +import { useAuthStore, useChainStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { Avatar, DaoAvatar } from '@buildeross/ui/Avatar' import { CopyButton } from '@buildeross/ui/CopyButton' @@ -19,7 +19,7 @@ import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' import { useDaoListPreferences } from 'src/hooks/useDaoListPreferences' import { profileStatBadge } from 'src/styles/profile.css' import { formatUnits } from 'viem' -import { useAccount, useBalance } from 'wagmi' +import { useBalance } from 'wagmi' import { ConnectButton } from '../ConnectButton' import { @@ -130,7 +130,7 @@ export const ProfileMenu: React.FC = ({ onOpenMenu, onSetActiveDropdown, }) => { - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const { chain: selectedChain } = useChainStore() const { displayName, ensAvatar } = useEnsData(address || '') const { data: balance } = useBalance({ @@ -330,13 +330,15 @@ export const ProfileMenu: React.FC = ({ const renderNavLinks = () => ( <> - - - - Dashboard - - - + {isAuthenticated && ( + + + + Dashboard + + + + )} diff --git a/apps/web/src/layouts/LayoutWrapper.tsx b/apps/web/src/layouts/LayoutWrapper.tsx index 759204a07..21178bc80 100644 --- a/apps/web/src/layouts/LayoutWrapper.tsx +++ b/apps/web/src/layouts/LayoutWrapper.tsx @@ -1,13 +1,13 @@ import { isBlocked } from '@buildeross/blocklist' +import { useAuthStore } from '@buildeross/stores' import { Box, Stack } from '@buildeross/zord' import React, { ReactNode } from 'react' import { Skull } from 'src/components/Skull' -import { useAccount } from 'wagmi' import { DefaultLayout } from './DefaultLayout' export function LayoutWrapper({ children }: { children: ReactNode }) { - const { address } = useAccount() + const { address } = useAuthStore() if (isBlocked(address)) return ( diff --git a/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.tsx b/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.tsx index 9f388b332..afdfe51cf 100644 --- a/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.tsx +++ b/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.tsx @@ -1,11 +1,12 @@ import { ZORA_COMMENTS } from '@buildeross/constants' import { zoraCommentsAbi } from '@buildeross/sdk' +import { useAuthStore } from '@buildeross/stores' import { ContractButton } from '@buildeross/ui/ContractButton' import { TextArea } from '@buildeross/ui/Fields' import { Box, Flex, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import { Address, erc20Abi, parseEther, zeroAddress } from 'viem' -import { useAccount, useConfig, useReadContract } from 'wagmi' +import { useConfig, useReadContract } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { @@ -26,7 +27,7 @@ export const CoinCommentForm: React.FC = ({ onCommentPosted, }) => { const config = useConfig() - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() const [commentText, setCommentText] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState() diff --git a/apps/web/src/modules/coin/CreateContentCoinForm/CreateContentCoinForm.tsx b/apps/web/src/modules/coin/CreateContentCoinForm/CreateContentCoinForm.tsx index 60d52c476..7020a2915 100644 --- a/apps/web/src/modules/coin/CreateContentCoinForm/CreateContentCoinForm.tsx +++ b/apps/web/src/modules/coin/CreateContentCoinForm/CreateContentCoinForm.tsx @@ -6,7 +6,7 @@ import { import { useClankerTokenPrice } from '@buildeross/hooks' import { uploadFile } from '@buildeross/ipfs-service' import { awaitSubgraphSync, ClankerTokenFragment } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { CoinFormFields, @@ -37,7 +37,7 @@ import { Form, Formik, type FormikHelpers, useFormikContext } from 'formik' import { useRouter } from 'next/router' import React, { useCallback, useEffect, useState } from 'react' import { type Address, decodeEventLog, zeroAddress, zeroHash } from 'viem' -import { useAccount, useConfig, useReadContract } from 'wagmi' +import { useConfig, useReadContract } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { NoCreatorCoinWarning } from './NoCreatorCoinWarning' @@ -91,7 +91,7 @@ const CreateContentCoinEconomicsPreview: React.FC< CreateContentCoinEconomicsPreviewProps > = ({ chainId, latestClankerToken, clankerTokenPriceUsd }) => { const formik = useFormikContext() - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() // Prepare arguments for coinAddress call const { encodedPoolConfig, shouldFetch } = React.useMemo(() => { @@ -212,7 +212,7 @@ export const CreateContentCoinForm: React.FC = ({ }) => { const router = useRouter() const config = useConfig() - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() const [submitError, setSubmitError] = useState() const [disclaimerAccepted, setDisclaimerAccepted] = useState(false) const [isDeploying, setIsDeploying] = useState(false) diff --git a/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx b/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx index 90e6521ea..9647f444d 100644 --- a/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx +++ b/apps/web/src/modules/coin/CreateContentCoinForm/NoCreatorCoinWarning.tsx @@ -1,15 +1,19 @@ import { useDelayedGovernance, useVotes } from '@buildeross/hooks' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' import { TransactionType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' import { useRouter } from 'next/router' import React from 'react' -import { useAccount } from 'wagmi' export const NoCreatorCoinWarning: React.FC = () => { const router = useRouter() - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() const { startProposalDraft } = useProposalStore() // Get addresses and chain from stores diff --git a/apps/web/src/modules/dashboard/Dashboard.tsx b/apps/web/src/modules/dashboard/Dashboard.tsx index 4e70fd7e4..8d5b0ad2f 100644 --- a/apps/web/src/modules/dashboard/Dashboard.tsx +++ b/apps/web/src/modules/dashboard/Dashboard.tsx @@ -6,6 +6,7 @@ import { useEnsData, } from '@buildeross/hooks' import { ProposalState } from '@buildeross/sdk/contract' +import { useAuthStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { AccordionItem } from '@buildeross/ui/Accordion' import { DisplayPanel } from '@buildeross/ui/DisplayPanel' @@ -13,7 +14,6 @@ import { Box, Stack, Text } from '@buildeross/zord' import React, { useMemo } from 'react' import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' import { useDaoListPreferences } from 'src/hooks/useDaoListPreferences' -import { useAccount } from 'wagmi' import { CreateActions } from './CreateActions' import { DaoAuctionCard } from './DaoAuctionCard' @@ -26,7 +26,7 @@ import { UserProfileCard } from './UserProfileCard' export type DashboardDaoProps = DashboardDaoWithState export const Dashboard: React.FC = () => { - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const { displayName, ensAvatar } = useEnsData(address) const [openAccordion, setOpenAccordion] = React.useState<'daos' | 'proposals' | null>( null @@ -41,7 +41,7 @@ export const Dashboard: React.FC = () => { refresh: mutate, } = useDashboardData({ address, - enabled: !!address, + enabled: !!address && isAuthenticated, }) const chainSortedDaos = useMemo(() => { @@ -171,7 +171,7 @@ export const Dashboard: React.FC = () => { const mainContent = ( <> - + {isAuthenticated && } ) @@ -181,7 +181,7 @@ export const Dashboard: React.FC = () => { if (error) { sidebarContent = ( - {address && ( + {address && isAuthenticated && ( <> { } else if (isLoading) { sidebarContent = ( - {address && ( + {address && isAuthenticated && ( <> { /> ) - } else if (!address) { + } else if (!address || !isAuthenticated) { sidebarContent = } else if (!daos?.length) { sidebarContent = ( diff --git a/apps/web/src/modules/explore/Explore.tsx b/apps/web/src/modules/explore/Explore.tsx index 0452de58f..e52844ad9 100644 --- a/apps/web/src/modules/explore/Explore.tsx +++ b/apps/web/src/modules/explore/Explore.tsx @@ -1,11 +1,10 @@ import { useDaoSearch, useExplore } from '@buildeross/hooks' -import { useChainStore } from '@buildeross/stores' +import { useAuthStore, useChainStore } from '@buildeross/stores' import { Pagination } from '@buildeross/ui/Pagination' import { Box, Grid, Text } from '@buildeross/zord' import { useRouter } from 'next/router' import React, { useCallback, useEffect, useState } from 'react' import { FAVORITE_DAO_LIMIT, useFavoriteDaos } from 'src/hooks/useFavoriteDaos' -import { useAccount } from 'wagmi' import { exploreGrid, searchContainer } from './Explore.css' import { ExploreDaoCard } from './ExploreDaoCard' @@ -26,7 +25,7 @@ export const Explore: React.FC = () => { const orderBy = Array.isArray(urlOrderBy) ? urlOrderBy[0] : urlOrderBy const urlSearch = Array.isArray(rawUrlSearch) ? rawUrlSearch[0] : rawUrlSearch const chain = useChainStore((x) => x.chain) - const { address } = useAccount() + const { address } = useAuthStore() const { hasReachedFavoriteLimit, isDaoFavorited, toggleFavorite } = useFavoriteDaos(address) const [searchInput, setSearchInput] = useState('') diff --git a/apps/web/src/modules/explore/ExploreFavorites.tsx b/apps/web/src/modules/explore/ExploreFavorites.tsx index 315c87839..a847ea667 100644 --- a/apps/web/src/modules/explore/ExploreFavorites.tsx +++ b/apps/web/src/modules/explore/ExploreFavorites.tsx @@ -1,16 +1,16 @@ +import { useAuthStore } from '@buildeross/stores' import { Box, Grid, Text } from '@buildeross/zord' import React from 'react' import { FAVORITE_DAO_LIMIT, useFavoriteDaos } from 'src/hooks/useFavoriteDaos' import { type FavoriteDao, getFavoriteDaoKey } from 'src/stores/favoriteDaosStore' import useSWR from 'swr' -import { useAccount } from 'wagmi' import { exploreGrid } from './Explore.css' import { ExploreDaoCard } from './ExploreDaoCard' import { ExploreToolbar } from './ExploreToolbar' export const ExploreFavorites = () => { - const { address } = useAccount() + const { address } = useAuthStore() const { favoriteCount, favorites, isDaoFavorited, toggleFavorite } = useFavoriteDaos(address) diff --git a/apps/web/src/modules/explore/ExploreMyDaos.tsx b/apps/web/src/modules/explore/ExploreMyDaos.tsx index 56ee31c0c..e8609a03b 100644 --- a/apps/web/src/modules/explore/ExploreMyDaos.tsx +++ b/apps/web/src/modules/explore/ExploreMyDaos.tsx @@ -1,8 +1,8 @@ import { useExploreUserDaos as useMyDaos } from '@buildeross/hooks/useExploreUserDaos' +import { useAuthStore } from '@buildeross/stores' import { Grid } from '@buildeross/zord' import React from 'react' import { FAVORITE_DAO_LIMIT, useFavoriteDaos } from 'src/hooks/useFavoriteDaos' -import { useAccount } from 'wagmi' import { exploreGrid } from './Explore.css' import { ExploreDaoCard } from './ExploreDaoCard' @@ -11,7 +11,7 @@ import { ExploreSkeleton } from './ExploreSkeleton' import { ExploreToolbar } from './ExploreToolbar' export const ExploreMyDaos = () => { - const { address } = useAccount() + const { address } = useAuthStore() const isWalletConnected = Boolean(address) const { hasReachedFavoriteLimit, isDaoFavorited, toggleFavorite } = useFavoriteDaos(address) diff --git a/apps/web/src/modules/explore/ExploreToolbar.tsx b/apps/web/src/modules/explore/ExploreToolbar.tsx index 17b052294..9919b2433 100644 --- a/apps/web/src/modules/explore/ExploreToolbar.tsx +++ b/apps/web/src/modules/explore/ExploreToolbar.tsx @@ -1,8 +1,8 @@ +import { useAuthStore } from '@buildeross/stores' import { Box, Flex, Text, vars } from '@buildeross/zord' import Link from 'next/link' import { useRouter } from 'next/router' import React from 'react' -import { useAccount } from 'wagmi' import { ExploreSortMenu } from './ExploreSortMenu' @@ -18,7 +18,7 @@ export const ExploreToolbar: React.FC = ({ helperText, }) => { const { query, pathname } = useRouter() - const { address } = useAccount() + const { address } = useAuthStore() return ( { - const { address, chain: wagmiChain } = useAccount() + const { address } = useAuthStore() + const { chain: wagmiChain } = useAccount() const chain = useChainStore((x) => x.chain) const { openConnectModal } = useConnectModal() diff --git a/apps/web/src/modules/playground/PlaygroundPage.tsx b/apps/web/src/modules/playground/PlaygroundPage.tsx index 33bfb79e7..8396a301b 100644 --- a/apps/web/src/modules/playground/PlaygroundPage.tsx +++ b/apps/web/src/modules/playground/PlaygroundPage.tsx @@ -1,7 +1,7 @@ +import { useAuthStore } from '@buildeross/stores' import { Box } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import type { DaoListItem } from 'src/modules/dashboard/SingleDaoSelector' -import { useAccount } from 'wagmi' import { CustomArtworkUpload } from './components/CustomArtworkUpload' import { DaoSelector } from './components/DaoSelector' @@ -12,7 +12,7 @@ import { PlaygroundLanding } from './components/PlaygroundLanding' export type PlaygroundView = 'landing' | 'dao' | 'custom' export const PlaygroundPage: React.FC = () => { - const { address } = useAccount() + const { address } = useAuthStore() const [view, setView] = useState('landing') const [selectedDao, setSelectedDao] = useState() diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 68af7e5e0..6af018763 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -19,22 +19,30 @@ import 'react-mde/lib/styles/css/react-mde-all.css' import 'src/styles/react-mde-theme.css' import { VercelAnalytics } from '@buildeross/analytics' +import { AuthStoreProvider, getAuthStore } from '@buildeross/stores' import { LinkComponentProvider } from '@buildeross/ui/LinkComponentProvider' import { NetworkController } from '@buildeross/ui/NetworkController' import { vars } from '@buildeross/zord' -import { RainbowKitProvider } from '@rainbow-me/rainbowkit' +import { + type AuthenticationStatus, + createAuthenticationAdapter, + RainbowKitAuthenticationProvider, + RainbowKitProvider, +} from '@rainbow-me/rainbowkit' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import type { NextPage } from 'next' import type { AppProps } from 'next/app' import Link from 'next/link' import NextNProgress from 'nextjs-progressbar' -import type { ReactElement, ReactNode } from 'react' +import { type ReactElement, type ReactNode, useMemo, useRef, useState } from 'react' import { Disclaimer } from 'src/components/Disclaimer' import { FrameProvider } from 'src/components/FrameProvider' import { LinksProvider } from 'src/components/LinksProvider' +import { useWagmiAuthSync } from 'src/hooks/useWagmiAuthSync' import { AppThemeProvider } from 'src/theme/AppThemeProvider' import { clientConfig } from 'src/utils/clientConfig' import { SWRConfig } from 'swr' +import { createSiweMessage } from 'viem/siwe' import { WagmiProvider } from 'wagmi' const queryClient = new QueryClient({ @@ -56,37 +64,118 @@ type AppPropsWithLayout = AppProps & { Component: NextPageWithLayout } -function App({ Component, pageProps, err }: AppPropsWithLayout) { +function AppContent({ Component, pageProps, err }: AppPropsWithLayout) { const getLayout = Component.getLayout ?? ((page) => page) const fallback = pageProps?.fallback ?? {} + const verifyingRef = useRef(false) + const [rainbowKitAuthStatus, setRainbowKitAuthStatus] = + useState('loading') + + // Single hook handles all wagmi <-> AuthStore synchronization + useWagmiAuthSync(rainbowKitAuthStatus, setRainbowKitAuthStatus) + + const authAdapter = useMemo(() => { + return createAuthenticationAdapter({ + getNonce: async () => { + const response = await fetch('/api/siwe/nonce') + return await response.text() + }, + + createMessage: ({ nonce, address, chainId }) => { + return createSiweMessage({ + domain: window.location.host, + address, + statement: 'Sign in with Ethereum to Nouns Builder', + uri: window.location.origin, + version: '1', + chainId, + nonce, + }) + }, + + verify: async ({ message, signature }) => { + verifyingRef.current = true + + // Access store directly to avoid hooks in useMemo + const { setAuthenticating } = getAuthStore().getState() + setAuthenticating(true) + + try { + const response = await fetch('/api/siwe/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, signature }), + }) + + const body = (await response.json()) as { ok?: boolean } + const authenticated = response.ok && body.ok === true + + if (authenticated) { + setRainbowKitAuthStatus('authenticated') + } else { + setAuthenticating(false) + } + + return authenticated + } catch (error) { + console.error('Error verifying signature', error) + setAuthenticating(false) + return false + } finally { + verifyingRef.current = false + } + }, + + signOut: async () => { + setRainbowKitAuthStatus('unauthenticated') + + // Access store directly to avoid hooks in useMemo + const { reset } = getAuthStore().getState() + reset() + + await fetch('/api/siwe/logout') + }, + }) + }, [setRainbowKitAuthStatus]) + + return ( + + + + + + + + + {getLayout()} + + + + + + + + + + + ) +} + +function App(props: AppPropsWithLayout) { return ( - - - - - - - - {getLayout()} - - - - - - - - - + + + ) diff --git a/apps/web/src/pages/api/abi.ts b/apps/web/src/pages/api/abi.ts index 9957abf11..ff7bc82f4 100644 --- a/apps/web/src/pages/api/abi.ts +++ b/apps/web/src/pages/api/abi.ts @@ -4,6 +4,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { ContractABIResult, getContractABIByAddress } from 'src/services/abiService' import { InvalidRequestError, NotFoundError } from 'src/services/errors' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' const handler = async ( req: NextApiRequest, @@ -35,4 +36,10 @@ const handler = async ( } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 30, + windowSeconds: 60, + keyPrefix: 'abi', + })(handler) +) diff --git a/apps/web/src/pages/api/ai/generateTxSummary.ts b/apps/web/src/pages/api/ai/generateTxSummary.ts index d03336431..f1c364fea 100644 --- a/apps/web/src/pages/api/ai/generateTxSummary.ts +++ b/apps/web/src/pages/api/ai/generateTxSummary.ts @@ -15,6 +15,7 @@ import { walletSnippet } from '@buildeross/utils/helpers' import * as Sentry from '@sentry/nextjs' import { NextApiRequest, NextApiResponse } from 'next' import { AI_MODEL, generateCachedAiText } from 'src/utils/api/ai/summaries' +import { withAuth } from 'src/utils/api/authMiddleware' import { withRateLimit } from 'src/utils/api/rateLimit' type RequestBody = { @@ -265,7 +266,11 @@ Final Instruction: Respond with 1-2 concise sentences describing this transaction, and nothing else.` } -async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler( + req: NextApiRequest, + res: NextApiResponse, + _session: { address: string } +) { if (req.method !== 'POST') { res.setHeader('Allow', 'POST') return res.status(405).json({ error: 'Method not allowed' }) @@ -325,4 +330,4 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { export default withRateLimit({ keyPrefix: 'ai:txSummary', -})(handler) +})(withAuth(handler)) diff --git a/apps/web/src/pages/api/alchemy/nft-balances.ts b/apps/web/src/pages/api/alchemy/nft-balances.ts index 5dcfa0acb..810769c11 100644 --- a/apps/web/src/pages/api/alchemy/nft-balances.ts +++ b/apps/web/src/pages/api/alchemy/nft-balances.ts @@ -3,7 +3,7 @@ import { AddressType, CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getCachedNFTBalance } from 'src/services/alchemyService' import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' +import { withTieredRateLimit } from 'src/utils/api/rateLimit' import { isAddress } from 'viem' const parseBooleanParam = (value: string | string[] | undefined): boolean | undefined => { @@ -64,7 +64,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } export default withCors()( - withRateLimit({ + withTieredRateLimit({ keyPrefix: 'alchemy:nftBalances', + anonymousMaxRequests: 5, + anonymousWindowSeconds: 60, + authenticatedMaxRequests: 60, + authenticatedWindowSeconds: 60, })(handler) ) diff --git a/apps/web/src/pages/api/alchemy/nft-metadata.ts b/apps/web/src/pages/api/alchemy/nft-metadata.ts index 32e80b6dc..aaa8fb1e1 100644 --- a/apps/web/src/pages/api/alchemy/nft-metadata.ts +++ b/apps/web/src/pages/api/alchemy/nft-metadata.ts @@ -2,7 +2,7 @@ import { AddressType, CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getCachedNftMetadata } from 'src/services/alchemyService' import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' +import { withTieredRateLimit } from 'src/utils/api/rateLimit' import { isAddress } from 'viem' async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -55,7 +55,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } export default withCors()( - withRateLimit({ + withTieredRateLimit({ keyPrefix: 'alchemy:nftMetadata', + anonymousMaxRequests: 3, + anonymousWindowSeconds: 60, + authenticatedMaxRequests: 30, + authenticatedWindowSeconds: 60, })(handler) ) diff --git a/apps/web/src/pages/api/alchemy/pinned-assets.ts b/apps/web/src/pages/api/alchemy/pinned-assets.ts index a66af585e..b3a74ee51 100644 --- a/apps/web/src/pages/api/alchemy/pinned-assets.ts +++ b/apps/web/src/pages/api/alchemy/pinned-assets.ts @@ -2,7 +2,7 @@ import { AddressType, CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getEnrichedPinnedAssets, PinnedAssetInput } from 'src/services/alchemyService' import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' +import { withTieredRateLimit } from 'src/utils/api/rateLimit' import { isAddress } from 'viem' async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -67,7 +67,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { export default withCors({ allowedMethods: ['POST'], })( - withRateLimit({ + withTieredRateLimit({ keyPrefix: 'alchemy:pinnedAssets', + anonymousMaxRequests: 3, + anonymousWindowSeconds: 60, + authenticatedMaxRequests: 30, + authenticatedWindowSeconds: 60, })(handler) ) diff --git a/apps/web/src/pages/api/alchemy/token-balances.ts b/apps/web/src/pages/api/alchemy/token-balances.ts index 3c8afc402..ceefd06ce 100644 --- a/apps/web/src/pages/api/alchemy/token-balances.ts +++ b/apps/web/src/pages/api/alchemy/token-balances.ts @@ -3,7 +3,7 @@ import { CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getEnrichedTokenBalances } from 'src/services/alchemyService' import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' +import { withTieredRateLimit } from 'src/utils/api/rateLimit' const parseBooleanParam = (value: string | string[] | undefined): boolean | undefined => { if (typeof value !== 'string') return undefined @@ -55,7 +55,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } export default withCors()( - withRateLimit({ + withTieredRateLimit({ keyPrefix: 'alchemy:tokenBalances', + anonymousMaxRequests: 5, + anonymousWindowSeconds: 60, + authenticatedMaxRequests: 60, + authenticatedWindowSeconds: 60, })(handler) ) diff --git a/apps/web/src/pages/api/alchemy/token-metadata.ts b/apps/web/src/pages/api/alchemy/token-metadata.ts index bc425b7fd..5788b346d 100644 --- a/apps/web/src/pages/api/alchemy/token-metadata.ts +++ b/apps/web/src/pages/api/alchemy/token-metadata.ts @@ -3,7 +3,7 @@ import { getCachedIsContract } from '@buildeross/utils' import { NextApiRequest, NextApiResponse } from 'next' import { getCachedTokenMetadatas } from 'src/services/alchemyService' import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' +import { withTieredRateLimit } from 'src/utils/api/rateLimit' async function handler(req: NextApiRequest, res: NextApiResponse) { const { chainId, addresses } = req.query @@ -68,7 +68,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } export default withCors()( - withRateLimit({ + withTieredRateLimit({ keyPrefix: 'alchemy:tokenMetadata', + anonymousMaxRequests: 5, + anonymousWindowSeconds: 60, + authenticatedMaxRequests: 60, + authenticatedWindowSeconds: 60, })(handler) ) diff --git a/apps/web/src/pages/api/alchemy/token-prices.ts b/apps/web/src/pages/api/alchemy/token-prices.ts index fe50ef0da..50b05d70e 100644 --- a/apps/web/src/pages/api/alchemy/token-prices.ts +++ b/apps/web/src/pages/api/alchemy/token-prices.ts @@ -2,7 +2,7 @@ import { CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getCachedTokenPrices } from 'src/services/alchemyService' import { withCors } from 'src/utils/api/cors' -import { withRateLimit } from 'src/utils/api/rateLimit' +import { withTieredRateLimit } from 'src/utils/api/rateLimit' async function handler(req: NextApiRequest, res: NextApiResponse) { const { chainId, addresses } = req.query @@ -64,7 +64,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } export default withCors()( - withRateLimit({ + withTieredRateLimit({ keyPrefix: 'alchemy:tokenPrices', + anonymousMaxRequests: 5, + anonymousWindowSeconds: 60, + authenticatedMaxRequests: 60, + authenticatedWindowSeconds: 60, })(handler) ) diff --git a/apps/web/src/pages/api/auctionHistory/[token].ts b/apps/web/src/pages/api/auctionHistory/[token].ts index 751ac0716..b0dbaf1f3 100644 --- a/apps/web/src/pages/api/auctionHistory/[token].ts +++ b/apps/web/src/pages/api/auctionHistory/[token].ts @@ -2,6 +2,7 @@ import { auctionHistoryRequest } from '@buildeross/sdk/subgraph' import { CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { token, chainId, startTime } = req.query @@ -20,4 +21,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { res.status(500).json({ error }) } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 30, + windowSeconds: 60, + keyPrefix: 'auction-history', + })(handler) +) diff --git a/apps/web/src/pages/api/daos/[user].tsx b/apps/web/src/pages/api/daos/[user].tsx index 42e446ba1..1b40201b6 100644 --- a/apps/web/src/pages/api/daos/[user].tsx +++ b/apps/web/src/pages/api/daos/[user].tsx @@ -5,6 +5,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { NotFoundError } from 'src/services/errors' import { getRedisConnection } from 'src/services/redisConnection' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { isAddress, keccak256 } from 'viem' /** @@ -176,4 +177,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 30, + windowSeconds: 60, + keyPrefix: 'daos:user', + })(handler) +) diff --git a/apps/web/src/pages/api/dashboard.ts b/apps/web/src/pages/api/dashboard.ts index 680558bdf..043f8a014 100644 --- a/apps/web/src/pages/api/dashboard.ts +++ b/apps/web/src/pages/api/dashboard.ts @@ -1,10 +1,16 @@ import type { AddressType } from '@buildeross/types' import type { NextApiRequest, NextApiResponse } from 'next' import { fetchDashboardDataService, getDashboardTtl } from 'src/services/dashboardService' +import { withAuth } from 'src/utils/api/authMiddleware' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { isAddress } from 'viem' -async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler( + req: NextApiRequest, + res: NextApiResponse, + session: { address: string } +) { const startTime = Date.now() // Validate address parameter @@ -18,6 +24,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return res.status(400).json({ error: 'Invalid address format' }) } + // Verify the authenticated user is requesting their own dashboard + if (address.toLowerCase() !== session.address.toLowerCase()) { + return res + .status(403) + .json({ error: 'Forbidden: You can only access your own dashboard' }) + } + try { // Fetch dashboard data with caching const data = await fetchDashboardDataService(address.toLowerCase() as AddressType) @@ -65,4 +78,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 60, + windowSeconds: 60, + keyPrefix: 'dashboard', + })(withAuth(handler)) +) diff --git a/apps/web/src/pages/api/decode.ts b/apps/web/src/pages/api/decode.ts index ebaaa6fb8..9094343a3 100644 --- a/apps/web/src/pages/api/decode.ts +++ b/apps/web/src/pages/api/decode.ts @@ -6,6 +6,7 @@ import { NextApiRequest, NextApiResponse } from 'next' import { decodeTransaction } from 'src/services/abiService' import { InvalidRequestError, NotFoundError } from 'src/services/errors' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { isHex } from 'viem' async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -50,4 +51,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } } -export default withCors({ allowedMethods: ['POST'] })(handler) +export default withCors({ allowedMethods: ['POST'] })( + withRateLimit({ + maxRequests: 20, + windowSeconds: 60, + keyPrefix: 'decode', + })(handler) +) diff --git a/apps/web/src/pages/api/feed.ts b/apps/web/src/pages/api/feed.ts index 6073b3cc6..e73c55985 100644 --- a/apps/web/src/pages/api/feed.ts +++ b/apps/web/src/pages/api/feed.ts @@ -4,9 +4,11 @@ import type { AddressType, CHAIN_ID } from '@buildeross/types' import type { NextApiRequest, NextApiResponse } from 'next' import { InvalidRequestError } from 'src/services/errors' import { fetchFeedDataService, getTtlByScope } from 'src/services/feedService' +import { withAuth } from 'src/utils/api/authMiddleware' +import { withRateLimit } from 'src/utils/api/rateLimit' import { isAddress } from 'viem' -export default async function handler(req: NextApiRequest, res: NextApiResponse) { +async function handler(req: NextApiRequest, res: NextApiResponse) { const startTime = Date.now() // Handle OPTIONS preflight @@ -184,3 +186,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(500).json({ error: 'Internal server error' }) } } + +export default withRateLimit({ + maxRequests: 60, + windowSeconds: 60, + keyPrefix: 'feed', +})(withAuth(handler)) diff --git a/apps/web/src/pages/api/membersList/[collectionId].ts b/apps/web/src/pages/api/membersList/[collectionId].ts index 68c719f6b..c72919de0 100644 --- a/apps/web/src/pages/api/membersList/[collectionId].ts +++ b/apps/web/src/pages/api/membersList/[collectionId].ts @@ -2,6 +2,7 @@ import { votersRequest } from '@buildeross/sdk/subgraph' import { CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { collectionId, chainId, page, limit } = req.query @@ -84,4 +85,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 5, + windowSeconds: 60, + keyPrefix: 'members-list', + })(handler) +) diff --git a/apps/web/src/pages/api/migrate/attributes.ts b/apps/web/src/pages/api/migrate/attributes.ts index 5894d47b2..f62229267 100644 --- a/apps/web/src/pages/api/migrate/attributes.ts +++ b/apps/web/src/pages/api/migrate/attributes.ts @@ -2,6 +2,7 @@ import { getMetadataAttributes } from '@buildeross/sdk/contract' import { NextApiRequest, NextApiResponse } from 'next' import { getRedisConnection } from 'src/services/redisConnection' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { Address } from 'viem' // Increase timeout to 60 seconds for DAOs with many tokens @@ -54,4 +55,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 10, + windowSeconds: 60, + keyPrefix: 'migrate:attributes', + })(handler) +) diff --git a/apps/web/src/pages/api/migrate/snapshot.ts b/apps/web/src/pages/api/migrate/snapshot.ts index 0fbb4d812..5a4bfbfab 100644 --- a/apps/web/src/pages/api/migrate/snapshot.ts +++ b/apps/web/src/pages/api/migrate/snapshot.ts @@ -3,6 +3,7 @@ import { CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getRedisConnection } from 'src/services/redisConnection' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { Address, isAddress } from 'viem' // Increase timeout to 60 seconds for DAOs with many members @@ -75,4 +76,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 10, + windowSeconds: 60, + keyPrefix: 'migrate:snapshot', + })(handler) +) diff --git a/apps/web/src/pages/api/migrated.ts b/apps/web/src/pages/api/migrated.ts index 38e406c55..17dfbbf7e 100644 --- a/apps/web/src/pages/api/migrated.ts +++ b/apps/web/src/pages/api/migrated.ts @@ -6,6 +6,7 @@ import { unpackOptionalArray } from '@buildeross/utils/helpers' import { serverConfig } from '@buildeross/utils/wagmi/serverConfig' import { NextApiRequest, NextApiResponse } from 'next' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { zeroAddress } from 'viem' import { readContract } from 'wagmi/actions' @@ -49,4 +50,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } as L2MigratedResponse) } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 30, + windowSeconds: 60, + keyPrefix: 'migrated', + })(handler) +) diff --git a/apps/web/src/pages/api/pinata/generate-jwt.ts b/apps/web/src/pages/api/pinata/generate-jwt.ts index 6ca1e26d8..02aede205 100644 --- a/apps/web/src/pages/api/pinata/generate-jwt.ts +++ b/apps/web/src/pages/api/pinata/generate-jwt.ts @@ -1,16 +1,26 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { generateUploadJWT } from 'src/services/pinataService' +import { withAuth } from 'src/utils/api/authMiddleware' import { withErrorHandling } from 'src/utils/api/error' +import { withRateLimit } from 'src/utils/api/rateLimit' -const handler = withErrorHandling(async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - res.setHeader('Allow', ['POST']) - return res.status(405).end(`Method ${req.method} Not Allowed`) - } +const handler = withErrorHandling( + withRateLimit({ + maxRequests: 20, + windowSeconds: 60, + keyPrefix: 'pinata:generate-jwt', + })( + withAuth(async (req: NextApiRequest, res: NextApiResponse, _session) => { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']) + return res.status(405).end(`Method ${req.method} Not Allowed`) + } - const result = await generateUploadJWT() - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') - res.status(200).json(result) -}) + const result = await generateUploadJWT() + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + res.status(200).json(result) + }) + ) +) export default handler diff --git a/apps/web/src/pages/api/pinata/pin-cid.ts b/apps/web/src/pages/api/pinata/pin-cid.ts index 8353e5fc7..553e263de 100644 --- a/apps/web/src/pages/api/pinata/pin-cid.ts +++ b/apps/web/src/pages/api/pinata/pin-cid.ts @@ -1,16 +1,26 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { pinCidToIPFS } from 'src/services/pinataService' +import { withAuth } from 'src/utils/api/authMiddleware' import { withErrorHandling } from 'src/utils/api/error' +import { withRateLimit } from 'src/utils/api/rateLimit' -const handler = withErrorHandling(async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - res.setHeader('Allow', ['POST']) - return res.status(405).end(`Method ${req.method} Not Allowed`) - } +const handler = withErrorHandling( + withRateLimit({ + maxRequests: 15, + windowSeconds: 60, + keyPrefix: 'pinata:pin-cid', + })( + withAuth(async (req: NextApiRequest, res: NextApiResponse, _session) => { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']) + return res.status(405).end(`Method ${req.method} Not Allowed`) + } - const { cid, name, group_id } = req.body - const result = await pinCidToIPFS({ cid, name, group_id }) - return res.status(200).json({ text: result.status }) -}) + const { cid, name, group_id } = req.body + const result = await pinCidToIPFS({ cid, name, group_id }) + return res.status(200).json({ text: result.status }) + }) + ) +) export default handler diff --git a/apps/web/src/pages/api/pinata/pin-json.ts b/apps/web/src/pages/api/pinata/pin-json.ts index 026046ced..a29932d5c 100644 --- a/apps/web/src/pages/api/pinata/pin-json.ts +++ b/apps/web/src/pages/api/pinata/pin-json.ts @@ -1,15 +1,25 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { pinJsonToIPFS } from 'src/services/pinataService' +import { withAuth } from 'src/utils/api/authMiddleware' import { withErrorHandling } from 'src/utils/api/error' +import { withRateLimit } from 'src/utils/api/rateLimit' -const handler = withErrorHandling(async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - res.setHeader('Allow', ['POST']) - return res.status(405).end(`Method ${req.method} Not Allowed`) - } +const handler = withErrorHandling( + withRateLimit({ + maxRequests: 15, + windowSeconds: 60, + keyPrefix: 'pinata:pin-json', + })( + withAuth(async (req: NextApiRequest, res: NextApiResponse, _session) => { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']) + return res.status(405).end(`Method ${req.method} Not Allowed`) + } - const result = await pinJsonToIPFS(req.body) - return res.status(200).json(result) -}) + const result = await pinJsonToIPFS(req.body) + return res.status(200).json(result) + }) + ) +) export default handler diff --git a/apps/web/src/pages/api/pinata/upload-url.ts b/apps/web/src/pages/api/pinata/upload-url.ts index b2f46ae31..956fc9a1a 100644 --- a/apps/web/src/pages/api/pinata/upload-url.ts +++ b/apps/web/src/pages/api/pinata/upload-url.ts @@ -1,16 +1,26 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { createSignedUploadUrl } from 'src/services/pinataService' +import { withAuth } from 'src/utils/api/authMiddleware' import { withErrorHandling } from 'src/utils/api/error' +import { withRateLimit } from 'src/utils/api/rateLimit' -const handler = withErrorHandling(async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - res.setHeader('Allow', ['POST']) - return res.status(405).end(`Method ${req.method} Not Allowed`) - } +const handler = withErrorHandling( + withRateLimit({ + maxRequests: 20, + windowSeconds: 60, + keyPrefix: 'pinata:upload-url', + })( + withAuth(async (req: NextApiRequest, res: NextApiResponse, _session) => { + if (req.method !== 'POST') { + res.setHeader('Allow', ['POST']) + return res.status(405).end(`Method ${req.method} Not Allowed`) + } - const { type } = req.body - const result = await createSignedUploadUrl(type) - return res.status(200).json(result) -}) + const { type } = req.body + const result = await createSignedUploadUrl(type) + return res.status(200).json(result) + }) + ) +) export default handler diff --git a/apps/web/src/pages/api/profile-preview/[user].ts b/apps/web/src/pages/api/profile-preview/[user].ts index c07c61223..9a278f440 100644 --- a/apps/web/src/pages/api/profile-preview/[user].ts +++ b/apps/web/src/pages/api/profile-preview/[user].ts @@ -7,6 +7,7 @@ import { AddressType } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getRedisConnection } from 'src/services/redisConnection' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { isAddress, keccak256 } from 'viem' const CACHE_TTL_SECONDS = 300 @@ -331,4 +332,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 20, + windowSeconds: 60, + keyPrefix: 'profile-preview', + })(handler) +) diff --git a/apps/web/src/pages/api/profile/[network]/[user]/tokens.ts b/apps/web/src/pages/api/profile/[network]/[user]/tokens.ts index f88d4639f..3f1762c7d 100644 --- a/apps/web/src/pages/api/profile/[network]/[user]/tokens.ts +++ b/apps/web/src/pages/api/profile/[network]/[user]/tokens.ts @@ -8,6 +8,7 @@ import { import { NextApiRequest, NextApiResponse } from 'next' import { NotFoundError } from 'src/services/errors' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { getAddress } from 'viem' export interface UserTokensResponse { @@ -53,4 +54,10 @@ export const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 30, + windowSeconds: 60, + keyPrefix: 'profile:tokens', + })(handler) +) diff --git a/apps/web/src/pages/api/property-items.ts b/apps/web/src/pages/api/property-items.ts index a4462340f..bf94b4799 100644 --- a/apps/web/src/pages/api/property-items.ts +++ b/apps/web/src/pages/api/property-items.ts @@ -3,6 +3,7 @@ import { AddressType, CHAIN_ID } from '@buildeross/types' import { NextApiRequest, NextApiResponse } from 'next' import { getRedisConnection } from 'src/services/redisConnection' import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' import { isAddress } from 'viem' // Increase timeout to 60 seconds for DAOs with many properties @@ -73,4 +74,10 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } } -export default withCors()(handler) +export default withCors()( + withRateLimit({ + maxRequests: 10, + windowSeconds: 60, + keyPrefix: 'property-items', + })(handler) +) diff --git a/apps/web/src/pages/api/simulate.ts b/apps/web/src/pages/api/simulate.ts index 9e36e7db0..bc4609a18 100644 --- a/apps/web/src/pages/api/simulate.ts +++ b/apps/web/src/pages/api/simulate.ts @@ -2,15 +2,16 @@ import { ErrorResult, SimulationResult } from '@buildeross/types' import type { NextApiRequest, NextApiResponse } from 'next' import { InvalidRequestError } from 'src/services/errors' import { simulate } from 'src/services/simulationService' +import { type DaoMembershipData, withDaoAuth } from 'src/utils/api/daoAuthMiddleware' +import { withRateLimit } from 'src/utils/api/rateLimit' +import type { SiweMessage } from 'viem/siwe' async function handler( req: NextApiRequest, - res: NextApiResponse + res: NextApiResponse, + _session: SiweMessage, + _membership: DaoMembershipData ) { - if (req.method !== 'POST') { - return res.status(405).send({ error: 'Only POST requests allowed' }) - } - try { const result = await simulate(req.body) return res.status(200).json(result) @@ -32,4 +33,19 @@ async function handler( } } -export default handler +const authedHandler = withRateLimit({ + maxRequests: 20, + windowSeconds: 60, + keyPrefix: 'simulate', +})(withDaoAuth(handler)) + +export default async function simulateRoute( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).send({ error: 'Only POST requests allowed' }) + } + + return authedHandler(req, res) +} diff --git a/apps/web/src/pages/api/siwe/logout.ts b/apps/web/src/pages/api/siwe/logout.ts new file mode 100644 index 000000000..c7144611f --- /dev/null +++ b/apps/web/src/pages/api/siwe/logout.ts @@ -0,0 +1,25 @@ +import { getIronSession } from 'iron-session' +import type { NextApiRequest, NextApiResponse } from 'next' +import { withRateLimit } from 'src/utils/api/rateLimit' +import { ironOptions, type IronSessionData } from 'src/utils/iron' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req + switch (method) { + case 'POST': + case 'GET': + const session = await getIronSession(req, res, ironOptions) + session.destroy() + res.send({ ok: true }) + break + default: + res.setHeader('Allow', ['GET', 'POST']) + res.status(405).end(`Method ${method} Not Allowed`) + } +} + +export default withRateLimit({ + maxRequests: 60, + windowSeconds: 60, + keyPrefix: 'siwe:logout', +})(handler) diff --git a/apps/web/src/pages/api/siwe/me.ts b/apps/web/src/pages/api/siwe/me.ts new file mode 100644 index 000000000..a5a453615 --- /dev/null +++ b/apps/web/src/pages/api/siwe/me.ts @@ -0,0 +1,23 @@ +import { getIronSession } from 'iron-session' +import type { NextApiRequest, NextApiResponse } from 'next' +import { withRateLimit } from 'src/utils/api/rateLimit' +import { ironOptions, type IronSessionData } from 'src/utils/iron' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req + switch (method) { + case 'GET': + const session = await getIronSession(req, res, ironOptions) + res.send({ address: session.siwe?.address }) + break + default: + res.setHeader('Allow', ['GET']) + res.status(405).end(`Method ${method} Not Allowed`) + } +} + +export default withRateLimit({ + maxRequests: 120, + windowSeconds: 60, + keyPrefix: 'siwe:me', +})(handler) diff --git a/apps/web/src/pages/api/siwe/nonce.ts b/apps/web/src/pages/api/siwe/nonce.ts new file mode 100644 index 000000000..664ab0fc5 --- /dev/null +++ b/apps/web/src/pages/api/siwe/nonce.ts @@ -0,0 +1,27 @@ +import { getIronSession } from 'iron-session' +import type { NextApiRequest, NextApiResponse } from 'next' +import { withRateLimit } from 'src/utils/api/rateLimit' +import { ironOptions, type IronSessionData } from 'src/utils/iron' +import { generateSiweNonce } from 'viem/siwe' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req + switch (method) { + case 'GET': + const session = await getIronSession(req, res, ironOptions) + session.nonce = generateSiweNonce() + await session.save() + res.setHeader('Content-Type', 'text/plain') + res.send(session.nonce) + break + default: + res.setHeader('Allow', ['GET']) + res.status(405).end(`Method ${method} Not Allowed`) + } +} + +export default withRateLimit({ + maxRequests: 30, + windowSeconds: 60, + keyPrefix: 'siwe:nonce', +})(handler) diff --git a/apps/web/src/pages/api/siwe/verify.ts b/apps/web/src/pages/api/siwe/verify.ts new file mode 100644 index 000000000..dda9d30d3 --- /dev/null +++ b/apps/web/src/pages/api/siwe/verify.ts @@ -0,0 +1,46 @@ +import { getIronSession } from 'iron-session' +import type { NextApiRequest, NextApiResponse } from 'next' +import { withRateLimit } from 'src/utils/api/rateLimit' +import { clientConfig } from 'src/utils/clientConfig' +import { ironOptions, type IronSessionData } from 'src/utils/iron' +import { parseSiweMessage, type SiweMessage, verifySiweMessage } from 'viem/siwe' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { method } = req + switch (method) { + case 'POST': + try { + const { message, signature } = req.body + const siweMessage = parseSiweMessage(message) as SiweMessage + + const valid = await verifySiweMessage(clientConfig.getClient(), { + address: siweMessage.address, + message, + signature, + }) + + if (!valid) throw new Error('Invalid signature.') + + const session = await getIronSession(req, res, ironOptions) + + if (siweMessage.nonce !== session.nonce) + return res.status(422).json({ message: 'Invalid nonce.' }) + + session.siwe = siweMessage + await session.save() + res.json({ ok: true }) + } catch (_error) { + res.json({ ok: false }) + } + break + default: + res.setHeader('Allow', ['POST']) + res.status(405).end(`Method ${method} Not Allowed`) + } +} + +export default withRateLimit({ + maxRequests: 10, + windowSeconds: 60, + keyPrefix: 'siwe:verify', +})(handler) diff --git a/apps/web/src/pages/bridge.tsx b/apps/web/src/pages/bridge.tsx index e8ced8089..3e7b41e58 100644 --- a/apps/web/src/pages/bridge.tsx +++ b/apps/web/src/pages/bridge.tsx @@ -1,4 +1,5 @@ import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { useAuthStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { Box, Button, Flex, Heading, Icon, Stack, Text } from '@buildeross/zord' import Head from 'next/head' @@ -6,7 +7,7 @@ import Image from 'next/image' import React from 'react' import { getDefaultLayout } from 'src/layouts/DefaultLayout' import { bridgeActionButton, bridgeCardBody } from 'src/styles/bridge.css' -import { useAccount, useBalance } from 'wagmi' +import { useBalance } from 'wagmi' import { NextPageWithLayout } from './_app' @@ -65,7 +66,7 @@ const NetworkCard: React.FC<{ bridgeURL: string bridgeLogo: string }> = ({ chainId, bridgeLogo, bridgeURL }) => { - const { address } = useAccount() + const { address } = useAuthStore() const { data: userBalance } = useBalance({ address, chainId, diff --git a/apps/web/src/pages/create.tsx b/apps/web/src/pages/create.tsx index 8cc94a661..e275b43c0 100644 --- a/apps/web/src/pages/create.tsx +++ b/apps/web/src/pages/create.tsx @@ -11,7 +11,7 @@ import { VetoForm, } from '@buildeross/create-dao-ui' import { useIsGnosisSafe } from '@buildeross/hooks/useIsGnosisSafe' -import { useChainStore } from '@buildeross/stores' +import { useAuthStore, useChainStore } from '@buildeross/stores' import { Uploading } from '@buildeross/ui/Uploading' import { Box, Flex, Text } from '@buildeross/zord' import { AnimatePresence, motion } from 'framer-motion' @@ -20,13 +20,12 @@ import React from 'react' import { Meta } from 'src/components/Meta' import { getCreateDaoLayout } from 'src/layouts/CreateDaoLayout' import { createWrapperHalf, formWrapper, pageGrid } from 'src/styles/create.css' -import { useAccount } from 'wagmi' import { NextPageWithLayout } from './_app' const CreatePage: NextPageWithLayout = () => { const { activeSection, isUploadingToIPFS, ipfsUploadProgress } = useFormStore() - const { address } = useAccount() + const { address } = useAuthStore() const chain = useChainStore((x) => x.chain) const { isGnosisSafe } = useIsGnosisSafe(address, chain.id) diff --git a/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx b/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx index ab0c6ec34..66203d2e1 100644 --- a/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx +++ b/apps/web/src/pages/dao/[network]/[token]/[tokenId].tsx @@ -16,10 +16,11 @@ import { useGovernorVersion } from '@buildeross/hooks/useContractVersion' import { useGalleryItems } from '@buildeross/hooks/useGalleryItems' import { useVotes } from '@buildeross/hooks/useVotes' import { OrderDirection, SubgraphSDK, Token_OrderBy } from '@buildeross/sdk/subgraph' -import { DaoContractAddresses } from '@buildeross/stores' +import { DaoContractAddresses, useAuthStore } from '@buildeross/stores' import { AddressType, Chain, CHAIN_ID, ProposalCreateStage } from '@buildeross/types' import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' import { isChainIdSupportedByDroposal } from '@buildeross/utils/droposal' +import { slugify } from '@buildeross/utils/slugify' import { Flex } from '@buildeross/zord' import { GetServerSideProps, GetServerSidePropsResult } from 'next' import { useRouter } from 'next/router' @@ -30,7 +31,6 @@ import { getDaoLayout } from 'src/layouts/DaoLayout' import { NextPageWithLayout } from 'src/pages/_app' import { DaoOgMetadata } from 'src/pages/api/og/dao' import { isAddress } from 'viem' -import { useAccount } from 'wagmi' interface TokenPageProps { collection: AddressType @@ -52,7 +52,7 @@ const TokenPage: NextPageWithLayout = ({ }) => { const { query, push, pathname } = useRouter() - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const chain = PUBLIC_DEFAULT_CHAINS.find((x) => x.id === chainId) as Chain @@ -219,10 +219,12 @@ const TokenPage: NextPageWithLayout = ({ title: 'Contracts', component: [], } - const daoFeed = { - title: 'Feed', - component: [], - } + const daoFeed = isAuthenticated + ? { + title: 'Feed', + component: [], + } + : null // Show Gallery tab if DAO has coins, drops, or creator coin const gallerySection = shouldShowGallery @@ -240,7 +242,7 @@ const TokenPage: NextPageWithLayout = ({ const publicSections = [ aboutSection, - daoFeed, + ...(daoFeed ? [daoFeed] : []), treasurySection, proposalsSection, ...(candidatesSection ? [candidatesSection] : []), @@ -250,6 +252,7 @@ const TokenPage: NextPageWithLayout = ({ return hasThreshold ? [...publicSections, adminSection] : publicSections }, [ + isAuthenticated, shouldShowGallery, hasThreshold, supportsCandidates, @@ -277,7 +280,13 @@ const TokenPage: NextPageWithLayout = ({ : 'activity' : requestedTab - const path = `/dao/${chain.slug}/${addresses.token}/${token.tokenId}?tab=${activeTab}` + const activeTabResolved = sections.some( + (section) => slugify(section.title) === activeTab + ) + ? activeTab + : slugify(sections[0].title) + + const path = `/dao/${chain.slug}/${addresses.token}/${token.tokenId}?tab=${activeTabResolved}` const onAuctionCreated = React.useCallback( (tokenId: bigint) => { @@ -320,7 +329,7 @@ const TokenPage: NextPageWithLayout = ({ /> openTab(tab, false)} /> diff --git a/apps/web/src/pages/dao/[network]/[token]/candidate/[candidateId].tsx b/apps/web/src/pages/dao/[network]/[token]/candidate/[candidateId].tsx index 3fb272a2b..7e276e5cb 100644 --- a/apps/web/src/pages/dao/[network]/[token]/candidate/[candidateId].tsx +++ b/apps/web/src/pages/dao/[network]/[token]/candidate/[candidateId].tsx @@ -19,6 +19,7 @@ import { getDAOAddresses, tokenAbi } from '@buildeross/sdk/contract' import { CandidateVoteSupport, getUserCandidateSignal } from '@buildeross/sdk/subgraph' import { type DaoContractAddresses, + useAuthStore, useCandidateStore, useChainStore, useDaoStore, @@ -45,7 +46,7 @@ import { NextPageWithLayout } from 'src/pages/_app' import { votePageWrapper } from 'src/styles/vote.css' import useSWR, { useSWRConfig } from 'swr' import { isAddressEqual } from 'viem' -import { useAccount, useReadContract } from 'wagmi' +import { useReadContract } from 'wagmi' interface CandidateDetailPageProps { candidateId: string @@ -150,7 +151,7 @@ const CandidateDetailPage: NextPageWithLayout = ({ initialData, }) => { const router = useRouter() - const { address } = useAccount() + const { address } = useAuthStore() const { query, push, pathname } = router const { chain } = useChainStore() const { addresses } = useDaoStore() diff --git a/apps/web/src/pages/dao/[network]/[token]/candidate/create.tsx b/apps/web/src/pages/dao/[network]/[token]/candidate/create.tsx index d5049b0cd..2ab4db4a4 100644 --- a/apps/web/src/pages/dao/[network]/[token]/candidate/create.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/candidate/create.tsx @@ -25,7 +25,12 @@ import { useScrollDirection } from '@buildeross/hooks/useScrollDirection' import { TRANSACTION_TYPES, TransactionTypeIcon } from '@buildeross/proposal-ui' import { getCandidateGroup } from '@buildeross/sdk' import { auctionAbi, getDAOAddresses } from '@buildeross/sdk/contract' -import { useCandidateStore, useChainStore, useDaoStore } from '@buildeross/stores' +import { + useAuthStore, + useCandidateStore, + useChainStore, + useDaoStore, +} from '@buildeross/stores' import { AddressType, TransactionType } from '@buildeross/types' import { AnimatedModal } from '@buildeross/ui/Modal' import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' @@ -40,7 +45,7 @@ import React, { useMemo, useState } from 'react' import { getDaoLayout } from 'src/layouts/DaoLayout' import { NextPageWithLayout } from 'src/pages/_app' import { isAddressEqual } from 'viem' -import { useAccount, useReadContract } from 'wagmi' +import { useReadContract } from 'wagmi' import { useShallow } from 'zustand/shallow' type CreateStage = 'draft' | 'transactions' | 'review' @@ -56,7 +61,7 @@ const CandidateCreatePageContent = () => { const router = useRouter() const addresses = useDaoStore((state) => state.addresses) const chain = useChainStore((state) => state.chain) - const { address } = useAccount() + const { address } = useAuthStore() const { openConnectModal } = useConnectModal() const { @@ -620,11 +625,11 @@ const CandidateCreatePageContent = () => { } const CandidateCreatePage: NextPageWithLayout = () => { - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const { addresses } = useDaoStore() const { openConnectModal } = useConnectModal() - if (!address || !addresses.token) { + if (!address || !isAuthenticated || !addresses.token) { return ( { Candidate creation is restricted - You need to connect a wallet before you can create or edit a candidate. + {!address + ? 'You need to connect a wallet before you can create or edit a candidate.' + : !isAuthenticated + ? 'You need to sign in with your wallet before you can create or edit a candidate.' + : 'Loading DAO information...'} - + ) } diff --git a/apps/web/src/pages/dao/[network]/[token]/index.tsx b/apps/web/src/pages/dao/[network]/[token]/index.tsx index 608961548..864ec6582 100644 --- a/apps/web/src/pages/dao/[network]/[token]/index.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/index.tsx @@ -16,7 +16,12 @@ import { import { useGovernorVersion } from '@buildeross/hooks/useContractVersion' import { auctionAbi, getDAOAddresses, tokenAbi } from '@buildeross/sdk/contract' import { OrderDirection, SubgraphSDK, Token_OrderBy } from '@buildeross/sdk/subgraph' -import { DaoContractAddresses, useChainStore, useDaoStore } from '@buildeross/stores' +import { + DaoContractAddresses, + useAuthStore, + useChainStore, + useDaoStore, +} from '@buildeross/stores' import { AddressType, CHAIN_ID, ProposalCreateStage } from '@buildeross/types' import { unpackOptionalArray } from '@buildeross/utils/helpers' import { serverConfig } from '@buildeross/utils/wagmi/serverConfig' @@ -28,7 +33,7 @@ import { Meta } from 'src/components/Meta' import { getDaoLayout } from 'src/layouts/DaoLayout' import { NextPageWithLayout } from 'src/pages/_app' import { isAddress } from 'viem' -import { useAccount, useReadContract, useReadContracts } from 'wagmi' +import { useReadContract, useReadContracts } from 'wagmi' import { readContract } from 'wagmi/actions' interface DaoPageProps { @@ -40,7 +45,7 @@ interface DaoPageProps { const DaoPage: NextPageWithLayout = ({ chainId, collectionAddress }) => { const { query, pathname, push } = useRouter() - const { address: signerAddress } = useAccount() + const { address: signerAddress } = useAuthStore() const { addresses } = useDaoStore() const chain = useChainStore((x) => x.chain) const chainIdKey = chain.id as keyof typeof MERKLE_RESERVE_MINTER diff --git a/apps/web/src/pages/dao/[network]/[token]/migrate.tsx b/apps/web/src/pages/dao/[network]/[token]/migrate.tsx index 4af6f99ef..8e44fb6f8 100644 --- a/apps/web/src/pages/dao/[network]/[token]/migrate.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/migrate.tsx @@ -7,14 +7,14 @@ import { } from '@buildeross/create-proposal-ui' import { getDAOAddresses, tokenAbi } from '@buildeross/sdk/contract' import { daoOGMetadataRequest } from '@buildeross/sdk/subgraph' -import { DaoContractAddresses } from '@buildeross/stores' +import { DaoContractAddresses, useAuthStore } from '@buildeross/stores' import { AddressType, CHAIN_ID } from '@buildeross/types' import { chainIdToSlug } from '@buildeross/utils' import { Box, Flex, Stack, Text } from '@buildeross/zord' import { GetServerSideProps } from 'next' import { useRouter } from 'next/router' import React, { useCallback } from 'react' -import { useAccount, useReadContracts } from 'wagmi' +import { useReadContracts } from 'wagmi' import { getDaoLayout } from '../../../../layouts/DaoLayout' import { NextPageWithLayout } from '../../../_app' @@ -39,7 +39,7 @@ const MigratePage: NextPageWithLayout = ({ addresses, chainId, }) => { - const { address: walletAddress, isConnected } = useAccount() + const { address: walletAddress, isConnected } = useAuthStore() const router = useRouter() const onNavigateToReview = useCallback(() => { diff --git a/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx b/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx index 0f510d695..a060976df 100644 --- a/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx @@ -35,7 +35,12 @@ import { TransactionTypeIcon, } from '@buildeross/proposal-ui' import { auctionAbi, getDAOAddresses } from '@buildeross/sdk/contract' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { isChainIdSupportedByCoining } from '@buildeross/utils/coining' import { isChainIdSupportedByDroposal } from '@buildeross/utils/droposal' @@ -54,7 +59,7 @@ import { getDaoLayout } from 'src/layouts/DaoLayout' import { NextPageWithLayout } from 'src/pages/_app' import * as styles from 'src/styles/create.css' import { getAddress, isAddress, isAddressEqual } from 'viem' -import { useAccount, useReadContract } from 'wagmi' +import { useReadContract } from 'wagmi' import { useShallow } from 'zustand/shallow' const createSelectOption = (type: TransactionType) => ({ @@ -251,7 +256,7 @@ const CreateProposalPage: NextPageWithLayout = () => { addresses, }) - const { address } = useAccount() + const { address, isConnected, isAuthenticating } = useAuthStore() const { openConnectModal } = useConnectModal() const { isLoading, hasThreshold } = useVotes({ @@ -639,6 +644,8 @@ const CreateProposalPage: NextPageWithLayout = () => { if (isLoading) return null + // Show auth guard if not authenticated + // address is only set when fully authenticated if (!address) { return ( { Proposal creation is restricted - You need to connect a wallet before you can create a proposal. + {!isConnected + ? 'You need to connect a wallet before you can create a proposal.' + : isAuthenticating + ? 'Signing you in...' + : 'You need to sign in with your wallet before you can create a proposal.'} - + ) } diff --git a/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx b/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx index 29972885b..ee9b9a4a6 100644 --- a/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/proposal/review.tsx @@ -12,7 +12,12 @@ import { useDelayedGovernance } from '@buildeross/hooks/useDelayedGovernance' import { useProposal } from '@buildeross/hooks/useProposal' import { useVotes } from '@buildeross/hooks/useVotes' import { getDAOAddresses } from '@buildeross/sdk/contract' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' import { AddressType, ProposalCreateStage } from '@buildeross/types' import { AnimatedModal, SuccessModalContent } from '@buildeross/ui/Modal' import { generateProposalSalt } from '@buildeross/utils/proposalMetadata' @@ -23,7 +28,6 @@ import { useRouter } from 'next/router' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getDaoLayout } from 'src/layouts/DaoLayout' import { NextPageWithLayout } from 'src/pages/_app' -import { useAccount } from 'wagmi' import { useShallow } from 'zustand/shallow' const ReviewProposalPage: NextPageWithLayout = () => { @@ -31,7 +35,7 @@ const ReviewProposalPage: NextPageWithLayout = () => { const { push } = useRouter() const { addresses } = useDaoStore() - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const { openConnectModal } = useConnectModal() const { isLoading, hasThreshold } = useVotes({ @@ -257,7 +261,7 @@ const ReviewProposalPage: NextPageWithLayout = () => { if (isLoading) return null - if (!address) { + if (!address || !isAuthenticated) { return ( { Proposal review is restricted - You need to connect a wallet before you can review and submit a proposal. + {!address + ? 'You need to connect a wallet before you can review and submit a proposal.' + : 'You need to sign in with your wallet before you can review and submit a proposal.'} - + ) } diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 570d4eb09..c63a94198 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -1,4 +1,5 @@ import { AuctionFragment } from '@buildeross/sdk/subgraph' +import { useAuthStore } from '@buildeross/stores' import { Stack } from '@buildeross/zord' import React, { ReactNode } from 'react' import { Meta } from 'src/components/Meta' @@ -15,14 +16,13 @@ import { VisitAlternate, } from 'src/modules/home' import { container } from 'src/styles/dashboard.css' -import { useAccount } from 'wagmi' import { NextPageWithLayout } from './_app' export type DaoProps = AuctionFragment['dao'] function ConditionalLayout({ children }: { children: ReactNode }) { - const { address } = useAccount() + const { address } = useAuthStore() if (address) { return ( @@ -42,7 +42,7 @@ function ConditionalLayout({ children }: { children: ReactNode }) { } const HomePage: NextPageWithLayout = () => { - const { address } = useAccount() + const { address } = useAuthStore() return ( <> @@ -66,7 +66,7 @@ const HomePage: NextPageWithLayout = () => { HomePage.getLayout = (page) => { // We need to check the page content to determine layout - // Since we can't access useAccount here, we'll create a wrapper that handles both cases + // Since we can't access useAuthStore here, we'll create a wrapper that handles both cases return {page} } diff --git a/apps/web/src/pages/profile/[user].tsx b/apps/web/src/pages/profile/[user].tsx index 7ac274008..9ee93369f 100644 --- a/apps/web/src/pages/profile/[user].tsx +++ b/apps/web/src/pages/profile/[user].tsx @@ -7,6 +7,7 @@ import { type ProfileDashboardChainResult, type ProfileDashboardQueryMode, } from '@buildeross/sdk/subgraph' +import { useAuthStore } from '@buildeross/stores' import type { AddressType, CHAIN_ID, FeedItem } from '@buildeross/types' import { Avatar } from '@buildeross/ui/Avatar' import { getEnsAddress, getEnsName } from '@buildeross/utils/ens' @@ -55,7 +56,6 @@ import { import { TOKEN_SORT_OPTIONS, type TokenSortOption } from 'src/utils/profileIdentity' import useSWR, { unstable_serialize } from 'swr' import { isAddress } from 'viem' -import { useAccount } from 'wagmi' interface ProfileProps { userAddress: string @@ -121,7 +121,8 @@ const ProfilePage: NextPageWithLayout = ({ ogImageURL, }) => { const router = useRouter() - const { address: connectedAddress } = useAccount() + const { address: connectedAddress } = useAuthStore() + const { ensName, ensAvatar } = useEnsData(userAddress) const { data: profileIdentity, mutate: mutateProfileIdentity } = useProfileIdentity( ensName && !isAddress(ensName, { strict: false }) ? ensName : undefined, diff --git a/apps/web/src/services/simulationService.test.ts b/apps/web/src/services/simulationService.test.ts index 05a17b469..5789d8007 100644 --- a/apps/web/src/services/simulationService.test.ts +++ b/apps/web/src/services/simulationService.test.ts @@ -22,8 +22,10 @@ describe('simulationService', () => { }) describe('simulate', () => { + const tokenAddress: Address = '0x1234567890123456789012345678901234567890' const treasuryAddress: Address = '0xbcdfd67cce7bf4f49c0631ddd14eadff4d5ca15d' const request: SimulationRequestBody = { + tokenAddress, treasuryAddress, chainId: CHAIN_ID.ETHEREUM, targets: [ @@ -50,6 +52,7 @@ describe('simulationService', () => { it('fails with mismatched input array lengths', async () => { await expect( simulate({ + tokenAddress, treasuryAddress, chainId: CHAIN_ID.ETHEREUM, targets: [ @@ -64,6 +67,7 @@ describe('simulationService', () => { await expect( simulate({ + tokenAddress, treasuryAddress, chainId: CHAIN_ID.ETHEREUM, targets: ['0x1111111111111111111111111111111111111111'], @@ -74,6 +78,7 @@ describe('simulationService', () => { await expect( simulate({ + tokenAddress, treasuryAddress, chainId: CHAIN_ID.ETHEREUM, targets: ['0x1111111111111111111111111111111111111111'], diff --git a/apps/web/src/services/simulationService.ts b/apps/web/src/services/simulationService.ts index 556532996..6da40453b 100644 --- a/apps/web/src/services/simulationService.ts +++ b/apps/web/src/services/simulationService.ts @@ -10,6 +10,7 @@ import { Address, isAddress } from 'viem' import { InvalidRequestError } from './errors' export type SimulationRequestBody = { + tokenAddress: Address treasuryAddress: Address chainId: CHAIN_ID targets: Address[] @@ -43,7 +44,7 @@ const simulateTransaction = async ({ targets, calldatas, values, -}: SimulationRequestBody): Promise => { +}: Omit): Promise => { const simulation = await axios.post( `https://api.tenderly.co/api/v1/account/${TENDERLY_USER}/project/${TENDERLY_PROJECT}/simulate-bundle`, { @@ -71,6 +72,7 @@ const simulateTransaction = async ({ } export async function simulate({ + tokenAddress: _tokenAddress, // Used by auth middleware, not needed in simulation treasuryAddress, chainId, targets, diff --git a/apps/web/src/utils/api/authMiddleware.ts b/apps/web/src/utils/api/authMiddleware.ts new file mode 100644 index 000000000..7679ef155 --- /dev/null +++ b/apps/web/src/utils/api/authMiddleware.ts @@ -0,0 +1,56 @@ +import { getIronSession } from 'iron-session' +import type { NextApiRequest, NextApiResponse } from 'next' +import { ironOptions, type IronSessionData } from 'src/utils/iron' +import type { SiweMessage } from 'viem/siwe' + +export type AuthenticatedNextApiHandler = ( + req: NextApiRequest, + res: NextApiResponse, + session: SiweMessage +) => Promise | any + +/** + * Middleware to require authentication on API routes + * Usage: + * export default withAuth(async (req, res, session) => { + * // session.address is guaranteed to exist here + * res.json({ address: session.address }) + * }) + */ +export function withAuth(handler: AuthenticatedNextApiHandler) { + return async (req: NextApiRequest, res: NextApiResponse) => { + try { + const session = await getIronSession(req, res, ironOptions) + const siweSession = session.siwe + + if (!siweSession || !siweSession.address) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'You must be signed in with Ethereum to access this resource', + }) + } + + return handler(req, res, siweSession) + } catch (error) { + console.error('Error in auth middleware:', error) + return res.status(500).json({ + error: 'Internal Server Error', + message: 'An error occurred while checking authentication', + }) + } + } +} + +/** + * Helper to get the current session in an API route without requiring authentication + * Returns null if not authenticated + */ +export async function getSession(req: NextApiRequest, res: NextApiResponse) { + try { + const session = await getIronSession(req, res, ironOptions) + return session.siwe || null + } catch (error) { + console.error('Error getting session:', error) + return null + } +} diff --git a/apps/web/src/utils/api/daoAuthMiddleware.ts b/apps/web/src/utils/api/daoAuthMiddleware.ts new file mode 100644 index 000000000..84e7c5147 --- /dev/null +++ b/apps/web/src/utils/api/daoAuthMiddleware.ts @@ -0,0 +1,120 @@ +import { getDAOMembership } from '@buildeross/sdk' +import type { AddressType, CHAIN_ID } from '@buildeross/types' +import type { NextApiRequest, NextApiResponse } from 'next' +import { type Address } from 'viem' +import type { SiweMessage } from 'viem/siwe' + +import { withAuth } from './authMiddleware' + +export interface DaoMembershipData { + userAddress: Address + tokenAddress: Address + treasuryAddress: Address + chainId: CHAIN_ID + isMember: boolean + hasBalance: boolean + hasVotes: boolean +} + +export type DaoAuthenticatedHandler = ( + req: NextApiRequest, + res: NextApiResponse, + session: SiweMessage, + membership: DaoMembershipData +) => Promise | any + +/** + * Middleware to require both SIWE authentication AND DAO membership + * + * Validates: + * 1. User is authenticated (via SIWE) + * 2. Request includes tokenAddress and treasuryAddress + * 3. Treasury address matches the DAO's actual treasury + * 4. User is a member (owns tokens OR has voting power) + * + * Usage: + * ```typescript + * export default withDaoAuth(async (req, res, session, membership) => { + * // User is authenticated AND is a DAO member + * // membership.isMember is guaranteed to be true + * res.json({ member: membership.userAddress }) + * }) + * ``` + */ +export function withDaoAuth( + handler: DaoAuthenticatedHandler, + options?: { allowNonMembers?: boolean } +) { + return withAuth( + async (req: NextApiRequest, res: NextApiResponse, session: SiweMessage) => { + try { + const { tokenAddress, treasuryAddress, chainId } = req.body as { + tokenAddress?: string + treasuryAddress?: string + chainId?: CHAIN_ID + } + + // Validate required parameters + if (!tokenAddress || !treasuryAddress || !chainId) { + return res.status(400).json({ + error: 'Bad Request', + message: 'Request must include tokenAddress, treasuryAddress, and chainId', + }) + } + + const userAddress = session.address as Address + + // Get DAO membership data via multicall (3 contract reads in 1 RPC call) + const membershipData = await getDAOMembership( + chainId, + tokenAddress as AddressType, + userAddress as AddressType + ) + + if (!membershipData) { + return res.status(404).json({ + error: 'Not Found', + message: 'DAO not found for the specified token address', + }) + } + + const { daoAddresses, hasBalance, hasVotes, isMember } = membershipData + + // Validate treasury address matches the DAO + if (daoAddresses.treasury.toLowerCase() !== treasuryAddress.toLowerCase()) { + return res.status(400).json({ + error: 'Bad Request', + message: 'Treasury address does not match the specified DAO token', + }) + } + + const membership: DaoMembershipData = { + userAddress, + tokenAddress: daoAddresses.token as Address, + treasuryAddress: daoAddresses.treasury as Address, + chainId, + isMember, + hasBalance, + hasVotes, + } + + // Check membership unless explicitly allowed to bypass + if (!isMember && !options?.allowNonMembers) { + return res.status(403).json({ + error: 'Forbidden', + message: + 'You must be a member of this DAO to access this resource. Hold at least one governance token or have delegated voting power.', + }) + } + + return handler(req, res, session, membership) + } catch (error) { + console.error('Error in DAO auth middleware:', error) + return res.status(500).json({ + error: 'Internal Server Error', + message: 'An error occurred while checking DAO membership', + }) + } + } + ) +} diff --git a/apps/web/src/utils/api/rateLimit.ts b/apps/web/src/utils/api/rateLimit.ts index 9ecff4f43..df90c88dd 100644 --- a/apps/web/src/utils/api/rateLimit.ts +++ b/apps/web/src/utils/api/rateLimit.ts @@ -1,6 +1,8 @@ import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next' import { getRedisConnection } from 'src/services/redisConnection' +import { getSession } from './authMiddleware' + interface RateLimitOptions { /** * Maximum number of requests allowed within the time window @@ -21,6 +23,44 @@ interface RateLimitOptions { keyPrefix: string } +interface TieredRateLimitOptions { + /** + * Shared key prefix for this endpoint family + */ + keyPrefix: string + + /** + * Anonymous request budget + */ + anonymousMaxRequests?: number + + /** + * Anonymous time window in seconds + */ + anonymousWindowSeconds?: number + + /** + * Authenticated request budget + */ + authenticatedMaxRequests?: number + + /** + * Authenticated time window in seconds + */ + authenticatedWindowSeconds?: number +} + +const getClientIp = (req: NextApiRequest) => { + const xff = req.headers['x-forwarded-for'] ?? req.headers['x-real-ip'] + const rawIp = Array.isArray(xff) + ? xff[0] + : typeof xff === 'string' + ? xff.split(',')[0]?.trim() + : (req.socket.remoteAddress ?? 'unknown') + + return rawIp || 'unknown' +} + /** * Creates a rate limiting wrapper for API endpoints * Uses Redis to track request counts per IP address @@ -93,3 +133,71 @@ export const withRateLimit = ({ }) as T } } + +export const withTieredRateLimit = ({ + keyPrefix, + anonymousMaxRequests = 5, + anonymousWindowSeconds = 60, + authenticatedMaxRequests = 60, + authenticatedWindowSeconds = 60, +}: TieredRateLimitOptions) => { + return (handler: T): T => { + return (async (req: NextApiRequest, res: NextApiResponse) => { + try { + const redisConnection = getRedisConnection() + + // Skip rate limiting if Redis is not available + if (!redisConnection) { + console.warn('Rate limiting skipped: Redis connection not available') + return handler(req, res) + } + + const route = req.url?.split('?')[0] ?? '' + const clientIp = getClientIp(req) + const session = await getSession(req, res) + const isAuthenticated = !!session?.address + const tier = isAuthenticated ? 'auth' : 'anon' + const identity = isAuthenticated ? session.address.toLowerCase() : clientIp + const maxRequests = isAuthenticated + ? authenticatedMaxRequests + : anonymousMaxRequests + const windowSeconds = isAuthenticated + ? authenticatedWindowSeconds + : anonymousWindowSeconds + + const rateLimitKey = `${keyPrefix}:ratelimit:${route}:${tier}:${identity}` + const requests = await redisConnection.incr(rateLimitKey) + + if (requests === 1) { + await redisConnection.expire(rateLimitKey, windowSeconds) + } + + if (requests > maxRequests) { + res.setHeader('Retry-After', windowSeconds.toString()) + res.status(429).json({ + error: 'Rate limit exceeded', + retryAfter: windowSeconds, + limit: maxRequests, + windowSeconds, + tier, + }) + return + } + + res.setHeader('X-RateLimit-Limit', maxRequests.toString()) + res.setHeader( + 'X-RateLimit-Remaining', + Math.max(0, maxRequests - requests).toString() + ) + res.setHeader('X-RateLimit-Tier', tier) + const resetAt = Math.floor(Date.now() / 1000) + windowSeconds + res.setHeader('X-RateLimit-Reset', resetAt.toString()) + + return handler(req, res) + } catch (error) { + console.error('Rate limiting error:', error) + return handler(req, res) + } + }) as T + } +} diff --git a/apps/web/src/utils/iron.ts b/apps/web/src/utils/iron.ts new file mode 100644 index 000000000..fe6fe28b5 --- /dev/null +++ b/apps/web/src/utils/iron.ts @@ -0,0 +1,15 @@ +import type { SessionOptions } from 'iron-session' +import type { SiweMessage } from 'viem/siwe' + +export const ironOptions: SessionOptions = { + cookieName: 'siwe', + cookieOptions: { + secure: process.env.NODE_ENV === 'production', + }, + password: process.env.IRON_PASSWORD as string, +} + +export interface IronSessionData { + nonce?: string + siwe?: SiweMessage +} diff --git a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx index 2d9eac0ee..b4ef28555 100644 --- a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx +++ b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx @@ -22,7 +22,6 @@ vi.mock('wagmi', async (importOriginal) => { return { ...actual, useAccount: () => ({ - address: '0x1234', chain: { id: CHAIN_ID.ETHEREUM }, }), useBalance: () => ({ data: { value: mockBalanceValue } }), @@ -31,6 +30,10 @@ vi.mock('wagmi', async (importOriginal) => { } }) +vi.mock('@buildeross/stores', () => ({ + useAuthStore: () => ({ address: '0x1234' }), +})) + vi.mock('wagmi/actions', () => ({ simulateContract: vi.fn(), waitForTransactionReceipt: vi.fn(), diff --git a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx index ef44cff8d..28ab06df1 100644 --- a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx +++ b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx @@ -2,6 +2,7 @@ import { BASE_URL, SWR_KEYS } from '@buildeross/constants' import { useMinBidIncrement } from '@buildeross/hooks/useMinBidIncrement' import { auctionAbi } from '@buildeross/sdk/contract' import { averageWinningBid } from '@buildeross/sdk/subgraph' +import { useAuthStore } from '@buildeross/stores' import { AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { useLinks } from '@buildeross/ui/LinksProvider' @@ -69,7 +70,8 @@ const InnerPlaceBid = ({ tokenAddress, onSuccess, }: PlaceBidProps) => { - const { address, chain: wagmiChain } = useAccount() + const { address } = useAuthStore() + const { chain: wagmiChain } = useAccount() const { data: balance } = useBalance({ address: address, chainId }) const { mutate } = useSWRConfig() const { getAuctionLink } = useLinks() diff --git a/packages/auction-ui/src/components/CurrentAuction/Settle.tsx b/packages/auction-ui/src/components/CurrentAuction/Settle.tsx index 6e1065504..e6ea97e7c 100644 --- a/packages/auction-ui/src/components/CurrentAuction/Settle.tsx +++ b/packages/auction-ui/src/components/CurrentAuction/Settle.tsx @@ -1,9 +1,10 @@ import { auctionAbi } from '@buildeross/sdk/contract' +import { useAuthStore } from '@buildeross/stores' import { AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { Button, Flex } from '@buildeross/zord' import { useCallback, useState } from 'react' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { auctionActionButtonVariants } from '../Auction.css' @@ -26,7 +27,7 @@ export const Settle = ({ compact = false, }: SettleProps) => { const config = useConfig() - const { address } = useAccount() + const { address } = useAuthStore() const isWinner = owner != undefined && address?.toLowerCase() == owner?.toLowerCase() diff --git a/packages/candidate-ui/src/components/CandidateCommentForm.tsx b/packages/candidate-ui/src/components/CandidateCommentForm.tsx index 2dfe9cd8a..753d17dd9 100644 --- a/packages/candidate-ui/src/components/CandidateCommentForm.tsx +++ b/packages/candidate-ui/src/components/CandidateCommentForm.tsx @@ -6,7 +6,7 @@ import { } from '@buildeross/sdk' import { governorAbi } from '@buildeross/sdk/contract' import { CandidateVoteSupport } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { ContractButton, TextArea, Toggle, WalletIdentity } from '@buildeross/ui' import { getErrorMessage } from '@buildeross/utils/errors' import { walletSnippet } from '@buildeross/utils/helpers' @@ -14,7 +14,7 @@ import type { IconType } from '@buildeross/zord' import { Box, Flex, Icon, Stack, Text, theme, vars } from '@buildeross/zord' import React, { useCallback, useEffect, useMemo, useState } from 'react' import { type Hex } from 'viem' -import { useAccount, useConfig, useReadContract, useWalletClient } from 'wagmi' +import { useConfig, useReadContract, useWalletClient } from 'wagmi' import { CANDIDATE_SIGNATURE_VALIDITY_DAYS, @@ -78,7 +78,7 @@ export const CandidateCommentForm: React.FC = ({ }) => { const config = useConfig() const { data: walletClient } = useWalletClient() - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const { chain } = useChainStore() const { addresses } = useDaoStore() const { ensName: replyToEnsName, ensAvatar: replyToEnsAvatar } = useEnsData( @@ -155,6 +155,7 @@ export const CandidateCommentForm: React.FC = ({ const canSubmit = useMemo(() => { return ( !!address && + isAuthenticated && !!addresses.token && // When replying, comment is required (no signals allowed) // When not replying, either comment OR signal is required @@ -163,7 +164,16 @@ export const CandidateCommentForm: React.FC = ({ : comment.trim().length > 0 || signalEnabled) && (!shouldSign || nonce !== undefined) ) - }, [address, addresses.token, comment, signalEnabled, shouldSign, nonce, replyTo]) + }, [ + address, + isAuthenticated, + addresses.token, + comment, + signalEnabled, + shouldSign, + nonce, + replyTo, + ]) const canSign = useMemo(() => { return ( diff --git a/packages/candidate-ui/src/components/CandidatePromoteButton.tsx b/packages/candidate-ui/src/components/CandidatePromoteButton.tsx index 8b49c6d5c..52a2d2454 100644 --- a/packages/candidate-ui/src/components/CandidatePromoteButton.tsx +++ b/packages/candidate-ui/src/components/CandidatePromoteButton.tsx @@ -1,12 +1,12 @@ import { governorAbi } from '@buildeross/sdk/contract' import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AnimatedModal, ContractButton, SuccessModalContent } from '@buildeross/ui' import { getErrorMessage } from '@buildeross/utils/errors' import { Stack, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import { type Hex } from 'viem' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' export interface ProposerSignature { @@ -44,7 +44,7 @@ export const CandidatePromoteButton: React.FC = ({ onSuccess, }) => { const config = useConfig() - const { address } = useAccount() + const { address } = useAuthStore() const { chain } = useChainStore() const { addresses } = useDaoStore() diff --git a/packages/candidate-ui/src/components/CandidatePromoteCard/CandidatePromoteCard.tsx b/packages/candidate-ui/src/components/CandidatePromoteCard/CandidatePromoteCard.tsx index 48ffb9a5a..278e06568 100644 --- a/packages/candidate-ui/src/components/CandidatePromoteCard/CandidatePromoteCard.tsx +++ b/packages/candidate-ui/src/components/CandidatePromoteCard/CandidatePromoteCard.tsx @@ -1,12 +1,12 @@ import { useVotes } from '@buildeross/hooks' import { governorAbi } from '@buildeross/sdk/contract' import { getCandidateSponsorSignatures } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { Box, Flex, Icon, Stack, Text } from '@buildeross/zord' import React from 'react' import useSWR from 'swr' import { type Hex } from 'viem' -import { useAccount, useReadContract } from 'wagmi' +import { useReadContract } from 'wagmi' import { filterValidSignatures } from '../../utils/candidateProposal' import { CandidatePromoteButton, type ProposerSignature } from '../CandidatePromoteButton' @@ -37,11 +37,12 @@ export const CandidatePromoteCard: React.FC = ({ }) => { const { chain } = useChainStore() const { addresses } = useDaoStore() - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const isCreator = React.useMemo( - () => !!address && address.toLowerCase() === proposer.toLowerCase(), - [address, proposer] + () => + !!address && isAuthenticated && address.toLowerCase() === proposer.toLowerCase(), + [address, isAuthenticated, proposer] ) // Fetch sponsor signatures diff --git a/packages/candidate-ui/src/components/CandidateSignatureButton.tsx b/packages/candidate-ui/src/components/CandidateSignatureButton.tsx index 28708d9b0..577946da3 100644 --- a/packages/candidate-ui/src/components/CandidateSignatureButton.tsx +++ b/packages/candidate-ui/src/components/CandidateSignatureButton.tsx @@ -1,12 +1,12 @@ import { attestCandidateSignature } from '@buildeross/sdk' import { governorAbi } from '@buildeross/sdk/contract' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AnimatedModal, ContractButton, SuccessModalContent } from '@buildeross/ui' import { getErrorMessage } from '@buildeross/utils/errors' import { Box, vars } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import { type Hex } from 'viem' -import { useAccount, useConfig, useReadContract, useWalletClient } from 'wagmi' +import { useConfig, useReadContract, useWalletClient } from 'wagmi' import { CANDIDATE_SIGNATURE_VALIDITY_DAYS, @@ -37,7 +37,7 @@ export const CandidateSignatureButton: React.FC = onSuccess, }) => { const config = useConfig() - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const { data: walletClient } = useWalletClient() const { chain } = useChainStore() const { addresses } = useDaoStore() @@ -60,9 +60,14 @@ export const CandidateSignatureButton: React.FC = const canSign = React.useMemo(() => { return ( - !!address && !!walletClient && nonce !== undefined && !isProposer && voteWeight > 0n + !!address && + isAuthenticated && + !!walletClient && + nonce !== undefined && + !isProposer && + voteWeight > 0n ) - }, [address, walletClient, nonce, isProposer, voteWeight]) + }, [address, isAuthenticated, walletClient, nonce, isProposer, voteWeight]) const handleSign = useCallback(async () => { if (!canSign || !address || !walletClient || nonce === undefined) return diff --git a/packages/candidate-ui/src/components/CandidateSigners/CandidateSigners.tsx b/packages/candidate-ui/src/components/CandidateSigners/CandidateSigners.tsx index ee0954277..b0f5e4d89 100644 --- a/packages/candidate-ui/src/components/CandidateSigners/CandidateSigners.tsx +++ b/packages/candidate-ui/src/components/CandidateSigners/CandidateSigners.tsx @@ -2,12 +2,12 @@ import { useEnsData, useVotes } from '@buildeross/hooks' import { governorAbi } from '@buildeross/sdk/contract' import type { CandidateSponsorSignature } from '@buildeross/sdk/subgraph' import { getCandidateSponsorSignatures } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { WalletIdentityWithPreview } from '@buildeross/ui' import { Box, Button, Flex, Icon, Stack, Text } from '@buildeross/zord' import React from 'react' import useSWR from 'swr' -import { useAccount, useReadContract } from 'wagmi' +import { useReadContract } from 'wagmi' import { filterValidSignatures, isSignatureExpired } from '../../utils/candidateProposal' import { CandidatePromoteButton, type ProposerSignature } from '../CandidatePromoteButton' @@ -40,7 +40,7 @@ export const CandidateSigners: React.FC = ({ }) => { const { chain } = useChainStore() const { addresses } = useDaoStore() - const { address } = useAccount() + const { address } = useAuthStore() const [expanded, setExpanded] = React.useState(false) const isCreator = React.useMemo( () => !!address && address.toLowerCase() === proposer.toLowerCase(), diff --git a/packages/candidate-ui/src/components/CandidateSubmitForm.tsx b/packages/candidate-ui/src/components/CandidateSubmitForm.tsx index cc5fa036b..d2d2572b5 100644 --- a/packages/candidate-ui/src/components/CandidateSubmitForm.tsx +++ b/packages/candidate-ui/src/components/CandidateSubmitForm.tsx @@ -8,7 +8,12 @@ import { ProposalSection, } from '@buildeross/proposal-ui' import { attestCandidate, type CandidateAttestationParams } from '@buildeross/sdk' -import { useCandidateStore, useChainStore, useDaoStore } from '@buildeross/stores' +import { + useAuthStore, + useCandidateStore, + useChainStore, + useDaoStore, +} from '@buildeross/stores' import { type ProposalDescriptionMetadataV1 } from '@buildeross/types' import { WalletIdentityWithPreview } from '@buildeross/ui' import { AnimatedModal, SuccessModalContent } from '@buildeross/ui/Modal' @@ -18,7 +23,7 @@ import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import useSWR from 'swr' import { type Hex, toHex } from 'viem' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { buildCandidateDescription } from '../utils/buildCandidateDescription' import { getCandidateId } from '../utils/candidateProposal' @@ -36,7 +41,7 @@ export const CandidateSubmitForm: React.FC = ({ onBack, }) => { const config = useConfig() - const { address } = useAccount() + const { address } = useAuthStore() const { displayName, ensAvatar } = useEnsData(address) const { chain } = useChainStore() const { addresses } = useDaoStore() diff --git a/packages/create-dao-ui/src/components/AllocationForm/AllocationForm.tsx b/packages/create-dao-ui/src/components/AllocationForm/AllocationForm.tsx index d1616070e..121936e36 100644 --- a/packages/create-dao-ui/src/components/AllocationForm/AllocationForm.tsx +++ b/packages/create-dao-ui/src/components/AllocationForm/AllocationForm.tsx @@ -1,6 +1,6 @@ import { PUBLIC_MANAGER_ADDRESS } from '@buildeross/constants/addresses' import { managerAbi } from '@buildeross/sdk/contract' -import { useChainStore } from '@buildeross/stores' +import { useAuthStore, useChainStore } from '@buildeross/stores' import { CHAIN_ID, type TokenAllocation } from '@buildeross/types' import { Toggle } from '@buildeross/ui' import { FIELD_TYPES, SmartInput } from '@buildeross/ui/Fields' @@ -15,7 +15,7 @@ import { FieldArray, Form, Formik, FormikProps, useFormikContext } from 'formik' import { motion } from 'framer-motion' import sum from 'lodash/sum' import React, { useEffect, useRef, useState } from 'react' -import { useAccount, useReadContract } from 'wagmi' +import { useReadContract } from 'wagmi' import { useShallow } from 'zustand/shallow' import { useFormStore } from '../../stores' @@ -117,7 +117,7 @@ export const AllocationForm: React.FC = ({ title }) => { })) ) - const { address } = useAccount() + const { address } = useAuthStore() const hasExistingAllocation = founderAllocation.length > 1 || diff --git a/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx b/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx index 75aa374b7..61a332603 100644 --- a/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx +++ b/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx @@ -4,7 +4,7 @@ import { RENDERER_BASE } from '@buildeross/constants/rendererBase' import { useManagerVersion } from '@buildeross/hooks' import { managerAbi, managerV1Abi, managerV3Abi } from '@buildeross/sdk/contract' import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import type { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { FallbackImage } from '@buildeross/ui/FallbackImage' @@ -24,7 +24,7 @@ import { parseEther, zeroAddress, } from 'viem' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { useFormStore } from '../../stores' @@ -96,7 +96,7 @@ export const ReviewAndDeploy: React.FC = ({ managerAddress: PUBLIC_MANAGER_ADDRESS[chain.id], }) - const { address } = useAccount() + const { address } = useAuthStore() const config = useConfig() const { diff --git a/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx b/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx index 03fdb2fdd..ee0fd87e7 100644 --- a/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx +++ b/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx @@ -1,6 +1,6 @@ import { metadataAbi, tokenAbi } from '@buildeross/sdk/contract' import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { ContractButton } from '@buildeross/ui/ContractButton' import { CopyButton } from '@buildeross/ui/CopyButton' import { walletSnippet } from '@buildeross/utils/helpers' @@ -10,7 +10,7 @@ import { } from '@buildeross/utils/transformFileProperties' import { Box, Flex, Paragraph, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' -import { useAccount, useConfig, useReadContract } from 'wagmi' +import { useConfig, useReadContract } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { useFormStore } from '../../stores' @@ -70,7 +70,7 @@ export const SuccessfulDeploy: React.FC = ({ } = useDaoStore() const [isPendingTransaction, setIsPendingTransaction] = useState(false) const [deploymentError, setDeploymentError] = useState() - const { address } = useAccount() + const { address } = useAuthStore() const { data: tokenOwner } = useReadContract({ query: { diff --git a/packages/create-proposal-ui/src/components/ProposalStageIndicator/ProposalStageIndicator.tsx b/packages/create-proposal-ui/src/components/ProposalStageIndicator/ProposalStageIndicator.tsx index 730794c2a..7be7793e8 100644 --- a/packages/create-proposal-ui/src/components/ProposalStageIndicator/ProposalStageIndicator.tsx +++ b/packages/create-proposal-ui/src/components/ProposalStageIndicator/ProposalStageIndicator.tsx @@ -1,6 +1,6 @@ +import { useAuthStore } from '@buildeross/stores' import { Button, Flex, Stack, Text } from '@buildeross/zord' import React from 'react' -import { useAccount } from 'wagmi' type ProposalStage = 'draft' | 'transactions' | 'review' @@ -43,7 +43,7 @@ export const ProposalStageIndicator: React.FC = ({ onStageSelect, isStageClickable, }) => { - const { address } = useAccount() + const { address } = useAuthStore() const [showCallout, setShowCallout] = React.useState(false) const currentStepIndex = STAGES.findIndex((stage) => stage.id === currentStage) diff --git a/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx b/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx index aacfc81ff..c352e832e 100644 --- a/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx +++ b/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx @@ -8,8 +8,14 @@ import { import { governorAbi, treasuryAbi } from '@buildeross/sdk/contract' import { type Proposal } from '@buildeross/sdk/subgraph' import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' +import { + type AddressType, ProposalState, type SimulationOutput, TransactionBundle, @@ -29,7 +35,7 @@ import dayjs from 'dayjs' import { Formik, type FormikProps } from 'formik' import React, { useState } from 'react' import { decodeEventLog, getAddress, type Hex, isAddress } from 'viem' -import { useAccount, useConfig, useReadContracts } from 'wagmi' +import { useConfig, useReadContracts } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { prepareProposalTransactions } from '../../utils/prepareTransactions' @@ -105,7 +111,7 @@ export const ReviewProposalForm = ({ const addresses = useDaoStore((state) => state.addresses) const chain = useChainStore((x) => x.chain) const config = useConfig() - const { address } = useAccount() + const { address } = useAuthStore() const { updateProposalId, clearProposal, @@ -276,7 +282,8 @@ export const ReviewProposalForm = ({ setSimulating(true) const simulationResult = await simulateTransactions({ - treasuryAddress: addresses.treasury, + tokenAddress: addresses.token as AddressType, + treasuryAddress: addresses.treasury as AddressType, chainId: chain.id, calldatas: calldata, values: transactionValues, diff --git a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step1_LoadConfig.tsx b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step1_LoadConfig.tsx index a9a8aa478..c5364f769 100644 --- a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step1_LoadConfig.tsx +++ b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step1_LoadConfig.tsx @@ -4,13 +4,12 @@ import { PUBLIC_MANAGER_ADDRESS, } from '@buildeross/constants/addresses' import { PUBLIC_ALL_CHAINS } from '@buildeross/constants/chains' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { isTestnetChain } from '@buildeross/utils' import { Box, Button, Flex, Heading, Icon, Label, Stack, Text } from '@buildeross/zord' import { useMemo, useState } from 'react' import { zeroAddress } from 'viem' -import { useAccount } from 'wagmi' import { useCrossChainMigration } from '../../../hooks/useCrossChainMigration' import { useFetchDAOConfigForMigration } from '../../../hooks/useFetchDAOConfigForMigration' @@ -20,7 +19,7 @@ export const Step1_LoadConfig: React.FC = () => { const { chain } = useChainStore() const sourceChainId = chain.id const { addresses } = useDaoStore() - const { address: walletAddress } = useAccount() + const { address: walletAddress } = useAuthStore() const { setChains, setSourceAddresses, setSourceConfig, goToNextStep } = useCrossChainMigration() diff --git a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step2_ReviewConfig.tsx b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step2_ReviewConfig.tsx index b4607fd99..a0612dd43 100644 --- a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step2_ReviewConfig.tsx +++ b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step2_ReviewConfig.tsx @@ -1,5 +1,6 @@ import { PUBLIC_MANAGER_ADDRESS } from '@buildeross/constants/addresses' import { useManagerVersion } from '@buildeross/hooks' +import { useAuthStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { DaysHoursMinsSecs, SmartInput } from '@buildeross/ui/Fields' import { getEnsAddress } from '@buildeross/utils/ens' @@ -7,7 +8,6 @@ import { Box, Button, Flex, Heading, Input, Label, Stack, Text } from '@buildero import { Form, Formik, FormikHelpers } from 'formik' import { useMemo } from 'react' import { getAddress, isAddress } from 'viem' -import { useAccount } from 'wagmi' import { useCrossChainMigration } from '../../../hooks/useCrossChainMigration' import { @@ -37,7 +37,7 @@ export const Step2_ReviewConfig: React.FC = () => { goToPreviousStep, targetChainId, } = useCrossChainMigration() - const { address: walletAddress } = useAccount() + const { address: walletAddress } = useAuthStore() // Check if target Manager contract supports v3 features const managerAddress = targetChainId ? PUBLIC_MANAGER_ADDRESS[targetChainId] : undefined diff --git a/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx b/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx index 5f3996603..85643945d 100644 --- a/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx +++ b/packages/create-proposal-ui/src/components/TransactionForm/Droposal/DroposalForm.tsx @@ -1,4 +1,4 @@ -import { useDaoStore } from '@buildeross/stores' +import { useAuthStore, useDaoStore } from '@buildeross/stores' import { DropdownSelect } from '@buildeross/ui/DropdownSelect' import { DatePicker, FIELD_TYPES, SmartInput, TextArea } from '@buildeross/ui/Fields' import { SingleMediaUpload } from '@buildeross/ui/SingleMediaUpload' @@ -6,7 +6,6 @@ import { defaultHelperTextStyle } from '@buildeross/ui/styles' import { Box, Button, Flex, Icon, Text } from '@buildeross/zord' import { Form, Formik, FormikHelpers } from 'formik' import { useCallback, useState } from 'react' -import { useAccount } from 'wagmi' import { defaultInputLabelStyle } from './Droposal.css' import droposalFormSchema, { DroposalFormValues } from './DroposalForm.schema' @@ -77,7 +76,7 @@ export const DroposalForm: React.FC = ({ onSubmit, disabled } const [splitState, setSplitState] = useState('none') const [activeSplitAddress, setActiveSplitAddress] = useState(null) const [showAdvanced, setShowAdvanced] = useState(false) - const { address: user } = useAccount() + const { address: user } = useAuthStore() const { treasury } = useDaoStore((x) => x.addresses) const initialValues: DroposalFormValues = { diff --git a/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx b/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx index 167fe054c..cce3e0da0 100644 --- a/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx +++ b/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx @@ -1,10 +1,11 @@ import { MERKLE_RESERVE_MINTER } from '@buildeross/constants/addresses' import { merkleReserveMinterAbi } from '@buildeross/sdk/contract' +import { useAuthStore } from '@buildeross/stores' import { AddressType, CHAIN_ID } from '@buildeross/types' import { MerkleTree } from 'merkletreejs' import { useState } from 'react' import { encodeAbiParameters, keccak256, parseAbiParameters } from 'viem' -import { useAccount, usePublicClient, useReadContract, useWriteContract } from 'wagmi' +import { usePublicClient, useReadContract, useWriteContract } from 'wagmi' import { DaoMemberSimplified } from './useGenerateMerkleRoots' @@ -26,7 +27,7 @@ export const useMintReservedTokens = ( const { writeContractAsync, isPending } = useWriteContract() const publicClient = usePublicClient({ chainId: targetChainId }) - const { address } = useAccount() + const { address } = useAuthStore() const minterAddress = targetChainId ? MERKLE_RESERVE_MINTER[targetChainId] : undefined diff --git a/packages/create-proposal-ui/src/utils/tenderlySimulation.ts b/packages/create-proposal-ui/src/utils/tenderlySimulation.ts index 3411cb1d6..2cfa52a60 100644 --- a/packages/create-proposal-ui/src/utils/tenderlySimulation.ts +++ b/packages/create-proposal-ui/src/utils/tenderlySimulation.ts @@ -18,6 +18,7 @@ const CHAINS_TO_SIMULATE = [ ] export interface SimulateTransactionsParams { + tokenAddress: AddressType treasuryAddress: AddressType chainId: CHAIN_ID calldatas: string[] @@ -28,7 +29,7 @@ export interface SimulateTransactionsParams { export async function simulateTransactions( params: SimulateTransactionsParams ): Promise { - const { treasuryAddress, chainId, calldatas, values, targets } = params + const { tokenAddress, treasuryAddress, chainId, calldatas, values, targets } = params if (!CHAINS_TO_SIMULATE.includes(chainId)) { throw new Error(`Chain ${chainId} is not supported for simulation`) @@ -36,6 +37,7 @@ export async function simulateTransactions( try { const response = await axios.post(`/api/simulate`, { + tokenAddress, treasuryAddress, chainId, calldatas, diff --git a/packages/dao-ui/src/components/About/About.tsx b/packages/dao-ui/src/components/About/About.tsx index c140b5540..7c5b8f8ac 100644 --- a/packages/dao-ui/src/components/About/About.tsx +++ b/packages/dao-ui/src/components/About/About.tsx @@ -2,7 +2,7 @@ import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { useDaoMembership } from '@buildeross/hooks/useDaoMembership' import { tokenAbi } from '@buildeross/sdk/contract' import { SubgraphSDK } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { Avatar } from '@buildeross/ui/Avatar' import { FallbackImage } from '@buildeross/ui/FallbackImage' import { parseDaoMetadataString } from '@buildeross/utils/daoMetadata' @@ -12,7 +12,7 @@ import { Box, Flex, Grid, Text } from '@buildeross/zord' import React from 'react' import useSWR from 'swr' import { Address, formatEther } from 'viem' -import { useAccount, useBalance, useReadContracts } from 'wagmi' +import { useBalance, useReadContracts } from 'wagmi' import { about, daoInfo, daoName, statisticContent } from '../../styles/About.css' import { MembersList } from '../MembersList' @@ -32,7 +32,7 @@ export const About: React.FC = ({ onOpenTreasury }) => { addresses: { token, treasury }, } = useDaoStore() const chain = useChainStore((x) => x.chain) - const { address } = useAccount() + const { address } = useAuthStore() const { data: membershipInfo } = useDaoMembership({ chainId: chain.id, diff --git a/packages/dao-ui/src/components/Activity/Activity.tsx b/packages/dao-ui/src/components/Activity/Activity.tsx index c4c37bf47..b4d66e2da 100644 --- a/packages/dao-ui/src/components/Activity/Activity.tsx +++ b/packages/dao-ui/src/components/Activity/Activity.tsx @@ -10,6 +10,7 @@ import { import { ProposalCard } from '@buildeross/proposal-ui' import { getProposals, ProposalsResponse } from '@buildeross/sdk/subgraph' import { + useAuthStore, useCandidateStore, useChainStore, useDaoStore, @@ -25,7 +26,6 @@ import { walletSnippet } from '@buildeross/utils/helpers' import { Box, Flex, Text } from '@buildeross/zord' import React from 'react' import useSWR from 'swr' -import { useAccount } from 'wagmi' import { activitySection } from '../../styles/Section.css' import { Upgrade } from '../Upgrade' @@ -53,7 +53,7 @@ export const Activity: React.FC = ({ const { startProposalDraft } = useProposalStore() const { startCandidateDraft } = useCandidateStore() - const { address } = useAccount() + const { address, isAuthenticated } = useAuthStore() const { query } = useQueryParams() const page: number = query.page ? Number(query.page) : 1 @@ -149,16 +149,24 @@ export const Activity: React.FC = ({ align={'center'} display={{ '@initial': 'none', '@768': 'flex' }} > - {address && !isDelegating && !isOwner && supportsCandidates && ( - - You have no votes. Create a candidate to gather support. - - )} - {address && !isDelegating && !isOwner && !supportsCandidates && ( - - You have no votes. - - )} + {address && + isAuthenticated && + !isDelegating && + !isOwner && + supportsCandidates && ( + + You have no votes. Create a candidate to gather support. + + )} + {address && + isAuthenticated && + !isDelegating && + !isOwner && + !supportsCandidates && ( + + You have no votes. + + )} {isDelegating && ( Your votes are delegated. @@ -192,20 +200,20 @@ export const Activity: React.FC = ({ className={createProposalBtn} chainId={chain.id} handleClick={ - address && !hasThreshold && supportsCandidates + address && isAuthenticated && !hasThreshold && supportsCandidates ? handleCandidateCreation : handleProposalCreation } disabled={ isGovernanceDelayed ? true - : address + : address && isAuthenticated ? !hasThreshold && !supportsCandidates : false } color={'tertiary'} > - {address && !hasThreshold && supportsCandidates + {address && isAuthenticated && !hasThreshold && supportsCandidates ? 'Create candidate' : 'Create proposal'} @@ -232,20 +240,20 @@ export const Activity: React.FC = ({ chainId={chain.id} className={createProposalBtn} handleClick={ - address && !hasThreshold && supportsCandidates + address && isAuthenticated && !hasThreshold && supportsCandidates ? handleCandidateCreation : handleProposalCreation } disabled={ isGovernanceDelayed ? true - : address + : address && isAuthenticated ? !hasThreshold && !supportsCandidates : false } color={'tertiary'} > - {address && !hasThreshold && supportsCandidates + {address && isAuthenticated && !hasThreshold && supportsCandidates ? 'Create candidate' : 'Create'} diff --git a/packages/dao-ui/src/components/Admin/AuctionRewards.tsx b/packages/dao-ui/src/components/Admin/AuctionRewards.tsx index 652180269..9cf01e9bb 100644 --- a/packages/dao-ui/src/components/Admin/AuctionRewards.tsx +++ b/packages/dao-ui/src/components/Admin/AuctionRewards.tsx @@ -1,7 +1,7 @@ import { PROTOCOL_REWARDS_MANAGER } from '@buildeross/constants' import { useAuctionRewards } from '@buildeross/hooks' import { protocolRewardsAbi } from '@buildeross/sdk/contract' -import { useChainStore } from '@buildeross/stores' +import { useAuthStore, useChainStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { ContractLink } from '@buildeross/ui/ContractLink' @@ -9,7 +9,7 @@ import { Tooltip } from '@buildeross/ui/Tooltip' import { Box, Flex, Stack, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import { formatEther, Hex } from 'viem' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { Section } from '../AdminForm/Section' @@ -33,7 +33,7 @@ const LabelWithTooltip: React.FC = ({ label, tooltip }) => ( export const AuctionRewards: React.FC = ({ auctionAddress }) => { const chain = useChainStore((state) => state.chain) const config = useConfig() - const { address } = useAccount() + const { address } = useAuthStore() const [isWithdrawing, setIsWithdrawing] = useState(false) const { data, isLoading, error, mutate } = useAuctionRewards({ chainId: chain.id, diff --git a/packages/dao-ui/src/components/Cards/CoinCard.tsx b/packages/dao-ui/src/components/Cards/CoinCard.tsx index 4f2a512fc..8da03eb96 100644 --- a/packages/dao-ui/src/components/Cards/CoinCard.tsx +++ b/packages/dao-ui/src/components/Cards/CoinCard.tsx @@ -1,5 +1,6 @@ import { BASE_URL } from '@buildeross/constants/baseUrl' import { useIpfsMetadata, useMediaType } from '@buildeross/hooks' +import { useAuthStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { FallbackImage } from '@buildeross/ui/FallbackImage' import { LikeButton } from '@buildeross/ui/LikeButton' @@ -11,7 +12,7 @@ import { StatBadge } from '@buildeross/ui/StatBadge' import { Box, Button, Flex, Text } from '@buildeross/zord' import React, { useMemo } from 'react' import { Address } from 'viem' -import { useAccount, useBalance } from 'wagmi' +import { useBalance } from 'wagmi' import { card, coinImage, tradeButtonContainer, typeBadge } from './Cards.css' @@ -65,7 +66,7 @@ export const CoinCard = ({ ? Date.now() / 1000 - parseInt(createdAt) < 7 * 24 * 60 * 60 : false - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() // Get user's coin balance to determine if they can trade // Only fetch balance if sellEnabled is true (otherwise we always show "Buy") diff --git a/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx b/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx index d2e967f6c..190c3d1b3 100644 --- a/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx +++ b/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx @@ -1,6 +1,6 @@ import { useEnsData } from '@buildeross/hooks/useEnsData' import { tokenAbi } from '@buildeross/sdk/contract' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { SmartInput } from '@buildeross/ui/Fields' @@ -8,13 +8,13 @@ import { getEnsAddress } from '@buildeross/utils/ens' import { Box, Flex, Heading, Input, Stack, Text, theme } from '@buildeross/zord' import React from 'react' import { isAddress } from 'viem' -import { useAccount, useConfig, useWriteContract } from 'wagmi' +import { useConfig, useWriteContract } from 'wagmi' import { waitForTransactionReceipt } from 'wagmi/actions' import { adminSection } from '../../styles/Section.css' export const CustomMinterForm: React.FC = () => { - const { address: signerAddress } = useAccount() + const { address: signerAddress } = useAuthStore() const { addresses } = useDaoStore() const chain = useChainStore((x) => x.chain) const config = useConfig() diff --git a/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx b/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx index 4568efa37..7626d66b9 100644 --- a/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx +++ b/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx @@ -1,10 +1,11 @@ import { erc721RedeemMinterAbi } from '@buildeross/sdk/contract' +import { useAuthStore } from '@buildeross/stores' import { AddressType, Chain } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { Box, Flex, Text, vars } from '@buildeross/zord' import React, { useCallback, useMemo, useState } from 'react' import { formatEther } from 'viem' -import { useAccount, useConfig, useReadContract } from 'wagmi' +import { useConfig, useReadContract } from 'wagmi' import { waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { @@ -32,7 +33,7 @@ export const MintingForm: React.FC = ({ tokenAddress, pricePerToken, }) => { - const { address } = useAccount() + const { address } = useAuthStore() const config = useConfig() const [tokenIdInput, setTokenIdInput] = useState('') const [isMinting, setIsMinting] = useState(false) diff --git a/packages/dao-ui/src/components/Gallery/Gallery.tsx b/packages/dao-ui/src/components/Gallery/Gallery.tsx index b29ea25fa..d2b9009a7 100644 --- a/packages/dao-ui/src/components/Gallery/Gallery.tsx +++ b/packages/dao-ui/src/components/Gallery/Gallery.tsx @@ -4,7 +4,12 @@ import { useDelayedGovernance } from '@buildeross/hooks/useDelayedGovernance' import { type GalleryItem, useGalleryItems } from '@buildeross/hooks/useGalleryItems' import { useNowSeconds } from '@buildeross/hooks/useNowSeconds' import { useVotes } from '@buildeross/hooks/useVotes' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' import { CHAIN_ID, ProposalCreateStage, TransactionType } from '@buildeross/types' import { DropdownSelect, type SelectOption } from '@buildeross/ui/DropdownSelect' import { DropMintWidget } from '@buildeross/ui/DropMintWidget' @@ -21,7 +26,6 @@ import { isChainIdSupportedByDroposal } from '@buildeross/utils/droposal' import { Box, Button, Flex, Icon, Text } from '@buildeross/zord' import React, { useMemo, useState } from 'react' import { Address, formatEther } from 'viem' -import { useAccount } from 'wagmi' import { CoinCard } from '../Cards/CoinCard' import { CreatorCoinSection } from '../Cards/CreatorCoinSection' @@ -147,7 +151,7 @@ export const Gallery: React.FC = ({ addresses: { token, governor }, } = useDaoStore() const chain = useChainStore((x) => x.chain) - const { address } = useAccount() + const { address } = useAuthStore() const { getCoinCreateLink } = useLinks() const { startProposalDraft } = useProposalStore() diff --git a/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx b/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx index 063cddd02..b04106ba2 100644 --- a/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx +++ b/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx @@ -1,7 +1,7 @@ import { ERC721_REDEEM_MINTER, MERKLE_RESERVE_MINTER } from '@buildeross/constants' import { useEnsData } from '@buildeross/hooks/useEnsData' import { tokenAbi } from '@buildeross/sdk/contract' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { SmartInput } from '@buildeross/ui/Fields' @@ -10,7 +10,7 @@ import { getEnsAddress } from '@buildeross/utils/ens' import { Box, Button, Flex, Heading, Text, theme } from '@buildeross/zord' import React from 'react' import { isAddress, zeroAddress } from 'viem' -import { useAccount, useConfig, useWriteContract } from 'wagmi' +import { useConfig, useWriteContract } from 'wagmi' import { readContract, waitForTransactionReceipt } from 'wagmi/actions' interface MinterManagementModalProps { @@ -143,7 +143,7 @@ export const MinterManagementModal: React.FC = ({ isERC721RedeemMinter, onMinterEnabled, }) => { - const { address: signerAddress } = useAccount() + const { address: signerAddress } = useAuthStore() const { addresses } = useDaoStore() const chain = useChainStore((x) => x.chain) const config = useConfig() diff --git a/packages/dao-ui/src/components/PreAuction/PreAuction.tsx b/packages/dao-ui/src/components/PreAuction/PreAuction.tsx index 9b115e3a8..4fea5278a 100644 --- a/packages/dao-ui/src/components/PreAuction/PreAuction.tsx +++ b/packages/dao-ui/src/components/PreAuction/PreAuction.tsx @@ -1,18 +1,12 @@ import { auctionAbi, tokenAbi } from '@buildeross/sdk/contract' import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' -import { useDaoStore } from '@buildeross/stores' +import { useAuthStore, useDaoStore } from '@buildeross/stores' import { AddressType, Chain } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { unpackOptionalArray } from '@buildeross/utils/helpers' import { Box, Button, Flex, Text, vars } from '@buildeross/zord' import React, { useState } from 'react' -import { - useAccount, - useConfig, - useReadContract, - useSimulateContract, - useWriteContract, -} from 'wagmi' +import { useConfig, useReadContract, useSimulateContract, useWriteContract } from 'wagmi' import { readContract, waitForTransactionReceipt } from 'wagmi/actions' import { @@ -41,7 +35,7 @@ export const PreAuction: React.FC = ({ remainingTokensInReserve, openMinterModal, }) => { - const { address } = useAccount() + const { address } = useAuthStore() const config = useConfig() const { addresses } = useDaoStore() diff --git a/packages/feed-ui/src/Actions/AuctionActions.tsx b/packages/feed-ui/src/Actions/AuctionActions.tsx index 15f5b550d..6501b6457 100644 --- a/packages/feed-ui/src/Actions/AuctionActions.tsx +++ b/packages/feed-ui/src/Actions/AuctionActions.tsx @@ -2,6 +2,7 @@ import { BASE_URL } from '@buildeross/constants/baseUrl' import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { useCurrentAuction } from '@buildeross/hooks' import { auctionAbi } from '@buildeross/sdk/contract' +import { useAuthStore } from '@buildeross/stores' import type { AddressType, CHAIN_ID, @@ -14,7 +15,7 @@ import { ShareButton } from '@buildeross/ui/ShareButton' import { Button, Flex, Text } from '@buildeross/zord' import React, { useCallback, useMemo, useState } from 'react' import { useSWRConfig } from 'swr' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import type { OnOpenBidModal } from '../types/modalStates' @@ -41,7 +42,7 @@ export const AuctionActions: React.FC = ({ const { getAuctionLink } = useLinks() const daoId = addresses.token const config = useConfig() - const { address: account } = useAccount() + const { address: account } = useAuthStore() const [isSettling, setIsSettling] = useState(false) diff --git a/packages/feed-ui/src/Actions/CoinActions.tsx b/packages/feed-ui/src/Actions/CoinActions.tsx index 8bf276202..dc392c437 100644 --- a/packages/feed-ui/src/Actions/CoinActions.tsx +++ b/packages/feed-ui/src/Actions/CoinActions.tsx @@ -1,4 +1,5 @@ import { BASE_URL } from '@buildeross/constants/baseUrl' +import { useAuthStore } from '@buildeross/stores' import { type AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { LikeButton } from '@buildeross/ui/LikeButton' @@ -11,7 +12,7 @@ import { } from '@buildeross/utils/coining' import { Button, Flex } from '@buildeross/zord' import React, { useCallback, useMemo } from 'react' -import { useAccount, useBalance } from 'wagmi' +import { useBalance } from 'wagmi' import type { OnOpenTradeModal } from '../types/modalStates' @@ -60,7 +61,7 @@ export const CoinActions: React.FC = ({ ? true : isChainIdSupportedForSaleOfZoraCoins(chainId) - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() // Get user's coin balance to determine if they can trade // Only fetch balance if sellEnabled is true (otherwise we always show "Buy") diff --git a/packages/feed-ui/src/Feed.tsx b/packages/feed-ui/src/Feed.tsx index 0c1cc064e..7247741cb 100644 --- a/packages/feed-ui/src/Feed.tsx +++ b/packages/feed-ui/src/Feed.tsx @@ -1,9 +1,9 @@ import { useFeed } from '@buildeross/hooks' import { FeedEventType } from '@buildeross/sdk/subgraph' +import { useAuthStore } from '@buildeross/stores' import type { AddressType, CHAIN_ID, FeedItem as FeedItemType } from '@buildeross/types' import { Button, Flex, Icon, Stack, Text } from '@buildeross/zord' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useAccount } from 'wagmi' import { FeedFiltersModal } from './FeedFiltersModal' import { FeedItem } from './FeedItem' @@ -92,7 +92,7 @@ export const Feed: React.FC = (props) => { const externalFilterMode = isExternalFilterMode(props) // Internal filter state (only used if not in external mode) - const { address } = useAccount() + const { address } = useAuthStore() const filterStore = useFeedFiltersStore(externalFilterMode ? undefined : address) const [isFilterModalOpen, setIsFilterModalOpen] = useState(false) diff --git a/packages/feed-ui/src/Modals/VoteModalWrapper.tsx b/packages/feed-ui/src/Modals/VoteModalWrapper.tsx index 7c7c13b21..df61f2d38 100644 --- a/packages/feed-ui/src/Modals/VoteModalWrapper.tsx +++ b/packages/feed-ui/src/Modals/VoteModalWrapper.tsx @@ -4,13 +4,13 @@ import { type ProposalVoteFragment, ProposalVoteSupport as Support, } from '@buildeross/sdk/subgraph' +import { useAuthStore } from '@buildeross/stores' import type { BytesType, CHAIN_ID, RequiredDaoContractAddresses } from '@buildeross/types' import { Avatar } from '@buildeross/ui/Avatar' import { AnimatedModal, SuccessModalContent } from '@buildeross/ui/Modal' import { walletSnippet } from '@buildeross/utils/helpers' import { Atoms, Box, Flex, Icon, IconType, Stack, Text } from '@buildeross/zord' import React, { ReactNode, useEffect, useRef, useState } from 'react' -import { useAccount } from 'wagmi' import { ModalHeader } from './ModalHeader' @@ -212,7 +212,7 @@ export const VoteModalWrapper: React.FC = ({ daoName, daoImage, }) => { - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() const [isSuccess, setIsSuccess] = useState(false) const successTimerRef = useRef | null>(null) diff --git a/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.test.tsx b/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.test.tsx index d6e856f2b..bb962f600 100644 --- a/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.test.tsx +++ b/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.test.tsx @@ -18,8 +18,8 @@ const DAO_TOKEN = '0x3333333333333333333333333333333333333333' let mockAddress: AddressType | undefined = USER let mockDaos: DashboardDaoWithState[] = [] -vi.mock('wagmi', () => ({ - useAccount: () => ({ address: mockAddress }), +vi.mock('@buildeross/stores', () => ({ + useAuthStore: () => ({ address: mockAddress }), })) vi.mock('@buildeross/hooks/useDashboardData', () => ({ diff --git a/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.tsx b/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.tsx index 7f151a88d..c8ff9f037 100644 --- a/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.tsx +++ b/packages/feed-ui/src/UrgencyAlerts/UrgencyAlerts.tsx @@ -1,9 +1,9 @@ import { useDashboardData } from '@buildeross/hooks/useDashboardData' import { useInterval } from '@buildeross/hooks/useInterval' import { useIsMounted } from '@buildeross/hooks/useIsMounted' +import { useAuthStore } from '@buildeross/stores' import { Flex, Stack } from '@buildeross/zord' import React, { useCallback, useMemo, useState } from 'react' -import { useAccount } from 'wagmi' import { UrgencyAlertItem } from './UrgencyAlertItem' import { @@ -23,7 +23,7 @@ export const UrgencyAlerts: React.FC = ({ thresholds = DEFAULT_URGENCY_THRESHOLDS, }) => { const isMounted = useIsMounted() - const { address } = useAccount() + const { address } = useAuthStore() const { daos } = useDashboardData({ address, enabled: !!address }) const { dismissedIds, dismissAlert } = useDismissedUrgencyAlerts(address) const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)) diff --git a/packages/hooks/src/useDashboardData.ts b/packages/hooks/src/useDashboardData.ts index fd387c2ef..217d22757 100644 --- a/packages/hooks/src/useDashboardData.ts +++ b/packages/hooks/src/useDashboardData.ts @@ -64,7 +64,6 @@ const fetcher = async ( * * @example * // Conditionally fetch based on address - * const { address } = useAccount() * const { daos, isLoading } = useDashboardData({ * address, * enabled: !!address, diff --git a/packages/hooks/src/useWalletDisconnect.ts b/packages/hooks/src/useWalletDisconnect.ts index d2bc58e6b..c596976b2 100644 --- a/packages/hooks/src/useWalletDisconnect.ts +++ b/packages/hooks/src/useWalletDisconnect.ts @@ -45,10 +45,17 @@ export function useWalletDisconnect(): () => void { console.warn('useWalletDisconnect: walletconnect provider cleanup failed:', e) } - // 3) targeted localStorage cleanup + // 3) SIWE session logout + try { + await fetch('/api/siwe/logout', { method: 'POST' }) + } catch (e) { + console.warn('useWalletDisconnect: SIWE logout failed:', e) + } + + // 4) targeted localStorage cleanup cleanupStorage() - // 4) Reset Wagmi State + // 5) Reset Wagmi State resetWagmi() }, [disconnectAsync, connector, cleanupStorage, resetWagmi]) diff --git a/packages/proposal-ui/src/components/ProposalActions/ProposalActions.tsx b/packages/proposal-ui/src/components/ProposalActions/ProposalActions.tsx index 2c6e44f06..0794b102d 100644 --- a/packages/proposal-ui/src/components/ProposalActions/ProposalActions.tsx +++ b/packages/proposal-ui/src/components/ProposalActions/ProposalActions.tsx @@ -1,11 +1,15 @@ import { useVotes } from '@buildeross/hooks/useVotes' import { Proposal } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { Flex } from '@buildeross/zord' import React, { Fragment, useMemo } from 'react' import { getAddress } from 'viem' -import { useAccount } from 'wagmi' import { parseProposalTransactions } from '../../utils/parseProposalTransactions' import { OverwriteDraftModal } from '../OverwriteDraftModal' @@ -27,7 +31,7 @@ export const ProposalActions: React.FC = ({ proposal, onNavigateToUpdateProposal, }) => { - const { address: userAddress } = useAccount() + const { address: userAddress, isAuthenticated } = useAuthStore() const addresses = useDaoStore((state) => state.addresses) const chain = useChainStore((state) => state.chain) const { @@ -129,7 +133,7 @@ export const ProposalActions: React.FC = ({ setShowOverwriteModal(false) }, []) - if (!userAddress) return + if (!userAddress || !isAuthenticated) return if (isLoading) return null return ( diff --git a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteStatus.tsx b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteStatus.tsx index defc876ca..ef89c5df0 100644 --- a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteStatus.tsx +++ b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteStatus.tsx @@ -3,12 +3,12 @@ import { ProposalVoteFragment as ProposalVote, ProposalVoteSupport as Support, } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { ContractButton } from '@buildeross/ui/ContractButton' import { Flex, Text } from '@buildeross/zord' import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' import { getAddress } from 'viem' -import { useAccount, useWatchContractEvent } from 'wagmi' +import { useWatchContractEvent } from 'wagmi' import { proposalActionButtonVariants } from '../ProposalActions.css' import Pending from './Pending' @@ -51,7 +51,7 @@ export const VoteStatus: React.FC = ({ candidateVersion, }) => { const chain = useChainStore((x) => x.chain) - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() const { governor } = useDaoStore((state) => state.addresses) const [showVoteModal, setShowVoteModal] = useState(false) const [vote, setVote] = useState(signerVote) diff --git a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.test.tsx b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.test.tsx index ee7db3553..1df9a1384 100644 --- a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.test.tsx +++ b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.test.tsx @@ -35,9 +35,9 @@ let mockMembershipReturn: { mutate: () => void } -vi.mock('wagmi', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, useAccount: () => mockAccount } +vi.mock('@buildeross/stores', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useAuthStore: () => mockAccount } }) vi.mock('@buildeross/hooks/useVotes', () => ({ diff --git a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.tsx b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.tsx index aa83822d1..0ace059ae 100644 --- a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.tsx +++ b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VotingPowerExplainer.tsx @@ -1,11 +1,10 @@ import { useDaoMembership } from '@buildeross/hooks/useDaoMembership' import { useEnsData } from '@buildeross/hooks/useEnsData' import { useVotes } from '@buildeross/hooks/useVotes' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { handleGMTOffset } from '@buildeross/utils/helpers' import dayjs from 'dayjs' import React, { useMemo } from 'react' -import { useAccount } from 'wagmi' import { getVotingPowerCase } from './VotingPowerExplainer.helper' import { VotingPowerExplainerView } from './VotingPowerExplainerView' @@ -21,7 +20,7 @@ export const VotingPowerExplainer: React.FC = ({ timeCreated, daoName, }) => { - const { address: userAddress } = useAccount() + const { address: userAddress } = useAuthStore() const chain = useChainStore((x) => x.chain) const { token, governor } = useDaoStore((x) => x.addresses) diff --git a/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx b/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx index 863a1e575..b99c948d7 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx @@ -2,6 +2,7 @@ import { ETHERSCAN_BASE_URL } from '@buildeross/constants/etherscan' import { AirdropInstanceData } from '@buildeross/hooks/useAirdropData' import { useEthUsdPrice } from '@buildeross/hooks/useEthUsdPrice' import { getFetchableUrls } from '@buildeross/ipfs-service' +import { useAuthStore } from '@buildeross/stores' import { type CHAIN_ID, TokenMetadata } from '@buildeross/types' import { AccordionItem } from '@buildeross/ui/Accordion' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -19,7 +20,7 @@ import { isAddressEqual, parseAbi, } from 'viem' -import { useAccount, useConfig, useReadContracts } from 'wagmi' +import { useConfig, useReadContracts } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { formatFeeDisplay } from '../utils/feeDisplay' @@ -80,7 +81,7 @@ export const AirdropItem = ({ chainId, tokenMetadata, }: AirdropItemProps) => { - const { address } = useAccount() + const { address } = useAuthStore() const config = useConfig() const { price: ethUsdPrice } = useEthUsdPrice() const [isClaiming, setIsClaiming] = useState(false) diff --git a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx index 6bd725b96..5f491ba93 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx @@ -4,14 +4,19 @@ import { useEnsData } from '@buildeross/hooks/useEnsData' import { type EscrowInstanceData } from '@buildeross/hooks/useInvoiceData' import { useIsGnosisSafe } from '@buildeross/hooks/useIsGnosisSafe' import { useTokenMetadataSingle } from '@buildeross/hooks/useTokenMetadata' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' import { AddressType, CHAIN_ID, TransactionType } from '@buildeross/types' import { useLinks } from '@buildeross/ui/LinksProvider' import { createSafeAppUrl, createSafeUrl } from '@buildeross/utils/safe' import { atoms, Box, Button, Icon, Spinner, Stack, Text } from '@buildeross/zord' import { useCallback, useMemo, useState } from 'react' import { encodeFunctionData, Hex } from 'viem' -import { useAccount, useConfig, useReadContract } from 'wagmi' +import { useConfig, useReadContract } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { MilestoneItem } from './MilestoneItem' @@ -60,7 +65,7 @@ export const EscrowInstance = ({ const { chain } = useChainStore() const { addresses } = useDaoStore() const { startProposalDraft } = useProposalStore() - const { address } = useAccount() + const { address } = useAuthStore() const config = useConfig() const { getProposalLink } = useLinks() diff --git a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/MilestoneDetails.tsx b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/MilestoneDetails.tsx index 7579de1c5..dafc46389 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/MilestoneDetails.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/MilestoneDetails.tsx @@ -1,9 +1,8 @@ import { type EscrowInstanceData, useInvoiceData } from '@buildeross/hooks/useInvoiceData' import { useVotes } from '@buildeross/hooks/useVotes' import { Proposal } from '@buildeross/sdk/subgraph' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { Spinner } from '@buildeross/zord' -import { useAccount } from 'wagmi' import { Section } from '../Section' import { EscrowInstance } from './EscrowInstance' @@ -19,7 +18,7 @@ export const MilestoneDetails = ({ }: MilestoneDetailsProps) => { const { chain } = useChainStore() const { addresses } = useDaoStore() - const { address } = useAccount() + const { address } = useAuthStore() const { hasThreshold } = useVotes({ chainId: chain.id, diff --git a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/SenderDelegation.tsx b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/SenderDelegation.tsx index 9d248daa0..06b86c917 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/SenderDelegation.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/SenderDelegation.tsx @@ -1,11 +1,11 @@ import { ETHERSCAN_BASE_URL } from '@buildeross/constants/etherscan' import { useEnsData } from '@buildeross/hooks/useEnsData' import { useIsGnosisSafe } from '@buildeross/hooks/useIsGnosisSafe' +import { useAuthStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { createSafeAppUrl, createSafeUrl } from '@buildeross/utils/safe' import { atoms, Box, Button, Icon, Stack, Text } from '@buildeross/zord' import { Address, isAddressEqual } from 'viem' -import { useAccount } from 'wagmi' interface SenderDelegationProps { chainId: CHAIN_ID @@ -22,7 +22,7 @@ export const SenderDelegation = ({ const { isGnosisSafe: isSenderAGnosisSafe } = useIsGnosisSafe(senderAddress, chainId) - const { address } = useAccount() + const { address } = useAuthStore() const isSenderConnected = address && isAddressEqual(senderAddress, address) diff --git a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx index 39a8c35ef..610c02234 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx @@ -3,7 +3,12 @@ import { useEnsData } from '@buildeross/hooks/useEnsData' import { useEthUsdPrice } from '@buildeross/hooks/useEthUsdPrice' import { useIsGnosisSafe } from '@buildeross/hooks/useIsGnosisSafe' import { useVotes } from '@buildeross/hooks/useVotes' -import { useChainStore, useDaoStore, useProposalStore } from '@buildeross/stores' +import { + useAuthStore, + useChainStore, + useDaoStore, + useProposalStore, +} from '@buildeross/stores' import { AddressType, TokenMetadata, TransactionType } from '@buildeross/types' import { AccordionItem } from '@buildeross/ui/Accordion' import { Avatar } from '@buildeross/ui/Avatar' @@ -27,7 +32,7 @@ import { createSafeAppUrl } from '@buildeross/utils/safe' import { atoms, Box, Button, Grid, Icon, Stack, Text } from '@buildeross/zord' import { useCallback, useMemo } from 'react' import { Address, encodeFunctionData, formatUnits, isAddressEqual } from 'viem' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' import { formatFeeDisplay } from '../utils/feeDisplay' @@ -79,7 +84,7 @@ export const StreamItem = ({ const { chain } = useChainStore() const { addresses } = useDaoStore() const { startProposalDraft } = useProposalStore() - const { address } = useAccount() + const { address } = useAuthStore() const config = useConfig() const { price: ethUsdPrice } = useEthUsdPrice() const { getProposalLink } = useLinks() diff --git a/packages/proposal-ui/src/components/UpdateProposalButton/UpdateProposalButton.tsx b/packages/proposal-ui/src/components/UpdateProposalButton/UpdateProposalButton.tsx index bfa83db7b..fbaf260f8 100644 --- a/packages/proposal-ui/src/components/UpdateProposalButton/UpdateProposalButton.tsx +++ b/packages/proposal-ui/src/components/UpdateProposalButton/UpdateProposalButton.tsx @@ -1,9 +1,8 @@ import { useProposalState, useProposalTimeline } from '@buildeross/hooks' -import { useChainStore, useDaoStore } from '@buildeross/stores' +import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import type { AddressType, BytesType } from '@buildeross/types' import { Tooltip } from '@buildeross/ui' import { Button, ButtonProps, Flex, Icon, Stack } from '@buildeross/zord' -import { useAccount } from 'wagmi' export interface UpdateProposalButtonProps extends Omit { proposalId: BytesType @@ -19,7 +18,7 @@ export function UpdateProposalButton({ candidateVersion, ...buttonProps }: UpdateProposalButtonProps) { - const { address: connectedAddress } = useAccount() + const { address: connectedAddress, isAuthenticated } = useAuthStore() const { addresses } = useDaoStore() const chain = useChainStore((x) => x.chain) @@ -49,7 +48,7 @@ export function UpdateProposalButton({ // Determine if user can update const isProposer = connectedAddress && proposerAddress.toLowerCase() === connectedAddress.toLowerCase() - const canUpdate = isUpdatable && isInUpdatablePeriod && isProposer + const canUpdate = isUpdatable && isInUpdatablePeriod && isProposer && isAuthenticated // Don't show button if proposal is not updatable if (!isUpdatable && !isLoading) { @@ -64,6 +63,8 @@ export function UpdateProposalButton({ // Generate tooltip message when button is disabled const getDisabledReason = (): string | undefined => { if (isLoading) return undefined + if (!connectedAddress) return 'Connect wallet to update proposals' + if (!isAuthenticated) return 'Sign in to update proposals' if (!isProposer) return 'Only the proposer can update this proposal' if (!isInUpdatablePeriod) { if (updateDeadline) { diff --git a/packages/sdk/src/contract/index.ts b/packages/sdk/src/contract/index.ts index 622777c79..445f9d9e3 100644 --- a/packages/sdk/src/contract/index.ts +++ b/packages/sdk/src/contract/index.ts @@ -5,6 +5,10 @@ export { type GetAuctionRewardsResponse, } from './requests/getAuctionRewards' export { default as getDAOAddresses } from './requests/getDAOAddresses' +export { + getDAOMembership, + type GetDAOMembershipResponse, +} from './requests/getDAOMembership' export { getMetadataAttributes } from './requests/getMetadataAttributes' export { getPropertyItems } from './requests/getPropertyItems' export { getProposalState, ProposalState } from './requests/getProposalState' diff --git a/packages/sdk/src/contract/requests/getDAOMembership.ts b/packages/sdk/src/contract/requests/getDAOMembership.ts new file mode 100644 index 000000000..eb9926505 --- /dev/null +++ b/packages/sdk/src/contract/requests/getDAOMembership.ts @@ -0,0 +1,86 @@ +import { PUBLIC_MANAGER_ADDRESS } from '@buildeross/constants' +import { + AddressType, + CHAIN_ID, + DaoContractAddresses, + RequiredDaoContractAddresses, +} from '@buildeross/types' +import { serverConfig, unpackOptionalArray } from '@buildeross/utils' +import { type Address, zeroAddress } from 'viem' +import { readContracts } from 'wagmi/actions' + +import { managerAbi, tokenAbi } from '../abis' + +export type GetDAOMembershipResponse = { + daoAddresses: RequiredDaoContractAddresses + balance: bigint + votes: bigint + hasBalance: boolean + hasVotes: boolean + isMember: boolean +} + +export const getDAOMembership = async ( + chainId: CHAIN_ID, + tokenAddress: AddressType, + userAddress: AddressType +): Promise => { + const [addresses, balance, votes] = await readContracts(serverConfig, { + allowFailure: false, + contracts: [ + { + address: PUBLIC_MANAGER_ADDRESS[chainId], + abi: managerAbi, + chainId, + functionName: 'getAddresses', + args: [tokenAddress], + }, + { + address: tokenAddress as Address, + abi: tokenAbi, + chainId, + functionName: 'balanceOf', + args: [userAddress as Address], + }, + { + address: tokenAddress as Address, + abi: tokenAbi, + chainId, + functionName: 'getVotes', + args: [userAddress as Address], + }, + ], + }) + + // Unpack DAO addresses from Manager response + const [metadata, auction, treasury, governor] = unpackOptionalArray(addresses, 4) + + const daoAddresses = { + token: tokenAddress, + auction, + governor, + metadata, + treasury, + } as DaoContractAddresses + + // Validate DAO addresses + const hasMissingAddresses = Object.values(daoAddresses).some( + (address) => address === zeroAddress || address === undefined + ) + + if (hasMissingAddresses) return null + + // Calculate membership flags + const hasBalance = balance > 0n + const hasVotes = votes > 0n + const isMember = hasBalance || hasVotes + + return { + daoAddresses: daoAddresses as RequiredDaoContractAddresses, + balance, + votes, + hasBalance, + hasVotes, + isMember, + } +} diff --git a/packages/stores/src/createAuthStore.ts b/packages/stores/src/createAuthStore.ts new file mode 100644 index 000000000..f7ec93c9a --- /dev/null +++ b/packages/stores/src/createAuthStore.ts @@ -0,0 +1,76 @@ +import type { AddressType } from '@buildeross/types' +import { createStore, type StateCreator } from 'zustand' + +/** + * AuthStore manages the user's authentication state + * + * Key invariant: address is ONLY set when user is fully authenticated + * (wallet connected + SIWE signed) + * + * Note: chainId is NOT stored here - use wagmi's useChainId() hook instead + */ + +export type AuthStoreState = { + // Only set when FULLY authenticated (connected + SIWE signed) + address: AddressType | undefined + + // State flags + isConnected: boolean // Wallet is connected + isConnecting: boolean // Wallet connection in progress + isAuthenticating: boolean // SIWE signature in progress + isAuthenticated: boolean // Fully authenticated (connected + signed) +} + +export type AuthStoreActions = { + // Set authentication state (sets address and isAuthenticated together) + setAuthenticated: (address: AddressType) => void + + // Set connection state + setConnected: (connected: boolean) => void + setConnecting: (connecting: boolean) => void + + // Set authentication loading state + setAuthenticating: (authenticating: boolean) => void + + // Clear all state (on disconnect or logout) + reset: () => void +} + +export type AuthStoreProps = AuthStoreState & AuthStoreActions + +const initialState: AuthStoreState = { + address: undefined, + isConnected: false, + isConnecting: false, + isAuthenticating: false, + isAuthenticated: false, +} + +const createAuthState: StateCreator = (set) => ({ + ...initialState, + + // Set authenticated state (address and isAuthenticated together) + setAuthenticated: (address) => + set({ + address, + isAuthenticated: true, + isAuthenticating: false, + }), + + // Set connection state + setConnected: (isConnected) => set({ isConnected }), + setConnecting: (isConnecting) => set({ isConnecting }), + + // Set authentication loading state + setAuthenticating: (isAuthenticating) => set({ isAuthenticating }), + + // Clear all state + reset: () => set(initialState), +}) + +// Singleton - only one auth session at a time +const authStoreInstance = createStore(createAuthState) + +export const getAuthStore = () => { + return authStoreInstance +} diff --git a/packages/stores/src/hooks/useAuthStore.ts b/packages/stores/src/hooks/useAuthStore.ts new file mode 100644 index 000000000..181ac018f --- /dev/null +++ b/packages/stores/src/hooks/useAuthStore.ts @@ -0,0 +1,24 @@ +import { useContext } from 'react' +import { useStore } from 'zustand' + +import { type AuthStoreProps, getAuthStore } from '../createAuthStore' +import { AuthStoreContext } from '../providers/AuthStoreProvider' + +// Overloads for type safety with selectors +export function useAuthStore(): AuthStoreProps +export function useAuthStore(selector: (s: AuthStoreProps) => T): T +export function useAuthStore(selector?: (s: AuthStoreProps) => T): T | AuthStoreProps { + const context = useContext(AuthStoreContext) + + const { store } = context ?? { + store: getAuthStore(), + } + + const storeState = useStore(store, (state) => state) + + if (selector) { + return selector(storeState) + } + + return storeState +} diff --git a/packages/stores/src/index.ts b/packages/stores/src/index.ts index 04e3ff1e9..fd0feea95 100644 --- a/packages/stores/src/index.ts +++ b/packages/stores/src/index.ts @@ -1,10 +1,17 @@ // Re-export hooks (maintains existing import paths) +export { useAuthStore } from './hooks/useAuthStore' export * from './hooks/useCandidateStore' export { type ChainStoreWithHydration, useChainStore } from './hooks/useChainStore' export { useDaoStore } from './hooks/useDaoStore' export * from './hooks/useProposalStore' // Export store factories +export { + type AuthStoreActions, + type AuthStoreProps, + type AuthStoreState, + getAuthStore, +} from './createAuthStore' export { buildCandidateStoreNamespace, CANDIDATE_STORE_IDENTIFIER, @@ -35,6 +42,7 @@ export { } from './createProposalStore' // Export providers +export { AuthStoreProvider } from './providers/AuthStoreProvider' export { CandidateStoreProvider } from './providers/CandidateStoreProvider' export { ChainStoreProvider } from './providers/ChainStoreProvider' export { DaoStoreProvider } from './providers/DaoStoreProvider' diff --git a/packages/stores/src/providers/AuthStoreProvider.tsx b/packages/stores/src/providers/AuthStoreProvider.tsx new file mode 100644 index 000000000..aff208c08 --- /dev/null +++ b/packages/stores/src/providers/AuthStoreProvider.tsx @@ -0,0 +1,17 @@ +import { createContext, type ReactNode } from 'react' + +import { getAuthStore } from '../createAuthStore' + +type AuthStoreContextType = { + store: ReturnType +} + +export const AuthStoreContext = createContext(null) + +export const AuthStoreProvider = ({ children }: { children: ReactNode }) => { + const store = getAuthStore() + + return ( + {children} + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46cb3870f..96b6669d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,7 +172,7 @@ importers: version: 2.2.10(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(wagmi@2.18.1(@tanstack/query-core@5.90.3)(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.8.1)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(zod@4.1.12)) '@sentry/nextjs': specifier: ^9.22.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) '@smartinvoicexyz/types': specifier: ^0.1.27 version: 0.1.28(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.1))(@types/react@19.2.2)(react@19.2.1))(@types/node@22.18.10)(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(framer-motion@12.23.24(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(graphql-ws@6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(graphql@16.11.0)(react-dom@19.2.1(react@19.2.1))(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) @@ -221,6 +221,9 @@ importers: ioredis: specifier: ^5.8.1 version: 5.8.1 + iron-session: + specifier: ^8.0.4 + version: 8.0.4 next: specifier: ^15.5.9 version: 15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -1478,7 +1481,7 @@ importers: dependencies: '@sentry/nextjs': specifier: ^9.22.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) graphql: specifier: ^16.11.0 version: 16.11.0 @@ -6914,6 +6917,10 @@ packages: resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + copy-to-clipboard@3.3.3: resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} @@ -8705,6 +8712,9 @@ packages: resolution: {integrity: sha512-zIaiEGX18QATxgaS0/EOQNoo33W0islREABAcxXE8n7y2MGAlB+hdsxXn4J0hGZge8IqVQhW8sWIb+oJz2yEvg==} engines: {node: '>=16.0.0', npm: '>=7.0.0'} + iron-session@8.0.4: + resolution: {integrity: sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -16628,7 +16638,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1)': + '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.37.0 @@ -19955,6 +19965,8 @@ snapshots: cookie@0.7.1: {} + cookie@0.7.2: {} + copy-to-clipboard@3.3.3: dependencies: toggle-selection: 1.0.6 @@ -22337,6 +22349,12 @@ snapshots: transitivePeerDependencies: - encoding + iron-session@8.0.4: + dependencies: + cookie: 0.7.2 + iron-webcrypto: 1.2.1 + uncrypto: 0.1.3 + iron-webcrypto@1.2.1: {} is-absolute@1.0.0: diff --git a/turbo.json b/turbo.json index 8d423e694..4aa079c47 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,7 @@ "globalDependencies": ["**/.env", "**/.env.*local"], "globalEnv": [ "ANALYZE", + "IRON_PASSWORD", "PINATA_API_KEY", "COINGECKO_API_KEY", "REDIS_URL", From dd11ffaa39794eec0c33c7020579ecb4dd8ecee9 Mon Sep 17 00:00:00 2001 From: dan13ram Date: Mon, 27 Jul 2026 17:15:22 +0530 Subject: [PATCH 2/6] feat: add Safe owner auth and connector infrastructure --- apps/web/package.json | 1 + apps/web/public/icons/wallets/brave.svg | 1 + apps/web/public/icons/wallets/coinbase.svg | 4 + apps/web/public/icons/wallets/injected.svg | 1 + apps/web/public/icons/wallets/ledger.svg | 1 + apps/web/public/icons/wallets/metamask.svg | 1 + apps/web/public/icons/wallets/phantom.svg | 1 + apps/web/public/icons/wallets/rabby.svg | 1 + apps/web/public/icons/wallets/rainbow.svg | 54 ++ apps/web/public/icons/wallets/safe.svg | 6 + apps/web/public/icons/wallets/trust.svg | 1 + .../public/icons/wallets/walletconnect.svg | 4 + apps/web/src/components/AsyncImage.tsx | 63 +++ apps/web/src/components/CustomWalletModal.tsx | 535 ++++++++++++++++++ apps/web/src/components/SafeAddressModal.tsx | 121 ++++ apps/web/src/components/WalletOption.tsx | 77 +++ apps/web/src/connectors/safeOwnerConnector.ts | 331 +++++++++++ apps/web/src/constants/walletConfig.ts | 142 +++++ apps/web/src/hooks/useAsyncImage.ts | 57 ++ apps/web/src/hooks/useWagmiAuthSync.ts | 133 ----- apps/web/src/hooks/useWalletConnectors.ts | 172 ++++++ .../layouts/DefaultLayout/ConnectButton.tsx | 137 ++++- .../DefaultLayout/NavMenu/ProfileMenu.tsx | 78 ++- apps/web/src/pages/_app.tsx | 185 ++++-- .../web/src/pages/api/ai/generateTxSummary.ts | 4 +- apps/web/src/pages/api/dashboard.ts | 7 +- apps/web/src/pages/api/feed.ts | 8 +- apps/web/src/pages/api/pinata/generate-jwt.ts | 22 +- apps/web/src/pages/api/pinata/pin-cid.ts | 22 +- apps/web/src/pages/api/pinata/pin-json.ts | 20 +- apps/web/src/pages/api/pinata/upload-url.ts | 22 +- apps/web/src/pages/api/simulate.ts | 2 - apps/web/src/pages/api/siwe/me.ts | 8 +- apps/web/src/pages/api/siwe/verify.ts | 45 +- apps/web/src/pages/create.tsx | 12 +- apps/web/src/utils/api/authMiddleware.ts | 82 ++- apps/web/src/utils/api/daoAuthMiddleware.ts | 121 +++- apps/web/src/utils/api/rateLimit.ts | 4 +- apps/web/src/utils/clientConfig.ts | 9 + apps/web/src/utils/iron.ts | 4 + apps/web/src/utils/isMobile.ts | 23 + apps/web/src/utils/recentWalletIds.ts | 28 + apps/web/src/utils/walletGrouping.ts | 136 +++++ package.json | 3 + packages/constants/src/safe.ts | 10 + packages/hooks/src/index.ts | 1 + packages/hooks/src/useSafeAuth.ts | 151 +++++ packages/stores/package.json | 3 +- packages/stores/src/createAuthStore.ts | 110 ++-- packages/stores/src/hooks/useAuthStore.ts | 31 +- packages/stores/src/index.ts | 4 +- .../src/providers/AuthStoreProvider.tsx | 26 +- packages/utils/src/index.ts | 4 + .../utils/src/providers/SafeOwnerProvider.ts | 283 +++++++++ packages/utils/src/providers/types.ts | 41 ++ packages/utils/src/safeService.ts | 188 ++++++ packages/utils/src/safeStorage.ts | 64 +++ pnpm-lock.yaml | 31 + 58 files changed, 3237 insertions(+), 399 deletions(-) create mode 100644 apps/web/public/icons/wallets/brave.svg create mode 100644 apps/web/public/icons/wallets/coinbase.svg create mode 100644 apps/web/public/icons/wallets/injected.svg create mode 100644 apps/web/public/icons/wallets/ledger.svg create mode 100644 apps/web/public/icons/wallets/metamask.svg create mode 100644 apps/web/public/icons/wallets/phantom.svg create mode 100644 apps/web/public/icons/wallets/rabby.svg create mode 100644 apps/web/public/icons/wallets/rainbow.svg create mode 100644 apps/web/public/icons/wallets/safe.svg create mode 100644 apps/web/public/icons/wallets/trust.svg create mode 100644 apps/web/public/icons/wallets/walletconnect.svg create mode 100644 apps/web/src/components/AsyncImage.tsx create mode 100644 apps/web/src/components/CustomWalletModal.tsx create mode 100644 apps/web/src/components/SafeAddressModal.tsx create mode 100644 apps/web/src/components/WalletOption.tsx create mode 100644 apps/web/src/connectors/safeOwnerConnector.ts create mode 100644 apps/web/src/constants/walletConfig.ts create mode 100644 apps/web/src/hooks/useAsyncImage.ts delete mode 100644 apps/web/src/hooks/useWagmiAuthSync.ts create mode 100644 apps/web/src/hooks/useWalletConnectors.ts create mode 100644 apps/web/src/utils/isMobile.ts create mode 100644 apps/web/src/utils/recentWalletIds.ts create mode 100644 apps/web/src/utils/walletGrouping.ts create mode 100644 packages/hooks/src/useSafeAuth.ts create mode 100644 packages/utils/src/providers/SafeOwnerProvider.ts create mode 100644 packages/utils/src/providers/types.ts create mode 100644 packages/utils/src/safeService.ts create mode 100644 packages/utils/src/safeStorage.ts diff --git a/apps/web/package.json b/apps/web/package.json index 9a788c742..1d0bcf28f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@vanilla-extract/next-plugin": "^2.4.11", "@vanilla-extract/recipes": "^0.5.7", "@vercel/og": "^0.6.8", + "@wagmi/core": "^2.22.1", "@zoralabs/coins-sdk": "^0.4.3", "@zoralabs/protocol-deployments": "^0.7.2", "ai": "^5.0.76", diff --git a/apps/web/public/icons/wallets/brave.svg b/apps/web/public/icons/wallets/brave.svg new file mode 100644 index 000000000..b90fc417b --- /dev/null +++ b/apps/web/public/icons/wallets/brave.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons/wallets/coinbase.svg b/apps/web/public/icons/wallets/coinbase.svg new file mode 100644 index 000000000..f5fabdf27 --- /dev/null +++ b/apps/web/public/icons/wallets/coinbase.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/public/icons/wallets/injected.svg b/apps/web/public/icons/wallets/injected.svg new file mode 100644 index 000000000..7e011718d --- /dev/null +++ b/apps/web/public/icons/wallets/injected.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons/wallets/ledger.svg b/apps/web/public/icons/wallets/ledger.svg new file mode 100644 index 000000000..4cf96c371 --- /dev/null +++ b/apps/web/public/icons/wallets/ledger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons/wallets/metamask.svg b/apps/web/public/icons/wallets/metamask.svg new file mode 100644 index 000000000..127a8ecb6 --- /dev/null +++ b/apps/web/public/icons/wallets/metamask.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons/wallets/phantom.svg b/apps/web/public/icons/wallets/phantom.svg new file mode 100644 index 000000000..4c6700787 --- /dev/null +++ b/apps/web/public/icons/wallets/phantom.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons/wallets/rabby.svg b/apps/web/public/icons/wallets/rabby.svg new file mode 100644 index 000000000..b7380148e --- /dev/null +++ b/apps/web/public/icons/wallets/rabby.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/public/icons/wallets/rainbow.svg b/apps/web/public/icons/wallets/rainbow.svg new file mode 100644 index 000000000..c16f942ef --- /dev/null +++ b/apps/web/public/icons/wallets/rainbow.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/icons/wallets/safe.svg b/apps/web/public/icons/wallets/safe.svg new file mode 100644 index 000000000..7ec49e4fa --- /dev/null +++ b/apps/web/public/icons/wallets/safe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/icons/wallets/trust.svg b/apps/web/public/icons/wallets/trust.svg new file mode 100644 index 000000000..0f5e9daac --- /dev/null +++ b/apps/web/public/icons/wallets/trust.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/icons/wallets/walletconnect.svg b/apps/web/public/icons/wallets/walletconnect.svg new file mode 100644 index 000000000..57b135fea --- /dev/null +++ b/apps/web/public/icons/wallets/walletconnect.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/components/AsyncImage.tsx b/apps/web/src/components/AsyncImage.tsx new file mode 100644 index 000000000..30bc6a605 --- /dev/null +++ b/apps/web/src/components/AsyncImage.tsx @@ -0,0 +1,63 @@ +import { Box } from '@buildeross/zord' +import { useReducer } from 'react' + +import { type AsyncImageSrc, useAsyncImage } from '../hooks/useAsyncImage' +import { isIOS } from '../utils/isMobile' + +interface AsyncImageProps { + alt?: string + src: string | AsyncImageSrc | undefined + width: number + height: number + background?: string + borderRadius?: string | number + className?: string +} + +export function AsyncImage({ + alt, + background, + borderRadius = '6px', + height, + src: srcProp, + width, + className, +}: AsyncImageProps) { + const ios = isIOS() + const src = useAsyncImage(srcProp) + const isRemoteImage = src && /^http/.test(src) + const [isRemoteImageLoaded, setRemoteImageLoaded] = useReducer(() => true, false) + + return ( + + {src && ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + )} + + ) +} diff --git a/apps/web/src/components/CustomWalletModal.tsx b/apps/web/src/components/CustomWalletModal.tsx new file mode 100644 index 000000000..f8c0566ad --- /dev/null +++ b/apps/web/src/components/CustomWalletModal.tsx @@ -0,0 +1,535 @@ +'use client' + +import { CHAIN_ID } from '@buildeross/types' +import { AnimatedModal } from '@buildeross/ui' +import { isOwnerOfSafe, isSafeAddress, setSafeInfo } from '@buildeross/utils' +import { Box, Button, Stack, Text } from '@buildeross/zord' +import { getConnectors } from '@wagmi/core' +import { useEffect, useState } from 'react' +import type { Address } from 'viem' +import { createSiweMessage } from 'viem/siwe' +import { + type Connector, + useAccount, + useConfig, + useConnect, + useDisconnect, + useSignMessage, +} from 'wagmi' + +import { useWalletConnectors } from '../hooks/useWalletConnectors' +import { addRecentWalletId } from '../utils/recentWalletIds' +import { SafeAddressModal } from './SafeAddressModal' +import { WalletOption } from './WalletOption' + +interface CustomWalletModalProps { + isOpen: boolean + onClose: () => void +} + +export function CustomWalletModal({ isOpen, onClose }: CustomWalletModalProps) { + const { address, isConnected, connector: activeConnector, chainId } = useAccount() + const wagmiConfig = useConfig() + const [showSafeFlow, setShowSafeFlow] = useState(false) + const [safeModeActive, setSafeModeActive] = useState(false) + const [showSignPrompt, setShowSignPrompt] = useState(false) + const [isAuthenticating, setIsAuthenticating] = useState(false) + const [isValidatingSafe, setIsValidatingSafe] = useState(false) + const [authError, setAuthError] = useState(null) + const [safeValidationError, setSafeValidationError] = useState(null) + const [pendingSafeInfo, setPendingSafeInfo] = useState<{ + safeAddress: Address + chainId: CHAIN_ID + } | null>(null) + const { signMessageAsync } = useSignMessage() + const { connectAsync } = useConnect() + const { disconnectAsync } = useDisconnect() + + // Get wallet connectors from our hook (similar to RainbowKit's useWalletConnectors) + const wallets = useWalletConnectors() + + // Show sign prompt when wallet connects (only for non-Safe connectors) + useEffect(() => { + if ( + isConnected && + !safeModeActive && + !showSignPrompt && + !isAuthenticating && + activeConnector?.id !== 'safeOwner' + ) { + setShowSignPrompt(true) + } + }, [isConnected, safeModeActive, showSignPrompt, isAuthenticating, activeConnector?.id]) + + // Show sign prompt after Safe connector switch completes + useEffect(() => { + if ( + isConnected && + activeConnector?.id === 'safeOwner' && + !showSignPrompt && + !isAuthenticating + ) { + setShowSignPrompt(true) + } + }, [isConnected, activeConnector?.id, showSignPrompt, isAuthenticating]) + + // Handle EOA connection in Safe mode + useEffect(() => { + const validateAndSwitchToSafe = async () => { + if ( + !isConnected || + !safeModeActive || + !pendingSafeInfo || + !address || + !activeConnector + ) + return + + setIsValidatingSafe(true) + setSafeValidationError(null) + + try { + // Validate that connected wallet is an owner + const isOwner = await isOwnerOfSafe( + address, + pendingSafeInfo.safeAddress, + pendingSafeInfo.chainId + ) + + if (!isOwner) { + setSafeValidationError('Your wallet is not an owner of this Safe') + setIsValidatingSafe(false) + return + } + + // Store Safe info with EOA connector ID for persistence + setSafeInfo( + pendingSafeInfo.safeAddress, + pendingSafeInfo.chainId, + activeConnector.id + ) + + // Find SafeOwnerConnector from wagmi config + const safeConnector = getConnectors(wagmiConfig).find( + (c: Connector) => c.id === 'safeOwner' + ) + + if (!safeConnector) { + throw new Error('SafeOwnerConnector not found in wagmi config') + } + + // Connect to Safe connector (don't disconnect EOA - SafeOwnerConnector needs it) + await connectAsync({ connector: safeConnector }) + + // Clear pending info + // Note: Don't set showSignPrompt here - let the useEffect handle it + // after wagmi state updates to safeOwner connector + setPendingSafeInfo(null) + setSafeModeActive(false) + setIsValidatingSafe(false) + } catch (error) { + console.error('[CustomWalletModal] Failed to switch to Safe connector:', error) + const errorMessage = + error instanceof Error ? error.message : 'Failed to validate Safe ownership' + setSafeValidationError(errorMessage) + setIsValidatingSafe(false) + } + } + + validateAndSwitchToSafe() + }, [ + isConnected, + safeModeActive, + pendingSafeInfo, + address, + activeConnector, + connectAsync, + disconnectAsync, + wagmiConfig, + ]) + + // Handle SIWE authentication when user clicks sign button + const handleSignMessage = async () => { + if (!address || !chainId) return + + setIsAuthenticating(true) + setAuthError(null) + + try { + // Detect if using SafeOwnerConnector + const isSafeMode = activeConnector?.id === 'safeOwner' + + // For Safe mode, get the EOA address that will actually sign + let signingAddress = address + let safeAddress: Address | null = null + + if (isSafeMode && activeConnector && 'getEOAAddress' in activeConnector) { + const eoaAddress = await (activeConnector as any).getEOAAddress() + if (eoaAddress) { + signingAddress = eoaAddress + safeAddress = address // address is the Safe address + } + } + + // 1. Get nonce + const nonceResponse = await fetch('/api/siwe/nonce') + const nonce = await nonceResponse.text() + + // 2. Create SIWE message + // Note: In Safe mode, we sign with EOA address, not Safe address + const message = createSiweMessage({ + domain: window.location.host, + address: signingAddress, // EOA address in Safe mode + statement: isSafeMode + ? `Sign in as owner for Safe ${safeAddress}` + : 'Sign in with Ethereum to Nouns Builder', + uri: window.location.origin, + version: '1', + chainId, + nonce, + }) + + // 3. Sign message with EOA + const signature = await signMessageAsync({ message }) + + // 4. Verify signature + const verifyResponse = await fetch('/api/siwe/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message, + signature, + safeAddress: safeAddress, // Send Safe address if in Safe mode + safeChainId: isSafeMode ? chainId : null, + }), + }) + + const verifyBody = await verifyResponse.json() + + if (verifyResponse.ok && verifyBody.ok) { + // Clear Safe mode state + setSafeModeActive(false) + setPendingSafeInfo(null) + + // Close modal immediately + onClose() + setIsAuthenticating(false) + setShowSignPrompt(false) + + // Trigger session re-verification after modal closes + setTimeout(() => { + window.dispatchEvent(new Event('focus')) + }, 100) + } else { + setAuthError(verifyBody.message || 'Verification failed') + setIsAuthenticating(false) + setShowSignPrompt(true) // Show sign prompt again for retry + } + } catch (error) { + console.error('[CustomWalletModal] Authentication error:', error) + const errorMessage = + error instanceof Error ? error.message : 'Failed to sign message' + + // Check if user rejected the signature + if (errorMessage.includes('User rejected') || errorMessage.includes('rejected')) { + setAuthError('Signature rejected. Please try again.') + } else { + setAuthError(errorMessage) + } + + setIsAuthenticating(false) + setShowSignPrompt(true) // Show sign prompt again for retry + } + } + + const handleWalletConnect = async (wallet: any) => { + // Add to recent wallets + addRecentWalletId(wallet.id) + + // Connect the wallet using the wallet's connect function + try { + await wallet.connect() + } catch (error) { + console.error('[CustomWalletModal] Connection error:', error) + // Don't throw - allow user to retry + } + } + + const handleSafeClick = () => { + setShowSafeFlow(true) + } + + const handleSafeSubmit = async (safeAddress: Address, safeChainId: number) => { + setIsValidatingSafe(true) + setSafeValidationError(null) + + try { + // 1. Validate that the address is actually a Safe + const isValid = await isSafeAddress(safeAddress, safeChainId as CHAIN_ID) + + if (!isValid) { + setSafeValidationError('This address is not a Safe on the selected network') + setIsValidatingSafe(false) + return + } + + // 2. If not connected, save Safe info and show wallet list + if (!isConnected) { + setPendingSafeInfo({ safeAddress, chainId: safeChainId as CHAIN_ID }) + setSafeModeActive(true) + setShowSafeFlow(false) + setIsValidatingSafe(false) + return + } + + // 3. Validate that connected wallet is an owner + const isOwner = await isOwnerOfSafe(address!, safeAddress, safeChainId as CHAIN_ID) + + if (!isOwner) { + setSafeValidationError('Your wallet is not an owner of this Safe') + setIsValidatingSafe(false) + return + } + + // 4. Create SafeOwnerConnector with current EOA connector + if (!activeConnector) { + setSafeValidationError('No wallet connected') + setIsValidatingSafe(false) + return + } + + // Store Safe info with EOA connector ID for persistence + setSafeInfo(safeAddress, safeChainId as CHAIN_ID, activeConnector.id) + + // Find SafeOwnerConnector from wagmi config + const safeConnector = getConnectors(wagmiConfig).find( + (c: Connector) => c.id === 'safeOwner' + ) + + if (!safeConnector) { + throw new Error('SafeOwnerConnector not found in wagmi config') + } + + // Connect to Safe connector (don't disconnect EOA - SafeOwnerConnector needs it) + await connectAsync({ connector: safeConnector }) + + // 5. Close Safe flow + // Note: Don't set showSignPrompt here - let the useEffect handle it + // after wagmi state updates to safeOwner connector + setShowSafeFlow(false) + setIsValidatingSafe(false) + } catch (error) { + console.error('[CustomWalletModal] Safe validation error:', error) + const errorMessage = + error instanceof Error ? error.message : 'Failed to validate Safe ownership' + setSafeValidationError(errorMessage) + setIsValidatingSafe(false) + } + } + + const handleSafeCancel = () => { + setShowSafeFlow(false) + setSafeModeActive(false) + } + + // Create consolidated wallet list: Installed first, then Popular, then Safe (unless in Safe mode) + const installedWallets = wallets.filter((wallet) => !wallet.isRainbowKitConnector) + const popularWallets = wallets.filter((wallet) => wallet.isRainbowKitConnector) + + // Add Safe as a pseudo-wallet at the end (hide when safeModeActive) + const safeWalletOption = { + id: 'safe', + name: 'Safe', + iconUrl: '/icons/wallets/safe.svg', + iconBackground: '#12ff80', + recent: false, + isSafe: true, + } + + const allWalletOptions = safeModeActive + ? [...installedWallets, ...popularWallets] + : [...installedWallets, ...popularWallets, safeWalletOption] + + if (showSafeFlow) { + return ( + + + + ) + } + + // Show sign prompt (wallet connected, waiting for user to click sign) + if (showSignPrompt && !isAuthenticating) { + return ( + + + + + Sign Message + + To complete authentication, you need to sign a message with your wallet. + {activeConnector?.id === 'safeOwner' && address && ( + <> + {' '} + You will be signing in as an owner for Safe{' '} + + {address.slice(0, 6)}...{address.slice(-4)} + + . + + )} + + + This request will not trigger a blockchain transaction or cost any gas + fees. + + + {authError && ( + + + {authError} + + + )} + + + + + + + + ) + } + + // Show authenticating state (after user clicks sign button) + if (isAuthenticating) { + return ( + + + + + Sign Message + + Please check your wallet and approve the signature request. + + + + + Waiting for signature... + + + + + + ) + } + + return ( + + + + + + {safeModeActive ? 'Connect as Safe Owner' : 'Connect Wallet'} + + + {safeModeActive + ? `Connect with a wallet that is one of the owners of this Safe` + : 'Choose how you want to connect'} + + + + {safeValidationError && safeModeActive && ( + + + {safeValidationError} + + + )} + + + {allWalletOptions.map((wallet) => { + const isActive = activeConnector?.id === wallet.id + const isSafe = 'isSafe' in wallet && wallet.isSafe + + return ( + + isSafe ? handleSafeClick() : handleWalletConnect(wallet) + } + recent={wallet.recent} + disabled={isActive} + /> + ) + })} + + + + By connecting a wallet, you acknowledge and agree to the Nouns Builder{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + + + + + ) +} diff --git a/apps/web/src/components/SafeAddressModal.tsx b/apps/web/src/components/SafeAddressModal.tsx new file mode 100644 index 000000000..f1f60b05a --- /dev/null +++ b/apps/web/src/components/SafeAddressModal.tsx @@ -0,0 +1,121 @@ +'use client' + +import { CHAIN_ID } from '@buildeross/types' +import { DropdownSelect, FIELD_TYPES, SmartInput } from '@buildeross/ui' +import { Button, Flex, Stack, Text } from '@buildeross/zord' +import { ChangeEvent, useState } from 'react' +import type { Address } from 'viem' +import { isAddress } from 'viem' + +interface SafeAddressModalProps { + onSubmit: (safeAddress: Address, chainId: CHAIN_ID) => Promise + onCancel: () => void + isValidating?: boolean + error?: string | null + initialSafeAddress?: Address + initialChainId?: CHAIN_ID +} + +const SUPPORTED_CHAINS = [ + { value: CHAIN_ID.ETHEREUM, label: 'Ethereum' }, + { value: CHAIN_ID.OPTIMISM, label: 'Optimism' }, + { value: CHAIN_ID.BASE, label: 'Base' }, + { value: CHAIN_ID.SEPOLIA, label: 'Sepolia' }, + { value: CHAIN_ID.BASE_SEPOLIA, label: 'Base Sepolia' }, +] + +export function SafeAddressModal({ + onSubmit, + onCancel, + isValidating = false, + error, + initialSafeAddress, + initialChainId, +}: SafeAddressModalProps) { + const [safeAddress, setSafeAddress] = useState(initialSafeAddress || '') + const [chainId, setChainId] = useState(initialChainId || CHAIN_ID.ETHEREUM) + const [inputError, setInputError] = useState(null) + + const handleSubmit = async () => { + // Validate address + if (!safeAddress) { + setInputError('Please enter a Safe address') + return + } + + if (!isAddress(safeAddress)) { + setInputError('Invalid Ethereum address') + return + } + + setInputError(null) + await onSubmit(safeAddress as Address, chainId) + } + + return ( + + + Connect as Safe Owner + + Select the network and enter the Safe address. You'll need to connect with a + wallet that is one of the owners of this Safe. + + + + + setChainId(value)} + inputLabel="Network" + disabled={isValidating} + height="x12" + /> + + ) => { + setSafeAddress(e.target.value) + setInputError(null) + }} + inputLabel="Safe Address" + placeholder="0x..." + disabled={isValidating} + isAddress={true} + errorMessage={inputError} + /> + + {error && ( + + + {error} + + + )} + + + + + + + + ) +} diff --git a/apps/web/src/components/WalletOption.tsx b/apps/web/src/components/WalletOption.tsx new file mode 100644 index 000000000..06222120e --- /dev/null +++ b/apps/web/src/components/WalletOption.tsx @@ -0,0 +1,77 @@ +'use client' + +import { Box, Flex, Stack, Text } from '@buildeross/zord' +import { useState } from 'react' + +import { AsyncImage } from './AsyncImage' + +interface WalletOptionProps { + name: string + icon: string + iconBackground: string + onClick: () => void + recent?: boolean + disabled?: boolean +} + +export function WalletOption({ + name, + icon, + iconBackground, + onClick, + recent = false, + disabled = false, +}: WalletOptionProps) { + const [isHovered, setIsHovered] = useState(false) + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + w="100%" + p="x2" + borderRadius="curved" + cursor={disabled ? 'not-allowed' : 'pointer'} + backgroundColor={isHovered && !disabled ? 'background2' : 'transparent'} + style={{ + border: 'none', + outline: 'none', + opacity: disabled ? 0.5 : 1, + textAlign: 'left', + }} + > + + + + + + {name} + + {recent && ( + + Recent + + )} + + + + + ) +} diff --git a/apps/web/src/connectors/safeOwnerConnector.ts b/apps/web/src/connectors/safeOwnerConnector.ts new file mode 100644 index 000000000..15cb9dbdb --- /dev/null +++ b/apps/web/src/connectors/safeOwnerConnector.ts @@ -0,0 +1,331 @@ +import { + clearSafeInfo, + type EIP1193Provider, + getSavedSafeInfo, + isOwnerOfSafe, + type SafeInfo, + SafeOwnerProvider, +} from '@buildeross/utils' +import { getSafeInfo as getSafeInfoFromChain } from '@buildeross/utils/safeService' +import { getConnectors } from '@wagmi/core' +import type { PublicClient } from 'viem' +import { createPublicClient, http } from 'viem' +import { + type Config, + type Connector, + createConnector, + type CreateConnectorFn, + ProviderNotFoundError, +} from 'wagmi' + +createSafeOwnerConnector.type = 'safeOwner' as const + +// Global reference to wagmi config (set after config is created) +let wagmiConfig: Config | null = null + +export function setWagmiConfig(config: Config) { + wagmiConfig = config +} + +/** + * Creates a static SafeOwnerConnector that reads Safe configuration from localStorage. + * This connector works with wagmi's built-in persistence and reconnection system. + * + * Safe info (address, chainId, eoaConnectorId) is stored in localStorage and loaded on-demand. + * The EOA connector is discovered from wagmi's config by ID. + */ +export function createSafeOwnerConnector(): CreateConnectorFn { + type Provider = SafeOwnerProvider | undefined + type Properties = { + safeInfo: SafeInfo | null + } + + let provider_: Provider | undefined + let safeInfo_: SafeInfo | null = null + let publicClient_: PublicClient | null = null + let eoaConnector_: Connector | null = null + + return createConnector((config) => { + /** + * Load Safe configuration from localStorage + */ + function loadSafeConfig() { + return getSavedSafeInfo() + } + + /** + * Find EOA connector by ID from wagmi config + */ + function findEOAConnector(connectorId: string): Connector | null { + if (!wagmiConfig) { + console.error('[SafeOwnerConnector] wagmiConfig not initialized') + return null + } + const connectors = getConnectors(wagmiConfig) + return connectors.find((c: Connector) => c.id === connectorId) || null + } + + /** + * Clear cached state + */ + function clearCache() { + provider_ = undefined + safeInfo_ = null + publicClient_ = null + eoaConnector_ = null + } + + return { + id: 'safeOwner', + name: 'Safe', + type: createSafeOwnerConnector.type, + + async setup() { + // Called once when connector is initialized + // We defer loading until needed for performance + }, + + /** + * Check if this connector should auto-reconnect. + * Returns true if: + * 1. Safe info exists in localStorage + * 2. EOA connector exists and is authorized + * 3. EOA is still a Safe owner + */ + async isAuthorized() { + try { + const saved = loadSafeConfig() + if (!saved) return false + + // Find the EOA connector + const eoaConnector = findEOAConnector(saved.eoaConnectorId) + if (!eoaConnector) return false + + // Check if EOA connector is still authorized + const eoaAuthorized = await eoaConnector.isAuthorized() + if (!eoaAuthorized) { + // EOA no longer authorized, clear stale Safe info + clearSafeInfo() + return false + } + + // Get EOA accounts + const eoaAccounts = await eoaConnector.getAccounts() + if (!eoaAccounts || eoaAccounts.length === 0) { + clearSafeInfo() + return false + } + + // Verify EOA is still a Safe owner + const isOwner = await isOwnerOfSafe( + eoaAccounts[0], + saved.safeAddress, + saved.chainId + ) + + if (!isOwner) { + // No longer an owner, clear stale Safe info + clearSafeInfo() + return false + } + + return true + } catch (error) { + console.error('[SafeOwnerConnector] isAuthorized error:', error) + return false + } + }, + + /** + * Connect method with type assertion for wagmi compatibility. + * + * Type assertion (as any) is necessary here due to TypeScript's limitation with + * generic conditional types. wagmi expects: + * connect(...) + * => Promise<{ accounts: withCapabilities extends true ? CapabilityAccount[] : Address[] }> + * + * This pattern is standard in wagmi core connectors. + * The runtime behavior is correct - we return Address[] (Safe doesn't support EIP-5792). + */ + connect: (async (_parameters?: { + chainId?: number + isReconnecting?: boolean + withCapabilities?: boolean + }) => { + const saved = loadSafeConfig() + if (!saved) { + throw new Error( + 'No Safe configuration found. Please connect via Safe mode first.' + ) + } + + // Find and cache EOA connector + eoaConnector_ = findEOAConnector(saved.eoaConnectorId) + if (!eoaConnector_) { + throw new Error( + `EOA connector '${saved.eoaConnectorId}' not found. Please reconnect your wallet.` + ) + } + + // Verify EOA connector is authorized (should be true from isAuthorized check) + const eoaAuthorized = await eoaConnector_.isAuthorized() + if (!eoaAuthorized) { + throw new Error('EOA wallet is not authorized. Please reconnect.') + } + + // Fetch Safe info if not already loaded + if (!safeInfo_) { + try { + safeInfo_ = await getSafeInfoFromChain(saved.safeAddress, saved.chainId) + } catch (error) { + console.error('[SafeOwnerConnector] Failed to fetch Safe info:', error) + throw new Error('Failed to load Safe information') + } + } + + // Return Safe address as the connected account + return { + accounts: [saved.safeAddress], + chainId: Number(saved.chainId), + } + }) as any, + + async disconnect() { + // Clean up provider + if (provider_) { + provider_.removeAllListeners() + } + + // Clear cached state + clearCache() + + // Clear Safe info from storage + clearSafeInfo() + }, + + async getAccounts() { + const saved = loadSafeConfig() + if (!saved) return [] + return [saved.safeAddress] + }, + + async getProvider() { + const saved = loadSafeConfig() + if (!saved) { + throw new Error('No Safe configuration found') + } + + if (!provider_) { + // Get EOA connector + if (!eoaConnector_) { + eoaConnector_ = findEOAConnector(saved.eoaConnectorId) + } + if (!eoaConnector_) { + throw new ProviderNotFoundError() + } + + // Get EOA provider + const rawProvider = await eoaConnector_.getProvider() + if (!rawProvider) { + throw new ProviderNotFoundError() + } + + // Type assert to EIP1193Provider + const eoaProvider = rawProvider as EIP1193Provider + + // Create public client if not already created + if (!publicClient_) { + const chain = config.chains.find((c) => c.id === saved.chainId) + if (!chain) { + throw new Error(`Chain ${saved.chainId} not found in wagmi config`) + } + + publicClient_ = createPublicClient({ + chain, + transport: http(), + }) + } + + // Ensure we have Safe info + if (!safeInfo_) { + safeInfo_ = await getSafeInfoFromChain(saved.safeAddress, saved.chainId) + } + + if (!safeInfo_) { + throw new Error('Failed to fetch Safe info') + } + + // Create SafeOwnerProvider + provider_ = new SafeOwnerProvider(safeInfo_, eoaProvider, publicClient_) + } + + return provider_ + }, + + async getChainId() { + const saved = loadSafeConfig() + if (!saved) { + throw new Error('No Safe configuration found') + } + return saved.chainId + }, + + async switchChain({ chainId: newChainId }) { + const saved = loadSafeConfig() + if (!saved) { + throw new Error('No Safe configuration found') + } + + // Safes are chain-specific contracts - cannot switch chains + if (newChainId !== saved.chainId) { + throw new Error( + 'Cannot switch chain for Safe. Safes are chain-specific smart contracts.' + ) + } + + return config.chains.find((c) => c.id === saved.chainId)! + }, + + onAccountsChanged(_accounts) { + const saved = loadSafeConfig() + if (saved) { + // EOA accounts changed, but we still report Safe address + config.emitter.emit('change', { accounts: [saved.safeAddress] }) + } + }, + + onChainChanged(newChainId) { + const saved = loadSafeConfig() + if (saved && Number(newChainId) !== saved.chainId) { + // Safe is on a different chain, disconnect + config.emitter.emit('disconnect') + } + }, + + onDisconnect() { + clearCache() + clearSafeInfo() + config.emitter.emit('disconnect') + }, + + // Custom property to expose Safe info + get safeInfo() { + return safeInfo_ + }, + + // Custom method to get EOA address for signing + async getEOAAddress() { + const saved = loadSafeConfig() + if (!saved) return null + + if (!eoaConnector_) { + eoaConnector_ = findEOAConnector(saved.eoaConnectorId) + } + if (!eoaConnector_) return null + + const eoaAccounts = await eoaConnector_.getAccounts() + return eoaAccounts?.[0] || null + }, + } + }) +} diff --git a/apps/web/src/constants/walletConfig.ts b/apps/web/src/constants/walletConfig.ts new file mode 100644 index 000000000..5660f98e1 --- /dev/null +++ b/apps/web/src/constants/walletConfig.ts @@ -0,0 +1,142 @@ +export interface WalletMetadata { + name: string + icon: string + iconBackground: string + iconAccent?: string +} + +export const WALLET_CONFIG: Record = { + // MetaMask + 'io.metamask': { + name: 'MetaMask', + icon: '/icons/wallets/metamask.svg', + iconBackground: '#fff', + iconAccent: '#f6851a', + }, + metaMask: { + name: 'MetaMask', + icon: '/icons/wallets/metamask.svg', + iconBackground: '#fff', + iconAccent: '#f6851a', + }, + + // Rainbow + 'me.rainbow': { + name: 'Rainbow', + icon: '/icons/wallets/rainbow.svg', + iconBackground: '#001e59', + }, + rainbow: { + name: 'Rainbow', + icon: '/icons/wallets/rainbow.svg', + iconBackground: '#001e59', + }, + + // Coinbase + 'com.coinbase.wallet': { + name: 'Coinbase Wallet', + icon: '/icons/wallets/coinbase.svg', + iconBackground: '#0052ff', + }, + coinbase: { + name: 'Coinbase Wallet', + icon: '/icons/wallets/coinbase.svg', + iconBackground: '#0052ff', + }, + coinbaseWallet: { + name: 'Coinbase Wallet', + icon: '/icons/wallets/coinbase.svg', + iconBackground: '#0052ff', + }, + + // WalletConnect + walletConnect: { + name: 'WalletConnect', + icon: '/icons/wallets/walletconnect.svg', + iconBackground: '#3b99fc', + }, + + // Rabby + 'io.rabby': { + name: 'Rabby Wallet', + icon: '/icons/wallets/rabby.svg', + iconBackground: '#8697ff', + }, + rabby: { + name: 'Rabby Wallet', + icon: '/icons/wallets/rabby.svg', + iconBackground: '#8697ff', + }, + + // Ledger + ledger: { + name: 'Ledger', + icon: '/icons/wallets/ledger.svg', + iconBackground: '#000', + }, + ledgerHid: { + name: 'Ledger', + icon: '/icons/wallets/ledger.svg', + iconBackground: '#000', + }, + + // Safe + safe: { + name: 'Safe', + icon: '/icons/wallets/safe.svg', + iconBackground: '#12ff80', + }, + + // Brave + 'com.brave.wallet': { + name: 'Brave Wallet', + icon: '/icons/wallets/brave.svg', + iconBackground: '#fff', + }, + brave: { + name: 'Brave Wallet', + icon: '/icons/wallets/brave.svg', + iconBackground: '#fff', + }, + + // Phantom + 'app.phantom': { + name: 'Phantom', + icon: '/icons/wallets/phantom.svg', + iconBackground: '#ab9ff2', + }, + phantom: { + name: 'Phantom', + icon: '/icons/wallets/phantom.svg', + iconBackground: '#ab9ff2', + }, + + // Trust Wallet + 'com.trustwallet.app': { + name: 'Trust Wallet', + icon: '/icons/wallets/trust.svg', + iconBackground: '#3375bb', + }, + trust: { + name: 'Trust Wallet', + icon: '/icons/wallets/trust.svg', + iconBackground: '#3375bb', + }, + + // Generic injected + injected: { + name: 'Browser Wallet', + icon: '/icons/wallets/injected.svg', + iconBackground: '#fff', + }, +} + +export function getWalletMetadata(connectorId: string): WalletMetadata { + return ( + WALLET_CONFIG[connectorId] || { + name: 'Wallet', + icon: '/icons/wallets/injected.svg', + iconBackground: '#fff', + } + ) +} diff --git a/apps/web/src/hooks/useAsyncImage.ts b/apps/web/src/hooks/useAsyncImage.ts new file mode 100644 index 000000000..59647be70 --- /dev/null +++ b/apps/web/src/hooks/useAsyncImage.ts @@ -0,0 +1,57 @@ +import { useEffect, useReducer } from 'react' + +export type AsyncImageSrc = () => Promise + +const cachedUrls = new Map() +const cachedRequestPromises = new Map>() + +async function loadAsyncImage(asyncImage: () => Promise) { + const cachedRequestPromise = cachedRequestPromises.get(asyncImage) + + // Don't fetch if we already have a request in progress / completed + if (cachedRequestPromise) { + return cachedRequestPromise + } + + const load = async () => + asyncImage().then(async (url: string) => { + cachedUrls.set(asyncImage, url) + return url + }) + + const requestPromise = load().catch(() => { + // Retry once if the request failed + return load().catch(() => { + // Ignore failed retry, remove failed request from promise cache + cachedRequestPromises.delete(asyncImage) + }) + }) + + cachedRequestPromises.set(asyncImage, requestPromise) + + return requestPromise +} + +export async function loadImages(...urls: (string | AsyncImageSrc)[]) { + return await Promise.all( + urls.map((url) => (typeof url === 'function' ? loadAsyncImage(url) : url)) + ) +} + +function useForceUpdate() { + const [, forceUpdate] = useReducer((x) => x + 1, 0) + return forceUpdate +} + +export function useAsyncImage(url?: string | AsyncImageSrc): string | undefined { + const cachedUrl = typeof url === 'function' ? cachedUrls.get(url) : undefined + const forceUpdate = useForceUpdate() + + useEffect(() => { + if (typeof url === 'function' && !cachedUrl) { + loadAsyncImage(url).then(forceUpdate) + } + }, [url, cachedUrl, forceUpdate]) + + return typeof url === 'function' ? cachedUrl : url +} diff --git a/apps/web/src/hooks/useWagmiAuthSync.ts b/apps/web/src/hooks/useWagmiAuthSync.ts deleted file mode 100644 index 73b9ac7fb..000000000 --- a/apps/web/src/hooks/useWagmiAuthSync.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { useAuthStore } from '@buildeross/stores' -import type { AuthenticationStatus } from '@rainbow-me/rainbowkit' -import { useCallback, useEffect, useRef } from 'react' -import { useAccount } from 'wagmi' - -/** - * Syncs wagmi wallet state with AuthStore and RainbowKit authentication - * - * Handles: - * - Wallet connection/disconnection - * - Account switching (clears auth on account change) - * - SIWE authentication state - * - Session verification on mount and window focus - * - * Bug fixes: - * - Proper event listener cleanup - * - Account switching detection - * - Stale closure prevention - * - Gap state handling - */ -export function useWagmiAuthSync( - rainbowKitAuthStatus: AuthenticationStatus, - setRainbowKitAuthStatus: (status: AuthenticationStatus) => void -) { - const { address, isConnected, connector } = useAccount() - const { setConnected, setConnecting, setAuthenticated, reset } = useAuthStore() - - const prevAddressRef = useRef(undefined) - const fetchingRef = useRef(false) - - // Core sync effect - handles the main state flow - useEffect(() => { - // 1. Handle disconnection - if (!isConnected) { - reset() - if (rainbowKitAuthStatus !== 'unauthenticated') { - setRainbowKitAuthStatus('unauthenticated') - } - prevAddressRef.current = undefined - return - } - - // 2. Update connected state - setConnected(true) - - // 3. Handle account switch (treat as logout - requires re-authentication) - if (prevAddressRef.current && prevAddressRef.current !== address) { - reset() - setRainbowKitAuthStatus('unauthenticated') - prevAddressRef.current = address - - void fetch('/api/siwe/logout', { method: 'POST' }) - return - } - prevAddressRef.current = address - - // 4. Set authenticated state when both conditions met - if (rainbowKitAuthStatus === 'authenticated' && address) { - setAuthenticated(address) - } else if (rainbowKitAuthStatus === 'unauthenticated') { - // Clear auth data if SIWE session expired - reset() - setConnected(true) // But keep connected state - } - }, [ - isConnected, - address, - rainbowKitAuthStatus, - setConnected, - setAuthenticated, - reset, - setRainbowKitAuthStatus, - ]) - - // Connector state - properly cleanup event listeners - useEffect(() => { - if (!connector?.emitter) { - setConnecting(false) - return - } - - // Use named functions so cleanup works correctly - const handleConnect = () => setConnecting(false) - const handleError = () => setConnecting(false) - - setConnecting(true) - connector.emitter.on('connect', handleConnect) - connector.emitter.on('error', handleError) - - return () => { - connector.emitter.off('connect', handleConnect) - connector.emitter.off('error', handleError) - } - }, [connector, setConnecting]) - - // Session verification - const verifySession = useCallback(async () => { - if (fetchingRef.current) return - - fetchingRef.current = true - try { - const response = await fetch('/api/siwe/me') - const json = await response.json() - - const sessionAddress = - typeof json.address === 'string' ? json.address.toLowerCase() : undefined - const connectedAddress = address?.toLowerCase() - - // Only consider the session authenticated when it matches the connected wallet. - const newStatus = - sessionAddress && connectedAddress && sessionAddress === connectedAddress - ? 'authenticated' - : 'unauthenticated' - - if (newStatus !== rainbowKitAuthStatus) { - setRainbowKitAuthStatus(newStatus) - } - } catch { - if (rainbowKitAuthStatus !== 'unauthenticated') { - setRainbowKitAuthStatus('unauthenticated') - } - } finally { - fetchingRef.current = false - } - }, [address, rainbowKitAuthStatus, setRainbowKitAuthStatus]) - - // Initial check and window focus - useEffect(() => { - verifySession() - window.addEventListener('focus', verifySession) - return () => window.removeEventListener('focus', verifySession) - }, [verifySession]) -} diff --git a/apps/web/src/hooks/useWalletConnectors.ts b/apps/web/src/hooks/useWalletConnectors.ts new file mode 100644 index 000000000..14ad3a754 --- /dev/null +++ b/apps/web/src/hooks/useWalletConnectors.ts @@ -0,0 +1,172 @@ +import { type Connector, useConnect } from 'wagmi' + +import { getRecentWalletIds } from '../utils/recentWalletIds' + +// Simplified WalletInstance type (from RainbowKit) +interface WalletInstance extends Connector { + isRainbowKitConnector?: boolean + groupName?: string + groupIndex?: number + rdns?: string + iconUrl?: string + iconBackground?: string + installed?: boolean + rkDetails?: { + isRainbowKitConnector?: boolean + groupName?: string + groupIndex?: number + rdns?: string + iconUrl?: string + iconBackground?: string + installed?: boolean + } +} + +export interface WalletConnector { + id: string + name: string + uid?: string + icon?: string + iconUrl: string + iconBackground: string + ready: boolean + groupName: string + groupIndex: number + recent: boolean + isRainbowKitConnector: boolean + connect: () => Promise + connector: Connector +} + +function isEIP6963Connector(wallet: WalletInstance): boolean { + return !!( + !wallet.isRainbowKitConnector && + !wallet.rkDetails?.isRainbowKitConnector && + wallet.icon?.replace(/\n/g, '').startsWith('data:image') && + wallet.uid && + wallet.name + ) +} + +function isRainbowKitConnector(wallet: WalletInstance): boolean { + return !!(wallet.isRainbowKitConnector || wallet.rkDetails?.isRainbowKitConnector) +} + +export function useWalletConnectors(): WalletConnector[] { + const { connectAsync, connectors: defaultConnectors_untyped } = useConnect() + const defaultCreatedConnectors = defaultConnectors_untyped as WalletInstance[] + + // Merge connector with its rkDetails (RainbowKit injects details via this property) + const defaultConnectors = defaultCreatedConnectors.map((connector) => ({ + ...connector, + ...(connector.rkDetails || {}), + })) as WalletInstance[] + + // Connect function that adds to recent wallets + async function connectWallet(connector: Connector): Promise { + const result = await connectAsync({ + connector, + }) + return result + } + + // Separate EIP-6963 (auto-discovered browser extensions) + const eip6963Connectors = defaultConnectors + .filter(isEIP6963Connector) + .map((connector) => ({ + ...connector, + groupIndex: 0, + groupName: 'Installed', + })) + + // Separate RainbowKit configured wallets + const seenIds = new Set() + const rainbowKitConnectors = defaultConnectors + .filter(isRainbowKitConnector) + .filter((wallet) => wallet.id !== 'safe') // Exclude Safe + .filter((wallet) => { + // Deduplicate: remove RainbowKit connector if EIP-6963 version exists + const existsInEIP6963 = eip6963Connectors.some( + (eip6963) => eip6963.id === wallet.rdns + ) + if (existsInEIP6963) return false + + // Deduplicate by connector ID (e.g., multiple walletConnect instances) + if (seenIds.has(wallet.id)) { + return false + } + seenIds.add(wallet.id) + return true + }) + + const combinedConnectors = [...eip6963Connectors, ...rainbowKitConnectors] + + // Create index by wallet ID for recent wallet lookup + const walletInstanceById: Record = {} + for (const wallet of combinedConnectors) { + walletInstanceById[wallet.id] = wallet + } + + // Get recent wallets + const MAX_RECENT_WALLETS = 3 + const recentWallets: WalletInstance[] = getRecentWalletIds() + .map((walletId) => walletInstanceById[walletId]) + .filter(Boolean) + .slice(0, MAX_RECENT_WALLETS) + + // Combine with recent wallets at the front + const combinedWithRecent = [ + ...recentWallets, + ...combinedConnectors.filter( + (wallet) => !recentWallets.some((recent) => recent.id === wallet.id) + ), + ] + + // Map to WalletConnector format + const walletConnectors: WalletConnector[] = [] + + for (const wallet of combinedWithRecent) { + if (!wallet) continue + + const eip6963 = isEIP6963Connector(wallet) + const recent = recentWallets.some((recentWallet) => recentWallet.id === wallet.id) + + if (eip6963) { + // EIP-6963 connectors (browser extensions) + walletConnectors.push({ + id: wallet.id, + name: wallet.name || 'Wallet', + uid: wallet.uid, + icon: wallet.icon, + iconUrl: wallet.icon || '/icons/wallets/injected.svg', + iconBackground: wallet.iconBackground || '#fff', + ready: true, + groupName: 'Installed', + groupIndex: 0, + recent, + isRainbowKitConnector: false, + connect: () => connectWallet(wallet), + connector: wallet, + }) + } else { + // RainbowKit configured wallets + walletConnectors.push({ + id: wallet.id, + name: wallet.name || 'Wallet', + uid: wallet.uid, + icon: wallet.icon, + iconUrl: wallet.iconUrl || wallet.icon || '/icons/wallets/injected.svg', + iconBackground: wallet.iconBackground || '#fff', + ready: wallet.installed ?? true, + groupName: wallet.groupName || 'Popular', + groupIndex: wallet.groupIndex || 999, + recent, + isRainbowKitConnector: true, + connect: () => connectWallet(wallet), + connector: wallet, + }) + } + } + + return walletConnectors +} diff --git a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx index a9e475d26..91cb08b2b 100644 --- a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx +++ b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx @@ -1,16 +1,15 @@ -import { useAuthStore } from '@buildeross/stores' -import { Flex } from '@buildeross/zord' +import { Button, Flex } from '@buildeross/zord' import { ConnectButton as RKConnectButton } from '@rainbow-me/rainbowkit' -import React from 'react' +import React, { useState } from 'react' +import { CustomWalletModal } from 'src/components/CustomWalletModal' +import { useAccount } from 'wagmi' import { connectButtonWrapper } from './Nav.styles.css' export const ConnectButton = () => { - const { address } = useAuthStore() - - if (address) { - return null - } + const [showModal, setShowModal] = useState(false) + const { connector } = useAccount() + const isSafeMode = connector?.id === 'safeOwner' return ( { justify="center" cursor={'pointer'} > - + + {({ + account, + chain, + openAccountModal, + openChainModal, + mounted, + authenticationStatus, + }) => { + // SSR safety + if (!mounted) { + return ( + + ) + } + + // Not connected OR not authenticated - show custom connect button + if (!account || authenticationStatus === 'unauthenticated') { + return ( + <> + + setShowModal(false)} + /> + + ) + } + + // Still loading authentication + if (authenticationStatus === 'loading') { + return ( + + ) + } + + // Connected but wrong chain + if (chain?.unsupported) { + return ( + + ) + } + + // Connected - wagmi automatically shows Safe address when using SafeOwnerConnector + return ( + + + + + ) + }} + ) } diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx index 3c44a5abe..d19fe53d4 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx @@ -18,8 +18,8 @@ import React from 'react' import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' import { useDaoListPreferences } from 'src/hooks/useDaoListPreferences' import { profileStatBadge } from 'src/styles/profile.css' -import { formatUnits } from 'viem' -import { useBalance } from 'wagmi' +import { type Address, formatUnits } from 'viem' +import { useAccount, useBalance } from 'wagmi' import { ConnectButton } from '../ConnectButton' import { @@ -132,7 +132,23 @@ export const ProfileMenu: React.FC = ({ }) => { const { address, isAuthenticated } = useAuthStore() const { chain: selectedChain } = useChainStore() + const { connector } = useAccount() + + // Detect Safe mode and get EOA address + const isSafeMode = connector?.id === 'safeOwner' + const [eoaAddress, setEoaAddress] = React.useState
(null) + + React.useEffect(() => { + if (isSafeMode && connector && 'getEOAAddress' in connector) { + ;(connector as any).getEOAAddress().then((addr: Address) => setEoaAddress(addr)) + } else { + setEoaAddress(null) + } + }, [isSafeMode, connector]) + const { displayName, ensAvatar } = useEnsData(address || '') + const eoaEnsData = useEnsData(eoaAddress || '') + const { data: balance } = useBalance({ address: address!, chainId: selectedChain.id, @@ -239,15 +255,61 @@ export const ProfileMenu: React.FC = ({ aria-label="Open profile" > - - {displayName} - - {userBalance} - + + {isSafeMode ? ( + <> + + + + {displayName} + + + + {userBalance} + + {eoaAddress && ( + + + Owner: + + + {eoaEnsData.displayName} + + + )} + + ) : ( + <> + {displayName} + + {userBalance} + + + )} - + + + {isSafeMode && eoaAddress && } + - - - - - - ) - } - - // Show authenticating state (after user clicks sign button) - if (isAuthenticating) { - return ( - - - - - Sign Message - - Please check your wallet and approve the signature request. - - - - - Waiting for signature... - - - - - - ) - } - - return ( - - - - - - {safeModeActive ? 'Connect as Safe Owner' : 'Connect Wallet'} - - - {safeModeActive - ? `Connect with a wallet that is one of the owners of this Safe` - : 'Choose how you want to connect'} - - - - {safeValidationError && safeModeActive && ( - - - {safeValidationError} - - - )} - - - {allWalletOptions.map((wallet) => { - const isActive = activeConnector?.id === wallet.id - const isSafe = 'isSafe' in wallet && wallet.isSafe - - return ( - - isSafe ? handleSafeClick() : handleWalletConnect(wallet) - } - recent={wallet.recent} - disabled={isActive} - /> - ) - })} - - - - By connecting a wallet, you acknowledge and agree to the Nouns Builder{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . - - - - - ) -} diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..b482a5d5b --- /dev/null +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -0,0 +1,128 @@ +import { Box, Button, Stack, Text } from '@buildeross/zord' +import React from 'react' + +import { debugAuth } from '../utils/debug' + +interface Props { + children: React.ReactNode + fallback?: React.ReactNode + onReset?: () => void | Promise + onError?: (error: Error, errorInfo: React.ErrorInfo) => void +} + +interface State { + hasError: boolean + error: Error | null + isResetting: boolean +} + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null, isResetting: false } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error, isResetting: false } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + debugAuth('Error caught by boundary: %O', { error, errorInfo }) + this.props.onError?.(error, errorInfo) + } + + handleReset = async () => { + if (this.state.isResetting) return + + this.setState({ isResetting: true }) + + try { + await this.props.onReset?.() + this.setState({ hasError: false, error: null, isResetting: false }) + } catch (error) { + debugAuth('Error boundary reset failed: %O', error) + this.setState({ isResetting: false }) + } + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback || ( + + + + + ! + + + + + Authentication interrupted + + + We hit a problem while restoring your session. Resetting will + disconnect the wallet and clear SIWE state. + + {this.state.error?.message && ( + + {this.state.error.message} + + )} + + + + + + + ) + ) + } + + return this.props.children + } +} diff --git a/apps/web/src/components/SafeAddressModal.tsx b/apps/web/src/components/SafeAddressModal.tsx index f1f60b05a..df4534fbf 100644 --- a/apps/web/src/components/SafeAddressModal.tsx +++ b/apps/web/src/components/SafeAddressModal.tsx @@ -1,5 +1,7 @@ 'use client' +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' import { CHAIN_ID } from '@buildeross/types' import { DropdownSelect, FIELD_TYPES, SmartInput } from '@buildeross/ui' import { Button, Flex, Stack, Text } from '@buildeross/zord' @@ -16,13 +18,17 @@ interface SafeAddressModalProps { initialChainId?: CHAIN_ID } -const SUPPORTED_CHAINS = [ - { value: CHAIN_ID.ETHEREUM, label: 'Ethereum' }, - { value: CHAIN_ID.OPTIMISM, label: 'Optimism' }, - { value: CHAIN_ID.BASE, label: 'Base' }, - { value: CHAIN_ID.SEPOLIA, label: 'Sepolia' }, - { value: CHAIN_ID.BASE_SEPOLIA, label: 'Base Sepolia' }, -] +// Dynamically build supported chains from SAFE_SERVICE_URL + PUBLIC_DEFAULT_CHAINS +const getSupportedChains = () => { + return PUBLIC_DEFAULT_CHAINS.filter( + (chain) => SAFE_SERVICE_URL[chain.id as CHAIN_ID] !== undefined + ).map((chain) => ({ + value: chain.id as CHAIN_ID, + label: chain.name, + })) +} + +const SUPPORTED_CHAINS = getSupportedChains() export function SafeAddressModal({ onSubmit, diff --git a/apps/web/src/components/SafeTransactionHandler.tsx b/apps/web/src/components/SafeTransactionHandler.tsx new file mode 100644 index 000000000..3612ae78e --- /dev/null +++ b/apps/web/src/components/SafeTransactionHandler.tsx @@ -0,0 +1,99 @@ +'use client' + +import { SafeTransactionModal } from '@buildeross/ui' +import { + registerSafeTransactionHandler, + SafeTransactionError, + SafeTransactionErrorCode, + unregisterSafeTransactionHandler, +} from '@buildeross/utils' +import { useMachine } from '@xstate/react' +import { useEffect, useRef } from 'react' + +import { safeTransactionMachine } from '../machines/safeTransactionMachine' +import { debugSafeTx } from '../utils/debug' + +export function SafeTransactionHandler() { + const [state, send] = useMachine(safeTransactionMachine) + const stateRef = useRef(state) + stateRef.current = state + + // Register global handler on mount + useEffect(() => { + registerSafeTransactionHandler(async (params) => { + debugSafeTx('Handler called with params: %O', params) + + // Reject new transactions if one is already in progress + if (!stateRef.current.matches('idle')) { + debugSafeTx('ERROR: Transaction already in progress, rejecting new transaction') + throw new SafeTransactionError( + 'Another Safe transaction is already in progress. Please complete or cancel it first.', + SafeTransactionErrorCode.TRANSACTION_IN_PROGRESS + ) + } + + // Show modal and wait for user interaction + return new Promise((resolve, reject) => { + send({ + type: 'PROPOSE', + params, + resolve, + reject, + }) + }) + }) + + return () => { + unregisterSafeTransactionHandler() + } + }, [send]) + + const handleConfirm = async () => { + return new Promise<{ safeTxHash: string }>((resolve, reject) => { + send({ type: 'CONFIRM', resolve, reject }) + }) + } + + const handleClose = () => { + const event = state.matches('error') || state.matches('success') ? 'CLOSE' : 'CANCEL' + send({ type: event }) + } + + const handleRetry = () => { + send({ type: 'RETRY' }) + } + + // Don't render if not in a state that needs the modal + if (state.matches('idle')) { + return null + } + + if (!state.context.params) { + return null + } + + const isOpen = !state.matches('idle') + + return ( + ({ + to: transaction.to, + value: transaction.value, + data: transaction.data, + }))} + onConfirm={handleConfirm} + /> + ) +} diff --git a/apps/web/src/components/WalletConnectDialog.tsx b/apps/web/src/components/WalletConnectDialog.tsx new file mode 100644 index 000000000..810af907b --- /dev/null +++ b/apps/web/src/components/WalletConnectDialog.tsx @@ -0,0 +1,607 @@ +'use client' + +import { AnimatedModal } from '@buildeross/ui' +import { getSafeInfo, setSafeInfo } from '@buildeross/utils' +import { getConnectors } from '@wagmi/core' +import { useMachine } from '@xstate/react' +import { useEffect, useMemo, useRef } from 'react' +import type { Address } from 'viem' +import { createSiweMessage } from 'viem/siwe' +import { useAccount, useConfig, useConnect, useSignMessage } from 'wagmi' + +import { useWalletConnectors } from '../hooks/useWalletConnectors' +import { walletModalMachine } from '../machines/walletModalMachine' +import type { WalletInfo } from '../types/auth' +import { debugWallet } from '../utils/debug' +import { beginSiweAuthFlow, SIWE_NONCE_PATH } from '../utils/siweAuthFlow' +import { ErrorView } from './wallet/ErrorView' +import { LoadingView } from './wallet/LoadingView' +import { SafeAddressView } from './wallet/SafeAddressView' +import { SignatureView } from './wallet/SignatureView' +import { WalletListView } from './wallet/WalletListView' + +interface WalletConnectDialogProps { + isOpen: boolean + onClose: () => void +} + +export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProps) { + const [state, send] = useMachine(walletModalMachine) + const { address, connector: activeConnector, isConnected, chainId } = useAccount() + const { connectAsync } = useConnect() + const { signMessageAsync } = useSignMessage() + const wagmiConfig = useConfig() + + // Track if we just authenticated to prevent immediate reopening + const justAuthenticatedRef = useRef(false) + + // Use proper wallet connector hook (handles deduplication) + const walletConnectors = useWalletConnectors() + + // Transform to WalletInfo format (memoized to prevent infinite re-renders) + const wallets: WalletInfo[] = useMemo( + () => + walletConnectors.map((wc) => ({ + id: wc.id, + name: wc.name, + iconUrl: wc.iconUrl, + isRainbowKitConnector: wc.isRainbowKitConnector, + connector: wc.connector, + })), + [walletConnectors] + ) + + // Open modal handler + useEffect(() => { + if (isOpen && state.matches('closed') && !justAuthenticatedRef.current) { + send({ type: 'OPEN', wallets }) + } + // Only react to isOpen and state changes, not wallets + // Wallets are captured in the closure when needed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, state, send]) + + // Close modal handler + useEffect(() => { + if (!isOpen && !state.matches('closed')) { + send({ type: 'CLOSE' }) + } + }, [isOpen, state, send]) + + // Handle wallet connection when wagmi connects + useEffect(() => { + const isWaitingForConnection = + state.matches('connectingWallet') || state.matches('switchingToSafe') + + if (isWaitingForConnection && isConnected && address && activeConnector) { + debugWallet('Wallet connected: %s via %s', address, activeConnector.name) + send({ + type: 'WALLET_CONNECTED', + address, + connector: activeConnector, + }) + } + }, [state, isConnected, address, activeConnector, send]) + + // Handle Safe address validation + useEffect(() => { + if (state.matches('validatingSafe') && state.context.pendingSafeAddress) { + const abortController = new AbortController() + + const validate = async () => { + try { + debugWallet( + 'Validating Safe: %s on chain %d', + state.context.pendingSafeAddress, + state.context.pendingSafeChainId + ) + + // Check abort before async operation + if (abortController.signal.aborted) { + debugWallet('Validation aborted before starting') + return + } + + const safeInfo = await getSafeInfo( + state.context.pendingSafeAddress!, + state.context.pendingSafeChainId! + ) + + // Check if component is still mounted after async operation + if (abortController.signal.aborted) { + debugWallet('Validation aborted after fetch') + return + } + + if (safeInfo) { + debugWallet('Safe validated: %O', safeInfo) + send({ type: 'SAFE_VALIDATED', safeInfo }) + } else { + debugWallet('Safe not found') + send({ + type: 'ERROR', + error: { + code: 'SAFE_NOT_FOUND', + address: state.context.pendingSafeAddress!, + }, + }) + } + } catch (error) { + // Check if component is still mounted + if (abortController.signal.aborted) { + debugWallet('Validation aborted in error handler') + return + } + + debugWallet('Safe validation error: %O', error) + send({ + type: 'ERROR', + error: { code: 'SAFE_NOT_FOUND', address: state.context.pendingSafeAddress! }, + }) + } + } + validate() + + return () => { + abortController.abort() + } + } + }, [state, send]) + + // Handle Safe connector switch + useEffect(() => { + if ( + state.matches('switchingToSafe') && + state.context.safeInfo && + state.context.connector + ) { + const abortController = new AbortController() + + const switchConnector = async () => { + try { + debugWallet('Switching to Safe connector...') + + // Check abort before starting + if (abortController.signal.aborted) { + debugWallet('Safe connector switch aborted before start') + return + } + + // Store Safe info with EOA connector ID for persistence + setSafeInfo(state.context.safeInfo!, state.context.connector!.id) + + // Find SafeOwnerConnector from wagmi config + const safeConnector = getConnectors(wagmiConfig).find( + (c) => c.id === 'safeOwner' + ) + + if (!safeConnector) { + throw new Error('SafeOwnerConnector not found in wagmi config') + } + + // Check abort before async operation + if (abortController.signal.aborted) { + debugWallet('Safe connector switch aborted before connect') + return + } + + debugWallet('Connecting to SafeOwnerConnector...') + await connectAsync({ connector: safeConnector }) + + // Check abort immediately after async operation (before state updates) + if (abortController.signal.aborted) { + debugWallet( + 'Safe connector switch aborted after connect (component unmounted)' + ) + return + } + + debugWallet('Safe connector switch complete') + + // The existing useEffect will detect the new connection and send WALLET_CONNECTED + } catch (error) { + // Check abort in catch block (before state updates) + if (abortController.signal.aborted) { + debugWallet('Safe connector switch aborted in error handler') + return + } + + debugWallet('Safe connector switch error: %O', error) + send({ + type: 'ERROR', + error: { + code: 'NETWORK_ERROR', + message: 'Failed to switch to Safe connector', + }, + }) + } + } + switchConnector() + + return () => { + abortController.abort() + } + } + }, [state, send, wagmiConfig, connectAsync]) + + // Debug state changes + useEffect(() => { + debugWallet('State changed: %s', state.value) + if (state.context.error) { + debugWallet('State has error: %O', state.context.error) + } + + // Log when authenticated + if (state.matches('authenticated')) { + debugWallet('AUTHENTICATED STATE REACHED! Modal should close in 500ms') + } + + // Log if we go back to selecting wallet (shouldn't happen after auth) + if (state.matches('selectingWallet') && state.context.address) { + debugWallet( + 'WARNING: Back to selectingWallet but have address: %s', + state.context.address + ) + } + }, [state]) + + // Handle modal close after authentication + useEffect(() => { + // Only close if we just authenticated (came from authenticated state) + if (state.matches('authenticated')) { + debugWallet('Authenticated! Scheduling modal close...') + justAuthenticatedRef.current = true + + // Wait for state machine to transition to closed, then notify parent + const timer = setTimeout(() => { + debugWallet('Closing modal after authentication') + onClose() + }, 600) // Slightly longer than state machine's 500ms delay + + return () => { + clearTimeout(timer) + // Reset flag if component unmounts before timer fires + if (justAuthenticatedRef.current) { + debugWallet('Component unmounting with authentication pending, resetting flag') + justAuthenticatedRef.current = false + } + } + } + }, [state, onClose]) + + // Clear the authentication flag when session becomes authenticated or modal closes + useEffect(() => { + if (state.matches('closed') && !isOpen) { + // Reset flag after a delay to allow session to update + const timer = setTimeout(() => { + debugWallet('Clearing justAuthenticated flag') + justAuthenticatedRef.current = false + }, 2000) // Wait 2 seconds for session to fully update + return () => clearTimeout(timer) + } + }, [state, isOpen]) + + // Handle wallet selection + const handleSelectWallet = async (walletId: string) => { + debugWallet('handleSelectWallet called with walletId: %s', walletId) + debugWallet( + 'Available wallets: %O', + wallets.map((w) => ({ id: w.id, name: w.name, hasConnector: !!w.connector })) + ) + + const wallet = wallets.find((w) => w.id === walletId) + + if (!wallet) { + debugWallet('ERROR: Wallet not found in list! walletId: %s', walletId) + send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } }) + return + } + + if (!wallet.connector) { + debugWallet('ERROR: Wallet connector is null/undefined! wallet: %O', wallet) + send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } }) + return + } + + send({ type: 'SELECT_WALLET', walletId }) + + try { + debugWallet( + 'Connecting to %s (id: %s, connector: %O)...', + wallet.name, + wallet.id, + wallet.connector + ) + const result = await connectAsync({ connector: wallet.connector }) + debugWallet('Connected successfully: %O', result) + } catch (error) { + debugWallet('Connection error: %O', error) + send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } }) + } + } + + // Handle Safe address submission + const handleSubmitSafeAddress = (safeAddress: Address, chainId: number) => { + send({ + type: 'SUBMIT_SAFE_ADDRESS', + address: safeAddress, + chainId, + }) + } + + // Handle signature request + const handleSign = async () => { + debugWallet('handleSign called, current state: %s', state.value) + debugWallet( + 'State context: address=%s, safeInfo=%O', + state.context.address, + state.context.safeInfo + ) + + send({ type: 'SIGN_MESSAGE' }) + + // Create AbortController with 60s timeout for signature request + const abortController = new AbortController() + const timeoutId = setTimeout(() => { + abortController.abort() + debugWallet('Signature request timed out after 60s') + }, 60000) + + try { + const currentAddress = state.context.address + const safeAddress = state.context.safeInfo?.safeAddress + const safeChainId = state.context.safeInfo?.chainId + + if (!currentAddress) { + debugWallet('ERROR: No address in state context!') + throw new Error('No address available') + } + + // Begin auth flow + beginSiweAuthFlow() + + // Get nonce from API + debugWallet('Fetching nonce from /api/siwe/nonce...') + const nonceRes = await fetch(SIWE_NONCE_PATH) + debugWallet('Nonce response status: %d', nonceRes.status) + + // Get response as text first to see what we're receiving + const nonceText = await nonceRes.text() + debugWallet('Nonce response text: %s', nonceText) + + // Parse the nonce - the endpoint might return plain text instead of JSON + let nonce: string + try { + const parsed = JSON.parse(nonceText) + nonce = parsed.nonce || parsed + } catch { + // If it's not JSON, use the text directly as the nonce + nonce = nonceText + } + + // Validate nonce format and length (SIWE spec requires minimum 8 characters) + if (!nonce || typeof nonce !== 'string' || nonce.trim().length < 8) { + debugWallet('Invalid nonce received: %s', nonce) + throw new Error('Invalid nonce received from server') + } + + debugWallet('Nonce received and validated: %s', nonce) + + // Use Safe chainId if available, otherwise use wagmi chainId, default to 1 + const currentChainId = safeChainId || chainId || 1 + debugWallet( + 'Using chainId: %d (Safe: %d, wagmi: %d)', + currentChainId, + safeChainId, + chainId + ) + + // Create SIWE message + const message = createSiweMessage({ + domain: window.location.host, + address: currentAddress, + statement: safeAddress + ? `Sign in as owner of Safe ${safeAddress}` + : 'Sign in with Ethereum to the app.', + uri: window.location.origin, + version: '1', + chainId: currentChainId, + nonce, + }) + + debugWallet('SIWE message created: %s', message) + debugWallet('Requesting signature from wallet...') + + // Sign message + const signature = await signMessageAsync({ message }) + + // Clear timeout on success + clearTimeout(timeoutId) + + // Check if aborted (timed out) + if (abortController.signal.aborted) { + debugWallet('Signature request was aborted (timeout)') + send({ + type: 'ERROR', + error: { + code: 'SIGNATURE_REJECTED', + message: 'Signature request timed out', + }, + }) + return + } + + debugWallet('Signature received successfully: %s', signature) + + send({ + type: 'SIGNATURE_RECEIVED', + signature, + message, + }) + + debugWallet('SIGNATURE_RECEIVED event sent to state machine') + } catch (error) { + // Clear timeout on error + clearTimeout(timeoutId) + + // Check if aborted (timed out) + if (abortController.signal.aborted) { + debugWallet('Signature request was aborted (timeout)') + send({ + type: 'ERROR', + error: { + code: 'SIGNATURE_REJECTED', + message: 'Signature request timed out', + }, + }) + return + } + + debugWallet('Signature error: %O', error) + debugWallet( + 'Error name: %s, message: %s', + (error as Error).name, + (error as Error).message + ) + send({ + type: 'ERROR', + error: { + code: 'SIGNATURE_REJECTED', + message: (error as Error).message || 'Unknown error', + }, + }) + } + } + + // Handle cancel + const handleCancel = () => { + send({ type: 'CANCEL' }) + } + + // Handle back navigation + const handleBack = () => { + send({ type: 'BACK' }) + } + + // Handle retry + const handleRetry = () => { + send({ type: 'RETRY' }) + } + + // Handle close + const handleClose = () => { + if (!state.matches('closed')) { + send({ type: 'CLOSE' }) + } + onClose() + } + + // Render appropriate view based on state + const renderContent = () => { + if (state.matches('selectingWallet')) { + return ( + send({ type: 'SELECT_SAFE' })} + showSafeOption={true} + /> + ) + } + + if (state.matches('enteringSafeAddress')) { + return ( + + ) + } + + if (state.matches('validatingSafe')) { + return + } + + if (state.matches('selectingSafeOwnerWallet')) { + return ( + {}} + showSafeOption={false} + /> + ) + } + + if (state.matches('connectingWallet')) { + const walletName = state.context.selectedWalletId + ? wallets.find((w) => w.id === state.context.selectedWalletId)?.name + : 'wallet' + return + } + + if (state.matches('checkingSafeOwnership')) { + return + } + + if (state.matches('switchingToSafe')) { + return + } + + if (state.matches('awaitingSignature') || state.matches('signingMessage')) { + const walletName = + state.context.connector?.name || + wallets.find((w) => w.id === state.context.selectedWalletId)?.name || + 'wallet' + + return ( + + ) + } + + if (state.matches('authenticating')) { + return + } + + if (state.matches('authenticated')) { + return + } + + if (state.context.error) { + return ( + + ) + } + + return + } + + return ( + + {renderContent()} + + ) +} diff --git a/apps/web/src/components/profile/DelegateToProfileModal.tsx b/apps/web/src/components/profile/DelegateToProfileModal.tsx index 3fcd44d16..9a436708d 100644 --- a/apps/web/src/components/profile/DelegateToProfileModal.tsx +++ b/apps/web/src/components/profile/DelegateToProfileModal.tsx @@ -3,10 +3,12 @@ import { ETHERSCAN_BASE_URL } from '@buildeross/constants/etherscan' import { useUserDaos } from '@buildeross/hooks/useUserDaos' import { tokenAbi } from '@buildeross/sdk/contract' import { daoMembershipRequest } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import type { AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { FallbackImage } from '@buildeross/ui/FallbackImage' import { AnimatedModal } from '@buildeross/ui/Modal' +import { getSafeErrorMessage } from '@buildeross/utils' import { walletSnippet } from '@buildeross/utils/helpers' import { Box, Button, Flex, Icon, Text } from '@buildeross/zord' import React from 'react' @@ -24,7 +26,7 @@ import { } from 'src/styles/profile.css' import useSWR from 'swr' import { useAccount, useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' type DelegateToProfileModalProps = { open: boolean @@ -156,16 +158,21 @@ export const DelegateToProfileModal: React.FC = ({ functionName: 'delegate', args: [profileAddress], }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: selectedDao.chainId }) - setTxHash(hash) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId: selectedDao.chainId, + }) + if (result.kind === 'safe-proposed') { + setError( + 'Delegation proposed to your Safe. Other owners must sign it before delegation takes effect.' + ) + return + } + setTxHash(result.hash) } catch (err) { console.error('Failed to delegate to profile:', err) - setError( - err instanceof Error - ? err.message - : 'Delegation failed. Please check your wallet and try again.' - ) + setError(getSafeErrorMessage(err)) } finally { setIsDelegating(false) } diff --git a/apps/web/src/components/profile/ProfileLinksEditModal.test.tsx b/apps/web/src/components/profile/ProfileLinksEditModal.test.tsx index 1695f627f..bc84c8c05 100644 --- a/apps/web/src/components/profile/ProfileLinksEditModal.test.tsx +++ b/apps/web/src/components/profile/ProfileLinksEditModal.test.tsx @@ -3,15 +3,20 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { ProfileLinksEditModal } from './ProfileLinksEditModal' -const { readContractMock, waitForTransactionReceiptMock, writeContractMock } = vi.hoisted( - () => ({ - readContractMock: vi.fn(), - waitForTransactionReceiptMock: vi.fn(), - writeContractMock: vi.fn(), - }) -) +const { + readContractMock, + simulateContractMock, + waitForTransactionReceiptMock, + writeContractMock, +} = vi.hoisted(() => ({ + readContractMock: vi.fn(), + simulateContractMock: vi.fn(), + waitForTransactionReceiptMock: vi.fn(), + writeContractMock: vi.fn(), +})) -vi.mock('wagmi', () => ({ +vi.mock('wagmi', async (importOriginal) => ({ + ...(await importOriginal()), useAccount: () => ({ chainId: 8453 }), useConfig: () => ({}), useSwitchChain: () => ({ switchChainAsync: vi.fn() }), @@ -19,6 +24,7 @@ vi.mock('wagmi', () => ({ vi.mock('wagmi/actions', () => ({ readContract: readContractMock, + simulateContract: simulateContractMock, waitForTransactionReceipt: waitForTransactionReceiptMock, writeContract: writeContractMock, })) @@ -32,6 +38,7 @@ describe('ProfileLinksEditModal', () => { beforeEach(() => { vi.clearAllMocks() writeContractMock.mockResolvedValue(`0x${'1'.repeat(64)}`) + simulateContractMock.mockImplementation((_config, request) => ({ request })) waitForTransactionReceiptMock.mockResolvedValue({ status: 'success' }) }) diff --git a/apps/web/src/components/profile/ProfileLinksEditModal.tsx b/apps/web/src/components/profile/ProfileLinksEditModal.tsx index d6972e154..a1b4793bf 100644 --- a/apps/web/src/components/profile/ProfileLinksEditModal.tsx +++ b/apps/web/src/components/profile/ProfileLinksEditModal.tsx @@ -5,6 +5,7 @@ import { PROFILE_LINK_SCHEMA, PROFILE_LINK_SCHEMA_UID, } from '@buildeross/constants' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import type { AddressType } from '@buildeross/types' import { AnimatedModal } from '@buildeross/ui/Modal' import { Box, Button, Flex, Text } from '@buildeross/zord' @@ -22,7 +23,7 @@ import { } from 'src/utils/profileIdentity' import { encodeAbiParameters, zeroHash } from 'viem' import { useAccount, useConfig, useSwitchChain } from 'wagmi' -import { waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' type ProfileLinkKey = 'website' | 'x' | 'farcaster' @@ -114,8 +115,8 @@ export const ProfileLinksEditModal: React.FC = ({ const attestProfileLinks = async ( easAddress: `0x${string}`, updates: ProfileLinkUpdate[] - ): Promise<`0x${string}`> => { - const hash = await writeContract(config, { + ): Promise<'safe-proposed' | `0x${string}`> => { + const { request } = await simulateContract(config, { abi: easAbi, address: easAddress, chainId: PROFILE_LINK_EAS_CHAIN_ID, @@ -142,18 +143,26 @@ export const ProfileLinksEditModal: React.FC = ({ ], ], }) - await waitForTransactionReceipt(config, { - hash, + + const result = await executeAppTransaction({ + config, + request, chainId: PROFILE_LINK_EAS_CHAIN_ID, }) - return hash + if (result.kind === 'safe-proposed') { + return 'safe-proposed' + } + + return result.hash } const saveProfileLinks = async ( easAddress: `0x${string}`, updates: ProfileLinkUpdate[] - ): Promise<`0x${string}`[]> => [await attestProfileLinks(easAddress, updates)] + ): Promise<('safe-proposed' | `0x${string}`)[]> => [ + await attestProfileLinks(easAddress, updates), + ] const handleSave = async () => { let updates: ProfileLinkUpdate[] @@ -190,7 +199,12 @@ export const ProfileLinksEditModal: React.FC = ({ const hashes = await saveProfileLinks(easAddress, updates) - setTxHashes(hashes) + // Don't show success for Safe proposals - user needs to execute via Safe UI + if (hashes.includes('safe-proposed')) { + return + } + + setTxHashes(hashes as `0x${string}`[]) onSaved?.() } catch (err) { console.error('Failed to update profile links:', err) diff --git a/apps/web/src/components/profile/ProfileTokenGallery.tsx b/apps/web/src/components/profile/ProfileTokenGallery.tsx index d13d9c75b..4708ac5a7 100644 --- a/apps/web/src/components/profile/ProfileTokenGallery.tsx +++ b/apps/web/src/components/profile/ProfileTokenGallery.tsx @@ -1,4 +1,5 @@ import { tokenAbi } from '@buildeross/sdk/contract' +import { executeAppTransactions } from '@buildeross/sdk/transaction' import type { AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { FallbackImage } from '@buildeross/ui/FallbackImage' @@ -55,9 +56,10 @@ import { type ProfileToken, } from 'src/utils/profileDashboard' import type { TokenSortOption } from 'src/utils/profileIdentity' +import type { WriteContractParameters } from 'viem' import { isAddress } from 'viem' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { ProfileChainIcon } from './ProfileChainIcon' @@ -154,6 +156,7 @@ const TokenTransferTray: React.FC = ({ const hashes: `0x${string}`[] = [] try { + const requests: WriteContractParameters[] = [] for (const token of selectedTokens) { const data = await simulateContract(config, { abi: tokenAbi, @@ -162,10 +165,30 @@ const TokenTransferTray: React.FC = ({ functionName: 'safeTransferFrom', args: [profileAddress, resolvedRecipient, BigInt(token.tokenId)], }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: token.chainId }) - setTxHashes((current) => [...current, hash]) - hashes.push(hash) + requests.push(data.request) + } + + const result = await executeAppTransactions({ + config, + requests, + chainId: selectedChainId, + }) + if (!Array.isArray(result)) { + if (result.kind === 'safe-proposed') { + setError( + `${selectedTokens.length} transfer${selectedTokens.length === 1 ? '' : 's'} proposed to your Safe. Other owners must sign and execute it before the NFTs move.` + ) + return + } + setTxHashes([result.hash]) + hashes.push(result.hash) + } else { + result.forEach((item) => { + if (item.kind === 'mined') { + setTxHashes((current) => [...current, item.hash]) + hashes.push(item.hash) + } + }) } onTransferComplete?.() } catch (err) { diff --git a/apps/web/src/components/wallet/ErrorView.tsx b/apps/web/src/components/wallet/ErrorView.tsx new file mode 100644 index 000000000..2852cf6be --- /dev/null +++ b/apps/web/src/components/wallet/ErrorView.tsx @@ -0,0 +1,88 @@ +'use client' + +import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' + +import type { AuthError } from '../../types/auth' + +interface ErrorViewProps { + error: AuthError + onRetry?: () => void + onBack?: () => void + onClose: () => void +} + +function getErrorMessage(error: AuthError): { title: string; message: string } { + switch (error.code) { + case 'WALLET_NOT_CONNECTED': + return { + title: 'Connection Failed', + message: 'Failed to connect to your wallet. Please try again.', + } + case 'SIGNATURE_REJECTED': + return { + title: 'Signature Rejected', + message: error.message || 'You rejected the signature request.', + } + case 'SAFE_NOT_FOUND': + return { + title: 'Safe Not Found', + message: + error.message || + `The Safe at ${error.address.slice(0, 6)}...${error.address.slice(-4)} could not be found on this network.`, + } + case 'NOT_SAFE_OWNER': + return { + title: 'Not a Safe Owner', + message: + error.message || + `Address ${error.address.slice(0, 6)}...${error.address.slice(-4)} is not an owner of Safe ${error.safeAddress.slice(0, 6)}...${error.safeAddress.slice(-4)}.`, + } + case 'VERIFICATION_FAILED': + return { + title: 'Verification Failed', + message: error.message || 'Failed to verify your signature.', + } + case 'NETWORK_ERROR': + return { + title: 'Network Error', + message: error.message || 'A network error occurred. Please try again.', + } + default: + return { + title: 'Error', + message: 'An unexpected error occurred.', + } + } +} + +export function ErrorView({ error, onRetry, onBack, onClose }: ErrorViewProps) { + const { title, message } = getErrorMessage(error) + + return ( + + {title} + + + + {message} + + + + + {onRetry && ( + + )} + {onBack && ( + + )} + + + + ) +} diff --git a/apps/web/src/components/wallet/LoadingView.tsx b/apps/web/src/components/wallet/LoadingView.tsx new file mode 100644 index 000000000..a00cb8baf --- /dev/null +++ b/apps/web/src/components/wallet/LoadingView.tsx @@ -0,0 +1,20 @@ +'use client' + +import { Flex, Spinner, Stack, Text } from '@buildeross/zord' + +interface LoadingViewProps { + message?: string +} + +export function LoadingView({ message = 'Loading...' }: LoadingViewProps) { + return ( + + + + + + {message} + + + ) +} diff --git a/apps/web/src/components/wallet/SafeAddressView.tsx b/apps/web/src/components/wallet/SafeAddressView.tsx new file mode 100644 index 000000000..03f75e25a --- /dev/null +++ b/apps/web/src/components/wallet/SafeAddressView.tsx @@ -0,0 +1,101 @@ +'use client' + +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' +import { CHAIN_ID } from '@buildeross/types' +import { DropdownSelect, FIELD_TYPES, SmartInput } from '@buildeross/ui' +import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' +import { ChangeEvent, useState } from 'react' +import type { Address } from 'viem' +import { isAddress } from 'viem' + +interface SafeAddressViewProps { + onSubmit: (address: Address, chainId: number) => void + onBack: () => void + error?: string +} + +// Dynamically build supported chains from SAFE_SERVICE_URL + PUBLIC_DEFAULT_CHAINS +const getSupportedChains = () => { + return PUBLIC_DEFAULT_CHAINS.filter( + (chain) => SAFE_SERVICE_URL[chain.id as CHAIN_ID] !== undefined + ).map((chain) => ({ + value: chain.id as CHAIN_ID, + label: chain.name, + })) +} + +const SUPPORTED_CHAINS = getSupportedChains() + +export function SafeAddressView({ onSubmit, onBack, error }: SafeAddressViewProps) { + const [address, setAddress] = useState('') + const [chainId, setChainId] = useState(CHAIN_ID.ETHEREUM) + const [validationError, setValidationError] = useState() + + const handleSubmit = () => { + if (!address) { + setValidationError('Please enter a Safe address') + return + } + + if (!isAddress(address)) { + setValidationError('Invalid address format') + return + } + + setValidationError(undefined) + onSubmit(address as Address, chainId) + } + + return ( + + Connect Safe Wallet + + + Enter your Safe address and select the network + + + + setChainId(value)} + inputLabel="Network" + height="x12" + /> + + ) => { + setAddress(e.target.value) + setValidationError(undefined) + }} + inputLabel="Safe Address" + placeholder="0x..." + isAddress={true} + errorMessage={validationError} + /> + + + {error && ( + + + {error} + + + )} + + + + + + + ) +} diff --git a/apps/web/src/components/wallet/SignatureView.tsx b/apps/web/src/components/wallet/SignatureView.tsx new file mode 100644 index 000000000..5f9cbc6c7 --- /dev/null +++ b/apps/web/src/components/wallet/SignatureView.tsx @@ -0,0 +1,89 @@ +'use client' + +import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' +import type { Address } from 'viem' + +interface SignatureViewProps { + address: Address + walletName: string + mode: 'eoa' | 'safe' + safeAddress?: Address + onSign: () => void + onCancel: () => void + error?: string + isAuthenticating?: boolean +} + +export function SignatureView({ + address, + walletName, + mode, + safeAddress, + onSign, + onCancel, + error, + isAuthenticating, +}: SignatureViewProps) { + return ( + + Sign Message + + + + + Wallet + + {walletName} + + + + + {mode === 'safe' ? 'Signing as owner' : 'Address'} + + + {address.slice(0, 6)}...{address.slice(-4)} + + + + {mode === 'safe' && safeAddress && ( + + + Safe address + + + {safeAddress.slice(0, 6)}...{safeAddress.slice(-4)} + + + )} + + + + + Confirm the message in {walletName} to continue + + + + {error && ( + + + {error} + + + )} + + + + + + + ) +} diff --git a/apps/web/src/components/wallet/WalletListView.tsx b/apps/web/src/components/wallet/WalletListView.tsx new file mode 100644 index 000000000..acd36039f --- /dev/null +++ b/apps/web/src/components/wallet/WalletListView.tsx @@ -0,0 +1,105 @@ +'use client' + +import { Stack, Text } from '@buildeross/zord' + +import type { WalletInfo } from '../../types/auth' +import { WalletOption } from '../WalletOption' + +interface WalletListViewProps { + wallets: WalletInfo[] + onSelectWallet: (walletId: string) => void + onSelectSafe: () => void + showSafeOption?: boolean +} + +export function WalletListView({ + wallets, + onSelectWallet, + onSelectSafe, + showSafeOption = true, +}: WalletListViewProps) { + const installedWallets = wallets.filter((w) => !w.isRainbowKitConnector) + const popularWallets = wallets.filter((w) => w.isRainbowKitConnector) + + return ( + + Connect Wallet + + {installedWallets.length > 0 && ( + + + Installed + + {installedWallets.map((wallet) => ( + onSelectWallet(wallet.id)} + /> + ))} + + )} + + + + Popular + + {popularWallets.map((wallet) => ( + onSelectWallet(wallet.id)} + /> + ))} + + + {showSafeOption && ( + + + Multi-Sig + + + + )} + + + By connecting a wallet, you acknowledge and agree to the Nouns Builder{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + + + ) +} diff --git a/apps/web/src/connectors/safeOwnerConnector.ts b/apps/web/src/connectors/safeOwnerConnector.ts index 15cb9dbdb..b36ca2ac6 100644 --- a/apps/web/src/connectors/safeOwnerConnector.ts +++ b/apps/web/src/connectors/safeOwnerConnector.ts @@ -6,7 +6,6 @@ import { type SafeInfo, SafeOwnerProvider, } from '@buildeross/utils' -import { getSafeInfo as getSafeInfoFromChain } from '@buildeross/utils/safeService' import { getConnectors } from '@wagmi/core' import type { PublicClient } from 'viem' import { createPublicClient, http } from 'viem' @@ -44,6 +43,7 @@ export function createSafeOwnerConnector(): CreateConnectorFn { let safeInfo_: SafeInfo | null = null let publicClient_: PublicClient | null = null let eoaConnector_: Connector | null = null + let eoaAddress_: `0x${string}` | null = null return createConnector((config) => { /** @@ -73,6 +73,7 @@ export function createSafeOwnerConnector(): CreateConnectorFn { safeInfo_ = null publicClient_ = null eoaConnector_ = null + eoaAddress_ = null } return { @@ -173,13 +174,21 @@ export function createSafeOwnerConnector(): CreateConnectorFn { throw new Error('EOA wallet is not authorized. Please reconnect.') } - // Fetch Safe info if not already loaded + // Cache EOA address for synchronous access + const eoaAccounts = await eoaConnector_.getAccounts() + eoaAddress_ = eoaAccounts?.[0] || null + + // Load Safe info from cache if not already loaded if (!safeInfo_) { - try { - safeInfo_ = await getSafeInfoFromChain(saved.safeAddress, saved.chainId) - } catch (error) { - console.error('[SafeOwnerConnector] Failed to fetch Safe info:', error) - throw new Error('Failed to load Safe information') + // Use cached SafeInfo from localStorage (already fetched during validation) + safeInfo_ = { + safeAddress: saved.safeAddress, + chainId: saved.chainId, + threshold: saved.threshold, + owners: saved.owners, + isReadOnly: false, + nonce: saved.nonce, + version: saved.version, } } @@ -248,11 +257,16 @@ export function createSafeOwnerConnector(): CreateConnectorFn { // Ensure we have Safe info if (!safeInfo_) { - safeInfo_ = await getSafeInfoFromChain(saved.safeAddress, saved.chainId) - } - - if (!safeInfo_) { - throw new Error('Failed to fetch Safe info') + // Use cached SafeInfo from localStorage (already fetched during validation) + safeInfo_ = { + safeAddress: saved.safeAddress, + chainId: saved.chainId, + threshold: saved.threshold, + owners: saved.owners, + isReadOnly: false, + nonce: saved.nonce, + version: saved.version, + } } // Create SafeOwnerProvider @@ -310,11 +324,48 @@ export function createSafeOwnerConnector(): CreateConnectorFn { // Custom property to expose Safe info get safeInfo() { + // Lazily load from localStorage if not already cached + if (!safeInfo_) { + const saved = loadSafeConfig() + if (saved) { + safeInfo_ = { + safeAddress: saved.safeAddress, + chainId: saved.chainId, + threshold: saved.threshold, + owners: saved.owners, + isReadOnly: false, + nonce: saved.nonce, + version: saved.version, + } + } + } return safeInfo_ }, + // Synchronous getter for cached EOA connector + get cachedEOAConnector(): Connector | null { + return eoaConnector_ + }, + + // Synchronous getter for cached EOA address + get cachedEOAAddress(): `0x${string}` | null { + return eoaAddress_ + }, + + // Custom method to get EOA connector + async getEOAConnector(): Promise { + const saved = loadSafeConfig() + if (!saved) return null + + if (!eoaConnector_) { + eoaConnector_ = findEOAConnector(saved.eoaConnectorId) + } + if (!eoaConnector_) return null + return eoaConnector_ + }, + // Custom method to get EOA address for signing - async getEOAAddress() { + async getEOAAddress(): Promise<`0x${string}` | null> { const saved = loadSafeConfig() if (!saved) return null diff --git a/apps/web/src/hooks/useAppDisconnect.ts b/apps/web/src/hooks/useAppDisconnect.ts new file mode 100644 index 000000000..8c99c3430 --- /dev/null +++ b/apps/web/src/hooks/useAppDisconnect.ts @@ -0,0 +1,59 @@ +import { useSafeAuth } from '@buildeross/hooks' +import { useWalletDisconnect } from '@buildeross/hooks/useWalletDisconnect' +import { useCallback, useRef } from 'react' +import { useSWRConfig } from 'swr' + +import { debugSession } from '../utils/debug' +import { + beginSiweLogout, + cancelSiweAuthFlow, + SIWE_LOGOUT_EVENT, + SIWE_ME_PATH, +} from '../utils/siweAuthFlow' + +/** + * App-specific disconnect hook that wraps useWalletDisconnect + * and adds SIWE session + Safe cleanup logic + */ +export function useAppDisconnect(): () => Promise { + const baseDisconnect = useWalletDisconnect() + const { clearSafe } = useSafeAuth() + const { mutate } = useSWRConfig() + + // Track if disconnect is in progress + const disconnectInProgressRef = useRef(false) + + return useCallback(async () => { + // Prevent concurrent disconnect operations + if (disconnectInProgressRef.current) { + debugSession('Disconnect already in progress, skipping') + return + } + + try { + disconnectInProgressRef.current = true + + // 1) Cancel any pending SIWE auth flow + beginSiweLogout() + cancelSiweAuthFlow() + + // 2) Clear Safe wallet info + clearSafe() + + // 3) Optimistically clear SWR cache to prevent showing stale session data + // Use mutate with revalidate=false to immediately update cache without refetching + mutate(SIWE_ME_PATH, null, false) + + // 4) Ask the app-level session machine to perform logout + window.dispatchEvent(new Event(SIWE_LOGOUT_EVENT)) + + // 5) Disconnect wallet only after logout has been initiated + await baseDisconnect() + } finally { + // Reset flag after a delay to prevent rapid re-triggers + setTimeout(() => { + disconnectInProgressRef.current = false + }, 500) + } + }, [baseDisconnect, clearSafe, mutate]) +} diff --git a/apps/web/src/hooks/useSession.ts b/apps/web/src/hooks/useSession.ts new file mode 100644 index 000000000..682a3c191 --- /dev/null +++ b/apps/web/src/hooks/useSession.ts @@ -0,0 +1,46 @@ +import useSWR from 'swr' + +import type { SessionData } from '../types/auth' +import { debugSession } from '../utils/debug' +import { SIWE_ME_PATH } from '../utils/siweAuthFlow' + +const fetcher = async (url: string): Promise => { + debugSession('Fetching session from %s', url) + const res = await fetch(url, { cache: 'no-store' }) + + if (!res.ok) { + debugSession('Session fetch failed: %d', res.status) + // Return empty for auth errors (expected case - not authenticated) + if (res.status === 401 || res.status === 403) { + return {} + } + // Throw for server errors so SWR can handle retry + throw new Error(`Session fetch failed: ${res.status}`) + } + + const data = await res.json() + debugSession('Session data: %O', data) + return data +} + +export function useSession() { + const { data, error, mutate, isLoading } = useSWR(SIWE_ME_PATH, fetcher, { + refreshInterval: 60000, // Auto-refresh every minute + revalidateOnFocus: true, // Check when tab focused + revalidateOnReconnect: true, // Check when network restored + shouldRetryOnError: false, // Don't retry 401s + dedupingInterval: 5000, // Dedupe within 5 seconds + }) + + return { + session: data, + address: data?.safeAddress || data?.address, + eoaAddress: data?.eoaAddress, + safeAddress: data?.safeAddress, + isAuthenticated: !!(data?.address || data?.safeAddress), + isSafeMode: !!data?.safeAddress, + isLoading, + error, + refresh: mutate, + } +} diff --git a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx index ce5c87c10..6584f7326 100644 --- a/apps/web/src/layouts/BaseLayout/BaseLayout.tsx +++ b/apps/web/src/layouts/BaseLayout/BaseLayout.tsx @@ -20,6 +20,8 @@ import { useConnectModal } from '@rainbow-me/rainbowkit' import React, { ReactNode, useMemo } from 'react' import { zeroAddress as ZERO_ADDRESS } from 'viem' +import { ErrorBoundary } from '../../components/ErrorBoundary' +import { SafeTransactionHandler } from '../../components/SafeTransactionHandler' import { Nav as DefaultLayoutNav } from '../DefaultLayout/Nav' type BoxProps = React.ComponentProps @@ -59,6 +61,9 @@ export function BaseLayout({ {footer} + + + diff --git a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx index 91cb08b2b..a75af5886 100644 --- a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx +++ b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx @@ -1,11 +1,20 @@ import { Button, Flex } from '@buildeross/zord' import { ConnectButton as RKConnectButton } from '@rainbow-me/rainbowkit' +import dynamic from 'next/dynamic' import React, { useState } from 'react' -import { CustomWalletModal } from 'src/components/CustomWalletModal' import { useAccount } from 'wagmi' import { connectButtonWrapper } from './Nav.styles.css' +// Lazy load WalletConnectDialog for better performance +const WalletConnectDialog = dynamic( + () => + import('src/components/WalletConnectDialog').then((mod) => ({ + default: mod.WalletConnectDialog, + })), + { ssr: false } +) + export const ConnectButton = () => { const [showModal, setShowModal] = useState(false) const { connector } = useAccount() @@ -62,7 +71,7 @@ export const ConnectButton = () => { > Connect - setShowModal(false)} /> diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx index 18e34d02a..09c73b0c3 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ChainMenu.tsx @@ -1,11 +1,11 @@ import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' -import { useWalletDisconnect } from '@buildeross/hooks/useWalletDisconnect' import { useChainStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { Box, Flex, Icon, PopUp, Stack, Text, vars } from '@buildeross/zord' import Image from 'next/image' import { useRouter } from 'next/router' import React, { useCallback, useEffect, useMemo } from 'react' +import { useAppDisconnect } from 'src/hooks/useAppDisconnect' import { useAccount, useSwitchChain } from 'wagmi' import { @@ -30,9 +30,9 @@ export const ChainMenu: React.FC = ({ }) => { const [isChainInitialized, setIsChainInitialized] = React.useState(false) const router = useRouter() - const { isConnected, chain: wagmiChain, connector } = useAccount() + const { isConnected, chain: wagmiChain } = useAccount() const { switchChain } = useSwitchChain() - const onDisconnect = useWalletDisconnect() + const onDisconnect = useAppDisconnect() const { chain: selectedChain, setChain, hasHydrated } = useChainStore() @@ -40,17 +40,21 @@ export const ChainMenu: React.FC = ({ const onSwitchChain = useCallback( (chainId: number) => { - if (!connector?.getChainId) return onDisconnect() switchChain( { chainId }, { onError(error) { console.error(`Failed to switch chain:`, error) + // Only disconnect if critical error (not user rejection) + if (error.message?.includes('User rejected') === false) { + console.warn('Chain switch failed critically, disconnecting') + onDisconnect() + } }, } ) }, - [switchChain, onDisconnect, connector] + [switchChain, onDisconnect] ) const onChainChange = useCallback( @@ -98,25 +102,45 @@ export const ChainMenu: React.FC = ({ const handleRouteChangeComplete = () => { if (selectedChain) { - switchChain( - { chainId: selectedChain.id }, - { - onError(error) { - console.error(`Failed to automatically switch chain:`, error) - }, - } - ) + setIsChainInitialized(true) } - setIsChainInitialized(true) } + // Only run on mount when hydrated (initial setup) handleRouteChangeComplete() + router.events.on('routeChangeComplete', handleRouteChangeComplete) return () => { router.events.off('routeChangeComplete', handleRouteChangeComplete) } - }, [router, hasHydrated, selectedChain, switchChain]) + // Only depend on hasHydrated and router, not selectedChain or switchChain + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router, hasHydrated]) + + // Separate effect to handle chain switching when needed + useEffect(() => { + if (!hasHydrated || !isChainInitialized) return + + // Only switch if connected and on wrong chain + if (isConnected && wagmiChain?.id !== selectedChain?.id) { + switchChain( + { chainId: selectedChain.id }, + { + onError(error) { + console.error(`Failed to automatically switch chain:`, error) + }, + } + ) + } + }, [ + hasHydrated, + isChainInitialized, + isConnected, + wagmiChain?.id, + selectedChain?.id, + switchChain, + ]) if (!hasHydrated || !isChainInitialized) { return null diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx index d19fe53d4..12059b846 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx @@ -1,8 +1,8 @@ import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' import { MOBILE_PROFILE_MENU_LAYER, NAV_BUTTON_LAYER } from '@buildeross/constants/layers' +import { SAFE_HOME_URL } from '@buildeross/constants/safe' import { useEnsData } from '@buildeross/hooks/useEnsData' import { useUserDaos } from '@buildeross/hooks/useUserDaos' -import { useWalletDisconnect } from '@buildeross/hooks/useWalletDisconnect' import { useAuthStore, useChainStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { Avatar, DaoAvatar } from '@buildeross/ui/Avatar' @@ -16,6 +16,7 @@ import NextImage from 'next/image' import Link from 'next/link' import React from 'react' import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' +import { useAppDisconnect } from 'src/hooks/useAppDisconnect' import { useDaoListPreferences } from 'src/hooks/useDaoListPreferences' import { profileStatBadge } from 'src/styles/profile.css' import { type Address, formatUnits } from 'viem' @@ -134,15 +135,27 @@ export const ProfileMenu: React.FC = ({ const { chain: selectedChain } = useChainStore() const { connector } = useAccount() - // Detect Safe mode and get EOA address + // Detect Safe mode and get EOA address + Safe chainId const isSafeMode = connector?.id === 'safeOwner' const [eoaAddress, setEoaAddress] = React.useState
(null) + const [safeChainId, setSafeChainId] = React.useState(null) React.useEffect(() => { - if (isSafeMode && connector && 'getEOAAddress' in connector) { - ;(connector as any).getEOAAddress().then((addr: Address) => setEoaAddress(addr)) + if (isSafeMode && connector) { + // Fetch EOA address + if ('getEOAAddress' in connector) { + ;(connector as any).getEOAAddress().then((addr: Address) => setEoaAddress(addr)) + } + + // Fetch Safe's actual chainId + if ('getChainId' in connector) { + ;(connector as any) + .getChainId() + .then((chainId: number) => setSafeChainId(chainId as CHAIN_ID)) + } } else { setEoaAddress(null) + setSafeChainId(null) } }, [isSafeMode, connector]) @@ -205,13 +218,18 @@ export const ProfileMenu: React.FC = ({ const [isMobile, setIsMobile] = React.useState(false) React.useEffect(() => { + // Check if window is defined (SSR safety) + if (typeof window === 'undefined') return + const handleResize = () => { setIsMobile(window.innerWidth <= 768) } - if (!!window) { - window.addEventListener('resize', handleResize) - handleResize() - } + + // Set initial value + handleResize() + + // Add listener + window.addEventListener('resize', handleResize) return () => { window.removeEventListener('resize', handleResize) @@ -230,7 +248,7 @@ export const ProfileMenu: React.FC = ({ } }, [isMobile, activeDropdown]) - const onDisconnect = useWalletDisconnect() + const onDisconnect = useAppDisconnect() const renderConnectedUserCommon = ({ isStatic = false }: { isStatic?: boolean }) => ( <> @@ -256,61 +274,82 @@ export const ProfileMenu: React.FC = ({ > - {isSafeMode ? ( - <> - - + {displayName} + + {userBalance} + + + + + + + + {/* Safe Details Card - Only shown in Safe mode */} + {isSafeMode && + connector && + 'safeInfo' in connector && + safeChainId && + SAFE_HOME_URL[safeChainId] && ( + + + {/* Safe Header - Clickable link to Safe app */} + + + + + Safe Multisig + + + + + + {/* Threshold Info */} + {(connector as any).safeInfo?.threshold && ( + + {(connector as any).safeInfo.threshold} of{' '} + {(connector as any).safeInfo.owners.length} signatures required + + )} + + {/* Owner Info */} + {eoaAddress && ( + + + + Owner: + + - {displayName} + {eoaEnsData.displayName} - - {userBalance} - - {eoaAddress && ( - - - Owner: - - - {eoaEnsData.displayName} - - - )} - - ) : ( - <> - {displayName} - - {userBalance} - - + + )} - - - - - {isSafeMode && eoaAddress && } - - + + )} diff --git a/apps/web/src/modules/dashboard/Dashboard.tsx b/apps/web/src/modules/dashboard/Dashboard.tsx index 8d5b0ad2f..778796757 100644 --- a/apps/web/src/modules/dashboard/Dashboard.tsx +++ b/apps/web/src/modules/dashboard/Dashboard.tsx @@ -15,6 +15,7 @@ import React, { useMemo } from 'react' import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' import { useDaoListPreferences } from 'src/hooks/useDaoListPreferences' +import { landingContainer } from '../playground/components/PlaygroundLanding.css' import { CreateActions } from './CreateActions' import { DaoAuctionCard } from './DaoAuctionCard' import { DaoProposals } from './DaoProposals' @@ -335,6 +336,14 @@ export const Dashboard: React.FC = () => { ) } + if (!address || !isAuthenticated) { + return ( +
+ +
+ ) + } + return ( { + inspect({ + iframe: false, // Use the browser extension instead + }) + }) +} import { useSafeAuth } from '@buildeross/hooks' -import { AuthStatusContext } from '@buildeross/stores' +import { AuthStatusContext, SessionContext } from '@buildeross/stores' import { LinkComponentProvider } from '@buildeross/ui/LinkComponentProvider' import { NetworkController } from '@buildeross/ui/NetworkController' import { vars } from '@buildeross/zord' @@ -31,27 +40,42 @@ import { RainbowKitProvider, } from '@rainbow-me/rainbowkit' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useMachine } from '@xstate/react' import type { NextPage } from 'next' import type { AppProps } from 'next/app' import Link from 'next/link' import NextNProgress from 'nextjs-progressbar' -import { - type ReactElement, - type ReactNode, - useEffect, - useMemo, - useRef, - useState, -} from 'react' +import * as React from 'react' +import { type ReactElement, type ReactNode, useEffect } from 'react' import { Disclaimer } from 'src/components/Disclaimer' +import { ErrorBoundary } from 'src/components/ErrorBoundary' import { FrameProvider } from 'src/components/FrameProvider' import { LinksProvider } from 'src/components/LinksProvider' +import { useAppDisconnect } from 'src/hooks/useAppDisconnect' +import { useSession } from 'src/hooks/useSession' +import { sessionMachine } from 'src/machines/sessionMachine' import { AppThemeProvider } from 'src/theme/AppThemeProvider' import { clientConfig } from 'src/utils/clientConfig' +import { debugSession } from 'src/utils/debug' +import { + beginSiweLogout, + cancelSiweAuthFlow, + isSiweLogoutInProgress, + markSiweAuthVerified, + shouldSuppressSiweLogout, + SIWE_LOGOUT_EVENT, + SIWE_LOGOUT_IN_PROGRESS_KEY, + SIWE_NONCE_PATH, + SIWE_REFRESH_EVENT, + SIWE_VERIFY_PATH, +} from 'src/utils/siweAuthFlow' import { SWRConfig } from 'swr' import { createSiweMessage } from 'viem/siwe' import { useConfig, WagmiProvider } from 'wagmi' +// Cross-tab storage event debounce interval +const CROSS_TAB_DEBOUNCE_MS = 100 + const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -75,170 +99,241 @@ function AppContent({ Component, pageProps, err }: AppPropsWithLayout) { const getLayout = Component.getLayout ?? ((page) => page) const fallback = pageProps?.fallback ?? {} - const fetchingStatusRef = useRef(false) - const verifyingRef = useRef(false) - const [rainbowKitAuthStatus, setRainbowKitAuthStatus] = - useState('unauthenticated') const { state: safeState, clearSafe } = useSafeAuth() + const resetApp = useAppDisconnect() const config = useConfig() + const logoutInProgress = isSiweLogoutInProgress() + + // Use ref to always get latest safeState in auth adapter callbacks + const safeStateRef = React.useRef(safeState) + React.useEffect(() => { + safeStateRef.current = safeState + }, [safeState]) + + // Session management with XState machine + const [sessionState, sendSession] = useMachine(sessionMachine) + + // Session data with SWR (auto-refresh, deduplication, focus revalidation) + const { session, refresh } = useSession() + + // Sync SWR session data with XState machine + useEffect(() => { + if (logoutInProgress) { + return + } + + // Don't sync session data if we're in the middle of logging out + // This prevents race conditions where SWR revalidation overrides logout + if (sessionState.matches('loggingOut')) { + return + } + + if (session?.address || session?.safeAddress) { + sendSession({ type: 'SESSION_FOUND', data: session }) + } else if (sessionState.matches('authenticated')) { + sendSession({ type: 'SESSION_NOT_FOUND' }) + } + }, [session, sessionState, sendSession, logoutInProgress]) - // Simple session verification (RainbowKit pattern) useEffect(() => { - const verifySession = async () => { - if (fetchingStatusRef.current || verifyingRef.current) { + const handleLogout = () => { + debugSession('Logout event received') + beginSiweLogout() + sendSession({ type: 'LOGOUT' }) + } + + window.addEventListener(SIWE_LOGOUT_EVENT, handleLogout) + return () => window.removeEventListener(SIWE_LOGOUT_EVENT, handleLogout) + }, [sendSession]) + + // Cross-tab storage sync with debouncing + useEffect(() => { + let debounceTimer: NodeJS.Timeout | null = null + + const handleStorage = (e: StorageEvent) => { + // Logout flag changes are used to re-enable refresh after logout + if (e.key === SIWE_LOGOUT_IN_PROGRESS_KEY) { + if (e.newValue === null) { + debugSession('Logout gate cleared, refreshing session') + refresh() + } return } - fetchingStatusRef.current = true - - try { - const response = await fetch('/api/siwe/me') - const json = await response.json() - - // Get current wagmi account - const currentAccount = config.state.current - ? config.state.connections.get(config.state.current)?.accounts?.[0] - : undefined - - // Authenticated if: - // 1. Session has an address - // 2. That address matches the current wagmi account (or no account connected) - const newStatus = - json.address && (!currentAccount || json.address === currentAccount) - ? 'authenticated' - : 'unauthenticated' - - setRainbowKitAuthStatus(newStatus) - } catch (_error) { - setRainbowKitAuthStatus('unauthenticated') - } finally { - fetchingStatusRef.current = false + // Only react to wagmi storage changes + if (!e.key?.startsWith('wagmi.')) return + + // Debounce to prevent multiple rapid fires + if (debounceTimer) { + clearTimeout(debounceTimer) } - } - // Verify on mount - verifySession() + debounceTimer = setTimeout(() => { + // Disconnect detected (storage removed) + if (e.newValue === null) { + debugSession('Cross-tab disconnect detected') - // Verify on window focus (in case user logs out of another window) - window.addEventListener('focus', verifySession) - return () => window.removeEventListener('focus', verifySession) - }, [config]) + // Reset wagmi state to match other tab + config.setState((x) => ({ + ...x, + connections: new Map(), + current: null, + status: 'disconnected', + })) - // Cross-tab synchronization: detect when another tab clears wagmi storage - useEffect(() => { - const handleStorageChange = (e: StorageEvent) => { - // Check if wagmi storage was cleared in another tab - if (e.key?.startsWith('wagmi.') && e.newValue === null) { - // Another tab disconnected - sync wagmi state immediately - config.setState((x) => ({ - ...x, - connections: new Map(), - current: null, - status: 'disconnected', - })) - // Also clear auth status - setRainbowKitAuthStatus('unauthenticated') + if (!isSiweLogoutInProgress()) { + // Logout in XState machine only when this is not part of an active logout + sendSession({ type: 'LOGOUT' }) + cancelSiweAuthFlow() + } + } + // Connection detected (storage added/changed) + else { + debugSession('Cross-tab state change detected, refreshing session') + // Refresh session to check if authenticated in other tab + if (!isSiweLogoutInProgress()) { + refresh() + } + } + }, CROSS_TAB_DEBOUNCE_MS) + } + + window.addEventListener('storage', handleStorage) + return () => { + window.removeEventListener('storage', handleStorage) + if (debounceTimer) { + clearTimeout(debounceTimer) } } + }, [config, sendSession, refresh]) - window.addEventListener('storage', handleStorageChange) - return () => window.removeEventListener('storage', handleStorageChange) - }, [config]) - - const authAdapter = useMemo(() => { - return createAuthenticationAdapter({ - getNonce: async () => { - const response = await fetch('/api/siwe/nonce') - const nonce = await response.text() - return nonce - }, - - createMessage: ({ nonce, address, chainId: msgChainId }) => { - const message = createSiweMessage({ - domain: window.location.host, - address, - statement: safeState.safeAddress - ? `Sign in as owner for Safe ${safeState.safeAddress}` - : 'Sign in with Ethereum to Nouns Builder', - uri: window.location.origin, - version: '1', - chainId: msgChainId, - nonce, - }) - return message - }, - - verify: async ({ message, signature }) => { - verifyingRef.current = true - - try { - const response = await fetch('/api/siwe/verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - message, - signature, - safeAddress: safeState.safeAddress, - safeChainId: safeState.chainId, - }), + // Custom refresh event (fired after SIWE verification) + useEffect(() => { + const handleRefresh = () => { + debugSession('Refresh event received, calling SWR refresh()') + refresh() // SWR will revalidate + } + + window.addEventListener(SIWE_REFRESH_EVENT, handleRefresh) + return () => window.removeEventListener(SIWE_REFRESH_EVENT, handleRefresh) + }, [refresh]) + + const authAdapter = React.useMemo( + () => + createAuthenticationAdapter({ + getNonce: async () => { + const response = await fetch(SIWE_NONCE_PATH) + const nonce = await response.text() + return nonce + }, + + createMessage: ({ nonce, address, chainId: msgChainId }) => { + // Use ref to get latest safeState (prevents stale closure) + const currentSafeState = safeStateRef.current + const message = createSiweMessage({ + domain: window.location.host, + address, + statement: currentSafeState.safeAddress + ? `Sign in as owner for Safe ${currentSafeState.safeAddress}` + : 'Sign in with Ethereum to Nouns Builder', + uri: window.location.origin, + version: '1', + chainId: msgChainId, + nonce, }) + return message + }, + + verify: async ({ message, signature }) => { + try { + // Use ref to get latest safeState (prevents stale closure) + const currentSafeState = safeStateRef.current + const response = await fetch(SIWE_VERIFY_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message, + signature, + safeAddress: currentSafeState.safeAddress, + safeChainId: currentSafeState.chainId, + }), + }) + + const body = (await response.json()) as { ok?: boolean } + const authenticated = response.ok && body.ok === true - const body = (await response.json()) as { ok?: boolean } - const authenticated = response.ok && body.ok === true + if (authenticated) { + markSiweAuthVerified() + // Trigger session refresh via SWR + refresh() + } else { + cancelSiweAuthFlow() + } - if (authenticated) { - setRainbowKitAuthStatus('authenticated') + return authenticated + } catch (error) { + debugSession('Error verifying signature: %O', error) + return false } + }, - return authenticated - } catch (error) { - console.error('[Auth] Error verifying signature', error) - return false - } finally { - verifyingRef.current = false - } - }, + signOut: async () => { + if (shouldSuppressSiweLogout()) { + return + } - signOut: async () => { - setRainbowKitAuthStatus('unauthenticated') - clearSafe() - await fetch('/api/siwe/logout', { method: 'POST' }) - }, - }) - }, [setRainbowKitAuthStatus, safeState.safeAddress, safeState.chainId, clearSafe]) + debugSession('Signing out') + beginSiweLogout() + sendSession({ type: 'LOGOUT' }) + clearSafe() + cancelSiweAuthFlow() + }, + }), + [sendSession, clearSafe, refresh] + ) + + // RainbowKit auth status derived from XState machine + const rainbowKitAuthStatus: AuthenticationStatus = + sessionState.matches('authenticated') && !logoutInProgress + ? 'authenticated' + : 'unauthenticated' return ( - - - - - - - - - - {getLayout()} - - - - - - - - - - - + + + + + + + + + + + + {getLayout()} + + + + + + + + + + + + + ) } diff --git a/apps/web/src/pages/api/siwe/logout.ts b/apps/web/src/pages/api/siwe/logout.ts index c7144611f..e56d0f485 100644 --- a/apps/web/src/pages/api/siwe/logout.ts +++ b/apps/web/src/pages/api/siwe/logout.ts @@ -2,6 +2,7 @@ import { getIronSession } from 'iron-session' import type { NextApiRequest, NextApiResponse } from 'next' import { withRateLimit } from 'src/utils/api/rateLimit' import { ironOptions, type IronSessionData } from 'src/utils/iron' +import { SIWE_LOGOUT_RATE_LIMIT_KEY_PREFIX } from 'src/utils/siweAuthFlow' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { method } = req @@ -21,5 +22,5 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { export default withRateLimit({ maxRequests: 60, windowSeconds: 60, - keyPrefix: 'siwe:logout', + keyPrefix: SIWE_LOGOUT_RATE_LIMIT_KEY_PREFIX, })(handler) diff --git a/apps/web/src/pages/api/siwe/me.ts b/apps/web/src/pages/api/siwe/me.ts index e6250a08a..8653865cd 100644 --- a/apps/web/src/pages/api/siwe/me.ts +++ b/apps/web/src/pages/api/siwe/me.ts @@ -2,6 +2,7 @@ import { getIronSession } from 'iron-session' import type { NextApiRequest, NextApiResponse } from 'next' import { withRateLimit } from 'src/utils/api/rateLimit' import { ironOptions, type IronSessionData } from 'src/utils/iron' +import { SIWE_ME_RATE_LIMIT_KEY_PREFIX } from 'src/utils/siweAuthFlow' const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { method } = req @@ -25,5 +26,5 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { export default withRateLimit({ maxRequests: 120, windowSeconds: 60, - keyPrefix: 'siwe:me', + keyPrefix: SIWE_ME_RATE_LIMIT_KEY_PREFIX, })(handler) diff --git a/apps/web/src/pages/api/siwe/nonce.ts b/apps/web/src/pages/api/siwe/nonce.ts index 664ab0fc5..7fdadcd49 100644 --- a/apps/web/src/pages/api/siwe/nonce.ts +++ b/apps/web/src/pages/api/siwe/nonce.ts @@ -2,6 +2,7 @@ import { getIronSession } from 'iron-session' import type { NextApiRequest, NextApiResponse } from 'next' import { withRateLimit } from 'src/utils/api/rateLimit' import { ironOptions, type IronSessionData } from 'src/utils/iron' +import { SIWE_NONCE_RATE_LIMIT_KEY_PREFIX } from 'src/utils/siweAuthFlow' import { generateSiweNonce } from 'viem/siwe' const handler = async (req: NextApiRequest, res: NextApiResponse) => { @@ -23,5 +24,5 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { export default withRateLimit({ maxRequests: 30, windowSeconds: 60, - keyPrefix: 'siwe:nonce', + keyPrefix: SIWE_NONCE_RATE_LIMIT_KEY_PREFIX, })(handler) diff --git a/apps/web/src/pages/api/siwe/verify.ts b/apps/web/src/pages/api/siwe/verify.ts index 2d004402a..6baf827eb 100644 --- a/apps/web/src/pages/api/siwe/verify.ts +++ b/apps/web/src/pages/api/siwe/verify.ts @@ -4,6 +4,7 @@ import { getIronSession } from 'iron-session' import type { NextApiRequest, NextApiResponse } from 'next' import { withRateLimit } from 'src/utils/api/rateLimit' import { ironOptions, type IronSessionData } from 'src/utils/iron' +import { SIWE_VERIFY_RATE_LIMIT_KEY_PREFIX } from 'src/utils/siweAuthFlow' import type { Address } from 'viem' import { verifyMessage } from 'viem' import { parseSiweMessage, type SiweMessage } from 'viem/siwe' @@ -73,5 +74,5 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { export default withRateLimit({ maxRequests: 10, windowSeconds: 60, - keyPrefix: 'siwe:verify', + keyPrefix: SIWE_VERIFY_RATE_LIMIT_KEY_PREFIX, })(handler) diff --git a/apps/web/src/pages/dao/[network]/[token]/migrate.tsx b/apps/web/src/pages/dao/[network]/[token]/migrate.tsx index 8e44fb6f8..147ac98a7 100644 --- a/apps/web/src/pages/dao/[network]/[token]/migrate.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/migrate.tsx @@ -14,7 +14,7 @@ import { Box, Flex, Stack, Text } from '@buildeross/zord' import { GetServerSideProps } from 'next' import { useRouter } from 'next/router' import React, { useCallback } from 'react' -import { useReadContracts } from 'wagmi' +import { useAccount, useReadContracts } from 'wagmi' import { getDaoLayout } from '../../../../layouts/DaoLayout' import { NextPageWithLayout } from '../../../_app' @@ -40,6 +40,7 @@ const MigratePage: NextPageWithLayout = ({ chainId, }) => { const { address: walletAddress, isConnected } = useAuthStore() + const { connector } = useAccount() const router = useRouter() const onNavigateToReview = useCallback(() => { @@ -103,6 +104,21 @@ const MigratePage: NextPageWithLayout = ({ return false }, [walletAddress, founders, builderBalance]) + if (isConnected && connector?.id === 'safeOwner') { + return ( + + + + Migration unavailable for Safe wallets + + + Connect an owner wallet directly to use the migration wizard. + + + + ) + } + // Show loading state while checking access if (isConnected && isLoadingAccess) { return ( diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts new file mode 100644 index 000000000..d5bc52fce --- /dev/null +++ b/apps/web/src/types/auth.ts @@ -0,0 +1,40 @@ +import type { Address } from 'viem' +import type { Connector } from 'wagmi' + +// Session data from /api/siwe/me +export interface SessionData { + address?: Address + eoaAddress?: Address + safeAddress?: Address + safeChainId?: number +} + +// Auth errors (typed discriminated union with consistent message field) +export type AuthError = + | { code: 'WALLET_NOT_CONNECTED'; message?: string } + | { code: 'SIGNATURE_REJECTED'; message: string } + | { code: 'INVALID_NONCE'; message?: string } + | { code: 'SAFE_NOT_FOUND'; address: Address; message?: string } + | { code: 'NOT_SAFE_OWNER'; address: Address; safeAddress: Address; message?: string } + | { code: 'VERIFICATION_FAILED'; message: string } + | { code: 'NETWORK_ERROR'; message: string } + +// Safe info structure (matches @buildeross/utils) +export interface SafeInfo { + safeAddress: Address + chainId: number + threshold: number + owners: Address[] + isReadOnly: boolean + nonce?: number + version?: string +} + +// Wallet types +export interface WalletInfo { + id: string + name: string + iconUrl?: string + isRainbowKitConnector: boolean + connector?: Connector +} diff --git a/apps/web/src/types/connectors.ts b/apps/web/src/types/connectors.ts new file mode 100644 index 000000000..493565227 --- /dev/null +++ b/apps/web/src/types/connectors.ts @@ -0,0 +1,28 @@ +import type { Address } from 'viem' +import type { Connector } from 'wagmi' + +import type { SafeInfo } from './auth' + +// Extend wagmi connector types for SafeOwnerConnector +declare module 'wagmi' { + interface Register { + connector: { + safeOwner: SafeOwnerConnectorType + } + } +} + +export interface SafeOwnerConnectorType extends Connector { + id: 'safeOwner' + name: 'Safe Owner' + safeInfo: SavedSafeInfo | null + cachedEOAConnector: Connector | null + cachedEOAAddress: Address | null + getEOAConnector(): Promise + getEOAAddress(): Promise
+} + +export interface SavedSafeInfo extends SafeInfo { + eoaConnectorId: string + timestamp: number +} diff --git a/apps/web/src/utils/debug-noop.js b/apps/web/src/utils/debug-noop.js new file mode 100644 index 000000000..5c96d2828 --- /dev/null +++ b/apps/web/src/utils/debug-noop.js @@ -0,0 +1,13 @@ +// No-op replacement for debug package in production builds +// This file replaces the debug module via webpack.NormalModuleReplacementPlugin +// Result: All debug() calls become empty functions that get optimized away + +function noop() {} +noop.enabled = false + +module.exports = function () { + return noop +} + +// Support both default and named exports +module.exports.default = module.exports diff --git a/apps/web/src/utils/debug.ts b/apps/web/src/utils/debug.ts new file mode 100644 index 000000000..f5d351a7d --- /dev/null +++ b/apps/web/src/utils/debug.ts @@ -0,0 +1,23 @@ +import debug from 'debug' + +// Namespace-based loggers for different parts of the auth flow +export const debugAuth = debug('app:auth') +export const debugSafe = debug('app:safe') +export const debugSafeTx = debug('app:safe:tx') +export const debugSession = debug('app:session') +export const debugWallet = debug('app:wallet') +export const debugConnector = debug('app:connector') + +// Helper for structured logging with state names +export function logState(namespace: debug.Debugger, state: string, data?: any) { + namespace(`[${state}]`, data || '') +} + +// Usage examples: +// debugAuth('User clicked sign message') +// logState(debugSafeTx, 'PROPOSING', { safeAddress, txHash }) +// +// To enable in browser console: +// localStorage.setItem('debug', 'app:*') // Enable all +// localStorage.setItem('debug', 'app:safe:*') // Only Safe logs +// localStorage.removeItem('debug') // Disable all diff --git a/apps/web/src/utils/siweAuthFlow.ts b/apps/web/src/utils/siweAuthFlow.ts new file mode 100644 index 000000000..94f606100 --- /dev/null +++ b/apps/web/src/utils/siweAuthFlow.ts @@ -0,0 +1,114 @@ +// Reduced to 2s to minimize logout suppression race condition window +const RECENT_VERIFICATION_WINDOW_MS = 2000 +const LOGOUT_IN_PROGRESS_WINDOW_MS = 60000 +export const SIWE_AUTH_FLOW_KEY = 'siwe:authFlowActive' +export const SIWE_LAST_VERIFIED_KEY = 'siwe:lastVerifiedAt' +export const SIWE_LOGOUT_IN_PROGRESS_KEY = 'siwe:logoutInProgressAt' +export const SIWE_LOGOUT_EVENT = 'siwe:logout' +export const SIWE_REFRESH_EVENT = 'siwe:refresh' +export const SIWE_NONCE_PATH = '/api/siwe/nonce' +export const SIWE_VERIFY_PATH = '/api/siwe/verify' +export const SIWE_LOGOUT_PATH = '/api/siwe/logout' +export const SIWE_ME_PATH = '/api/siwe/me' +export const SIWE_NONCE_RATE_LIMIT_KEY_PREFIX = 'siwe:nonce' +export const SIWE_VERIFY_RATE_LIMIT_KEY_PREFIX = 'siwe:verify' +export const SIWE_LOGOUT_RATE_LIMIT_KEY_PREFIX = 'siwe:logout' +export const SIWE_ME_RATE_LIMIT_KEY_PREFIX = 'siwe:me' + +// Use sessionStorage instead of module-level variables to avoid cross-tab issues +function isAuthFlowActive(): boolean { + if (typeof window === 'undefined') return false + return sessionStorage.getItem(SIWE_AUTH_FLOW_KEY) === 'true' +} + +function setAuthFlowActive(active: boolean): void { + if (typeof window === 'undefined') return + if (active) { + sessionStorage.setItem(SIWE_AUTH_FLOW_KEY, 'true') + } else { + sessionStorage.removeItem(SIWE_AUTH_FLOW_KEY) + } +} + +function getLastVerifiedAt(): number { + if (typeof window === 'undefined') return 0 + const value = sessionStorage.getItem(SIWE_LAST_VERIFIED_KEY) + return value ? parseInt(value, 10) : 0 +} + +function setLastVerifiedAt(timestamp: number): void { + if (typeof window === 'undefined') return + if (timestamp > 0) { + sessionStorage.setItem(SIWE_LAST_VERIFIED_KEY, timestamp.toString()) + } else { + sessionStorage.removeItem(SIWE_LAST_VERIFIED_KEY) + } +} + +function getLogoutStartedAt(): number { + if (typeof window === 'undefined') return 0 + + const value = localStorage.getItem(SIWE_LOGOUT_IN_PROGRESS_KEY) + if (!value) return 0 + + const timestamp = Number(value) + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function setLogoutStartedAt(timestamp: number): void { + if (typeof window === 'undefined') return + + if (timestamp > 0) { + localStorage.setItem(SIWE_LOGOUT_IN_PROGRESS_KEY, String(timestamp)) + } else { + localStorage.removeItem(SIWE_LOGOUT_IN_PROGRESS_KEY) + } +} + +export function beginSiweAuthFlow() { + setAuthFlowActive(true) +} + +export function cancelSiweAuthFlow() { + setAuthFlowActive(false) +} + +export function markSiweAuthVerified() { + setAuthFlowActive(false) + setLastVerifiedAt(Date.now()) +} + +export function beginSiweLogout() { + setLogoutStartedAt(Date.now()) +} + +export function endSiweLogout() { + setLogoutStartedAt(0) +} + +export function isSiweLogoutInProgress() { + const startedAt = getLogoutStartedAt() + if (!startedAt) return false + + const elapsed = Date.now() - startedAt + if (elapsed > LOGOUT_IN_PROGRESS_WINDOW_MS) { + endSiweLogout() + return false + } + + return true +} + +export function shouldSuppressSiweLogout() { + const authActive = isAuthFlowActive() + const lastVerified = getLastVerifiedAt() + return ( + authActive || + isSiweLogoutInProgress() || + (lastVerified > 0 && Date.now() - lastVerified < RECENT_VERIFICATION_WINDOW_MS) + ) +} + +export function shouldSuppressSiwePrompt() { + return shouldSuppressSiweLogout() +} diff --git a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx index b4ef28555..8d85c6a18 100644 --- a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx +++ b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx @@ -87,7 +87,7 @@ describe('PlaceBid', () => { mockBalanceValue = parseEther('1') vi.mocked(simulateContract).mockResolvedValue({ request: {} as any } as any) vi.mocked(writeContract).mockResolvedValue('0x1234' as `0x${string}`) - vi.mocked(waitForTransactionReceipt).mockResolvedValue({} as any) + vi.mocked(waitForTransactionReceipt).mockResolvedValue({ status: 'success' } as any) }) it('shows insufficient balance error while typing a higher bid', async () => { diff --git a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx index 28ab06df1..8ffd604fb 100644 --- a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx +++ b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.tsx @@ -2,6 +2,7 @@ import { BASE_URL, SWR_KEYS } from '@buildeross/constants' import { useMinBidIncrement } from '@buildeross/hooks/useMinBidIncrement' import { auctionAbi } from '@buildeross/sdk/contract' import { averageWinningBid } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore } from '@buildeross/stores' import { AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -13,9 +14,10 @@ import { formatCryptoVal } from '@buildeross/utils/numbers' import { Box, Button, Flex, Text } from '@buildeross/zord' import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' import useSWR, { useSWRConfig } from 'swr' +import type { WriteContractParameters } from 'viem' import { formatEther, parseEther, stringToHex } from 'viem' import { useAccount, useBalance, useConfig, useReadContracts } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { auctionActionButtonVariants, @@ -158,7 +160,7 @@ const InnerPlaceBid = ({ try { setCreatingBid(true) - let txHash: `0x${string}` + let request: WriteContractParameters if (referral) { const data = await simulateContract(config, { abi: auctionAbi, @@ -169,7 +171,7 @@ const InnerPlaceBid = ({ chainId, ...(bidCommentDataSuffix ? { dataSuffix: bidCommentDataSuffix } : {}), }) - txHash = await writeContract(config, data.request) + request = data.request } else { const data = await simulateContract(config, { abi: auctionAbi, @@ -180,10 +182,11 @@ const InnerPlaceBid = ({ chainId, ...(bidCommentDataSuffix ? { dataSuffix: bidCommentDataSuffix } : {}), }) - txHash = await writeContract(config, data.request) + request = data.request } - if (txHash) await waitForTransactionReceipt(config, { hash: txHash, chainId }) + const result = await executeAppTransaction({ config, request, chainId }) + if (result.kind === 'safe-proposed') return await mutate([ SWR_KEYS.AUCTION_BIDS, diff --git a/packages/auction-ui/src/components/CurrentAuction/Settle.tsx b/packages/auction-ui/src/components/CurrentAuction/Settle.tsx index e6ea97e7c..71c778f2c 100644 --- a/packages/auction-ui/src/components/CurrentAuction/Settle.tsx +++ b/packages/auction-ui/src/components/CurrentAuction/Settle.tsx @@ -1,11 +1,12 @@ import { auctionAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore } from '@buildeross/stores' import { AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { Button, Flex } from '@buildeross/zord' import { useCallback, useState } from 'react' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { auctionActionButtonVariants } from '../Auction.css' @@ -43,10 +44,22 @@ export const Settle = ({ chainId, }) - const txHash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash: txHash, chainId }) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId, + }) + + // Don't reset state for Safe proposals - user needs to execute via Safe UI + if (result.kind === 'safe-proposed') { + setSettling(false) + return + } + + // Transaction mined successfully } catch (error) { console.error('Error settling auction', error) + throw error } finally { setSettling(false) } diff --git a/packages/candidate-ui/src/components/CandidateCommentForm.tsx b/packages/candidate-ui/src/components/CandidateCommentForm.tsx index 753d17dd9..3a617a1fb 100644 --- a/packages/candidate-ui/src/components/CandidateCommentForm.tsx +++ b/packages/candidate-ui/src/components/CandidateCommentForm.tsx @@ -194,9 +194,10 @@ export const CandidateCommentForm: React.FC = ({ const actualSupport = signalEnabled ? support : CandidateVoteSupportEnum.NONE try { + let result if (withSignature) { // Submit signal/comment + signature in one transaction - await attestCommentWithSignature({ + result = await attestCommentWithSignature({ config, chainId: chain.id, walletClient: walletClient!, @@ -215,7 +216,7 @@ export const CandidateCommentForm: React.FC = ({ }) } else { // Submit only comment/signal - await attestCandidateComment({ + result = await attestCandidateComment({ config, chainId: chain.id, daoTokenAddress: addresses.token!, @@ -226,6 +227,11 @@ export const CandidateCommentForm: React.FC = ({ }) } + // Don't reset form or show success for Safe proposals - user needs to execute via Safe UI + if (result.kind === 'safe-proposed') { + return + } + // Reset form setComment('') setShouldSign(false) diff --git a/packages/candidate-ui/src/components/CandidatePromoteButton.tsx b/packages/candidate-ui/src/components/CandidatePromoteButton.tsx index 52a2d2454..2b78b9773 100644 --- a/packages/candidate-ui/src/components/CandidatePromoteButton.tsx +++ b/packages/candidate-ui/src/components/CandidatePromoteButton.tsx @@ -1,5 +1,5 @@ import { governorAbi } from '@buildeross/sdk/contract' -import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AnimatedModal, ContractButton, SuccessModalContent } from '@buildeross/ui' import { getErrorMessage } from '@buildeross/utils/errors' @@ -7,7 +7,7 @@ import { Stack, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import { type Hex } from 'viem' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' export interface ProposerSignature { signer: `0x${string}` @@ -98,17 +98,13 @@ export const CandidatePromoteButton: React.FC = ({ ], }) - // 2. Write the transaction - const txHash = await writeContract(config, simulation.request) - - // 3. Wait for confirmation - const receipt = await waitForTransactionReceipt(config, { - hash: txHash, + const result = await executeAppTransaction({ + config, + request: simulation.request, chainId: chain.id, }) - - // 4. Wait for subgraph to sync - await awaitSubgraphSync(chain.id, receipt.blockNumber) + if (result.kind === 'safe-proposed') return + const txHash = result.hash // 5. Use txHash as proposal identifier setIsTxSuccess(true) diff --git a/packages/candidate-ui/src/components/CandidateSignatureButton.tsx b/packages/candidate-ui/src/components/CandidateSignatureButton.tsx index 577946da3..80a97c3c0 100644 --- a/packages/candidate-ui/src/components/CandidateSignatureButton.tsx +++ b/packages/candidate-ui/src/components/CandidateSignatureButton.tsx @@ -81,7 +81,7 @@ export const CandidateSignatureButton: React.FC = Math.floor(Date.now() / 1000) + CANDIDATE_SIGNATURE_VALIDITY_SECONDS try { - await attestCandidateSignature({ + const result = await attestCandidateSignature({ config, chainId: chain.id, walletClient, @@ -96,6 +96,11 @@ export const CandidateSignatureButton: React.FC = deadline: freshDeadline, }) + // Don't show success for Safe proposals - user needs to execute via Safe UI + if (result.kind === 'safe-proposed') { + return + } + setIsTxSuccess(true) if (onSuccess) { diff --git a/packages/candidate-ui/src/components/CandidateSubmitForm.tsx b/packages/candidate-ui/src/components/CandidateSubmitForm.tsx index d2d2572b5..f34047b6f 100644 --- a/packages/candidate-ui/src/components/CandidateSubmitForm.tsx +++ b/packages/candidate-ui/src/components/CandidateSubmitForm.tsx @@ -203,6 +203,11 @@ export const CandidateSubmitForm: React.FC = ({ const result = await attestCandidate(params) + // Don't show success for Safe proposals - user needs to execute via Safe UI + if (result.kind === 'safe-proposed') { + return + } + setIsTxSuccess(true) clearCandidate() diff --git a/packages/constants/src/layers.ts b/packages/constants/src/layers.ts index c477c14c7..9b1b8f6dc 100644 --- a/packages/constants/src/layers.ts +++ b/packages/constants/src/layers.ts @@ -1,6 +1,7 @@ // z-indexes export const NAV_BUTTON_LAYER = 101 +export const SAFE_TRANSACTION_LAYER = 80 export const MODAL_CONTENT_LAYER = 70 export const MODAL_BACKDROP_LAYER = 60 export const NAV_CONTENT_LAYER = 50 diff --git a/packages/constants/src/safe.ts b/packages/constants/src/safe.ts index 97711bc30..3b0a5b499 100644 --- a/packages/constants/src/safe.ts +++ b/packages/constants/src/safe.ts @@ -13,7 +13,6 @@ export const SAFE_SERVICE_URL: Partial> = { [CHAIN_ID.ETHEREUM]: 'https://safe-transaction-mainnet.safe.global', [CHAIN_ID.OPTIMISM]: 'https://safe-transaction-optimism.safe.global', [CHAIN_ID.BASE]: 'https://safe-transaction-base.safe.global', - [CHAIN_ID.ZORA]: undefined, [CHAIN_ID.SEPOLIA]: 'https://safe-transaction-sepolia.safe.global', [CHAIN_ID.OPTIMISM_SEPOLIA]: undefined, [CHAIN_ID.BASE_SEPOLIA]: 'https://safe-transaction-base-sepolia.safe.global', @@ -27,3 +26,12 @@ export const SAFE_HOME_URL: Partial> = { [CHAIN_ID.OPTIMISM_SEPOLIA]: undefined, [CHAIN_ID.BASE_SEPOLIA]: 'https://app.safe.global/home?safe=basesep', } + +export const SAFE_CHAIN_PREFIX: Partial> = { + [CHAIN_ID.ETHEREUM]: 'eth', + [CHAIN_ID.OPTIMISM]: 'oeth', + [CHAIN_ID.BASE]: 'base', + [CHAIN_ID.SEPOLIA]: 'sep', + [CHAIN_ID.OPTIMISM_SEPOLIA]: undefined, + [CHAIN_ID.BASE_SEPOLIA]: 'basesep', +} diff --git a/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx b/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx index 61a332603..cf10ec269 100644 --- a/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx +++ b/packages/create-dao-ui/src/components/ReviewAndDeploy/ReviewAndDeploy.tsx @@ -3,7 +3,7 @@ import { L2_CHAINS } from '@buildeross/constants/chains' import { RENDERER_BASE } from '@buildeross/constants/rendererBase' import { useManagerVersion } from '@buildeross/hooks' import { managerAbi, managerV1Abi, managerV3Abi } from '@buildeross/sdk/contract' -import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import type { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -25,7 +25,7 @@ import { zeroAddress, } from 'viem' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { useFormStore } from '../../stores' import { TokenAllocation } from '../AllocationForm' @@ -269,7 +269,7 @@ export const ReviewAndDeploy: React.FC = ({ setIsPendingTransaction(true) let transaction try { - let txHash: `0x${string}` + let result if (version?.startsWith('3')) { // Use v3 ABI with proposalUpdatablePeriod const data = await simulateContract(config, { @@ -284,7 +284,11 @@ export const ReviewAndDeploy: React.FC = ({ govParams, ], }) - txHash = await writeContract(config, data.request) + result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) } else if (version?.startsWith('2')) { // Use v2 ABI without proposalUpdatablePeriod const data = await simulateContract(config, { @@ -299,7 +303,11 @@ export const ReviewAndDeploy: React.FC = ({ govParams, ], }) - txHash = await writeContract(config, data.request) + result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) } else { // Use v1 ABI (no reservedUntilTokenId) const data = await simulateContract(config, { @@ -309,16 +317,21 @@ export const ReviewAndDeploy: React.FC = ({ functionName: 'deploy', args: [founderParams, tokenParams, auctionParams, govParams], }) - txHash = await writeContract(config, data.request) - } - - if (txHash) { - transaction = await waitForTransactionReceipt(config, { - hash: txHash, + result = await executeAppTransaction({ + config, + request: data.request, chainId: chain.id, }) - await awaitSubgraphSync(chain.id, transaction.blockNumber) } + + if (result?.kind === 'safe-proposed') { + setIsPendingTransaction(false) + setDeploymentError( + 'Deployment proposal submitted to Safe. Additional signatures are required before deployment.' + ) + return + } + transaction = result.receipt } catch (e) { console.error('Error deploying DAO:', e) setIsPendingTransaction(false) diff --git a/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx b/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx index ee0fd87e7..bc2dea9f8 100644 --- a/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx +++ b/packages/create-dao-ui/src/components/ReviewAndDeploy/SuccessfulDeploy.tsx @@ -1,5 +1,5 @@ import { metadataAbi, tokenAbi } from '@buildeross/sdk/contract' -import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { ContractButton } from '@buildeross/ui/ContractButton' import { CopyButton } from '@buildeross/ui/CopyButton' @@ -11,7 +11,7 @@ import { import { Box, Flex, Paragraph, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' import { useConfig, useReadContract } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { useFormStore } from '../../stores' import { deployPendingButtonStyle } from './ReviewAndDeploy.css' @@ -118,12 +118,12 @@ export const SuccessfulDeploy: React.FC = ({ chainId: chain.id, args: [transaction.names, transaction.items, transaction.data], }) - const txHash = await writeContract(config, data.request) - const reciept = await waitForTransactionReceipt(config, { - hash: txHash, + const result = await executeAppTransaction({ + config, + request: data.request, chainId: chain.id, }) - await awaitSubgraphSync(chain.id, reciept.blockNumber) + if (result.kind !== 'mined') return } catch (err) { console.warn(err) setIsPendingTransaction(false) diff --git a/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx b/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx index c352e832e..2700e3298 100644 --- a/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx +++ b/packages/create-proposal-ui/src/components/ReviewProposalForm/ReviewProposalForm.tsx @@ -7,7 +7,7 @@ import { } from '@buildeross/proposal-ui' import { governorAbi, treasuryAbi } from '@buildeross/sdk/contract' import { type Proposal } from '@buildeross/sdk/subgraph' -import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, @@ -36,7 +36,7 @@ import { Formik, type FormikProps } from 'formik' import React, { useState } from 'react' import { decodeEventLog, getAddress, type Hex, isAddress } from 'viem' import { useConfig, useReadContracts } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { prepareProposalTransactions } from '../../utils/prepareTransactions' import { @@ -363,7 +363,7 @@ export const ReviewProposalForm = ({ return } - let hash: Hex + let result if (isUpdate) { const data = await simulateContract(config, { abi: governorAbi, @@ -379,7 +379,11 @@ export const ReviewProposalForm = ({ values.updateMessage || '', // updateMessage - optional message about what changed ], }) - hash = await writeContract(config, data.request) + result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) } else { const data = await simulateContract(config, { abi: governorAbi, @@ -388,15 +392,15 @@ export const ReviewProposalForm = ({ chainId: chain.id, args: [params.targets, params.values, params.calldatas, params.description], }) - hash = await writeContract(config, data.request) + result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) } - const receipt = await waitForTransactionReceipt(config, { - hash, - chainId: chain.id, - }) - - await awaitSubgraphSync(chain.id, receipt.blockNumber) + if (result.kind === 'safe-proposed') return + const receipt = result.receipt // Parse logs to find the proposal ID let proposalId: string | null = null @@ -493,12 +497,12 @@ export const ReviewProposalForm = ({ chainId: chain.id, args: [updateProposalId as Hex], }) - const cancelHash = await writeContract(config, cancelData.request) - - await waitForTransactionReceipt(config, { - hash: cancelHash, + const cancelResult = await executeAppTransaction({ + config, + request: cancelData.request, chainId: chain.id, }) + if (cancelResult.kind === 'safe-proposed') return } // Clear the updateProposalId so the form submits as a new proposal diff --git a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step3_DeployDAO.tsx b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step3_DeployDAO.tsx index 2e1a1c416..2a18dbeab 100644 --- a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step3_DeployDAO.tsx +++ b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step3_DeployDAO.tsx @@ -19,10 +19,8 @@ export const Step3_DeployDAO: React.FC = () => { goToNextStep, } = useCrossChainMigration() - const { deploy, txHash, isDeploying, isConfirming, isSuccess } = useDeployDAO( - editedConfig as any, - targetChainId - ) + const { deploy, txHash, isDeploying, isConfirming, isSuccess, isSafeProposal } = + useDeployDAO(editedConfig as any, targetChainId) const publicClient = usePublicClient({ chainId: targetChainId }) @@ -44,7 +42,7 @@ export const Step3_DeployDAO: React.FC = () => { // When deployment is successful, parse transaction receipt to extract addresses useEffect(() => { const extractAddresses = async () => { - if (!isSuccess || !txHash || !publicClient) return + if (!isSuccess || isSafeProposal || !txHash || !publicClient) return try { // Get transaction receipt @@ -118,7 +116,7 @@ export const Step3_DeployDAO: React.FC = () => { } extractAddresses() - }, [isSuccess, txHash, publicClient, setTargetAddresses]) + }, [isSuccess, isSafeProposal, txHash, publicClient, setTargetAddresses]) const handleContinue = () => { // Addresses are already set via useEffect, just proceed to next step @@ -181,7 +179,7 @@ export const Step3_DeployDAO: React.FC = () => { )} - {(txHash || deployTxHash) && targetChainId && ( + {(txHash || deployTxHash) && targetChainId && !isSafeProposal && ( Transaction Hash: @@ -205,6 +203,21 @@ export const Step3_DeployDAO: React.FC = () => { )} + {isSafeProposal && ( + + + Safe Proposal Hash: + + + {txHash || deployTxHash} + + + Deployment proposed to Safe. Additional signatures are required before it can + be executed. + + + )} + {isDeploymentComplete && targetAddresses && ( <> diff --git a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step5_SetupMerkleRoots.tsx b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step5_SetupMerkleRoots.tsx index 0e27adb9d..9d7aff7d1 100644 --- a/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step5_SetupMerkleRoots.tsx +++ b/packages/create-proposal-ui/src/components/TransactionForm/CrossChainMigration/Step5_SetupMerkleRoots.tsx @@ -165,8 +165,10 @@ export const Step5_SetupMerkleRoots: React.FC = () => { try { // Serialize transactions to avoid nonce issues - await setAttributesRoot(activeAttributesRoot) - await setMintSettings(activeMemberRoot) + const attributesResult = await setAttributesRoot(activeAttributesRoot) + if (typeof attributesResult !== 'string') return + const membersResult = await setMintSettings(activeMemberRoot) + if (typeof membersResult !== 'string') return // Save to context (in case they came from cache and weren't already saved) setAttributesMerkleRoot(activeAttributesRoot) diff --git a/packages/create-proposal-ui/src/hooks/useAuthorizeMinter.tsx b/packages/create-proposal-ui/src/hooks/useAuthorizeMinter.tsx index 3202c9e5a..8e109fa76 100644 --- a/packages/create-proposal-ui/src/hooks/useAuthorizeMinter.tsx +++ b/packages/create-proposal-ui/src/hooks/useAuthorizeMinter.tsx @@ -1,8 +1,10 @@ import { MERKLE_RESERVE_MINTER } from '@buildeross/constants/addresses' import { tokenAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { AddressType, CHAIN_ID } from '@buildeross/types' import { useState } from 'react' -import { usePublicClient, useReadContract, useWriteContract } from 'wagmi' +import { useConfig, useReadContract } from 'wagmi' +import { simulateContract } from 'wagmi/actions' export const useAuthorizeMinter = ( tokenAddress?: AddressType, @@ -11,9 +13,9 @@ export const useAuthorizeMinter = ( ) => { const [authorizeTxHash, setAuthorizeTxHash] = useState<`0x${string}`>() const [error, setError] = useState() + const [isAuthorizing, setIsAuthorizing] = useState(false) - const { writeContractAsync, isPending } = useWriteContract() - const publicClient = usePublicClient({ chainId }) + const config = useConfig() const minterAddress = chainId ? MERKLE_RESERVE_MINTER[chainId] : undefined @@ -23,7 +25,6 @@ export const useAuthorizeMinter = ( abi: tokenAbi, functionName: 'minter', args: minterAddress ? [minterAddress] : undefined, - chainId, query: { enabled: !!tokenAddress && !!minterAddress && !!chainId, }, @@ -36,10 +37,11 @@ export const useAuthorizeMinter = ( } setError(undefined) + setIsAuthorizing(true) try { // Call updateMinters with [(minterAddress, true)] - const hash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: tokenAbi, address: tokenAddress, functionName: 'updateMinters', @@ -47,15 +49,18 @@ export const useAuthorizeMinter = ( chainId, }) - setAuthorizeTxHash(hash) + const result = await executeAppTransaction({ + config, + request, + chainId, + }) - // Wait for transaction receipt - if (publicClient) { - const receipt = await publicClient.waitForTransactionReceipt({ hash }) + setAuthorizeTxHash(result.hash) + if (result.kind === 'safe-proposed') return result.hash - if (receipt.status !== 'success') { - throw new Error('Transaction failed to authorize minter') - } + // Wait for transaction receipt + if (result.receipt.status !== 'success') { + throw new Error('Transaction failed to authorize minter') } // Refetch authorization status @@ -63,17 +68,19 @@ export const useAuthorizeMinter = ( onAuthorized?.() - return hash + return result.hash } catch (err) { const message = err instanceof Error ? err.message : 'Failed to authorize minter' setError(message) throw new Error(message) + } finally { + setIsAuthorizing(false) } } return { authorizeMinter, - isAuthorizing: isPending, + isAuthorizing, isMinterAuthorized, authorizeTxHash, error, diff --git a/packages/create-proposal-ui/src/hooks/useDeployDAO.tsx b/packages/create-proposal-ui/src/hooks/useDeployDAO.tsx index 8bc503df2..51632832e 100644 --- a/packages/create-proposal-ui/src/hooks/useDeployDAO.tsx +++ b/packages/create-proposal-ui/src/hooks/useDeployDAO.tsx @@ -2,22 +2,25 @@ import { PUBLIC_MANAGER_ADDRESS } from '@buildeross/constants/addresses' import { RENDERER_BASE } from '@buildeross/constants/rendererBase' import { useManagerVersion } from '@buildeross/hooks' import { managerAbi, managerV3Abi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { CHAIN_ID } from '@buildeross/types' import { useState } from 'react' import { encodeAbiParameters, parseAbiParameters, zeroAddress } from 'viem' -import { usePublicClient, useWaitForTransactionReceipt, useWriteContract } from 'wagmi' +import { useConfig, usePublicClient } from 'wagmi' +import { simulateContract } from 'wagmi/actions' import { DAOConfigParams, DeployedContracts } from './useCrossChainMigration' export const useDeployDAO = (config?: DAOConfigParams, targetChainId?: CHAIN_ID) => { const [deployedAddresses, setDeployedAddresses] = useState() - const { writeContractAsync, data: txHash, isPending } = useWriteContract() + const configClient = useConfig() const publicClient = usePublicClient({ chainId: targetChainId }) - const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ - hash: txHash, - }) + const [txHash, setTxHash] = useState<`0x${string}`>() + const [isConfirming, setIsConfirming] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) + const [isSafeProposal, setIsSafeProposal] = useState(false) // Check if target Manager contract supports v3 features const managerAddress = targetChainId ? PUBLIC_MANAGER_ADDRESS[targetChainId] : undefined @@ -98,7 +101,9 @@ export const useDeployDAO = (config?: DAOConfigParams, targetChainId?: CHAIN_ID) : baseGovParams // Execute deployment with version-appropriate ABI - const hash = await writeContractAsync({ + setIsConfirming(true) + setIsSafeProposal(false) + const { request } = await simulateContract(configClient, { abi: isV3OrHigher ? managerV3Abi : managerAbi, address: managerAddress, functionName: 'deploy', @@ -106,7 +111,20 @@ export const useDeployDAO = (config?: DAOConfigParams, targetChainId?: CHAIN_ID) chainId: targetChainId, }) - return hash + try { + const result = await executeAppTransaction({ + config: configClient, + chainId: targetChainId, + request, + }) + setTxHash(result.hash) + setIsSafeProposal(result.kind === 'safe-proposed') + setIsSuccess(result.kind === 'mined' && result.receipt.status === 'success') + + return result.hash + } finally { + setIsConfirming(false) + } } const fetchDeployedAddresses = async (tokenAddress: string) => { @@ -138,8 +156,9 @@ export const useDeployDAO = (config?: DAOConfigParams, targetChainId?: CHAIN_ID) fetchDeployedAddresses, deployedAddresses, txHash, - isDeploying: isPending, + isDeploying: isConfirming, isConfirming, isSuccess, + isSafeProposal, } } diff --git a/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx b/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx index cce3e0da0..beb6323d7 100644 --- a/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx +++ b/packages/create-proposal-ui/src/hooks/useMintReservedTokens.tsx @@ -1,11 +1,13 @@ import { MERKLE_RESERVE_MINTER } from '@buildeross/constants/addresses' import { merkleReserveMinterAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore } from '@buildeross/stores' import { AddressType, CHAIN_ID } from '@buildeross/types' import { MerkleTree } from 'merkletreejs' import { useState } from 'react' import { encodeAbiParameters, keccak256, parseAbiParameters } from 'viem' -import { usePublicClient, useReadContract, useWriteContract } from 'wagmi' +import { useConfig, usePublicClient, useReadContract } from 'wagmi' +import { simulateContract } from 'wagmi/actions' import { DaoMemberSimplified } from './useGenerateMerkleRoots' @@ -24,8 +26,9 @@ export const useMintReservedTokens = ( const [tokensMinted, setTokensMinted] = useState(alreadyMinted || []) const [txHashes, setTxHashes] = useState<`0x${string}`[]>([]) const [error, setError] = useState() + const [isMinting, setIsMinting] = useState(false) - const { writeContractAsync, isPending } = useWriteContract() + const config = useConfig() const publicClient = usePublicClient({ chainId: targetChainId }) const { address } = useAuthStore() @@ -65,6 +68,7 @@ export const useMintReservedTokens = ( } setError(undefined) + setIsMinting(true) try { // Create merkle tree from member snapshot @@ -165,7 +169,7 @@ export const useMintReservedTokens = ( } } - const hash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: merkleReserveMinterAbi, address: minterAddress, functionName: 'mintFromReserve', @@ -174,17 +178,27 @@ export const useMintReservedTokens = ( gas: gasLimit, }) + const result = await executeAppTransaction({ + config, + chainId: targetChainId, + request, + }) + const hash = result.hash + + if (result.kind === 'safe-proposed') { + hashes.push(hash) + setTxHashes([...hashes]) + onTxHash?.(hash) + return { minted, hashes, calculatedRoot } + } + hashes.push(hash) setTxHashes([...hashes]) onTxHash?.(hash) // Wait for transaction receipt before continuing to next batch - if (publicClient) { - const receipt = await publicClient.waitForTransactionReceipt({ hash }) - - if (receipt.status !== 'success') { - throw new Error(`Transaction failed for batch ${i / batchSize + 1}`) - } + if (result.kind === 'mined' && result.receipt.status !== 'success') { + throw new Error(`Transaction failed for batch ${i / batchSize + 1}`) } const batchTokenIds = batch.map((claim) => Number(claim.tokenId)) @@ -198,6 +212,8 @@ export const useMintReservedTokens = ( const message = err instanceof Error ? err.message : 'Failed to mint tokens' setError(message) throw new Error(message) + } finally { + setIsMinting(false) } } @@ -205,7 +221,7 @@ export const useMintReservedTokens = ( return { startMinting, - isMinting: isPending, + isMinting, totalTokens, tokensMinted, progress: totalTokens > 0 ? (tokensMinted.length / totalTokens) * 100 : 0, diff --git a/packages/create-proposal-ui/src/hooks/useSetAttributes.tsx b/packages/create-proposal-ui/src/hooks/useSetAttributes.tsx index 236670cba..127ac4c9b 100644 --- a/packages/create-proposal-ui/src/hooks/useSetAttributes.tsx +++ b/packages/create-proposal-ui/src/hooks/useSetAttributes.tsx @@ -1,9 +1,11 @@ import { merklePropertyMetadataAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { AddressType, CHAIN_ID } from '@buildeross/types' import { MerkleTree } from 'merkletreejs' import { useState } from 'react' import { encodePacked, keccak256 } from 'viem' -import { usePublicClient, useWriteContract } from 'wagmi' +import { useConfig } from 'wagmi' +import { simulateContract } from 'wagmi/actions' export const DEFAULT_ATTRIBUTES_BATCH_SIZE = 50 @@ -19,9 +21,9 @@ export const useSetAttributes = ( const [tokensSet, setTokensSet] = useState(alreadySet?.length || 0) const [totalTokens, setTotalTokens] = useState(0) const [batchSize, setBatchSize] = useState(DEFAULT_ATTRIBUTES_BATCH_SIZE) + const [isSettingAttributes, setIsSettingAttributes] = useState(false) - const { writeContractAsync, isPending } = useWriteContract() - const publicClient = usePublicClient({ chainId: targetChainId }) + const config = useConfig() const setAllAttributes = async () => { if (!targetMetadataAddress || !attributesData || !attributesMerkleRoot) { @@ -31,6 +33,7 @@ export const useSetAttributes = ( } setError(undefined) + setIsSettingAttributes(true) setTxHashes([]) setTokensSet(0) @@ -159,13 +162,6 @@ export const useSetAttributes = ( setTotalTokens(allParams.length + (alreadySet?.length || 0)) - // Validate publicClient before processing - if (!publicClient) { - const errorMsg = 'Public client not available for transaction verification' - setError(errorMsg) - throw new Error(errorMsg) - } - // Batch process const hashes: `0x${string}`[] = [] const setTokenIds: number[] = [...(alreadySet || [])] // Start with already-set tokens @@ -174,7 +170,7 @@ export const useSetAttributes = ( for (let i = 0; i < allParams.length; i += batchSize) { const batch = allParams.slice(i, i + batchSize) - const hash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: merklePropertyMetadataAbi, address: targetMetadataAddress, functionName: 'setManyAttributes', @@ -182,13 +178,20 @@ export const useSetAttributes = ( chainId: targetChainId, }) + const result = await executeAppTransaction({ + config, + chainId: targetChainId!, + request, + }) + const hash = result.hash + hashes.push(hash) setTxHashes([...hashes]) - // Wait for transaction receipt - const receipt = await publicClient.waitForTransactionReceipt({ hash }) + if (result.kind === 'safe-proposed') return { hashes, setTokenIds } - if (receipt.status !== 'success') { + // Wait for transaction receipt + if (result.receipt.status !== 'success') { throw new Error(`Transaction failed for batch ${Math.floor(i / batchSize) + 1}`) } @@ -205,6 +208,8 @@ export const useSetAttributes = ( console.error('[useSetAttributes] Error:', err) setError(message) throw err + } finally { + setIsSettingAttributes(false) } } @@ -212,7 +217,7 @@ export const useSetAttributes = ( return { setAllAttributes, - isSettingAttributes: isPending, + isSettingAttributes, txHashes, tokensSet, totalTokens, diff --git a/packages/create-proposal-ui/src/hooks/useSetMerkleRoots.tsx b/packages/create-proposal-ui/src/hooks/useSetMerkleRoots.tsx index 7171677e1..22c47a166 100644 --- a/packages/create-proposal-ui/src/hooks/useSetMerkleRoots.tsx +++ b/packages/create-proposal-ui/src/hooks/useSetMerkleRoots.tsx @@ -3,9 +3,11 @@ import { merklePropertyMetadataAbi, merkleReserveMinterAbi, } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { AddressType, CHAIN_ID } from '@buildeross/types' import { useState } from 'react' -import { usePublicClient, useWriteContract } from 'wagmi' +import { useConfig } from 'wagmi' +import { simulateContract } from 'wagmi/actions' const UINT_64_MAX = 18446744073709551615n @@ -17,9 +19,9 @@ export const useSetMerkleRoots = ( const [attributesTxHash, setAttributesTxHash] = useState<`0x${string}`>() const [membersTxHash, setMembersTxHash] = useState<`0x${string}`>() const [error, setError] = useState() + const [isSettingRoots, setIsSettingRoots] = useState(false) - const { writeContractAsync, isPending } = useWriteContract() - const publicClient = usePublicClient({ chainId: targetChainId }) + const config = useConfig() const setAttributesRoot = async (merkleRoot: `0x${string}`) => { if (!targetMetadataAddress) { @@ -28,9 +30,10 @@ export const useSetMerkleRoots = ( } setError(undefined) + setIsSettingRoots(true) try { - const hash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: merklePropertyMetadataAbi, address: targetMetadataAddress, functionName: 'setAttributeMerkleRoot', @@ -38,15 +41,19 @@ export const useSetMerkleRoots = ( chainId: targetChainId, }) + const result = await executeAppTransaction({ + config, + chainId: targetChainId!, + request, + }) + if (result.kind === 'safe-proposed') return result + const hash = result.hash + setAttributesTxHash(hash) // Wait for transaction receipt - if (publicClient) { - const receipt = await publicClient.waitForTransactionReceipt({ hash }) - - if (receipt.status !== 'success') { - throw new Error('Transaction failed for setAttributeMerkleRoot') - } + if (result.kind === 'mined' && result.receipt.status !== 'success') { + throw new Error('Transaction failed for setAttributeMerkleRoot') } return hash @@ -54,6 +61,8 @@ export const useSetMerkleRoots = ( const message = err instanceof Error ? err.message : 'Failed to set attributes root' setError(message) throw new Error(message) + } finally { + setIsSettingRoots(false) } } @@ -70,9 +79,10 @@ export const useSetMerkleRoots = ( } setError(undefined) + setIsSettingRoots(true) try { - const hash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: merkleReserveMinterAbi, address: minterAddress, functionName: 'setMintSettings', @@ -88,15 +98,19 @@ export const useSetMerkleRoots = ( chainId: targetChainId, }) + const result = await executeAppTransaction({ + config, + chainId: targetChainId, + request, + }) + if (result.kind === 'safe-proposed') return result + const hash = result.hash + setMembersTxHash(hash) // Wait for transaction receipt - if (publicClient) { - const receipt = await publicClient.waitForTransactionReceipt({ hash }) - - if (receipt.status !== 'success') { - throw new Error('Transaction failed for setMintSettings') - } + if (result.kind === 'mined' && result.receipt.status !== 'success') { + throw new Error('Transaction failed for setMintSettings') } return hash @@ -104,13 +118,15 @@ export const useSetMerkleRoots = ( const message = err instanceof Error ? err.message : 'Failed to set mint settings' setError(message) throw new Error(message) + } finally { + setIsSettingRoots(false) } } return { setAttributesRoot, setMintSettings, - isSettingRoots: isPending, + isSettingRoots, attributesTxHash, membersTxHash, error, diff --git a/packages/create-proposal-ui/src/hooks/useSetupMetadata.tsx b/packages/create-proposal-ui/src/hooks/useSetupMetadata.tsx index 1ea92ab6b..76551b21a 100644 --- a/packages/create-proposal-ui/src/hooks/useSetupMetadata.tsx +++ b/packages/create-proposal-ui/src/hooks/useSetupMetadata.tsx @@ -1,11 +1,13 @@ import { BASE_URL } from '@buildeross/constants/baseUrl' import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { metadataAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { AddressType, CHAIN_ID, Property } from '@buildeross/types' import { useCallback, useState } from 'react' import useSWRImmutable from 'swr/immutable' import { decodeFunctionData, encodeFunctionData } from 'viem' -import { usePublicClient, useWriteContract } from 'wagmi' +import { useConfig } from 'wagmi' +import { simulateContract } from 'wagmi/actions' export const MAX_ITEMS_PER_CHUNK = 50 @@ -201,9 +203,9 @@ export const useSetupMetadata = ( ) const [txHashes, setTxHashes] = useState<`0x${string}`[]>(persistedTxHashes || []) const [addError, setAddError] = useState() + const [isAddingProperties, setIsAddingProperties] = useState(false) - const { writeContractAsync, isPending } = useWriteContract() - const publicClient = usePublicClient({ chainId: targetChainId }) + const config = useConfig() // Combine fetch error and add error const error = fetchError?.message || addError @@ -219,6 +221,7 @@ export const useSetupMetadata = ( } setAddError(undefined) + setIsAddingProperties(true) const hashes: `0x${string}`[] = [] try { @@ -232,7 +235,7 @@ export const useSetupMetadata = ( data: properties[i] as `0x${string}`, }) - const hash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: metadataAbi, address: targetMetadataAddress, functionName: 'addProperties', @@ -240,17 +243,27 @@ export const useSetupMetadata = ( chainId: targetChainId, }) + const result = await executeAppTransaction({ + config, + chainId: targetChainId!, + request, + }) + const hash = result.hash + + if (result.kind === 'safe-proposed') { + hashes.push(hash) + setTxHashes([...hashes]) + onTxHashAdded?.(hash) + return + } + hashes.push(hash) setTxHashes([...hashes]) onTxHashAdded?.(hash) // Wait for transaction receipt before continuing - if (publicClient) { - const receipt = await publicClient.waitForTransactionReceipt({ hash }) - - if (receipt.status !== 'success') { - throw new Error(`Transaction failed for property ${i + 1}`) - } + if (result.kind === 'mined' && result.receipt.status !== 'success') { + throw new Error(`Transaction failed for property ${i + 1}`) } } @@ -265,13 +278,14 @@ export const useSetupMetadata = ( }) setAddError(errorMsg) throw err + } finally { + setIsAddingProperties(false) } }, [ targetMetadataAddress, targetChainId, properties, - writeContractAsync, - publicClient, + config, currentPropertyIndex, onProgressUpdate, onTxHashAdded, @@ -281,7 +295,7 @@ export const useSetupMetadata = ( properties: properties || [], isLoadingProperties, addAllProperties, - isAddingProperties: isPending, + isAddingProperties, progress: { current: persistedProgress?.current ?? currentPropertyIndex, total: persistedProgress?.total ?? (properties?.length || 0), diff --git a/packages/create-proposal-ui/src/hooks/useUpdateDelayedGovernance.tsx b/packages/create-proposal-ui/src/hooks/useUpdateDelayedGovernance.tsx index 3a7c40f98..493e38446 100644 --- a/packages/create-proposal-ui/src/hooks/useUpdateDelayedGovernance.tsx +++ b/packages/create-proposal-ui/src/hooks/useUpdateDelayedGovernance.tsx @@ -1,7 +1,9 @@ import { governorAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { AddressType, CHAIN_ID } from '@buildeross/types' import { useState } from 'react' -import { usePublicClient, useWriteContract } from 'wagmi' +import { useConfig } from 'wagmi' +import { simulateContract } from 'wagmi/actions' export const useUpdateDelayedGovernance = ( targetGovernorAddress?: AddressType, @@ -13,8 +15,7 @@ export const useUpdateDelayedGovernance = ( const [isSuccess, setIsSuccess] = useState(false) const [error, setError] = useState() - const { writeContractAsync, isPending } = useWriteContract() - const publicClient = usePublicClient({ chainId: targetChainId }) + const config = useConfig() const updateDelayedGovernance = async (delayedTimestamp: bigint) => { if (!targetGovernorAddress || !targetChainId) { @@ -28,7 +29,7 @@ export const useUpdateDelayedGovernance = ( setIsSuccess(false) try { - const hash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: governorAbi, address: targetGovernorAddress, functionName: 'updateDelayedGovernanceExpirationTimestamp', @@ -36,17 +37,22 @@ export const useUpdateDelayedGovernance = ( chainId: targetChainId, }) + const result = await executeAppTransaction({ + config, + chainId: targetChainId, + request, + }) + const hash = result.hash + setTxHash(hash) setIsUpdating(false) // Wait for transaction receipt - if (publicClient) { + if (result.kind === 'mined') { setIsConfirming(true) - const receipt = await publicClient.waitForTransactionReceipt({ hash }) - setIsConfirming(false) - if (receipt.status !== 'success') { + if (result.receipt.status !== 'success') { throw new Error( 'Transaction failed for updateDelayedGovernanceExpirationTimestamp' ) @@ -69,7 +75,7 @@ export const useUpdateDelayedGovernance = ( return { updateDelayedGovernance, txHash, - isUpdating: isUpdating || isPending, + isUpdating, isConfirming, isSuccess, error, diff --git a/packages/dao-ui/src/components/Activity/DelegateForm.tsx b/packages/dao-ui/src/components/Activity/DelegateForm.tsx index 0461de0f1..c394e0e5e 100644 --- a/packages/dao-ui/src/components/Activity/DelegateForm.tsx +++ b/packages/dao-ui/src/components/Activity/DelegateForm.tsx @@ -1,4 +1,5 @@ import { tokenAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useChainStore, useDaoStore } from '@buildeross/stores' import { ContractButton } from '@buildeross/ui/ContractButton' import { SmartInput } from '@buildeross/ui/Fields' @@ -8,7 +9,7 @@ import { Field, Form as FormikForm, Formik } from 'formik' import React, { useState } from 'react' import { Address } from 'viem' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { proposalFormTitle } from './Activity.css' import { delegateValidationSchema } from './DelegateForm.schema' @@ -41,9 +42,18 @@ export const DelegateForm = ({ handleBack, handleUpdate }: DelegateFormProps) => functionName: 'delegate', args: [delegate], }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: chain.id }) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) + + // Don't update UI for Safe proposals - user needs to execute via Safe UI + if (result.kind === 'safe-proposed') { + return + } + // Only update delegate for mined transactions handleUpdate(values.address) } catch (e) { console.error(e) diff --git a/packages/dao-ui/src/components/Admin/AuctionRewards.tsx b/packages/dao-ui/src/components/Admin/AuctionRewards.tsx index 9cf01e9bb..81f2677c0 100644 --- a/packages/dao-ui/src/components/Admin/AuctionRewards.tsx +++ b/packages/dao-ui/src/components/Admin/AuctionRewards.tsx @@ -1,6 +1,7 @@ import { PROTOCOL_REWARDS_MANAGER } from '@buildeross/constants' import { useAuctionRewards } from '@buildeross/hooks' import { protocolRewardsAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -8,9 +9,9 @@ import { ContractLink } from '@buildeross/ui/ContractLink' import { Tooltip } from '@buildeross/ui/Tooltip' import { Box, Flex, Stack, Text } from '@buildeross/zord' import React, { useCallback, useState } from 'react' -import { formatEther, Hex } from 'viem' +import { formatEther } from 'viem' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { Section } from '../AdminForm/Section' @@ -58,12 +59,12 @@ export const AuctionRewards: React.FC = ({ auctionAddress } account: address, }) - const txHash: Hex = await writeContract(config, simulateData.request) - - await waitForTransactionReceipt(config, { - hash: txHash, + const result = await executeAppTransaction({ + config, + request: simulateData.request, chainId: chain.id, }) + if (result.kind === 'safe-proposed') return // Refresh the auction rewards data await mutate() diff --git a/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx b/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx index 190c3d1b3..82039d8fc 100644 --- a/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx +++ b/packages/dao-ui/src/components/CustomMinterForm/CustomMinterForm.tsx @@ -1,5 +1,6 @@ import { useEnsData } from '@buildeross/hooks/useEnsData' import { tokenAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -8,8 +9,8 @@ import { getEnsAddress } from '@buildeross/utils/ens' import { Box, Flex, Heading, Input, Stack, Text, theme } from '@buildeross/zord' import React from 'react' import { isAddress } from 'viem' -import { useConfig, useWriteContract } from 'wagmi' -import { waitForTransactionReceipt } from 'wagmi/actions' +import { useConfig } from 'wagmi' +import { simulateContract } from 'wagmi/actions' import { adminSection } from '../../styles/Section.css' @@ -18,7 +19,6 @@ export const CustomMinterForm: React.FC = () => { const { addresses } = useDaoStore() const chain = useChainStore((x) => x.chain) const config = useConfig() - const { writeContractAsync } = useWriteContract() const [recipient, setRecipient] = React.useState('') const [tokenId, setTokenId] = React.useState('') @@ -61,7 +61,7 @@ export const CustomMinterForm: React.FC = () => { return } - const txHash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: tokenAbi, address: addresses.token!, functionName: 'mintFromReserveTo', @@ -69,8 +69,13 @@ export const CustomMinterForm: React.FC = () => { chainId: chain.id, }) - if (txHash) { - await waitForTransactionReceipt(config, { hash: txHash, chainId: chain.id }) + const result = await executeAppTransaction({ + config, + chainId: chain.id, + request, + }) + + if (result.kind === 'mined') { const displayRecipient = recipient.endsWith('.eth') ? recipient : `${resolvedRecipient.slice(0, 6)}...${resolvedRecipient.slice(-4)}` @@ -84,7 +89,7 @@ export const CustomMinterForm: React.FC = () => { } finally { setIsMinting(false) } - }, [recipient, tokenId, addresses.token, chain.id, config, writeContractAsync]) + }, [recipient, tokenId, addresses.token, chain.id, config]) const displaySignerAddress = signerEnsName || diff --git a/packages/dao-ui/src/components/ERC721RedeemMinterForm/ERC721RedeemMinterForm.tsx b/packages/dao-ui/src/components/ERC721RedeemMinterForm/ERC721RedeemMinterForm.tsx index 24c4d16cd..b95aa27d2 100644 --- a/packages/dao-ui/src/components/ERC721RedeemMinterForm/ERC721RedeemMinterForm.tsx +++ b/packages/dao-ui/src/components/ERC721RedeemMinterForm/ERC721RedeemMinterForm.tsx @@ -1,5 +1,6 @@ import { ERC721_REDEEM_MINTER } from '@buildeross/constants' import { erc721RedeemMinterAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useChainStore, useDaoStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { ContractLink } from '@buildeross/ui/ContractLink' @@ -8,7 +9,7 @@ import { Box, Button, Flex, Stack, Text, vars } from '@buildeross/zord' import React, { useCallback, useMemo, useState } from 'react' import { formatEther, isAddressEqual, zeroAddress } from 'viem' import { useConfig, useReadContract } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { adminSection } from '../../styles/Section.css' import { Section } from '../AdminForm/Section' @@ -58,10 +59,15 @@ export const ERC721RedeemMinterForm: React.FC = () => { args: [addresses.token!], chainId: chain.id, }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: chain.id }) - await refetch() - setIsEditing(true) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) + if (result.kind === 'mined') { + await refetch() + setIsEditing(true) + } } catch (error) { console.error('Failed to reset settings:', error) } finally { diff --git a/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx b/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx index 7626d66b9..4e4b5356c 100644 --- a/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx +++ b/packages/dao-ui/src/components/ERC721RedeemMinterForm/MintingForm.tsx @@ -1,4 +1,5 @@ import { erc721RedeemMinterAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore } from '@buildeross/stores' import { AddressType, Chain } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -6,7 +7,7 @@ import { Box, Flex, Text, vars } from '@buildeross/zord' import React, { useCallback, useMemo, useState } from 'react' import { formatEther } from 'viem' import { useConfig, useReadContract } from 'wagmi' -import { waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { errorMessage, @@ -38,6 +39,7 @@ export const MintingForm: React.FC = ({ const [tokenIdInput, setTokenIdInput] = useState('') const [isMinting, setIsMinting] = useState(false) const [mintSuccess, setMintSuccess] = useState(false) + const [mintError, setMintError] = useState(null) const { error, tokenIds, isValid } = useMemo( () => validateTokenIdInput(tokenIdInput), @@ -65,9 +67,10 @@ export const MintingForm: React.FC = ({ setIsMinting(true) setMintSuccess(false) + setMintError(null) try { - const hash = await writeContract(config, { + const { request } = await simulateContract(config, { abi: erc721RedeemMinterAbi, address: minterAddress, functionName: 'mintFromReserve', @@ -76,12 +79,27 @@ export const MintingForm: React.FC = ({ chainId: chain.id, }) - await waitForTransactionReceipt(config, { hash, chainId: chain.id }) + const result = await executeAppTransaction({ + config, + request, + chainId: chain.id, + }) + // Don't show success for Safe proposals - user needs to execute via Safe UI + if (result.kind === 'safe-proposed') { + setIsMinting(false) + return + } + + // Only show success for mined transactions setMintSuccess(true) setTokenIdInput('') + setMintError(null) } catch (error) { console.error('Minting failed:', error) + const errorMessage = + error instanceof Error ? error.message : 'Minting failed. Please try again.' + setMintError(errorMessage) } finally { setIsMinting(false) } @@ -154,6 +172,21 @@ export const MintingForm: React.FC = ({ )} + {mintError && ( + + + {mintError} + + + )} + = ({ chainId: chain.id, }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: chain.id }) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) + if (result.kind !== 'mined') return onSuccess() } catch (error) { diff --git a/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx b/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx index b04106ba2..712e2f5f2 100644 --- a/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx +++ b/packages/dao-ui/src/components/MinterManagementModal/MinterManagementModal.tsx @@ -1,6 +1,7 @@ import { ERC721_REDEEM_MINTER, MERKLE_RESERVE_MINTER } from '@buildeross/constants' import { useEnsData } from '@buildeross/hooks/useEnsData' import { tokenAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, useDaoStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -10,8 +11,8 @@ import { getEnsAddress } from '@buildeross/utils/ens' import { Box, Button, Flex, Heading, Text, theme } from '@buildeross/zord' import React from 'react' import { isAddress, zeroAddress } from 'viem' -import { useConfig, useWriteContract } from 'wagmi' -import { readContract, waitForTransactionReceipt } from 'wagmi/actions' +import { useConfig } from 'wagmi' +import { readContract, simulateContract } from 'wagmi/actions' interface MinterManagementModalProps { open: boolean @@ -147,7 +148,6 @@ export const MinterManagementModal: React.FC = ({ const { addresses } = useDaoStore() const chain = useChainStore((x) => x.chain) const config = useConfig() - const { writeContractAsync } = useWriteContract() const [isSettingUpMinter, setIsSettingUpMinter] = React.useState(false) const [customMinterInput, setCustomMinterInput] = React.useState('') @@ -318,7 +318,7 @@ export const MinterManagementModal: React.FC = ({ try { setIsSettingUpMinter(true) - const txHash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: tokenAbi, address: addresses.token!, functionName: 'updateMinters', @@ -326,9 +326,13 @@ export const MinterManagementModal: React.FC = ({ chainId: chain.id, }) - if (txHash) { - await waitForTransactionReceipt(config, { hash: txHash, chainId: chain.id }) - } + const result = await executeAppTransaction({ + config, + chainId: chain.id, + request, + }) + + if (result.kind === 'safe-proposed') return setPendingMinterStates({}) close() @@ -343,15 +347,7 @@ export const MinterManagementModal: React.FC = ({ } finally { setIsSettingUpMinter(false) } - }, [ - getChangesToApply, - chain.id, - addresses.token, - config, - writeContractAsync, - close, - onMinterEnabled, - ]) + }, [getChangesToApply, chain.id, addresses.token, config, close, onMinterEnabled]) const handleResetChanges = React.useCallback(() => { setPendingMinterStates({}) diff --git a/packages/dao-ui/src/components/PreAuction/PreAuction.tsx b/packages/dao-ui/src/components/PreAuction/PreAuction.tsx index 4fea5278a..d88eed318 100644 --- a/packages/dao-ui/src/components/PreAuction/PreAuction.tsx +++ b/packages/dao-ui/src/components/PreAuction/PreAuction.tsx @@ -1,13 +1,13 @@ import { auctionAbi, tokenAbi } from '@buildeross/sdk/contract' -import { awaitSubgraphSync } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useDaoStore } from '@buildeross/stores' import { AddressType, Chain } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { unpackOptionalArray } from '@buildeross/utils/helpers' import { Box, Button, Flex, Text, vars } from '@buildeross/zord' import React, { useState } from 'react' -import { useConfig, useReadContract, useSimulateContract, useWriteContract } from 'wagmi' -import { readContract, waitForTransactionReceipt } from 'wagmi/actions' +import { useConfig, useReadContract, useSimulateContract } from 'wagmi' +import { readContract, simulateContract } from 'wagmi/actions' import { preAuctionButtonVariants, @@ -73,21 +73,20 @@ export const PreAuction: React.FC = ({ chainId: chain.id, }) - const { writeContractAsync } = useWriteContract() - /* handle start of auction */ const handleStartAuction = async () => { if (!unpauseData) return setIsUnPausing(true) try { - const txHash = await writeContractAsync(unpauseData.request) - const receipt = await waitForTransactionReceipt(config, { - hash: txHash, + const result = await executeAppTransaction({ + config, + request: unpauseData.request, chainId: chain.id, }) - await awaitSubgraphSync(chain.id, receipt.blockNumber) + if (result.kind !== 'mined') return } catch (e) { console.error(e) + return } finally { setIsUnPausing(false) } @@ -166,20 +165,27 @@ export const PreAuction: React.FC = ({ setIsUpdatingReservedTokenId(true) try { - const txHash = await writeContractAsync({ + const { request } = await simulateContract(config, { abi: tokenAbi, address: addresses.token as AddressType, functionName: 'setReservedUntilTokenId', args: [BigInt(newReservedTokenId)], chainId: chain.id, }) - await waitForTransactionReceipt(config, { hash: txHash, chainId: chain.id }) + + const result = await executeAppTransaction({ + config, + chainId: chain.id, + request, + }) + if (result.kind === 'safe-proposed') return await refetchReservedTokenId() setIsEditingReservedTokenId(false) setNewReservedTokenId('') setValidationError('') } catch (e) { console.error(e) + return } finally { setIsUpdatingReservedTokenId(false) } diff --git a/packages/dao-ui/src/components/PreAuctionForm/PreAuctionForm.tsx b/packages/dao-ui/src/components/PreAuctionForm/PreAuctionForm.tsx index 5dbd79fac..eb6664cd6 100644 --- a/packages/dao-ui/src/components/PreAuctionForm/PreAuctionForm.tsx +++ b/packages/dao-ui/src/components/PreAuctionForm/PreAuctionForm.tsx @@ -1,4 +1,5 @@ import { auctionAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useChainStore, useDaoStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' import { @@ -20,7 +21,7 @@ import isEqual from 'lodash/isEqual' import React, { BaseSyntheticEvent } from 'react' import { formatEther, isAddressEqual, parseEther, zeroAddress } from 'viem' import { useConfig, useReadContracts } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { adminSection } from '../../styles/Section.css' import { Section } from '../AdminForm/Section' @@ -89,8 +90,12 @@ export const PreAuctionForm: React.FC = () => { functionName: 'setDuration', args: [BigInt(toSeconds(newDuration))], }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: chain.id }) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) + if (result.kind === 'safe-proposed') return } const newReservePrice = values.auctionReservePrice @@ -101,8 +106,12 @@ export const PreAuctionForm: React.FC = () => { functionName: 'setReservePrice', args: [parseEther(newReservePrice.toString())], }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: chain.id }) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) + if (result.kind === 'safe-proposed') return } if (supportsFounderReward) { @@ -138,8 +147,12 @@ export const PreAuctionForm: React.FC = () => { }, ], }) - const hash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash, chainId: chain.id }) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId: chain.id, + }) + if (result.kind === 'safe-proposed') return } } } finally { diff --git a/packages/feed-ui/src/Actions/AuctionActions.tsx b/packages/feed-ui/src/Actions/AuctionActions.tsx index 6501b6457..50ead4929 100644 --- a/packages/feed-ui/src/Actions/AuctionActions.tsx +++ b/packages/feed-ui/src/Actions/AuctionActions.tsx @@ -2,6 +2,7 @@ import { BASE_URL } from '@buildeross/constants/baseUrl' import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { useCurrentAuction } from '@buildeross/hooks' import { auctionAbi } from '@buildeross/sdk/contract' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore } from '@buildeross/stores' import type { AddressType, @@ -16,7 +17,7 @@ import { Button, Flex, Text } from '@buildeross/zord' import React, { useCallback, useMemo, useState } from 'react' import { useSWRConfig } from 'swr' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import type { OnOpenBidModal } from '../types/modalStates' @@ -91,11 +92,17 @@ export const AuctionActions: React.FC = ({ chainId, }) - const txHash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { hash: txHash, chainId }) - await mutate([SWR_KEYS.AUCTION, chainId, addresses.auction.toLowerCase()]) + const result = await executeAppTransaction({ + config, + request: data.request, + chainId, + }) + if (result.kind === 'mined') { + await mutate([SWR_KEYS.AUCTION, chainId, addresses.auction.toLowerCase()]) + } } catch (error) { console.error('Error settling auction:', error) + throw error } finally { setIsSettling(false) } diff --git a/packages/hooks/package.json b/packages/hooks/package.json index f5a24a96b..76cf03660 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -5,7 +5,8 @@ "dependencies": { "@smartinvoicexyz/types": "^0.1.27", "@zoralabs/coins-sdk": "^0.4.3", - "dayjs": "^1.11.13" + "dayjs": "^1.11.13", + "debug": "^4.4.3" }, "devDependencies": { "@buildeross/constants": "workspace:*", @@ -18,6 +19,7 @@ "@buildeross/utils": "workspace:*", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", + "@types/debug": "^4.1.13", "@types/node": "^22.15.21", "@types/react": "^19.2.2", "@types/react-dom": "^19.1.5", diff --git a/packages/hooks/src/useExecuteSwap.ts b/packages/hooks/src/useExecuteSwap.ts index 3edc02869..0464966e5 100644 --- a/packages/hooks/src/useExecuteSwap.ts +++ b/packages/hooks/src/useExecuteSwap.ts @@ -6,6 +6,7 @@ import { PublicClient, WalletClient } from 'viem' interface UseExecuteSwapParams { walletClient?: WalletClient publicClient?: PublicClient + isSafeMode?: boolean } interface ExecuteSwapParams { @@ -47,6 +48,7 @@ export function applySlippageBps(amountOut: bigint, slippageBps: bigint): bigint export function useExecuteSwap({ walletClient, publicClient, + isSafeMode = false, }: UseExecuteSwapParams): UseExecuteSwapReturn { const [isExecuting, setIsExecuting] = useState(false) const [error, setError] = useState(null) @@ -59,6 +61,9 @@ export function useExecuteSwap({ amountOut, slippage = 0.01, }: ExecuteSwapParams): Promise<`0x${string}`> => { + if (isSafeMode) { + throw new Error('Swaps are not supported with Safe wallets') + } if (!walletClient) { throw new Error('Wallet client not connected') } diff --git a/packages/hooks/src/useSafeAuth.ts b/packages/hooks/src/useSafeAuth.ts index dd095bb8d..485aac113 100644 --- a/packages/hooks/src/useSafeAuth.ts +++ b/packages/hooks/src/useSafeAuth.ts @@ -120,8 +120,8 @@ export function useSafeAuth(): UseSafeAuthReturn { error: null, }) - // Note: localStorage persistence is handled by CustomWalletModal during connection - // This hook only manages React state for the auth adapter + // Persistence is handled by the app-level wallet flow during connection. + // This hook only manages React state for the auth adapter. return true }, []) diff --git a/packages/hooks/src/useWalletDisconnect.ts b/packages/hooks/src/useWalletDisconnect.ts index c596976b2..8ac0a9ed5 100644 --- a/packages/hooks/src/useWalletDisconnect.ts +++ b/packages/hooks/src/useWalletDisconnect.ts @@ -1,7 +1,13 @@ +import debug from 'debug' import * as React from 'react' import { useAccount, useConfig, useDisconnect } from 'wagmi' -export function useWalletDisconnect(): () => void { +const debugDisconnect = debug('app:wallet:disconnect') + +// Prevent rapid-fire disconnects within this window +const DISCONNECT_THROTTLE_MS = 1000 + +export function useWalletDisconnect(): () => Promise { const { connector } = useAccount() const { disconnectAsync } = useDisconnect() const config = useConfig() @@ -18,11 +24,17 @@ export function useWalletDisconnect(): () => void { const cleanupStorage = React.useCallback(() => { try { const PREFIXES = ['wagmi', 'wc@', '@appkit', 'rainbowkit'] + const EXACT_KEYS = ['WALLETCONNECT_DEEPLINK_CHOICE'] // Mobile deep link preference + + // Remove prefixed keys for (const key of Object.keys(localStorage)) { if (PREFIXES.some((p) => key.startsWith(p))) localStorage.removeItem(key) } + + // Remove exact match keys + EXACT_KEYS.forEach((key) => localStorage.removeItem(key)) } catch (e) { - console.warn('useWalletDisconnect: targeted storage cleanup failed:', e) + debugDisconnect('targeted storage cleanup failed:', e) } }, []) @@ -31,26 +43,114 @@ export function useWalletDisconnect(): () => void { try { await disconnectAsync() } catch (e) { - console.warn('useWalletDisconnect: wagmi disconnect failed:', e) + debugDisconnect('wagmi disconnect failed:', e) } - // 2) WalletConnect: kill session if present + // 2) Connector-specific cleanup try { - if (connector?.id === 'walletConnect' && connector.getProvider) { + if (connector?.getProvider) { const provider: any = await connector.getProvider() - await provider?.disconnect?.() - provider?.destroy?.() + + if (connector.id === 'walletConnect') { + // WalletConnect: comprehensive session termination + + // a) Abort any ongoing pairing attempt + try { + await provider?.abortPairingAttempt?.() + } catch (e) { + debugDisconnect('abort pairing failed:', e) + } + + // b) Clean up pending pairings + try { + await provider?.cleanupPendingPairings?.({ deletePairings: true }) + } catch (e) { + debugDisconnect('cleanup pairings failed:', e) + } + + // c) Disconnect all sessions + await provider?.disconnect?.() + + // d) Destroy the provider + provider?.destroy?.() + + // e) Remove all event listeners (EIP-1193) + try { + if (provider?.removeAllListeners) { + provider.removeAllListeners() + } + } catch (e) { + debugDisconnect('remove listeners failed:', e) + } + + // f) Clear WalletConnect IndexedDB storage + try { + if (typeof window !== 'undefined' && window.indexedDB) { + // WalletConnect v2 uses 'keyvaluestorage' database + window.indexedDB.deleteDatabase('keyvaluestorage') + } + } catch (e) { + debugDisconnect('clear WC IndexedDB failed:', e) + } + } else { + // Injected/EIP-6963 wallets: revoke permissions (EIP-2255) + // This tells the wallet to "forget" this dApp connection + const isInjected = + connector.id.includes('.') || // EIP-6963 RDNS format (io.rabby, io.metamask, etc.) + connector.id === 'injected' || + connector.id === 'metaMask' || + connector.id === 'coinbaseWallet' + + if (isInjected && provider?.request) { + // a) Remove event listeners first (EIP-1193) + try { + if (provider.removeAllListeners) { + provider.removeAllListeners() + } else { + // Fallback: remove specific event listeners + const events = [ + 'accountsChanged', + 'chainChanged', + 'connect', + 'disconnect', + 'message', + ] + events.forEach((event) => { + try { + if (provider.removeListener) { + provider.removeListener(event, () => {}) + } else if (provider.off) { + provider.off(event, () => {}) + } + } catch (e) { + // Some providers may not support removing specific listeners + } + }) + } + } catch (e) { + debugDisconnect('remove listeners failed:', e) + } + + // b) Revoke wallet permissions (EIP-2255) + try { + await provider.request({ + method: 'wallet_revokePermissions', + params: [{ eth_accounts: {} }], + }) + } catch (revokeError) { + // Not all wallets support wallet_revokePermissions (EIP-2255) + // This is expected for some wallets - fail silently + debugDisconnect('wallet_revokePermissions not supported:', revokeError) + } + } + } } } catch (e) { - console.warn('useWalletDisconnect: walletconnect provider cleanup failed:', e) + debugDisconnect('provider cleanup failed:', e) } - // 3) SIWE session logout - try { - await fetch('/api/siwe/logout', { method: 'POST' }) - } catch (e) { - console.warn('useWalletDisconnect: SIWE logout failed:', e) - } + // 3) SIWE session logout - handled by session machine in useAppDisconnect + // Removed to prevent duplicate logout calls that cause race conditions // 4) targeted localStorage cleanup cleanupStorage() @@ -60,11 +160,31 @@ export function useWalletDisconnect(): () => void { }, [disconnectAsync, connector, cleanupStorage, resetWagmi]) const inFlightRef = React.useRef | null>(null) + const lastSuccessRef = React.useRef(0) + const runOnce = React.useCallback((fn: () => Promise) => { + // If already in progress, return existing promise if (inFlightRef.current) return inFlightRef.current + + // If recently succeeded, skip to prevent rapid-fire disconnects + const timeSinceSuccess = Date.now() - lastSuccessRef.current + if (timeSinceSuccess < DISCONNECT_THROTTLE_MS) { + debugDisconnect( + 'Disconnect called too soon after last success (%dms), skipping', + timeSinceSuccess + ) + return Promise.resolve() + } + const p = (async () => { try { await fn() + lastSuccessRef.current = Date.now() + debugDisconnect('Disconnect completed successfully') + } catch (error) { + debugDisconnect('Disconnect failed: %O', error) + // Don't update lastSuccessRef on failure, allowing retry + throw error } finally { inFlightRef.current = null } @@ -75,7 +195,7 @@ export function useWalletDisconnect(): () => void { // Public API: user-initiated disconnect (simple, no extra checks) const disconnect = React.useCallback(() => { - void runOnce(disconnectCore) + return runOnce(disconnectCore) }, [runOnce, disconnectCore]) return disconnect diff --git a/packages/hooks/src/useZoraMint.ts b/packages/hooks/src/useZoraMint.ts index 64e686473..f1c3cf26b 100644 --- a/packages/hooks/src/useZoraMint.ts +++ b/packages/hooks/src/useZoraMint.ts @@ -1,13 +1,10 @@ +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { CHAIN_ID } from '@buildeross/types' import { useCallback, useEffect, useRef, useState } from 'react' import type { Address } from 'viem' import { isAddress, isAddressEqual, parseEther, zeroAddress } from 'viem' -import { - useAccount, - useSimulateContract, - useWaitForTransactionReceipt, - useWriteContract, -} from 'wagmi' +import { useAccount, useConfig, useSimulateContract } from 'wagmi' +import { simulateContract } from 'wagmi/actions' // Zora protocol reward fee per token (0.000777 ETH) export const ZORA_PROTOCOL_REWARD = 0.000777 @@ -99,14 +96,11 @@ export function useZoraMint({ chainId: chainId, }) - const { writeContractAsync, data: pendingHash, reset: resetWrite } = useWriteContract() + const config = useConfig() + const [pendingHash, setPendingHash] = useState<`0x${string}`>() - const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ - hash: pendingHash, - query: { - enabled: Boolean(pendingHash), - }, - }) + const [isConfirming, setIsConfirming] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) // Cleanup timer on unmount useEffect(() => { @@ -129,7 +123,7 @@ export function useZoraMint({ // Reset to idle after a delay const timer = setTimeout(() => { setMintStatus('idle') - resetWrite() + setPendingHash(undefined) }, 3000) return () => { @@ -137,7 +131,7 @@ export function useZoraMint({ } } return () => {} - }, [isSuccess, pendingHash, onSuccess, resetWrite]) + }, [isSuccess, pendingHash, onSuccess]) const mint = useCallback( async (quantity: number = 1, comment?: string): Promise<`0x${string}` | null> => { @@ -167,6 +161,8 @@ export function useZoraMint({ try { setMintError(null) + setIsSuccess(false) + setIsConfirming(true) // Phase 1: Waiting for wallet confirmation setMintStatus('confirming-wallet') @@ -177,8 +173,8 @@ export function useZoraMint({ parseEther(String(ZORA_PROTOCOL_REWARD)) * BigInt(quantity) const totalPriceWei = salePriceWei + protocolRewardWei - // Use mintWithRewards - const txHash = await writeContractAsync({ + // Simulate the transaction to get the properly typed request + const { request } = await simulateContract(config, { abi: zoraNftMintAbi, address: dropAddress, functionName: 'mintWithRewards', @@ -189,14 +185,30 @@ export function useZoraMint({ (mintReferral || address) as Address, // mintReferral ], value: totalPriceWei, - chainId: chainId, + chainId, }) + // Execute the transaction + const result = await executeAppTransaction({ + config, + chainId, + request, + }) + const txHash = result.hash + setPendingHash(txHash) + setIsConfirming(false) + if (result.kind === 'safe-proposed') { + setMintStatus('pending-tx') + return txHash + } + setIsSuccess(result.receipt.status === 'success') + // Phase 2: Transaction submitted, waiting for confirmation setMintStatus('pending-tx') return txHash } catch (err: unknown) { + setIsConfirming(false) setMintStatus('error') const error = err instanceof Error ? err : new Error('Mint failed') const message = error.message @@ -250,16 +262,7 @@ export function useZoraMint({ return null } }, - [ - chainId, - dropAddress, - address, - isReady, - priceEth, - mintReferral, - onError, - writeContractAsync, - ] + [chainId, dropAddress, address, isReady, priceEth, mintReferral, onError, config] ) const isPending = diff --git a/packages/proposal-ui/src/components/GovernorContractButton/GovernorContractButton.tsx b/packages/proposal-ui/src/components/GovernorContractButton/GovernorContractButton.tsx index b73acf7a6..5ecc82698 100644 --- a/packages/proposal-ui/src/components/GovernorContractButton/GovernorContractButton.tsx +++ b/packages/proposal-ui/src/components/GovernorContractButton/GovernorContractButton.tsx @@ -1,6 +1,7 @@ import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { governorAbi } from '@buildeross/sdk/contract' -import { awaitSubgraphSync, getProposal } from '@buildeross/sdk/subgraph' +import { getProposal } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useChainStore, useDaoStore } from '@buildeross/stores' import { ContractButton } from '@buildeross/ui/ContractButton' import { Box, ButtonProps } from '@buildeross/zord' @@ -8,12 +9,7 @@ import { useCallback, useState } from 'react' import { useSWRConfig } from 'swr' import { ContractFunctionName, encodeFunctionData } from 'viem' import { useConfig, UseSimulateContractParameters } from 'wagmi' -import { - estimateGas, - simulateContract, - waitForTransactionReceipt, - writeContract, -} from 'wagmi/actions' +import { estimateGas, simulateContract } from 'wagmi/actions' import { uploadingSpinnerWhite } from './GovernorContractButton.css' @@ -51,7 +47,7 @@ export function GovernorContractButton({ try { setIsPending(true) - await simulateContract(config, { + const { request } = await simulateContract(config, { address: addresses.governor, abi: governorAbi, functionName: functionName, @@ -70,17 +66,15 @@ export function GovernorContractButton({ chainId: chain.id, }) - const hash = await writeContract(config, { - address: addresses.governor, - abi: governorAbi, - functionName: functionName, - args: args, + const result = await executeAppTransaction({ + config, + request: { + ...request, + gas: (gas * 3n) / 2n, // add extra gas for safety + }, chainId: chain.id, - gas: (gas * 3n) / 2n, // add extra gas for safety }) - const receipt = await waitForTransactionReceipt(config, { hash, chainId: chain.id }) - - await awaitSubgraphSync(chain.id, receipt.blockNumber) + if (result.kind !== 'mined') return await mutate( [SWR_KEYS.PROPOSAL, chain.id, proposalId.toLowerCase()], diff --git a/packages/proposal-ui/src/components/PropDates/PropDateForm.tsx b/packages/proposal-ui/src/components/PropDates/PropDateForm.tsx index 909428974..39b4afc6c 100644 --- a/packages/proposal-ui/src/components/PropDates/PropDateForm.tsx +++ b/packages/proposal-ui/src/components/PropDates/PropDateForm.tsx @@ -5,7 +5,8 @@ import { PROPDATE_SCHEMA_UID, } from '@buildeross/constants/eas' import { useEnsData } from '@buildeross/hooks/useEnsData' -import { awaitSubgraphSync, MessageType } from '@buildeross/sdk/subgraph' +import { MessageType } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useChainStore, useDaoStore } from '@buildeross/stores' import { CHAIN_ID, RequiredDaoContractAddresses } from '@buildeross/types' import { WalletIdentity } from '@buildeross/ui' @@ -23,7 +24,7 @@ import { Field, FieldProps, Form, Formik } from 'formik' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { encodeAbiParameters, getAddress, type Hex, zeroHash } from 'viem' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import * as Yup from 'yup' import { proposalDescription as messageStyle } from '../ProposalDescription/ProposalDescription.css' @@ -172,12 +173,12 @@ export const PropDateForm = ({ chainId: chainId, args: [attestParams], }) - const txHash = await writeContract(config, data.request) - const receipt = await waitForTransactionReceipt(config, { - hash: txHash, - chainId: chainId, + const result = await executeAppTransaction({ + config, + request: data.request, + chainId, }) - await awaitSubgraphSync(chainId, receipt.blockNumber) + if (result.kind === 'safe-proposed') return setIsTxSuccess(true) } catch (err: unknown) { console.error('Error submitting propdate (signing):', err) diff --git a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteModal.tsx b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteModal.tsx index 716aa4684..f24900534 100644 --- a/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteModal.tsx +++ b/packages/proposal-ui/src/components/ProposalActions/VoteStatus/VoteModal.tsx @@ -1,6 +1,7 @@ import { SWR_KEYS } from '@buildeross/constants/swrKeys' import { governorAbi } from '@buildeross/sdk/contract' -import { awaitSubgraphSync, getProposal } from '@buildeross/sdk/subgraph' +import { getProposal } from '@buildeross/sdk/subgraph' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useChainStore, useDaoStore } from '@buildeross/stores' import { BytesType, CHAIN_ID, RequiredDaoContractAddresses } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' @@ -21,7 +22,7 @@ import { Field, Formik } from 'formik' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSWRConfig } from 'swr' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { voteModalFieldset, @@ -179,14 +180,13 @@ export const SubmitVoteForm: React.FC<{ functionName: hasReason ? 'castVoteWithReason' : 'castVote', args: hasReason ? [proposalId, choice, values.reason] : [proposalId, choice], }) - const txHash = await writeContract(config, data.request) - - const receipt = await waitForTransactionReceipt(config, { - hash: txHash, - chainId: chainId, + const result = await executeAppTransaction({ + config, + request: data.request, + chainId, }) - await awaitSubgraphSync(chainId, receipt.blockNumber) + if (result.kind === 'safe-proposed') return await mutate( [SWR_KEYS.PROPOSAL, chainId, proposalId.toLowerCase()], @@ -196,6 +196,7 @@ export const SubmitVoteForm: React.FC<{ onSuccess() } catch (err) { console.error('Error casting vote:', err) + throw err } }, [governorAddress, chainId, proposalId, config, mutate, onSuccess] diff --git a/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx b/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx index b99c948d7..14327fda4 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/AirdropDetails/AirdropItem.tsx @@ -2,6 +2,7 @@ import { ETHERSCAN_BASE_URL } from '@buildeross/constants/etherscan' import { AirdropInstanceData } from '@buildeross/hooks/useAirdropData' import { useEthUsdPrice } from '@buildeross/hooks/useEthUsdPrice' import { getFetchableUrls } from '@buildeross/ipfs-service' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore } from '@buildeross/stores' import { type CHAIN_ID, TokenMetadata } from '@buildeross/types' import { AccordionItem } from '@buildeross/ui/Accordion' @@ -21,7 +22,7 @@ import { parseAbi, } from 'viem' import { useConfig, useReadContracts } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { formatFeeDisplay } from '../utils/feeDisplay' @@ -227,12 +228,17 @@ export const AirdropItem = ({ value: minFeeWei, }) - const hash = await writeContract(config, simulation.request) - await waitForTransactionReceipt(config, { hash, chainId }) + const result = await executeAppTransaction({ + config, + request: simulation.request, + chainId, + }) + if (result.kind === 'safe-proposed') return await refetchClaimState() } catch (error) { console.error('Failed to claim airdrop:', error) + throw error } finally { setIsClaiming(false) } diff --git a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx index 5f491ba93..770768132 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/MilestoneDetails/EscrowInstance.tsx @@ -4,6 +4,7 @@ import { useEnsData } from '@buildeross/hooks/useEnsData' import { type EscrowInstanceData } from '@buildeross/hooks/useInvoiceData' import { useIsGnosisSafe } from '@buildeross/hooks/useIsGnosisSafe' import { useTokenMetadataSingle } from '@buildeross/hooks/useTokenMetadata' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, @@ -17,7 +18,7 @@ import { atoms, Box, Button, Icon, Spinner, Stack, Text } from '@buildeross/zord import { useCallback, useMemo, useState } from 'react' import { encodeFunctionData, Hex } from 'viem' import { useConfig, useReadContract } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { MilestoneItem } from './MilestoneItem' @@ -175,11 +176,12 @@ export const EscrowInstance = ({ args: [BigInt(milestone)], }) - const txHash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { - hash: txHash, + const result = await executeAppTransaction({ + config, + request: data.request, chainId: chain.id, }) + if (result.kind !== 'mined') return } catch (error) { console.error('Error releasing milestone:', error) } finally { diff --git a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx index 610c02234..fbe4e9c17 100644 --- a/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx +++ b/packages/proposal-ui/src/components/ProposalDescription/StreamDetails/StreamItem.tsx @@ -3,6 +3,7 @@ import { useEnsData } from '@buildeross/hooks/useEnsData' import { useEthUsdPrice } from '@buildeross/hooks/useEthUsdPrice' import { useIsGnosisSafe } from '@buildeross/hooks/useIsGnosisSafe' import { useVotes } from '@buildeross/hooks/useVotes' +import { executeAppTransaction } from '@buildeross/sdk/transaction' import { useAuthStore, useChainStore, @@ -33,7 +34,7 @@ import { atoms, Box, Button, Grid, Icon, Stack, Text } from '@buildeross/zord' import { useCallback, useMemo } from 'react' import { Address, encodeFunctionData, formatUnits, isAddressEqual } from 'viem' import { useConfig } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' import { formatFeeDisplay } from '../utils/feeDisplay' import { SenderDelegation } from './SenderDelegation' @@ -159,11 +160,12 @@ export const StreamItem = ({ value: liveData.minFeeWei, }) - const txHash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { - hash: txHash, + const result = await executeAppTransaction({ + config, + request: data.request, chainId: chain.id, }) + if (result.kind !== 'mined') return refetchLiveData() } catch (error) { console.error('Error withdrawing from stream:', error) @@ -192,11 +194,12 @@ export const StreamItem = ({ args: [liveData.streamId], }) - const txHash = await writeContract(config, data.request) - await waitForTransactionReceipt(config, { - hash: txHash, + const result = await executeAppTransaction({ + config, + request: data.request, chainId: chain.id, }) + if (result.kind !== 'mined') return } catch (error) { console.error('Error canceling stream:', error) } finally { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index fda9b384b..19ea20dc5 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -5,7 +5,10 @@ "dependencies": { "graphql": "^16.11.0", "graphql-request": "^7.1.2", - "graphql-tag": "^2.12.6" + "graphql-tag": "^2.12.6", + "@buildeross/utils": "workspace:*", + "@buildeross/types": "workspace:*", + "wagmi": "^2.18.1" }, "devDependencies": { "@buildeross/constants": "workspace:*", @@ -46,6 +49,11 @@ "import": "./dist/eas/index.mjs", "require": "./dist/eas/index.js" }, + "./transaction": { + "types": "./dist/transaction/index.d.ts", + "import": "./dist/transaction/index.mjs", + "require": "./dist/transaction/index.js" + }, "./*": { "types": "./dist/*.d.ts", "import": "./dist/*.mjs", diff --git a/packages/sdk/src/eas/attestCandidate.ts b/packages/sdk/src/eas/attestCandidate.ts index 58b0e85f0..a6b6aeea3 100644 --- a/packages/sdk/src/eas/attestCandidate.ts +++ b/packages/sdk/src/eas/attestCandidate.ts @@ -8,9 +8,10 @@ import { CHAIN_ID } from '@buildeross/types' import type { Hex } from 'viem' import { encodeAbiParameters, getAddress, zeroHash } from 'viem' import type { Config } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' -import { awaitSubgraphSync } from '../subgraph/requests/sync' +import { executeAppTransaction } from '../transaction' +import { extractSingleAttestationUID } from './utils' export interface CandidateAttestationParams { config: Config @@ -24,10 +25,16 @@ export interface CandidateAttestationParams { description: string } -export interface CandidateAttestationResult { - attestationUID: Hex - transactionHash: Hex -} +export type CandidateAttestationResult = + | { + kind: 'mined' + attestationUID: Hex + transactionHash: Hex + } + | { + kind: 'safe-proposed' + transactionHash: Hex + } /** * Submits a candidate attestation to EAS @@ -90,25 +97,26 @@ export async function attestCandidate( }) // 4. Write the transaction - const txHash = await writeContract(config, simulation.request) - - // 5. Wait for confirmation - const receipt = await waitForTransactionReceipt(config, { - hash: txHash, - chainId: chainId, + const result = await executeAppTransaction({ + config, + request: simulation.request, + chainId, }) - // 6. Sync with subgraph - await awaitSubgraphSync(chainId, receipt.blockNumber) + // Handle Safe proposals vs mined transactions + if (result.kind === 'safe-proposed') { + return { + kind: 'safe-proposed', + transactionHash: result.hash, + } + } - // 7. Extract attestation UID from logs - // The attest function returns the attestation UID - // For now, we'll use the transaction hash as a placeholder - // In a real implementation, you'd decode the logs to get the actual UID - const attestationUID = txHash + // Extract attestation UID from logs for mined transactions + const attestationUID = extractSingleAttestationUID(result.receipt, easAddress) return { + kind: 'mined', attestationUID, - transactionHash: txHash, + transactionHash: result.hash, } } diff --git a/packages/sdk/src/eas/attestCandidateComment.ts b/packages/sdk/src/eas/attestCandidateComment.ts index 29d816392..c8c04f4a8 100644 --- a/packages/sdk/src/eas/attestCandidateComment.ts +++ b/packages/sdk/src/eas/attestCandidateComment.ts @@ -8,9 +8,10 @@ import { CHAIN_ID } from '@buildeross/types' import type { Hex } from 'viem' import { encodeAbiParameters, getAddress, zeroHash } from 'viem' import type { Config } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' -import { awaitSubgraphSync } from '../subgraph/requests/sync' +import { executeAppTransaction } from '../transaction' +import { extractSingleAttestationUID } from './utils' export enum CandidateVoteSupportEnum { FOR = 0, @@ -29,10 +30,16 @@ export interface CandidateCommentParams { parentCommentUID?: Hex } -export interface CandidateCommentResult { - attestationUID: Hex - transactionHash: Hex -} +export type CandidateCommentResult = + | { + kind: 'mined' + attestationUID: Hex + transactionHash: Hex + } + | { + kind: 'safe-proposed' + transactionHash: Hex + } /** * Submits a candidate comment/vote attestation to EAS @@ -92,22 +99,26 @@ export async function attestCandidateComment( }) // 4. Write the transaction - const txHash = await writeContract(config, simulation.request) - - // 5. Wait for confirmation - const receipt = await waitForTransactionReceipt(config, { - hash: txHash, - chainId: chainId, + const result = await executeAppTransaction({ + config, + request: simulation.request, + chainId, }) - // 6. Sync with subgraph - await awaitSubgraphSync(chainId, receipt.blockNumber) + // Handle Safe proposals vs mined transactions + if (result.kind === 'safe-proposed') { + return { + kind: 'safe-proposed', + transactionHash: result.hash, + } + } - // 7. Use transaction hash as attestation UID - const attestationUID = txHash + // Extract attestation UID from logs for mined transactions + const attestationUID = extractSingleAttestationUID(result.receipt, easAddress) return { + kind: 'mined', attestationUID, - transactionHash: txHash, + transactionHash: result.hash, } } diff --git a/packages/sdk/src/eas/attestCandidateSignature.ts b/packages/sdk/src/eas/attestCandidateSignature.ts index 58f1b1fa3..a1f55f657 100644 --- a/packages/sdk/src/eas/attestCandidateSignature.ts +++ b/packages/sdk/src/eas/attestCandidateSignature.ts @@ -8,9 +8,10 @@ import type { AddressType, CHAIN_ID } from '@buildeross/types' import type { Hex, WalletClient } from 'viem' import { encodeAbiParameters, getAddress, zeroHash } from 'viem' import type { Config } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' -import { awaitSubgraphSync } from '../subgraph/requests/sync' +import { executeAppTransaction } from '../transaction' +import { extractSingleAttestationUID } from './utils' export interface CandidateSignatureParams { config: Config @@ -27,11 +28,18 @@ export interface CandidateSignatureParams { deadline: number } -export interface CandidateSignatureResult { - attestationUID: Hex - transactionHash: Hex - signature: Hex -} +export type CandidateSignatureResult = + | { + kind: 'mined' + attestationUID: Hex + transactionHash: Hex + signature: Hex + } + | { + kind: 'safe-proposed' + transactionHash: Hex + signature: Hex + } /** * Generate an EIP-712 signature for a candidate and submit it as an attestation @@ -142,23 +150,28 @@ export async function attestCandidateSignature( }) // 5. Write the transaction - const txHash = await writeContract(config, simulation.request) - - // 6. Wait for confirmation - const receipt = await waitForTransactionReceipt(config, { - hash: txHash, - chainId: chainId, + const result = await executeAppTransaction({ + config, + request: simulation.request, + chainId, }) - // 7. Sync with subgraph - await awaitSubgraphSync(chainId, receipt.blockNumber) + // Handle Safe proposals vs mined transactions + if (result.kind === 'safe-proposed') { + return { + kind: 'safe-proposed', + transactionHash: result.hash, + signature, + } + } - // 8. Use transaction hash as attestation UID - const attestationUID = txHash + // Extract attestation UID from logs for mined transactions + const attestationUID = extractSingleAttestationUID(result.receipt, easAddress) return { + kind: 'mined', attestationUID, - transactionHash: txHash, + transactionHash: result.hash, signature, } } diff --git a/packages/sdk/src/eas/attestCommentWithSignature.ts b/packages/sdk/src/eas/attestCommentWithSignature.ts index 7548b0479..009eaa023 100644 --- a/packages/sdk/src/eas/attestCommentWithSignature.ts +++ b/packages/sdk/src/eas/attestCommentWithSignature.ts @@ -28,12 +28,19 @@ export interface CommentWithSignatureParams { parentCommentUID?: Hex } -export interface CommentWithSignatureResult { - commentUID: Hex - signatureUID: Hex - transactionHash: Hex - signature: Hex -} +export type CommentWithSignatureResult = + | { + kind: 'mined' + commentUID: Hex + signatureUID: Hex + transactionHash: Hex + signature: Hex + } + | { + kind: 'safe-proposed' + transactionHash: Hex + signature: Hex + } /** * Submit both a comment/vote AND a sponsor signature in a single transaction @@ -161,7 +168,17 @@ export async function attestCommentWithSignature( requests, }) + // Handle Safe proposals vs mined transactions + if (result.kind === 'safe-proposed') { + return { + kind: 'safe-proposed', + transactionHash: result.transactionHash, + signature, + } + } + return { + kind: 'mined', commentUID: result.attestationUIDs[0], signatureUID: result.attestationUIDs[1], transactionHash: result.transactionHash, diff --git a/packages/sdk/src/eas/multiAttest.ts b/packages/sdk/src/eas/multiAttest.ts index a12b10fa3..f11f3050f 100644 --- a/packages/sdk/src/eas/multiAttest.ts +++ b/packages/sdk/src/eas/multiAttest.ts @@ -2,9 +2,10 @@ import { EAS_CONTRACT_ADDRESS, easAbi } from '@buildeross/constants/eas' import { CHAIN_ID } from '@buildeross/types' import type { Hex } from 'viem' import type { Config } from 'wagmi' -import { simulateContract, waitForTransactionReceipt, writeContract } from 'wagmi/actions' +import { simulateContract } from 'wagmi/actions' -import { awaitSubgraphSync } from '../subgraph/requests/sync' +import { executeAppTransaction } from '../transaction' +import { extractAttestationUIDs } from './utils' export interface MultiAttestationRequest { schema: Hex @@ -24,10 +25,16 @@ export interface MultiAttestParams { requests: MultiAttestationRequest[] } -export interface MultiAttestResult { - attestationUIDs: Hex[] - transactionHash: Hex -} +export type MultiAttestResult = + | { + kind: 'mined' + attestationUIDs: Hex[] + transactionHash: Hex + } + | { + kind: 'safe-proposed' + transactionHash: Hex + } /** * Submit multiple attestations in a single transaction using multiAttest @@ -60,23 +67,34 @@ export async function multiAttest(params: MultiAttestParams): Promise txHash) + // Validate we got the expected number of UIDs + const totalAttestations = requests.reduce((sum, req) => sum + req.data.length, 0) + if (attestationUIDs.length !== totalAttestations) { + throw new Error( + `Expected ${totalAttestations} attestation UIDs but got ${attestationUIDs.length}` + ) + } return { + kind: 'mined', attestationUIDs, - transactionHash: txHash, + transactionHash: result.hash, } } diff --git a/packages/sdk/src/eas/utils.ts b/packages/sdk/src/eas/utils.ts new file mode 100644 index 000000000..ea1470e1c --- /dev/null +++ b/packages/sdk/src/eas/utils.ts @@ -0,0 +1,57 @@ +import { easAbi } from '@buildeross/constants/eas' +import type { Hex, TransactionReceipt } from 'viem' +import { decodeEventLog } from 'viem' + +/** + * Extract attestation UIDs from the Attested events in a transaction receipt + * @param receipt - The transaction receipt containing the logs + * @param easAddress - The EAS contract address to filter logs by + * @returns Array of attestation UIDs extracted from Attested events + */ +export function extractAttestationUIDs( + receipt: TransactionReceipt, + easAddress: Hex +): Hex[] { + const attestedEvents = receipt.logs + .filter((log) => log.address.toLowerCase() === easAddress.toLowerCase()) + .map((log) => { + try { + return decodeEventLog({ + abi: easAbi, + data: log.data, + topics: log.topics, + }) + } catch { + return null + } + }) + .filter((event) => event !== null && event.eventName === 'Attested') + + return attestedEvents.map((event) => (event as any).args.uid as Hex) +} + +/** + * Extract a single attestation UID from transaction receipt + * @param receipt - The transaction receipt containing the logs + * @param easAddress - The EAS contract address to filter logs by + * @returns The attestation UID + * @throws Error if no Attested event is found + */ +export function extractSingleAttestationUID( + receipt: TransactionReceipt, + easAddress: Hex +): Hex { + const uids = extractAttestationUIDs(receipt, easAddress) + + if (uids.length === 0) { + throw new Error('Attested event not found in transaction logs') + } + + if (uids.length > 1) { + throw new Error( + `Expected 1 Attested event but found ${uids.length}. Use extractAttestationUIDs instead.` + ) + } + + return uids[0] +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index e4323fdf1..df1d7d460 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,3 +2,4 @@ export * from './contract' export * from './eas' export * from './signatures' export * from './subgraph' +export * from './transaction' diff --git a/packages/sdk/src/transaction/executeAppTransaction.test.ts b/packages/sdk/src/transaction/executeAppTransaction.test.ts new file mode 100644 index 000000000..a1dd8233a --- /dev/null +++ b/packages/sdk/src/transaction/executeAppTransaction.test.ts @@ -0,0 +1,126 @@ +import { recordSafeProposalHash } from '@buildeross/utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { executeAppTransaction, executeAppTransactions } from './executeAppTransaction' + +const { writeContract, waitForTransactionReceipt, awaitSubgraphSync } = vi.hoisted( + () => ({ + writeContract: vi.fn(), + waitForTransactionReceipt: vi.fn(), + awaitSubgraphSync: vi.fn(), + }) +) + +vi.mock('wagmi/actions', () => ({ + writeContract, + waitForTransactionReceipt, +})) + +vi.mock('../subgraph/requests/sync', () => ({ + awaitSubgraphSync, +})) + +const request = (index: number) => + ({ + abi: [{ type: 'function', name: 'act', inputs: [], outputs: [] }], + address: `0x${String(index).padStart(40, '0')}`, + functionName: 'act', + args: [], + }) as any + +const config = (connector?: { id: string; getProvider: () => Promise }) => + ({ + state: { + current: connector ? 'connection' : null, + connections: new Map(connector ? [['connection', { connector }]] : []), + }, + }) as any + +describe('executeAppTransactions', () => { + beforeEach(() => { + vi.clearAllMocks() + waitForTransactionReceipt.mockResolvedValue({ status: 'success', blockNumber: 10n }) + awaitSubgraphSync.mockResolvedValue(true) + }) + + it('returns an empty result without contacting the wallet', async () => { + await expect( + executeAppTransactions({ config: config(), requests: [], chainId: 1 as any }) + ).resolves.toEqual([]) + expect(writeContract).not.toHaveBeenCalled() + }) + + it('proposes all requests as one Safe transaction', async () => { + const hash = `0x${'a'.repeat(64)}` as `0x${string}` + recordSafeProposalHash(hash) + const provider = { request: vi.fn().mockResolvedValue(hash) } + const result = await executeAppTransactions({ + config: config({ id: 'safeOwner', getProvider: async () => provider }), + requests: [request(1), request(2)], + chainId: 1 as any, + }) + + expect(result).toEqual({ kind: 'safe-proposed', hash }) + expect(provider.request).toHaveBeenCalledTimes(1) + expect(provider.request.mock.calls[0][0].method).toBe('eth_sendTransaction') + expect(provider.request.mock.calls[0][0].params[0].safeTransactions).toHaveLength(2) + expect(writeContract).not.toHaveBeenCalled() + }) + + it('sends EOA requests sequentially and waits for each receipt', async () => { + writeContract + .mockResolvedValueOnce(`0x${'1'.repeat(64)}`) + .mockResolvedValueOnce(`0x${'2'.repeat(64)}`) + const result = await executeAppTransactions({ + config: config(), + requests: [request(1), request(2)], + chainId: 1 as any, + }) + + expect(result).toHaveLength(2) + expect(writeContract).toHaveBeenCalledTimes(2) + expect(waitForTransactionReceipt).toHaveBeenCalledTimes(2) + expect(awaitSubgraphSync).toHaveBeenCalledTimes(2) + }) + + it('rejects a reverted threshold-one Safe batch', async () => { + const hash = `0x${'b'.repeat(64)}` as `0x${string}` + const provider = { request: vi.fn().mockResolvedValue(hash) } + waitForTransactionReceipt.mockResolvedValue({ status: 'reverted', blockNumber: 10n }) + + await expect( + executeAppTransactions({ + config: config({ id: 'safeOwner', getProvider: async () => provider }), + requests: [request(1), request(2)], + chainId: 1 as any, + }) + ).rejects.toThrow(`Transaction reverted: ${hash}`) + }) + + it('propagates Safe provider failures', async () => { + const provider = { + request: vi.fn().mockRejectedValue(new Error('wallet unavailable')), + } + + await expect( + executeAppTransactions({ + config: config({ id: 'safeOwner', getProvider: async () => provider }), + requests: [request(1)], + chainId: 1 as any, + }) + ).rejects.toThrow('wallet unavailable') + }) +}) + +describe('executeAppTransaction', () => { + it('does not wait for a receipt for a Safe proposal', async () => { + const hash = `0x${'c'.repeat(64)}` as `0x${string}` + recordSafeProposalHash(hash) + writeContract.mockResolvedValue(hash) + + await expect( + executeAppTransaction({ config: config(), request: request(1), chainId: 1 as any }) + ).resolves.toEqual({ kind: 'safe-proposed', hash }) + expect(waitForTransactionReceipt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/sdk/src/transaction/executeAppTransaction.ts b/packages/sdk/src/transaction/executeAppTransaction.ts new file mode 100644 index 000000000..ff80dd060 --- /dev/null +++ b/packages/sdk/src/transaction/executeAppTransaction.ts @@ -0,0 +1,174 @@ +import type { CHAIN_ID } from '@buildeross/types' +import { isSafeProposalHash } from '@buildeross/utils' +import { + encodeFunctionData, + type TransactionReceipt, + type WriteContractParameters, +} from 'viem' +import type { Config } from 'wagmi' +import { waitForTransactionReceipt, writeContract } from 'wagmi/actions' + +import { awaitSubgraphSync } from '../subgraph/requests/sync' + +/** + * Result of executing an app transaction. + * Discriminated union based on whether the transaction was mined on-chain + * or proposed to a Safe multi-sig. + */ +export type AppTransactionResult = + | { + /** Transaction was mined on-chain (EOA or 1-of-1 Safe) */ + kind: 'mined' + /** Transaction hash */ + hash: `0x${string}` + /** Transaction receipt with logs and events */ + receipt: TransactionReceipt + /** Whether the subgraph has synced to this transaction */ + subgraphSynced: boolean + } + | { + /** Transaction was proposed to Safe multi-sig (not yet executed) */ + kind: 'safe-proposed' + /** Safe transaction hash (for tracking in Safe UI) */ + hash: `0x${string}` + } + +/** + * Parameters for executing an app transaction + */ +type ExecuteAppTransactionParams = { + /** Wagmi config with connected wallet */ + config: Config + /** + * Write contract request parameters from simulateContract. + * simulateContract returns parameters without wallet-specific fields + * that writeContract will fill from the wagmi config. + */ + request: WriteContractParameters + /** Chain ID where the transaction will be executed */ + chainId: CHAIN_ID + /** + * Whether to wait for subgraph to sync after transaction mines. + * Only applies to 'mined' transactions (not Safe proposals). + * @default true + */ + waitForSubgraphSync?: boolean +} + +export async function executeAppTransactions({ + config, + requests, + chainId, +}: { + config: Config + requests: WriteContractParameters[] + chainId: CHAIN_ID +}): Promise { + if (requests.length === 0) return [] + const connection = config.state.current + ? config.state.connections.get(config.state.current) + : undefined + const connector = connection?.connector + if (connector?.id === 'safeOwner') { + const provider = (await connector.getProvider()) as { + request: (args: { method: string; params?: unknown[] }) => Promise + } + const transactions = requests.map((request) => ({ + to: request.address, + data: encodeFunctionData({ + abi: request.abi, + functionName: request.functionName, + args: request.args, + }), + value: request.value, + })) + const hash = (await provider.request({ + method: 'eth_sendTransaction', + params: [{ ...transactions[0], safeTransactions: transactions }], + })) as `0x${string}` + if (isSafeProposalHash(hash)) return { kind: 'safe-proposed', hash } + const receipt = await waitForTransactionReceipt(config, { hash, chainId }) + if (receipt.status !== 'success') throw new Error(`Transaction reverted: ${hash}`) + let subgraphSynced = false + try { + subgraphSynced = await awaitSubgraphSync(chainId, receipt.blockNumber) + } catch { + subgraphSynced = false + } + return { kind: 'mined', hash, receipt, subgraphSynced } + } + const results: AppTransactionResult[] = [] + for (const request of requests) { + results.push(await executeAppTransaction({ config, request, chainId })) + } + return results +} + +/** + * Execute a write transaction, handling both EOA and Safe wallets. + * + * This is the unified transaction execution function for the entire app. + * It abstracts away the difference between EOA wallets and Safe multi-sig wallets: + * + * - **EOA wallets**: Transaction is sent on-chain immediately and we wait for it to mine + * - **1-of-1 Safes**: Transaction is executed immediately (no multi-sig needed) + * - **Multi-sig Safes**: Transaction is proposed to Safe Service, requiring additional signatures + * + * @example + * ```typescript + * // Typical usage with simulateContract + * const { request } = await simulateContract(config, { + * address: contractAddress, + * abi: contractAbi, + * functionName: 'transfer', + * args: [recipient, amount], + * }) + * + * const result = await executeAppTransaction({ config, request, chainId }) + * + * if (result.kind === 'safe-proposed') { + * // Transaction needs more signatures in Safe UI + * console.log('Safe proposal hash:', result.hash) + * return // Don't show success UI yet + * } + * + * // Transaction mined successfully + * const receipt = result.receipt + * const logs = receipt.logs + * // Extract event data, show success UI, etc. + * ``` + * + * @param params - Transaction execution parameters + * @returns Promise resolving to transaction result (mined or safe-proposed) + * @throws Error if transaction fails or is rejected by user + */ +export async function executeAppTransaction({ + config, + request, + chainId, + waitForSubgraphSync = true, +}: ExecuteAppTransactionParams): Promise { + const hash = await writeContract(config, request) + + if (isSafeProposalHash(hash)) { + return { kind: 'safe-proposed', hash } + } + + const receipt = await waitForTransactionReceipt(config, { hash, chainId }) + if (receipt.status !== 'success') { + throw new Error(`Transaction reverted: ${hash}`) + } + + let subgraphSynced = false + if (waitForSubgraphSync) { + try { + subgraphSynced = await awaitSubgraphSync(chainId, receipt.blockNumber) + } catch { + // The chain result is authoritative. A delayed subgraph must not make users + // retry an already-mined transaction. + subgraphSynced = false + } + } + + return { kind: 'mined', hash, receipt, subgraphSynced } +} diff --git a/packages/sdk/src/transaction/index.ts b/packages/sdk/src/transaction/index.ts new file mode 100644 index 000000000..465897acb --- /dev/null +++ b/packages/sdk/src/transaction/index.ts @@ -0,0 +1 @@ +export * from './executeAppTransaction' diff --git a/packages/sdk/tsup.config.ts b/packages/sdk/tsup.config.ts index bf89603f9..e5c1b9e75 100644 --- a/packages/sdk/tsup.config.ts +++ b/packages/sdk/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'src/contract/*.ts', 'src/subgraph/*.ts', 'src/eas/*.ts', + 'src/transaction/*.ts', '!src/*.test.ts', ], format: ['cjs', 'esm'], diff --git a/packages/stores/src/createAuthStore.ts b/packages/stores/src/createAuthStore.ts index 62b3f866e..74991ee03 100644 --- a/packages/stores/src/createAuthStore.ts +++ b/packages/stores/src/createAuthStore.ts @@ -9,7 +9,7 @@ import { useAccount } from 'wagmi' export type AuthenticationStatus = 'loading' | 'unauthenticated' | 'authenticated' /** - * AuthStore - Derived state from RainbowKit + wagmi + * AuthStore - Derived state from RainbowKit + wagmi + Session * * Key invariant: address is ONLY set when user is fully authenticated * (wallet connected + SIWE signed) @@ -24,6 +24,11 @@ export type AuthStoreState = { // Only set when FULLY authenticated (connected + SIWE signed) address: AddressType | undefined + // Safe-specific fields (when user authenticated via Safe wallet) + eoaAddress?: AddressType + safeAddress?: AddressType + isSafeMode: boolean + // State flags isConnected: boolean // Wallet is connected isConnecting: boolean // Wallet connection in progress (always false for now) @@ -31,6 +36,23 @@ export type AuthStoreState = { isAuthenticated: boolean // Fully authenticated (connected + signed) } +/** + * Session data from useSession hook + * Can be provided via SessionContext + */ +export interface SessionData { + address?: AddressType + eoaAddress?: AddressType + safeAddress?: AddressType + safeChainId?: number +} + +/** + * Context to share session data from SWR + * This allows session data to be accessed across the app + */ +export const SessionContext = createContext(null) + /** * Context to share RainbowKit authentication status * This is set by the app's _app.tsx @@ -39,11 +61,13 @@ export const AuthStatusContext = createContext('loading') /** * Hook to get authentication state - * Derives state from RainbowKit's authentication status (via context) and wagmi's wallet connection + * Derives state from RainbowKit's authentication status (via context), + * wagmi's wallet connection, and session data (from SWR) */ export function useAuthStore(): AuthStoreState { - const { address: walletAddress, isConnected: walletConnected } = useAccount() + const { isConnected: walletConnected } = useAccount() const authStatus = useContext(AuthStatusContext) + const session = useContext(SessionContext) // SSR safety: always treat as loading during server-side rendering // This prevents hydration mismatches between server and client @@ -53,10 +77,17 @@ export function useAuthStore(): AuthStoreState { const isAuthenticated = safeAuthStatus === 'authenticated' const isAuthenticating = safeAuthStatus === 'loading' + // Determine primary address (Safe address takes precedence) + const address = session?.safeAddress || session?.address + return { - // Address only available when authenticated - address: - isAuthenticated && walletAddress ? (walletAddress as AddressType) : undefined, + // Address from session (Safe address if available, otherwise EOA) + address: isAuthenticated && address ? (address as AddressType) : undefined, + + // Safe-specific fields + eoaAddress: session?.eoaAddress as AddressType | undefined, + safeAddress: session?.safeAddress as AddressType | undefined, + isSafeMode: !!session?.safeAddress, isConnected: walletConnected, isConnecting: false, // wagmi handles this internally @@ -64,21 +95,3 @@ export function useAuthStore(): AuthStoreState { isAuthenticated, } } - -// For backward compatibility - some code may call getAuthStore().getState() -// This is deprecated and should be replaced with useAuthStore() hook -export const getAuthStore = () => { - console.warn( - 'getAuthStore() is deprecated. Use the useAuthStore() hook instead. ' + - 'Direct store access is no longer supported.' - ) - return { - getState: () => ({ - address: undefined, - isConnected: false, - isConnecting: false, - isAuthenticating: false, - isAuthenticated: false, - }), - } -} diff --git a/packages/stores/src/index.ts b/packages/stores/src/index.ts index d94dadabe..8a6a6a9c0 100644 --- a/packages/stores/src/index.ts +++ b/packages/stores/src/index.ts @@ -10,7 +10,8 @@ export { type AuthenticationStatus, AuthStatusContext, type AuthStoreState, - getAuthStore, + SessionContext, + type SessionData, } from './createAuthStore' export { buildCandidateStoreNamespace, diff --git a/packages/ui/src/Modal/SafeToastModal.css.ts b/packages/ui/src/Modal/SafeToastModal.css.ts new file mode 100644 index 000000000..7c0ede933 --- /dev/null +++ b/packages/ui/src/Modal/SafeToastModal.css.ts @@ -0,0 +1,46 @@ +import * as z from '@buildeross/constants/layers' +import { vars } from '@buildeross/zord' +import { style } from '@vanilla-extract/css' + +export const safeToastWrapper = style({ + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh', + pointerEvents: 'none', // Don't block clicks on backdrop + zIndex: z.SAFE_TRANSACTION_LAYER, + background: vars.color.backdrop, + opacity: 0.4, // Faint backdrop, less prominent than regular modals + backdropFilter: 'blur(5px)', // Lighter blur effect + WebkitBackdropFilter: 'blur(5px)', +}) + +export const safeToastContainer = style({ + position: 'fixed', + top: '16px', + right: '16px', + width: '400px', + maxWidth: 'calc(100vw - 32px)', + zIndex: z.SAFE_TRANSACTION_LAYER, + background: vars.color.background1, + borderRadius: '12px', + padding: '20px', + boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15)', + pointerEvents: 'auto', // Re-enable pointer events for the content + selectors: { + 'html[data-theme-mode="dark"] &': { + background: vars.color.background1, + border: `1px solid ${vars.color.border}`, + boxShadow: '0 4px 24px rgba(0, 0, 0, 0.4)', + }, + }, + '@media': { + 'screen and (max-width: 768px)': { + top: '8px', + right: '8px', + width: 'calc(100vw - 16px)', + padding: '16px', + }, + }, +}) diff --git a/packages/ui/src/Modal/SafeToastModal.tsx b/packages/ui/src/Modal/SafeToastModal.tsx new file mode 100644 index 000000000..f836c7609 --- /dev/null +++ b/packages/ui/src/Modal/SafeToastModal.tsx @@ -0,0 +1,73 @@ +import { AnimatePresence, motion } from 'framer-motion' +import React, { type ReactNode } from 'react' +import { createPortal } from 'react-dom' + +import { safeToastContainer, safeToastWrapper } from './SafeToastModal.css' + +export interface SafeToastModalProps { + children: ReactNode + isOpen: boolean + onClose?: () => void +} + +/** + * Toast-style modal for Safe transactions + * Appears at top-right, 16px below nav bar, with faint backdrop + */ +export const SafeToastModal: React.FC = ({ + children, + isOpen, + onClose: _onClose, +}) => { + return createPortal( + + {isOpen && ( + { + e.stopPropagation() + }} + > + { + e.stopPropagation() + }} + > + {children} + + + )} + , + document.body + ) +} diff --git a/packages/ui/src/Modal/SafeTransactionModal.tsx b/packages/ui/src/Modal/SafeTransactionModal.tsx new file mode 100644 index 000000000..d365e480f --- /dev/null +++ b/packages/ui/src/Modal/SafeTransactionModal.tsx @@ -0,0 +1,229 @@ +'use client' + +import { SAFE_CHAIN_PREFIX } from '@buildeross/constants/safe' +import type { CHAIN_ID } from '@buildeross/types' +import { + getSafeErrorMessage, + isUserCancellation, + truncateAddress, +} from '@buildeross/utils' +import { Box, Button, Flex, Icon, Stack, Text } from '@buildeross/zord' +import { useState } from 'react' +import type { Address } from 'viem' + +import { SafeToastModal } from './SafeToastModal' + +interface SafeTransactionModalProps { + isOpen: boolean + onClose: () => void + onRetry: () => void + safeAddress: Address + threshold: number + ownersCount: number + chainId: CHAIN_ID + targetAddress: string + txValue?: string + txData?: string + transactions?: Array<{ to: string; value?: string; data?: string }> + onConfirm: () => Promise<{ safeTxHash: string }> +} + +export function SafeTransactionModal({ + isOpen, + onClose, + onRetry, + safeAddress, + threshold, + ownersCount, + chainId, + targetAddress, + txValue, + txData, + transactions = [{ to: targetAddress, value: txValue, data: txData }], + onConfirm, +}: SafeTransactionModalProps) { + const [state, setState] = useState<'idle' | 'proposing' | 'success' | 'error'>('idle') + const [safeTxHash, setSafeTxHash] = useState(null) + const [error, setError] = useState(null) + + const handleConfirm = async () => { + if (state !== 'idle') return + setState('proposing') + setError(null) + + try { + const result = await onConfirm() + setSafeTxHash(result.safeTxHash) + setState('success') + } catch (err) { + const errorMessage = getSafeErrorMessage(err) + setError(errorMessage) + setState('error') + } + } + + const handleClose = () => { + setState('idle') + setSafeTxHash(null) + setError(null) + onClose() + } + + const safeAppUrl = + safeTxHash && SAFE_CHAIN_PREFIX[chainId] + ? `https://app.safe.global/transactions/queue?safe=${SAFE_CHAIN_PREFIX[chainId]}:${safeAddress}` + : null + + return ( + + + {/* Idle State */} + {state === 'idle' && ( + <> + + + Propose Transaction to Safe + + + This is a multi-signature Safe wallet requiring {threshold} of{' '} + {ownersCount} signatures. + + + + {transactions.length} action{transactions.length === 1 ? '' : 's'} will + be proposed + + {transactions.map((transaction, index) => ( + + + {index + 1}. To: {truncateAddress(transaction.to)} + {transaction.data && transaction.data !== '0x' + ? ` | Function: ${transaction.data.slice(0, 10)}` + : ''} + + + ))} + + + + + + + + )} + + {/* Proposing State */} + {state === 'proposing' && ( + + + + Waiting for wallet signature... + + + Review the transaction details in your wallet. After signing, the proposal + will be submitted to your Safe. + + + )} + + {/* Success State */} + {state === 'success' && ( + <> + + + + Transaction Proposed + + + This transaction has been proposed to your Safe ({threshold} of{' '} + {ownersCount} signature{ownersCount > 1 ? 's' : ''} required). Other + owners can review and sign in the Safe App. It has not been executed + on-chain yet. + + + + {safeAppUrl && ( + + )} + + + + )} + + {/* Error State */} + {state === 'error' && ( + <> + + + + {isUserCancellation(error) + ? 'Transaction Cancelled' + : 'Transaction Proposal Failed'} + + {error && ( + + + {error} + + + )} + + + + + + + )} + + + ) +} diff --git a/packages/ui/src/Modal/index.ts b/packages/ui/src/Modal/index.ts index dd1dfa2e8..56fe2733a 100644 --- a/packages/ui/src/Modal/index.ts +++ b/packages/ui/src/Modal/index.ts @@ -1,2 +1,4 @@ export { AnimatedModal } from './AnimatedModal' +export { SafeToastModal } from './SafeToastModal' +export { SafeTransactionModal } from './SafeTransactionModal' export { SuccessModalContent } from './SuccessModalContent' diff --git a/packages/ui/src/SwapWidget/SwapWidget.tsx b/packages/ui/src/SwapWidget/SwapWidget.tsx index 773f54edb..09903214c 100644 --- a/packages/ui/src/SwapWidget/SwapWidget.tsx +++ b/packages/ui/src/SwapWidget/SwapWidget.tsx @@ -54,7 +54,8 @@ export const SwapWidget = ({ const [pendingTxHash, setPendingTxHash] = useState<`0x${string}` | null>(null) const [isWaitingForConfirmation, setIsWaitingForConfirmation] = useState(false) - const { address: userAddress } = useAccount() + const { address: userAddress, connector } = useAccount() + const isSafeMode = connector?.id === 'safeOwner' const { data: walletClient } = useWalletClient() const publicClient = usePublicClient({ chainId }) @@ -242,9 +243,11 @@ export const SwapWidget = ({ } = useExecuteSwap({ walletClient: walletClient ?? undefined, publicClient: publicClient ?? undefined, + isSafeMode, }) const handleSwap = async () => { + if (isExecuting || isWaitingForConfirmation) return if ( !path || !walletClient?.account || @@ -435,11 +438,13 @@ export const SwapWidget = ({ const errorMessage = getErrorMessage() const canSwap = + !isSafeMode && !!path && !!amountInBigInt && amountInBigInt > 0n && !!walletClient?.account && !isLoading && + !isExecuting && !exceedsBalance && !exceedsPoolLimit @@ -521,6 +526,17 @@ export const SwapWidget = ({ const buttonText = getButtonText() + if (isSafeMode) { + return ( + + + Swaps are not available while connected with a Safe. Connect an owner wallet to + swap. + + + ) + } + return ( {/* Buy/Sell Toggle */} diff --git a/packages/utils/package.json b/packages/utils/package.json index 2246dde37..eac6c8737 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -3,7 +3,11 @@ "description": "Shared utilities and helper functions for BuilderOSS apps", "version": "0.3.2", "dependencies": { + "@safe-global/api-kit": "^5.0.2", + "@safe-global/protocol-kit": "^8.0.5", + "@safe-global/safe-core-sdk-types": "^5.1.0", "bignumber.js": "^9.3.0", + "debug": "^4.4.3", "decimal.js": "^10.6.0", "sablier": "^2.0.1", "tinycolor2": "^1.4.2", @@ -16,6 +20,7 @@ "@buildeross/ipfs-service": "workspace:*", "@buildeross/tsconfig": "workspace:*", "@buildeross/types": "workspace:*", + "@types/debug": "^4.1.13", "@types/node": "^22.15.21", "@types/tinycolor2": "^1.4.6", "bs58": "^6.0.0", diff --git a/packages/utils/src/basename.ts b/packages/utils/src/basename.ts index d91a9225a..e0c1d919d 100644 --- a/packages/utils/src/basename.ts +++ b/packages/utils/src/basename.ts @@ -51,7 +51,7 @@ export const convertReverseNodeToBytes = ( chainId: number = CHAIN_ID.BASE ) => { const addressFormatted = address.toLowerCase() as Address - const addressNode = keccak256(addressFormatted) + const addressNode = keccak256(addressFormatted as `0x${string}`) const chainCoinType = convertChainIdToCoinType(chainId) const baseReverseNode = namehash(`${chainCoinType}.reverse`) const addressReverseNode = keccak256( diff --git a/packages/utils/src/helpers.ts b/packages/utils/src/helpers.ts index 2407e3cae..898561116 100644 --- a/packages/utils/src/helpers.ts +++ b/packages/utils/src/helpers.ts @@ -154,7 +154,7 @@ export const truncateAddress = (addr: string | undefined, chars: number = 5): st return addr } - return truncateHex(getAddress(addr), chars) + return truncateHex(getAddress(addr) as `0x${string}`, chars) } /** diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2594d0879..47c196c9f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -29,6 +29,10 @@ export * from './providers/SafeOwnerProvider' export * from './providers/types' export * from './sablier' export * from './safe' +export * from './safe/errors' +export * from './safe/handler' +export * from './safe/proposeTransaction' +export * from './safe/transactionResult' export * from './safeService' export * from './safeStorage' export * from './sanitize' diff --git a/packages/utils/src/isContract.ts b/packages/utils/src/isContract.ts index 4937c5853..8a57ac240 100644 --- a/packages/utils/src/isContract.ts +++ b/packages/utils/src/isContract.ts @@ -20,7 +20,7 @@ export const getCachedIsContract = async ( ): Promise => { if (!isAddress(address)) return false // eslint-disable-next-line no-param-reassign - address = getAddress(address) + address = getAddress(address) as Hex if (!isContractMap) isContractMap = new Map() const key = `${chainId}:${address}` diff --git a/packages/utils/src/providers/SafeOwnerProvider.ts b/packages/utils/src/providers/SafeOwnerProvider.ts index e5daf76ba..49745136c 100644 --- a/packages/utils/src/providers/SafeOwnerProvider.ts +++ b/packages/utils/src/providers/SafeOwnerProvider.ts @@ -1,6 +1,10 @@ +import debug from 'debug' import { EventEmitter } from 'events' import type { PublicClient } from 'viem' +import type { SafeTransactionHandler } from '../safe/handler' +import { getSafeTransactionHandler } from '../safe/handler' +import { executeSafeTransaction } from '../safe/proposeTransaction' import type { CallParams, EIP1193Provider, @@ -8,16 +12,53 @@ import type { SendTransactionParams, } from './types' +const debugSafe = debug('app:safe') + /** - * Custom EIP-1193 provider that presents a Safe as the connected account + * Custom EIP-1193 provider that presents a Safe wallet as the connected account * while delegating signing operations to an EOA wallet. * - * Based on @safe-global/safe-apps-provider but works outside iframe context. + * This provider enables seamless integration of Safe multi-sig wallets with wagmi/viem + * by implementing the standard EIP-1193 provider interface. It intelligently routes + * different types of requests: + * + * - **Account/Chain info**: Returns Safe address and chain + * - **Signing operations** (personal_sign, eth_signTypedData): Delegates to EOA for SIWE/authentication + * - **Transactions** (eth_sendTransaction): Routes through Safe transaction handler for multi-sig + * - **Read operations** (eth_call, getBalance, etc.): Uses public client for efficiency + * + * Based on @safe-global/safe-apps-provider but designed to work outside Safe Apps iframe context, + * allowing Safe wallets to be used as a standard wagmi connector. + * + * @example + * ```typescript + * const safeProvider = new SafeOwnerProvider( + * { + * safeAddress: '0x123...', + * chainId: 1, + * threshold: 2, + * owners: ['0xabc...', '0xdef...'], + * }, + * eoaProvider, // From wallet (MetaMask, etc.) + * publicClient // Viem public client for reads + * ) + * + * // Use with wagmi + * const config = createConfig({ + * // ... + * connectors: [injected({ target: safeProvider })], + * }) + * + * // Clean up when done + * safeProvider.destroy() + * ``` */ export class SafeOwnerProvider extends EventEmitter implements EIP1193Provider { private readonly safe: SafeInfo private readonly eoaProvider: EIP1193Provider private readonly publicClient: PublicClient + private transactionHandler?: SafeTransactionHandler + private eventCleanup?: () => void constructor(safe: SafeInfo, eoaProvider: EIP1193Provider, publicClient: PublicClient) { super() @@ -29,26 +70,62 @@ export class SafeOwnerProvider extends EventEmitter implements EIP1193Provider { this.setupEventForwarding() } + /** + * Cleanup event listeners to prevent memory leaks. + * Call this when the provider is no longer needed. + */ + destroy(): void { + this.eventCleanup?.() + this.removeAllListeners() + } + + /** + * Set a custom transaction handler for this provider instance + * Useful for testing or special cases. Falls back to global handler if not set. + */ + setTransactionHandler(handler: SafeTransactionHandler): void { + this.transactionHandler = handler + } + private setupEventForwarding(): void { // Forward certain events from EOA provider, but modify accounts - const originalOn = this.eoaProvider.on.bind(this.eoaProvider) + const originalOn = this.eoaProvider.on?.bind(this.eoaProvider) + const originalOff = this.eoaProvider.removeListener?.bind(this.eoaProvider) + + // Track event handlers for cleanup + const accountsHandler = (..._args: unknown[]) => { + const [accounts] = _args as [string[] | undefined] + // The Safe account is only usable while a backing owner EOA is connected. + this.emit( + 'accountsChanged', + accounts && accounts.length > 0 ? [this.safe.safeAddress] : [] + ) + } + + const chainHandler = (...args: unknown[]) => { + // Forward chain changes + const [chainId] = args + this.emit('chainChanged', chainId as string) + } + + const disconnectHandler = () => { + this.emit('disconnect') + } // Listen to EOA provider events if (originalOn) { - originalOn('accountsChanged', (..._args: unknown[]) => { - // EOA changed, but we still report Safe address - this.emit('accountsChanged', [this.safe.safeAddress]) - }) - - originalOn('chainChanged', (...args: unknown[]) => { - // Forward chain changes - const [chainId] = args - this.emit('chainChanged', chainId as string) - }) - - originalOn('disconnect', () => { - this.emit('disconnect') - }) + originalOn('accountsChanged', accountsHandler) + originalOn('chainChanged', chainHandler) + originalOn('disconnect', disconnectHandler) + + // Setup cleanup function + this.eventCleanup = () => { + if (originalOff) { + originalOff('accountsChanged', accountsHandler) + originalOff('chainChanged', chainHandler) + originalOff('disconnect', disconnectHandler) + } + } } } @@ -111,14 +188,71 @@ export class SafeOwnerProvider extends EventEmitter implements EIP1193Provider { return this.eoaProvider.request({ method, params: modifiedParams }) } - // Transaction methods - error for now (Safe Transaction Service TBD) - case 'eth_sendTransaction': + // Transaction methods - handle Safe transactions + case 'eth_sendTransaction': { + // Validate transaction parameters before type assertion + if (!paramsArray || !paramsArray[0] || typeof paramsArray[0] !== 'object') { + throw new Error('eth_sendTransaction requires transaction parameters') + } + + const txParams = paramsArray[0] as SendTransactionParams & { + safeTransactions?: SendTransactionParams[] + } + + // Validate required transaction fields + if (!txParams.to && !txParams.safeTransactions?.length) { + throw new Error('Transaction must have a "to" address') + } + + debugSafe('✓ eth_sendTransaction called:', { + to: txParams.to, + value: txParams.value, + safeAddress: this.safe.safeAddress, + threshold: this.safe.threshold, + }) + + // Auto-execute for 1-of-N Safes (no multi-sig needed) + if (this.safe.threshold === 1) { + debugSafe(' Threshold is 1, auto-executing transaction') + const txHash = await executeSafeTransaction( + this.safe, + txParams.safeTransactions ?? txParams, + this.eoaProvider + ) + debugSafe(' Transaction executed:', txHash) + return txHash + } + + // Multi-sig: use instance handler or fall back to global handler + debugSafe(' Multi-sig Safe, using handler') + const handler = this.transactionHandler ?? getSafeTransactionHandler() + if (!handler) { + debugSafe('ERROR: No handler registered!') + throw new Error( + 'Safe transaction handler not initialized. ' + + 'This is a multi-signature Safe transaction that requires approval from other owners. ' + + 'Please ensure SafeTransactionProvider is mounted in your app.' + ) + } + + debugSafe(' Handler found, calling it...') + // Handler will show modal and return safeTxHash + const result = await handler({ + safeInfo: this.safe, + transaction: txParams, + ...(txParams.safeTransactions + ? { transactions: txParams.safeTransactions } + : {}), + eoaProvider: this.eoaProvider, + }) + + debugSafe(' Handler returned result:', result) + // Return safeTxHash (treated like txHash by wagmi) + return result.safeTxHash + } + case 'eth_sendRawTransaction': - throw new Error( - 'Safe transactions are not yet supported. ' + - 'This feature will use Safe Transaction Service for multi-signature approval. ' + - 'For now, only SIWE authentication is supported.' - ) + throw new Error('Raw transactions are not supported for Safe wallets') // Read operations - use publicClient case 'eth_call': { diff --git a/packages/utils/src/safe/ensureCorrectChain.ts b/packages/utils/src/safe/ensureCorrectChain.ts new file mode 100644 index 000000000..4c69e7324 --- /dev/null +++ b/packages/utils/src/safe/ensureCorrectChain.ts @@ -0,0 +1,83 @@ +import debug from 'debug' + +import type { EIP1193Provider } from '../providers/types' +import { SafeTransactionError, SafeTransactionErrorCode } from './errors' + +const debugSafeTx = debug('app:safe:tx') + +/** + * Ensures the provider is on the correct chain, switching if necessary. + * + * This function: + * 1. Checks the current chain of the provider + * 2. If on wrong chain, requests wallet to switch + * 3. Handles error cases (chain not configured, user rejection) + * + * @param provider - EIP-1193 provider to check/switch + * @param targetChainId - The required chain ID + * @throws {SafeTransactionError} If chain not configured, user rejects, or switch fails + */ +export async function ensureCorrectChain( + provider: EIP1193Provider, + targetChainId: number +): Promise { + debugSafeTx('Checking current chain...') + const currentChainId = await provider.request({ method: 'eth_chainId' }) + const currentChainIdNumber = + typeof currentChainId === 'string' + ? parseInt(currentChainId, 16) + : Number(currentChainId) + + debugSafeTx( + 'Current chain: %d, Required chain: %d', + currentChainIdNumber, + targetChainId + ) + + if (currentChainIdNumber !== targetChainId) { + // Request wallet to switch to the target chain + debugSafeTx('Requesting chain switch...') + try { + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }) + const switchedChainId = await provider.request({ method: 'eth_chainId' }) + const switchedChainIdNumber = + typeof switchedChainId === 'string' + ? parseInt(switchedChainId, 16) + : Number(switchedChainId) + if (switchedChainIdNumber !== targetChainId) { + throw new SafeTransactionError( + `Wallet is still connected to chain ${switchedChainIdNumber}. Please switch to the required network.`, + SafeTransactionErrorCode.UNKNOWN + ) + } + debugSafeTx('Chain switched successfully') + } catch (switchError: any) { + // Handle specific error codes + if (switchError.code === 4902) { + // Chain not added to wallet + debugSafeTx('ERROR: Chain not configured in wallet') + throw new SafeTransactionError( + `Chain ${targetChainId} is not configured in your wallet. Please add it manually.`, + SafeTransactionErrorCode.CHAIN_NOT_CONFIGURED + ) + } + if (switchError.code === 4001) { + // User rejected the request + debugSafeTx('ERROR: User rejected chain switch') + throw new SafeTransactionError( + 'Chain switch was rejected', + SafeTransactionErrorCode.CHAIN_SWITCH_REJECTED + ) + } + // Generic error - wrap in SafeTransactionError + debugSafeTx('ERROR: Chain switch failed: %O', switchError) + throw new SafeTransactionError( + `Failed to switch to chain ${targetChainId}: ${switchError?.message || switchError?.toString() || 'Unknown error'}`, + SafeTransactionErrorCode.UNKNOWN + ) + } + } +} diff --git a/packages/utils/src/safe/errors.ts b/packages/utils/src/safe/errors.ts new file mode 100644 index 000000000..528e2d7b3 --- /dev/null +++ b/packages/utils/src/safe/errors.ts @@ -0,0 +1,101 @@ +/** + * Error codes for Safe transaction operations + */ +export enum SafeTransactionErrorCode { + /** User rejected the transaction signature */ + USER_REJECTED = 'USER_REJECTED', + /** User cancelled the transaction in the modal */ + USER_CANCELLED = 'USER_CANCELLED', + /** Chain not configured in wallet */ + CHAIN_NOT_CONFIGURED = 'CHAIN_NOT_CONFIGURED', + /** Chain switch rejected by user */ + CHAIN_SWITCH_REJECTED = 'CHAIN_SWITCH_REJECTED', + /** EOA wallet not connected */ + EOA_NOT_CONNECTED = 'EOA_NOT_CONNECTED', + /** Connected EOA is not an owner of the Safe */ + NOT_SAFE_OWNER = 'NOT_SAFE_OWNER', + /** Safe Service API error */ + API_ERROR = 'API_ERROR', + /** Transaction handler not initialized */ + HANDLER_NOT_INITIALIZED = 'HANDLER_NOT_INITIALIZED', + /** Another transaction is already in progress */ + TRANSACTION_IN_PROGRESS = 'TRANSACTION_IN_PROGRESS', + /** Failed to initialize Safe Protocol Kit */ + SAFE_INIT_FAILED = 'SAFE_INIT_FAILED', + /** Transaction execution failed */ + EXECUTION_FAILED = 'EXECUTION_FAILED', + /** Generic error */ + UNKNOWN = 'UNKNOWN', +} + +/** + * Custom error class for Safe transaction operations + */ +export class SafeTransactionError extends Error { + constructor( + message: string, + public readonly code: SafeTransactionErrorCode + ) { + super(message) + this.name = 'SafeTransactionError' + } +} + +/** + * Detect if an error represents a user cancellation/rejection + */ +export function isUserCancellation(error: unknown): boolean { + if (error instanceof SafeTransactionError) { + return ( + error.code === SafeTransactionErrorCode.USER_REJECTED || + error.code === SafeTransactionErrorCode.USER_CANCELLED + ) + } + + // Fallback: check common error patterns from wallet providers + const message = + error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + return /cancel|reject|denied|user denied/i.test(message) +} + +/** + * Get a user-friendly error message for display + */ +export function getSafeErrorMessage(error: unknown): string { + if (error instanceof SafeTransactionError) { + switch (error.code) { + case SafeTransactionErrorCode.USER_REJECTED: + case SafeTransactionErrorCode.USER_CANCELLED: + return 'Transaction proposal was cancelled.' + case SafeTransactionErrorCode.CHAIN_NOT_CONFIGURED: + return 'The required network is not configured in your wallet.' + case SafeTransactionErrorCode.CHAIN_SWITCH_REJECTED: + return 'Network switch was rejected.' + case SafeTransactionErrorCode.EOA_NOT_CONNECTED: + return 'Please connect your wallet to continue.' + case SafeTransactionErrorCode.NOT_SAFE_OWNER: + return 'Your connected wallet is not an owner of this Safe.' + case SafeTransactionErrorCode.API_ERROR: + return 'Unable to connect to Safe Service. Please try again.' + case SafeTransactionErrorCode.HANDLER_NOT_INITIALIZED: + return 'Safe transaction handler not initialized. Please refresh the page.' + case SafeTransactionErrorCode.TRANSACTION_IN_PROGRESS: + return 'Another Safe transaction is already in progress. Please complete or cancel it first.' + case SafeTransactionErrorCode.SAFE_INIT_FAILED: + return 'Failed to initialize Safe. Please try again.' + case SafeTransactionErrorCode.EXECUTION_FAILED: + return 'Transaction execution failed. Please try again.' + default: + return error.message || 'An unexpected error occurred.' + } + } + + // Fallback for non-SafeTransactionError + if (isUserCancellation(error)) { + return 'Transaction proposal was cancelled.' + } + + return error instanceof Error + ? error.message + : 'Unable to propose transaction. Please try again.' +} diff --git a/packages/utils/src/safe/handler.ts b/packages/utils/src/safe/handler.ts new file mode 100644 index 000000000..74d5a1ba6 --- /dev/null +++ b/packages/utils/src/safe/handler.ts @@ -0,0 +1,45 @@ +import type { EIP1193Provider, SafeInfo, SendTransactionParams } from '../providers/types' + +export interface SafeTransactionParams { + safeInfo: SafeInfo + transaction: SendTransactionParams + transactions?: SendTransactionParams[] + eoaProvider: EIP1193Provider +} + +export interface SafeTransactionResult { + safeTxHash: string +} + +export type SafeTransactionHandler = ( + params: SafeTransactionParams +) => Promise + +/** + * Global handler for Safe multi-sig transactions + * Set by SafeTransactionProvider on mount + */ +let globalHandler: SafeTransactionHandler | null = null + +/** + * Register global handler for Safe multi-sig transactions + * Called once by SafeTransactionProvider on mount + */ +export function registerSafeTransactionHandler(handler: SafeTransactionHandler): void { + globalHandler = handler +} + +/** + * Get the registered global handler + * Called by SafeOwnerProvider when multi-sig transaction is initiated + */ +export function getSafeTransactionHandler(): SafeTransactionHandler | null { + return globalHandler +} + +/** + * Unregister handler (for cleanup/testing) + */ +export function unregisterSafeTransactionHandler(): void { + globalHandler = null +} diff --git a/packages/utils/src/safe/proposeTransaction.ts b/packages/utils/src/safe/proposeTransaction.ts new file mode 100644 index 000000000..ed348efa7 --- /dev/null +++ b/packages/utils/src/safe/proposeTransaction.ts @@ -0,0 +1,330 @@ +import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' +import type { CHAIN_ID } from '@buildeross/types' +import SafeApiKit from '@safe-global/api-kit' +import Safe from '@safe-global/protocol-kit' +import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import debug from 'debug' +import { getAddress } from 'viem' + +import type { EIP1193Provider, SafeInfo, SendTransactionParams } from '../providers/types' +import { ensureCorrectChain } from './ensureCorrectChain' +import { SafeTransactionError, SafeTransactionErrorCode } from './errors' +import { recordSafeProposalHash } from './transactionResult' + +const debugSafeTx = debug('app:safe:tx') + +/** + * Propose a transaction to Safe Service API for multi-sig approval. + * + * This function handles the complete flow of proposing a transaction to a multi-signature Safe: + * 1. Validates and switches to the correct chain if needed + * 2. Initializes Safe Protocol Kit with the EOA signer + * 3. Creates a Safe transaction from the provided parameters + * 4. Re-validates chain before requesting signature (prevents race conditions) + * 5. Requests EOA to sign the Safe transaction hash + * 6. Submits the signed transaction to Safe Service API + * + * The transaction will NOT execute immediately. Other Safe owners must sign it + * through the Safe UI before it can be executed. + * + * @param safeInfo - Safe wallet information (address, chain, threshold, owners) + * @param transaction - Transaction parameters (to, value, data) + * @param eoaProvider - EIP-1193 provider for the EOA wallet that will sign + * @returns Safe transaction hash (used for tracking/deep linking to Safe UI) + * @throws {SafeTransactionError} If chain not configured, user rejects, or API error + * @throws {Error} If Safe Service unavailable or signature fails + * + * @example + * ```typescript + * const safeTxHash = await proposeSafeTransaction( + * { + * safeAddress: '0x123...', + * chainId: 1, + * threshold: 2, + * owners: ['0xabc...', '0xdef...'], + * }, + * { + * to: '0x789...', + * value: '1000000000000000000', // 1 ETH + * data: '0x...', + * }, + * eoaProvider + * ) + * + * // Deep link to Safe UI + * const safeUrl = `https://app.safe.global/transactions/queue?safe=eth:${safeAddress}` + * ``` + */ +export async function proposeSafeTransaction( + safeInfo: SafeInfo, + transaction: SendTransactionParams | SendTransactionParams[], + eoaProvider: EIP1193Provider +): Promise { + debugSafeTx('proposeSafeTransaction called: %O', { + safeAddress: safeInfo.safeAddress, + chainId: safeInfo.chainId, + to: Array.isArray(transaction) ? transaction.map(({ to }) => to) : transaction.to, + }) + + // Verify provider is on the correct chain, switch if necessary + await ensureCorrectChain(eoaProvider, safeInfo.chainId) + + const apiKey = process.env.NEXT_PUBLIC_SAFE_API_KEY + if (!apiKey) { + debugSafeTx('ERROR: NEXT_PUBLIC_SAFE_API_KEY is not set!') + throw new SafeTransactionError( + 'NEXT_PUBLIC_SAFE_API_KEY is required for Safe multi-sig transactions', + SafeTransactionErrorCode.API_ERROR + ) + } + debugSafeTx('API key present') + + const serviceUrl = SAFE_SERVICE_URL[safeInfo.chainId as CHAIN_ID] + if (!serviceUrl) { + debugSafeTx('ERROR: No Safe Service URL for chain: %d', safeInfo.chainId) + throw new SafeTransactionError( + `Safe Service not available for chain ${safeInfo.chainId}`, + SafeTransactionErrorCode.API_ERROR + ) + } + debugSafeTx('Safe Service URL: %s', serviceUrl) + + // Get EOA address (signer) and checksum it + debugSafeTx('Getting EOA accounts...') + const accounts = (await eoaProvider.request({ method: 'eth_accounts' })) as string[] + const rawSenderAddress = accounts[0] + + if (!rawSenderAddress) { + debugSafeTx('ERROR: No EOA account found') + throw new SafeTransactionError( + 'No EOA account connected', + SafeTransactionErrorCode.EOA_NOT_CONNECTED + ) + } + const senderAddress = getAddress(rawSenderAddress) + debugSafeTx('EOA address (checksummed): %s', senderAddress) + + // Create Safe API Kit with API key + debugSafeTx('Initializing Safe API Kit...') + const apiKit = new SafeApiKit({ + chainId: BigInt(safeInfo.chainId), + apiKey, + }) + + // Checksum addresses for Safe API + const safeAddress = getAddress(safeInfo.safeAddress) + const transactions = Array.isArray(transaction) ? transaction : [transaction] + + // Create Protocol Kit instance using EIP1193Provider directly + debugSafeTx('Initializing Safe Protocol Kit...') + let protocolKit: Safe + try { + protocolKit = await Safe.init({ + // Safe SDK's Eip1193Provider type has slightly different params signature than our EIP1193Provider, + // but they are functionally compatible. The difference is in the params type definition only. + provider: eoaProvider as any, + signer: senderAddress, + safeAddress, + }) + debugSafeTx('Protocol Kit initialized') + } catch (error) { + debugSafeTx('ERROR: Failed to initialize Protocol Kit: %O', error) + throw new SafeTransactionError( + `Failed to initialize Safe: ${error instanceof Error ? error.message : 'Unknown error'}`, + SafeTransactionErrorCode.SAFE_INIT_FAILED + ) + } + + // Validate that the connected EOA is actually a Safe owner + debugSafeTx('Validating Safe ownership...') + let owners: string[] + try { + owners = await protocolKit.getOwners() + } catch (error) { + debugSafeTx('ERROR: Failed to read Safe owners: %O', error) + throw new SafeTransactionError( + `Failed to validate Safe ownership: ${error instanceof Error ? error.message : 'Unknown error'}`, + SafeTransactionErrorCode.SAFE_INIT_FAILED + ) + } + debugSafeTx('Safe owners: %O', owners) + if (!owners.map((owner) => owner.toLowerCase()).includes(senderAddress.toLowerCase())) { + debugSafeTx('ERROR: EOA %s is not an owner of Safe %s', senderAddress, safeAddress) + throw new SafeTransactionError( + 'Your connected wallet is not an owner of this Safe', + SafeTransactionErrorCode.NOT_SAFE_OWNER + ) + } + debugSafeTx('Ownership validated: EOA is a Safe owner') + + // Build Safe transaction with checksummed address + const safeTransactions: MetaTransactionData[] = transactions.map((tx) => ({ + to: getAddress(tx.to), + value: tx.value?.toString() || '0', + data: tx.data || '0x', + })) + debugSafeTx('Creating Safe transaction...') + + const safeTx = await protocolKit.createTransaction({ + transactions: safeTransactions, + }) + debugSafeTx('Safe transaction created') + + // Get transaction hash + debugSafeTx('Getting transaction hash...') + const safeTxHash = await protocolKit.getTransactionHash(safeTx) + debugSafeTx('Transaction hash: %s', safeTxHash) + + // Re-validate chain before requesting signature (user may have switched) + debugSafeTx('Re-validating chain before signature request...') + await ensureCorrectChain(eoaProvider, safeInfo.chainId) + + // Sign transaction hash with EOA + debugSafeTx('Requesting signature from EOA...') + const senderSignature = await protocolKit.signHash(safeTxHash) + debugSafeTx('Transaction hash signed') + + // Propose to Safe Service + debugSafeTx('Proposing transaction to Safe Service...') + try { + await apiKit.proposeTransaction({ + safeAddress, + safeTransactionData: safeTx.data, + safeTxHash, + senderAddress, + senderSignature: senderSignature.data, + }) + debugSafeTx('Transaction successfully proposed to Safe Service') + } catch (error) { + debugSafeTx('ERROR: Failed to propose transaction to Safe Service: %O', error) + throw new SafeTransactionError( + `Failed to propose transaction to Safe Service: ${error instanceof Error ? error.message : 'Unknown error'}`, + SafeTransactionErrorCode.API_ERROR + ) + } + + recordSafeProposalHash(safeTxHash as `0x${string}`) + + return safeTxHash +} + +/** + * Execute a transaction directly for 1-of-N Safes where threshold = 1. + * + * This function is used for Safe wallets with threshold = 1, where only one + * signature is needed (1 out of N owners). The transaction executes immediately + * on-chain, just like an EOA transaction. + * + * Flow: + * 1. Validates and switches to the correct chain if needed + * 2. Initializes Safe Protocol Kit with the EOA signer + * 3. Creates and executes the Safe transaction immediately + * 4. Returns the Ethereum transaction hash + * + * @param safeInfo - Safe wallet information (must have threshold = 1, can have N owners) + * @param transaction - Transaction parameters (to, value, data) + * @param eoaProvider - EIP-1193 provider for the EOA wallet that will sign and execute + * @returns Ethereum transaction hash (regular on-chain tx hash, not Safe tx hash) + * @throws {Error} If chain not configured, user rejects, or execution fails + * + * @example + * ```typescript + * const txHash = await executeSafeTransaction( + * { + * safeAddress: '0x123...', + * chainId: 1, + * threshold: 1, // Important: must be 1 + * owners: ['0xabc...', '0xdef...'], // Can have multiple owners + * }, + * { + * to: '0x789...', + * value: '0', + * data: '0xa9059cbb...', // transfer function + * }, + * eoaProvider + * ) + * + * // Wait for transaction receipt like a normal transaction + * const receipt = await waitForTransactionReceipt(config, { hash: txHash }) + * ``` + */ +export async function executeSafeTransaction( + safeInfo: SafeInfo, + transaction: SendTransactionParams | SendTransactionParams[], + eoaProvider: EIP1193Provider +): Promise { + debugSafeTx('executeSafeTransaction called (threshold=1): %O', { + safeAddress: safeInfo.safeAddress, + chainId: safeInfo.chainId, + to: Array.isArray(transaction) ? transaction.map(({ to }) => to) : transaction.to, + }) + + // Verify provider is on the correct chain, switch if necessary + await ensureCorrectChain(eoaProvider, safeInfo.chainId) + + // Get EOA address (signer) and checksum it + debugSafeTx('Getting EOA accounts...') + const accounts = (await eoaProvider.request({ method: 'eth_accounts' })) as string[] + const rawSenderAddress = accounts[0] + + if (!rawSenderAddress) { + debugSafeTx('ERROR: No EOA account found') + throw new SafeTransactionError( + 'No EOA account connected', + SafeTransactionErrorCode.EOA_NOT_CONNECTED + ) + } + const senderAddress = getAddress(rawSenderAddress) + debugSafeTx('EOA address (checksummed): %s', senderAddress) + + // Checksum addresses for Safe API + const safeAddress = getAddress(safeInfo.safeAddress) + const transactions = Array.isArray(transaction) ? transaction : [transaction] + + // Create Protocol Kit instance using EIP1193Provider directly + debugSafeTx('Initializing Safe Protocol Kit...') + let protocolKit: Safe + try { + protocolKit = await Safe.init({ + // Safe SDK's Eip1193Provider type has slightly different params signature than our EIP1193Provider, + // but they are functionally compatible. The difference is in the params type definition only. + provider: eoaProvider as any, + signer: senderAddress, + safeAddress, + }) + debugSafeTx('Protocol Kit initialized') + } catch (error) { + debugSafeTx('ERROR: Failed to initialize Protocol Kit: %O', error) + throw new SafeTransactionError( + `Failed to initialize Safe: ${error instanceof Error ? error.message : 'Unknown error'}`, + SafeTransactionErrorCode.SAFE_INIT_FAILED + ) + } + + // Build Safe transaction with checksummed address + const safeTransactions: MetaTransactionData[] = transactions.map((tx) => ({ + to: getAddress(tx.to), + value: tx.value?.toString() || '0', + data: tx.data || '0x', + })) + debugSafeTx('Creating Safe transaction...') + + const safeTx = await protocolKit.createTransaction({ + transactions: safeTransactions, + }) + debugSafeTx('Safe transaction created') + + // Execute immediately (threshold = 1, so only one signature needed) + debugSafeTx('Executing transaction (threshold=1)...') + try { + const executeTxResponse = await protocolKit.executeTransaction(safeTx) + debugSafeTx('Transaction executed successfully, hash: %s', executeTxResponse.hash) + return executeTxResponse.hash + } catch (error) { + debugSafeTx('ERROR: Transaction execution failed: %O', error) + throw new SafeTransactionError( + `Transaction execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + SafeTransactionErrorCode.EXECUTION_FAILED + ) + } +} diff --git a/packages/utils/src/safe/transactionResult.test.ts b/packages/utils/src/safe/transactionResult.test.ts new file mode 100644 index 000000000..998e9c31b --- /dev/null +++ b/packages/utils/src/safe/transactionResult.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { isSafeProposalHash, recordSafeProposalHash } from './transactionResult' + +describe('Safe transaction result registry', () => { + it('recognizes recorded Safe proposal hashes only', () => { + const hash = `0x${'1'.repeat(64)}` as `0x${string}` + + expect(isSafeProposalHash(hash)).toBe(false) + recordSafeProposalHash(hash) + expect(isSafeProposalHash(hash)).toBe(true) + }) + + it('maintains LRU cache with max size', () => { + // Clear any existing hashes by recording 101 new ones + const hashes = Array.from( + { length: 101 }, + (_, i) => `0x${i.toString(16).padStart(64, '0')}` as `0x${string}` + ) + + hashes.forEach(recordSafeProposalHash) + + // First hash should be evicted (LRU), last 100 should remain + expect(isSafeProposalHash(hashes[0])).toBe(false) + expect(isSafeProposalHash(hashes[1])).toBe(true) + expect(isSafeProposalHash(hashes[100])).toBe(true) + }) + + it('moves hash to end when recorded again', () => { + const hash1 = `0x${'1'.repeat(64)}` as `0x${string}` + const hash2 = `0x${'2'.repeat(64)}` as `0x${string}` + + recordSafeProposalHash(hash1) + recordSafeProposalHash(hash2) + + // Record hash1 again - should move to end + recordSafeProposalHash(hash1) + + // Both should still be recognized + expect(isSafeProposalHash(hash1)).toBe(true) + expect(isSafeProposalHash(hash2)).toBe(true) + }) +}) diff --git a/packages/utils/src/safe/transactionResult.ts b/packages/utils/src/safe/transactionResult.ts new file mode 100644 index 000000000..99493af54 --- /dev/null +++ b/packages/utils/src/safe/transactionResult.ts @@ -0,0 +1,27 @@ +import type { Hex } from 'viem' + +export type SafeTransactionResultKind = 'onchain' | 'safe-proposed' + +// LRU cache for Safe proposal hashes to prevent unbounded memory growth +const MAX_SAFE_PROPOSAL_HASHES = 100 +const safeProposalHashes: Hex[] = [] + +export function recordSafeProposalHash(hash: Hex): void { + // Remove if already exists to move to end + const existingIndex = safeProposalHashes.indexOf(hash) + if (existingIndex !== -1) { + safeProposalHashes.splice(existingIndex, 1) + } + + // Add to end + safeProposalHashes.push(hash) + + // Remove oldest if over limit + if (safeProposalHashes.length > MAX_SAFE_PROPOSAL_HASHES) { + safeProposalHashes.shift() + } +} + +export function isSafeProposalHash(hash: Hex): boolean { + return safeProposalHashes.includes(hash) +} diff --git a/packages/utils/src/safeService.ts b/packages/utils/src/safeService.ts index 012d5525b..a2ccd1bbe 100644 --- a/packages/utils/src/safeService.ts +++ b/packages/utils/src/safeService.ts @@ -1,9 +1,12 @@ import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' import { CHAIN_ID } from '@buildeross/types' +import debug from 'debug' import type { Address } from 'viem' import type { SafeInfo } from './providers/types' +const debugSafe = debug('app:safe') + // Re-export SafeInfo for backwards compatibility export type { SafeInfo } @@ -63,7 +66,7 @@ export async function isDelegateForSafe( const response = await fetch(url) if (!response.ok) { - console.error(`Safe API error: ${response.status}`, await response.text()) + debugSafe(`Safe API error: ${response.status}`, await response.text()) return false } @@ -75,7 +78,7 @@ export async function isDelegateForSafe( return isDelegate } catch (error) { - console.error('Error checking delegate status:', error) + debugSafe('Error checking delegate status:', error) return false } } @@ -98,14 +101,14 @@ export async function getSafesForDelegate( const response = await fetch(url) if (!response.ok) { - console.error(`Safe API error: ${response.status}`, await response.text()) + debugSafe(`Safe API error: ${response.status}`, await response.text()) return [] } const data: DelegatesResponse = await response.json() return data.results.map((d) => d.safe as Address) } catch (error) { - console.error('Error fetching Safes for delegate:', error) + debugSafe('Error fetching Safes for delegate:', error) return [] } } @@ -131,7 +134,7 @@ export async function getSafeInfo( if (response.status === 404) { return null } - console.error(`Safe API error: ${response.status}`, await response.text()) + debugSafe(`Safe API error: ${response.status}`, await response.text()) return null } @@ -148,7 +151,7 @@ export async function getSafeInfo( version: data.version, } } catch (error) { - console.error('Error fetching Safe info:', error) + debugSafe('Error fetching Safe info:', error) return null } } diff --git a/packages/utils/src/safeStorage.ts b/packages/utils/src/safeStorage.ts index 30ecdc5d3..8a4f0412f 100644 --- a/packages/utils/src/safeStorage.ts +++ b/packages/utils/src/safeStorage.ts @@ -1,11 +1,17 @@ import type { Address } from 'viem' +import type { SafeInfo } from './providers/types' + const STORAGE_KEY = 'safe-info' export interface SavedSafeInfo { safeAddress: Address chainId: number eoaConnectorId: string + threshold: number + owners: Address[] + nonce?: number + version?: string timestamp: number } @@ -30,18 +36,18 @@ export function getSavedSafeInfo(): SavedSafeInfo | null { /** * Save Safe information to localStorage */ -export function setSafeInfo( - safeAddress: Address, - chainId: number, - eoaConnectorId: string -): void { +export function setSafeInfo(safeInfo: SafeInfo, eoaConnectorId: string): void { if (typeof window === 'undefined') return try { const data: SavedSafeInfo = { - safeAddress, - chainId, + safeAddress: safeInfo.safeAddress, + chainId: safeInfo.chainId, eoaConnectorId, + threshold: safeInfo.threshold, + owners: safeInfo.owners, + nonce: safeInfo.nonce, + version: safeInfo.version, timestamp: Date.now(), } localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8b6821e0..ce3327d1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,7 +176,7 @@ importers: version: 2.2.10(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(wagmi@2.18.1(@tanstack/query-core@5.90.3)(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.8.1)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(zod@4.1.12)) '@sentry/nextjs': specifier: ^9.22.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) '@smartinvoicexyz/types': specifier: ^0.1.27 version: 0.1.28(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.1))(@types/react@19.2.2)(react@19.2.1))(@types/node@22.18.10)(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(framer-motion@12.23.24(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(graphql-ws@6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(graphql@16.11.0)(react-dom@19.2.1(react@19.2.1))(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) @@ -195,6 +195,9 @@ importers: '@wagmi/core': specifier: ^2.22.1 version: 2.22.1(@tanstack/query-core@5.90.3)(@types/react@19.2.2)(react@19.2.1)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.1))(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12)) + '@xstate/react': + specifier: ^6.1.0 + version: 6.1.0(@types/react@19.2.2)(react@19.2.1)(xstate@5.32.6) '@zoralabs/coins-sdk': specifier: ^0.4.3 version: 0.4.3(abitype@1.2.3(typescript@5.9.3)(zod@4.1.12))(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12)) @@ -206,13 +209,16 @@ importers: version: 5.0.76(zod@4.1.12) alchemy-sdk: specifier: ^3.6.0 - version: 3.6.5(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) + version: 3.6.5(bufferutil@4.0.9)(debug@4.4.3)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) axios: specifier: ^1.12.2 - version: 1.12.2 + version: 1.12.2(debug@4.4.3) dayjs: specifier: ^1.11.13 version: 1.11.18 + debug: + specifier: ^4.4.3 + version: 4.4.3(supports-color@8.1.1) flatpickr: specifier: ^4.6.13 version: 4.6.13 @@ -267,6 +273,9 @@ importers: wagmi: specifier: ^2.18.1 version: 2.18.1(@tanstack/query-core@5.90.3)(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.8.1)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(zod@4.1.12) + xstate: + specifier: ^5.32.6 + version: 5.32.6 zustand: specifier: ^5.0.4 version: 5.0.8(@types/react@19.2.2)(react@19.2.1)(use-sync-external-store@1.4.0(react@19.2.1)) @@ -298,6 +307,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/debug': + specifier: ^4.1.13 + version: 4.1.13 '@types/lodash': specifier: ^4.17.17 version: 4.17.20 @@ -325,6 +337,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.5.0 version: 4.7.0(vite@6.3.7(@types/node@22.18.10)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1)) + '@xstate/inspect': + specifier: ^0.8.0 + version: 0.8.0(@types/ws@8.18.1)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(xstate@5.32.6) dotenv: specifier: ^16.5.0 version: 16.6.1 @@ -400,7 +415,7 @@ importers: version: 1.17.4(babel-plugin-macros@3.1.0) axios: specifier: ^1.12.2 - version: 1.12.2 + version: 1.12.2(debug@4.4.3) dayjs: specifier: ^1.11.13 version: 1.11.18 @@ -677,7 +692,7 @@ importers: version: 1.17.4(babel-plugin-macros@3.1.0) axios: specifier: ^1.12.2 - version: 1.12.2 + version: 1.12.2(debug@4.4.3) dayjs: specifier: ^1.11.13 version: 1.11.18 @@ -828,7 +843,7 @@ importers: version: 0.7.2 axios: specifier: ^1.12.2 - version: 1.12.2 + version: 1.12.2(debug@4.4.3) bs58: specifier: ^6.0.0 version: 6.0.0 @@ -973,7 +988,7 @@ importers: version: 1.17.4(babel-plugin-macros@3.1.0) axios: specifier: ^1.12.2 - version: 1.12.2 + version: 1.12.2(debug@4.4.3) dayjs: specifier: ^1.11.13 version: 1.11.18 @@ -1246,6 +1261,9 @@ importers: dayjs: specifier: ^1.11.13 version: 1.11.18 + debug: + specifier: ^4.4.3 + version: 4.4.3(supports-color@8.1.1) react: specifier: ^19.2.1 version: 19.2.1 @@ -1283,6 +1301,9 @@ importers: '@testing-library/react': specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@types/debug': + specifier: ^4.1.13 + version: 4.1.13 '@types/node': specifier: ^22.15.21 version: 22.18.10 @@ -1364,7 +1385,7 @@ importers: version: 1.17.4(babel-plugin-macros@3.1.0) axios: specifier: ^1.12.2 - version: 1.12.2 + version: 1.12.2(debug@4.4.3) dayjs: specifier: ^1.11.13 version: 1.11.18 @@ -1486,9 +1507,15 @@ importers: packages/sdk: dependencies: + '@buildeross/types': + specifier: workspace:* + version: link:../types + '@buildeross/utils': + specifier: workspace:* + version: link:../utils '@sentry/nextjs': specifier: ^9.22.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) graphql: specifier: ^16.11.0 version: 16.11.0 @@ -1514,12 +1541,6 @@ importers: '@buildeross/tsconfig': specifier: workspace:* version: link:../tsconfig - '@buildeross/types': - specifier: workspace:* - version: link:../types - '@buildeross/utils': - specifier: workspace:* - version: link:../utils '@graphql-codegen/cli': specifier: ^5.0.6 version: 5.0.7(@types/node@22.18.10)(bufferutil@4.0.9)(crossws@0.3.5)(encoding@0.1.13)(enquirer@2.4.1)(graphql@16.11.0)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -1851,9 +1872,21 @@ importers: packages/utils: dependencies: + '@safe-global/api-kit': + specifier: ^5.0.2 + version: 5.0.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@safe-global/protocol-kit': + specifier: ^8.0.5 + version: 8.0.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@safe-global/safe-core-sdk-types': + specifier: ^5.1.0 + version: 5.1.0(typescript@5.9.3)(zod@4.1.12) bignumber.js: specifier: ^9.3.0 version: 9.3.1 + debug: + specifier: ^4.4.3 + version: 4.4.3(supports-color@8.1.1) decimal.js: specifier: ^10.6.0 version: 10.6.0 @@ -1894,6 +1927,9 @@ importers: '@buildeross/types': specifier: workspace:* version: link:../types + '@types/debug': + specifier: ^4.1.13 + version: 4.1.13 '@types/node': specifier: ^22.15.21 version: 22.18.10 @@ -4728,16 +4764,36 @@ packages: '@rushstack/eslint-patch@1.14.0': resolution: {integrity: sha512-WJFej426qe4RWOm9MMtP4V3CV4AucXolQty+GRgAWLgQXmpCuwzs7hEpxxhSc/znXUSxum9d/P/32MW0FlAAlA==} + '@safe-global/api-kit@5.0.2': + resolution: {integrity: sha512-KhsqqUT5Ixn5WjvbAysjpaEHH68+nUYbppR0nFiWzclWfDUdScGzi0TnimlUu+0YoSRG1ekOfTaVs8Dkdpjrgg==} + + '@safe-global/protocol-kit@8.0.5': + resolution: {integrity: sha512-Tl5SqM+JZPrguf30seXaheV0CD3MmIXnL6ziRyvgY/bkNAEB+gGeI24xuxsYFHWbEuG9FiEr4VTVu/NxRz5tRg==} + '@safe-global/safe-apps-provider@0.18.6': resolution: {integrity: sha512-4LhMmjPWlIO8TTDC2AwLk44XKXaK6hfBTWyljDm0HQ6TWlOEijVWNrt2s3OCVMSxlXAcEzYfqyu1daHZooTC2Q==} '@safe-global/safe-apps-sdk@9.1.0': resolution: {integrity: sha512-N5p/ulfnnA2Pi2M3YeWjULeWbjo7ei22JwU/IXnhoHzKq3pYCN6ynL9mJBOlvDVv892EgLPCWCOwQk/uBT2v0Q==} + '@safe-global/safe-core-sdk-types@5.1.0': + resolution: {integrity: sha512-UzXR4zWmVzux25FcIm4H049QhZZpVpIBL5HE+V0p5gHpArZROL+t24fZmsKUf403CtBxIJM5zZSVQL0nFJi+IQ==} + deprecated: 'WARNING: This project has been renamed to @safe-global/types-kit. Please, migrate from @safe-global/safe-core-sdk-types@5.1.0 to @safe-global/types-kit@1.0.0.' + + '@safe-global/safe-deployments@1.37.61': + resolution: {integrity: sha512-bPjznEFWFN698CJupwGd+ZNI5aBslylV6FjnGt7CBodZs9rZPLfKT/HhI+ppTvMgh+Tgn/KJNGMwKW9aPVIBng==} + engines: {node: '>=22.0.0', pnpm: '>=10.16.0'} + '@safe-global/safe-gateway-typescript-sdk@3.23.1': resolution: {integrity: sha512-6ORQfwtEJYpalCeVO21L4XXGSdbEMfyp2hEv6cP82afKXSwvse6d3sdelgaPWUxHIsFRkWvHDdzh8IyyKHZKxw==} engines: {node: '>=16'} + '@safe-global/safe-modules-deployments@3.0.9': + resolution: {integrity: sha512-tgW/ln4if6dLohAQVUT51/a+tTUMuTm3dKvGPgXmHaLUFS8P3f28+cs671rsyMlMa4A/zXJKOdFgBsfCTyfw0A==} + + '@safe-global/types-kit@4.0.1': + resolution: {integrity: sha512-zmIYyAH9mcBcqHszPgcfNjOJYuPSvWCcc/f8zeznh7N1HSA7jEoFErO06O4QDfwAAS4aEyoyjhPyfHbVYRheZg==} + '@scure/base@1.1.9': resolution: {integrity: sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==} @@ -5279,9 +5335,6 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -5996,6 +6049,25 @@ packages: resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} engines: {node: '>=8'} + '@xstate/inspect@0.8.0': + resolution: {integrity: sha512-wSkFeOnp+7dhn+zTThO0M4D2FEqZN9lGIWowJu5JLa2ojjtlzRwK8SkjcHZ4rLX8VnMev7kGjgQLrGs8kxy+hw==} + peerDependencies: + '@types/ws': ^8.0.0 + ws: ^8.0.0 + xstate: ^4.37.0 + peerDependenciesMeta: + '@types/ws': + optional: true + + '@xstate/react@6.1.0': + resolution: {integrity: sha512-ep9F0jGTI63B/jE8GHdMpUqtuz7yRebNaKv8EMUaiSi29NOglywc2X2YSOV/ygbIK+LtmgZ0q9anoEA2iBSEOw==} + peerDependencies: + react: ^19.2.1 + xstate: ^5.28.0 + peerDependenciesMeta: + xstate: + optional: true + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -11016,6 +11088,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -12020,6 +12097,15 @@ packages: '@types/react': optional: true + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^19.2.1 + peerDependenciesMeta: + '@types/react': + optional: true + use-sidecar@1.1.3: resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} @@ -12431,6 +12517,9 @@ packages: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + xstate@5.32.6: + resolution: {integrity: sha512-WfA8WNrh6r9osuGwVm+aIPM4jmMW2LKHV1Yv9thYpOtL4HVF/kobh95K/O8v2jDCpDmP2EoY+6uvuqHXCZ6ZLw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -15459,7 +15548,7 @@ snapshots: '@metamask/superstruct': 3.2.1 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 '@types/lodash': 4.17.20 debug: 4.4.3(supports-color@8.1.1) lodash: 4.17.21 @@ -15474,7 +15563,7 @@ snapshots: '@ethereumjs/tx': 4.2.0 '@types/debug': 4.1.13 debug: 4.4.3(supports-color@8.1.1) - semver: 7.7.4 + semver: 7.8.5 superstruct: 1.0.4 transitivePeerDependencies: - supports-color @@ -15499,7 +15588,7 @@ snapshots: '@metamask/superstruct': 3.2.1 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3(supports-color@8.1.1) pony-cause: 2.1.11 semver: 7.7.3 @@ -16455,6 +16544,36 @@ snapshots: '@rushstack/eslint-patch@1.14.0': {} + '@safe-global/api-kit@5.0.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + dependencies: + '@safe-global/protocol-kit': 8.0.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@safe-global/types-kit': 4.0.1(typescript@5.9.3)(zod@4.1.12) + node-fetch: 2.7.0(encoding@0.1.13) + viem: 2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + - zod + + '@safe-global/protocol-kit@8.0.5(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + dependencies: + '@safe-global/safe-deployments': 1.37.61 + '@safe-global/safe-modules-deployments': 3.0.9 + '@safe-global/types-kit': 4.0.1(typescript@5.9.3)(zod@4.1.12) + abitype: 1.2.3(typescript@5.9.3)(zod@4.1.12) + semver: 7.8.5 + viem: 2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) + optionalDependencies: + '@noble/curves': 1.9.7 + '@peculiar/asn1-schema': 2.5.0 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + '@safe-global/safe-apps-provider@0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12)': dependencies: '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) @@ -16475,8 +16594,28 @@ snapshots: - utf-8-validate - zod + '@safe-global/safe-core-sdk-types@5.1.0(typescript@5.9.3)(zod@4.1.12)': + dependencies: + abitype: 1.2.3(typescript@5.9.3)(zod@4.1.12) + transitivePeerDependencies: + - typescript + - zod + + '@safe-global/safe-deployments@1.37.61': + dependencies: + semver: 7.8.5 + '@safe-global/safe-gateway-typescript-sdk@3.23.1': {} + '@safe-global/safe-modules-deployments@3.0.9': {} + + '@safe-global/types-kit@4.0.1(typescript@5.9.3)(zod@4.1.12)': + dependencies: + abitype: 1.2.3(typescript@5.9.3)(zod@4.1.12) + transitivePeerDependencies: + - typescript + - zod + '@scure/base@1.1.9': {} '@scure/base@1.2.6': {} @@ -16648,7 +16787,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1)': + '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.37.0 @@ -17302,10 +17441,6 @@ snapshots: dependencies: '@types/node': 22.19.15 - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -18910,6 +19045,24 @@ snapshots: dependencies: tslib: 2.8.1 + '@xstate/inspect@0.8.0(@types/ws@8.18.1)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(xstate@5.32.6)': + dependencies: + fast-safe-stringify: 2.1.1 + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + xstate: 5.32.6 + optionalDependencies: + '@types/ws': 8.18.1 + + '@xstate/react@6.1.0(@types/react@19.2.2)(react@19.2.1)(xstate@5.32.6)': + dependencies: + react: 19.2.1 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.2)(react@19.2.1) + use-sync-external-store: 1.6.0(react@19.2.1) + optionalDependencies: + xstate: 5.32.6 + transitivePeerDependencies: + - '@types/react' + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -19037,7 +19190,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy-sdk@3.6.5(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10): + alchemy-sdk@3.6.5(bufferutil@4.0.9)(debug@4.4.3)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10): dependencies: '@ethersproject/abi': 5.8.0 '@ethersproject/abstract-provider': 5.8.0 @@ -19051,7 +19204,7 @@ snapshots: '@ethersproject/wallet': 5.8.0 '@ethersproject/web': 5.8.0 '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10) - axios: 1.12.2 + axios: 1.12.2(debug@4.4.3) sturdy-websocket: 0.2.1 websocket: 1.0.35 transitivePeerDependencies: @@ -19304,7 +19457,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.12.2: + axios@1.12.2(debug@4.4.3): dependencies: follow-redirects: 1.15.11(debug@4.4.3) form-data: 4.0.4 @@ -23444,7 +23597,7 @@ snapshots: micromark@4.0.2: dependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3(supports-color@8.1.1) decode-named-character-reference: 1.2.0 devlop: 1.1.0 @@ -24980,6 +25133,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -26095,6 +26250,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.2 + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.2)(react@19.2.1): + dependencies: + react: 19.2.1 + optionalDependencies: + '@types/react': 19.2.2 + use-sidecar@1.1.3(@types/react@19.2.2)(react@19.2.1): dependencies: detect-node-es: 1.1.0 @@ -26692,6 +26853,8 @@ snapshots: xmlhttprequest-ssl@2.1.2: {} + xstate@5.32.6: {} + xtend@4.0.2: {} y18n@4.0.3: {} diff --git a/turbo.json b/turbo.json index 4aa079c47..d8f291eae 100644 --- a/turbo.json +++ b/turbo.json @@ -28,6 +28,7 @@ "NEXT_PUBLIC_WALLET_CONNECT_METADATA", "NEXT_PUBLIC_BASE_BUILDER_CODE", "NEXT_PUBLIC_ZORA_COINS_SDK_API_KEY", + "NEXT_PUBLIC_SAFE_API_KEY", "SENTRY_DSN", "SENTRY_ORG", "SENTRY_PROJECT", From 35a5372299e65939ef33f2cc57c3016f4061e053 Mon Sep 17 00:00:00 2001 From: dan13ram Date: Sun, 6 Sep 2026 10:10:12 +0530 Subject: [PATCH 4/6] fix: stabilize wallet connect and Safe session handling --- .env.example | 2 +- apps/web/.env.local.example | 2 +- apps/web/package.json | 1 + .../src/components/WalletConnectDialog.tsx | 2 + .../profile/DelegateToProfileButton.tsx | 4 +- .../profile/DelegateToProfileModal.test.tsx | 6 +- .../profile/DelegateToProfileModal.tsx | 5 +- .../profile/ProfileDaoChainIcon.test.tsx | 7 +- .../components/profile/ProfileDaoSelector.tsx | 4 +- .../profile/ProfileLinksEditButton.tsx | 14 +-- .../profile/ProfileLinksEditModal.test.tsx | 3 +- .../profile/ProfileLinksEditModal.tsx | 7 +- .../profile/ProfileWalletScannerMenu.tsx | 4 +- .../src/components/wallet/WalletListView.tsx | 33 ++++++- apps/web/src/connectors/safeOwnerConnector.ts | 5 +- .../layouts/DefaultLayout/ConnectButton.tsx | 5 +- .../DefaultLayout/NavMenu/ChainMenu.tsx | 24 ----- .../DefaultLayout/NavMenu/ProfileMenu.tsx | 28 +----- apps/web/src/modules/home/GetStarted.tsx | 27 ++--- apps/web/src/pages/api/safe/propose.ts | 76 ++++++++++++++ apps/web/src/pages/create.tsx | 7 +- .../pages/dao/[network]/[token]/migrate.tsx | 7 +- .../CurrentAuction/PlaceBid.test.tsx | 2 +- .../transaction/executeAppTransaction.test.ts | 66 +++++++++++-- .../src/transaction/executeAppTransaction.ts | 30 +++++- packages/stores/src/createAuthStore.ts | 6 +- .../ContractButton/ContractButton.test.tsx | 33 +++++++ .../ui/src/ContractButton/ContractButton.tsx | 24 ++++- .../ui/src/Modal/SafeTransactionModal.tsx | 7 +- packages/utils/src/index.ts | 1 - .../src/providers/SafeOwnerProvider.test.ts | 98 +++++++++++++++++++ .../utils/src/providers/SafeOwnerProvider.ts | 17 ++-- packages/utils/src/safe/proposeTransaction.ts | 48 ++++----- .../utils/src/safe/transactionResult.test.ts | 43 -------- packages/utils/src/safe/transactionResult.ts | 27 ----- pnpm-lock.yaml | 3 + turbo.json | 2 +- 37 files changed, 439 insertions(+), 241 deletions(-) create mode 100644 apps/web/src/pages/api/safe/propose.ts create mode 100644 packages/utils/src/providers/SafeOwnerProvider.test.ts delete mode 100644 packages/utils/src/safe/transactionResult.test.ts delete mode 100644 packages/utils/src/safe/transactionResult.ts diff --git a/.env.example b/.env.example index 3a752b826..b875819fe 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,4 @@ NEXT_PUBLIC_ALLOW_FAST_DAO_ON_MAINNET=false # Safe API Key for transaction proposal service # Get your API key from: https://dashboard.safe.global/ # Required for multi-signature Safe wallet transactions -NEXT_PUBLIC_SAFE_API_KEY= \ No newline at end of file +SAFE_API_KEY= diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index bf1dbbcf8..d8dacc59a 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -27,4 +27,4 @@ NEXT_PUBLIC_WALLET_CONNECT_METADATA= IRON_PASSWORD=your-secret-here-at-least-32-chars # For proposing Safe transactions -NEXT_PUBLIC_SAFE_API_KEY= +SAFE_API_KEY= diff --git a/apps/web/package.json b/apps/web/package.json index 0ecfba7f4..f136ef748 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@fontsource/inter": "5.2.5", "@fontsource/londrina-solid": "^5.2.5", "@rainbow-me/rainbowkit": "^2.2.10", + "@safe-global/api-kit": "^5.0.2", "@sentry/nextjs": "^9.22.0", "@smartinvoicexyz/types": "^0.1.27", "@tanstack/react-query": "^5.90.3", diff --git a/apps/web/src/components/WalletConnectDialog.tsx b/apps/web/src/components/WalletConnectDialog.tsx index 810af907b..7d8cc591a 100644 --- a/apps/web/src/components/WalletConnectDialog.tsx +++ b/apps/web/src/components/WalletConnectDialog.tsx @@ -534,6 +534,8 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp onSelectWallet={handleSelectWallet} onSelectSafe={() => {}} showSafeOption={false} + title="Connect Safe Owner Wallet" + description="Choose a wallet that is an owner of this Safe. Your connected wallet cannot be used unless it has owner permissions for the Safe." /> ) } diff --git a/apps/web/src/components/profile/DelegateToProfileButton.tsx b/apps/web/src/components/profile/DelegateToProfileButton.tsx index 4cf46f82a..0445203d1 100644 --- a/apps/web/src/components/profile/DelegateToProfileButton.tsx +++ b/apps/web/src/components/profile/DelegateToProfileButton.tsx @@ -1,7 +1,7 @@ +import { useAuthStore } from '@buildeross/stores' import { Button } from '@buildeross/zord' import React from 'react' import { isOwnProfileAddress } from 'src/utils/profileDashboard' -import { useAccount } from 'wagmi' import { DelegateToProfileModal } from './DelegateToProfileModal' @@ -14,7 +14,7 @@ export const DelegateToProfileButton: React.FC = ( profileAddress, profileName, }) => { - const { address } = useAccount() + const { address } = useAuthStore() const [isOpen, setIsOpen] = React.useState(false) const isOwnProfile = isOwnProfileAddress(address, profileAddress) diff --git a/apps/web/src/components/profile/DelegateToProfileModal.test.tsx b/apps/web/src/components/profile/DelegateToProfileModal.test.tsx index 46984c132..fada3d0cb 100644 --- a/apps/web/src/components/profile/DelegateToProfileModal.test.tsx +++ b/apps/web/src/components/profile/DelegateToProfileModal.test.tsx @@ -37,9 +37,13 @@ vi.mock('@buildeross/ui/Modal', () => ({ vi.mock('@buildeross/ui/ContractButton', () => ({ ContractButton: ({ children }: React.PropsWithChildren) => , })) +vi.mock('@buildeross/stores', () => ({ + useAuthStore: () => ({ + address: '0xUser000000000000000000000000000000000000', + }), +})) vi.mock('wagmi', async (importOriginal) => ({ ...(await importOriginal()), - useAccount: () => ({ address: '0xUser000000000000000000000000000000000000' }), useConfig: () => ({}), })) vi.mock('wagmi/actions', () => ({ diff --git a/apps/web/src/components/profile/DelegateToProfileModal.tsx b/apps/web/src/components/profile/DelegateToProfileModal.tsx index 9a436708d..93e95f8ce 100644 --- a/apps/web/src/components/profile/DelegateToProfileModal.tsx +++ b/apps/web/src/components/profile/DelegateToProfileModal.tsx @@ -4,6 +4,7 @@ import { useUserDaos } from '@buildeross/hooks/useUserDaos' import { tokenAbi } from '@buildeross/sdk/contract' import { daoMembershipRequest } from '@buildeross/sdk/subgraph' import { executeAppTransaction } from '@buildeross/sdk/transaction' +import { useAuthStore } from '@buildeross/stores' import type { AddressType, CHAIN_ID } from '@buildeross/types' import { ContractButton } from '@buildeross/ui/ContractButton' import { FallbackImage } from '@buildeross/ui/FallbackImage' @@ -25,7 +26,7 @@ import { filterLabel, } from 'src/styles/profile.css' import useSWR from 'swr' -import { useAccount, useConfig } from 'wagmi' +import { useConfig } from 'wagmi' import { simulateContract } from 'wagmi/actions' type DelegateToProfileModalProps = { @@ -53,7 +54,7 @@ export const DelegateToProfileModal: React.FC = ({ profileName, }) => { const config = useConfig() - const { address } = useAccount() + const { address } = useAuthStore() const { daos, isLoading: isLoadingDaos } = useUserDaos({ address, enabled: open && !!address, diff --git a/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx b/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx index 59b23f48f..ff0b9b189 100644 --- a/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx +++ b/apps/web/src/components/profile/ProfileDaoChainIcon.test.tsx @@ -29,9 +29,10 @@ vi.mock('src/components/ProfileDaoList', () => ({ }, })) -vi.mock('wagmi', async (importOriginal) => ({ - ...(await importOriginal()), - useAccount: () => ({ address: '0xabc' }), +vi.mock('@buildeross/stores', () => ({ + useAuthStore: () => ({ + address: '0xabc', + }), })) vi.mock('next/image', () => ({ diff --git a/apps/web/src/components/profile/ProfileDaoSelector.tsx b/apps/web/src/components/profile/ProfileDaoSelector.tsx index ef3ada0e7..e84851e64 100644 --- a/apps/web/src/components/profile/ProfileDaoSelector.tsx +++ b/apps/web/src/components/profile/ProfileDaoSelector.tsx @@ -1,4 +1,5 @@ import type { MyDaosResponse } from '@buildeross/sdk/subgraph' +import { useAuthStore } from '@buildeross/stores' import { CHAIN_ID } from '@buildeross/types' import { Button, Text } from '@buildeross/zord' import React from 'react' @@ -11,7 +12,6 @@ import { profileSurface, } from 'src/styles/profile.css' import { createDaoKey, isOwnProfileAddress } from 'src/utils/profileDashboard' -import { useAccount } from 'wagmi' import { getProfileChainMetadata } from './ProfileChainIcon' @@ -45,7 +45,7 @@ export const ProfileDaoSelector: React.FC = ({ onToggle, onClear, }) => { - const { address: connectedAddress } = useAccount() + const { address: connectedAddress } = useAuthStore() return (
= ({ profileAddress, onSaved, }) => { - const { address } = useAccount() + const { address } = useAuthStore() const [isOpen, setIsOpen] = React.useState(false) const isOwnProfile = isOwnProfileAddress(address, profileAddress) @@ -26,14 +27,15 @@ export const ProfileLinksEditButton: React.FC = ({ return ( <> - + ({ ...(await importOriginal()), useAccount: () => ({ chainId: 8453 }), - useConfig: () => ({}), - useSwitchChain: () => ({ switchChainAsync: vi.fn() }), + useConfig: () => ({ state: { current: null, connections: new Map() } }), })) vi.mock('wagmi/actions', () => ({ diff --git a/apps/web/src/components/profile/ProfileLinksEditModal.tsx b/apps/web/src/components/profile/ProfileLinksEditModal.tsx index a1b4793bf..d722a079b 100644 --- a/apps/web/src/components/profile/ProfileLinksEditModal.tsx +++ b/apps/web/src/components/profile/ProfileLinksEditModal.tsx @@ -22,7 +22,7 @@ import { validateWebsiteUrl, } from 'src/utils/profileIdentity' import { encodeAbiParameters, zeroHash } from 'viem' -import { useAccount, useConfig, useSwitchChain } from 'wagmi' +import { useAccount, useConfig } from 'wagmi' import { simulateContract } from 'wagmi/actions' type ProfileLinkKey = 'website' | 'x' | 'farcaster' @@ -49,7 +49,6 @@ export const ProfileLinksEditModal: React.FC = ({ }) => { const config = useConfig() const { chainId } = useAccount() - const { switchChainAsync } = useSwitchChain() const [website, setWebsite] = React.useState('') const [xHandle, setXHandle] = React.useState('') const [farcasterHandle, setFarcasterHandle] = React.useState('') @@ -184,8 +183,8 @@ export const ProfileLinksEditModal: React.FC = ({ setIsSaving(true) try { - if (chainId !== PROFILE_LINK_EAS_CHAIN_ID && switchChainAsync) { - await switchChainAsync({ chainId: PROFILE_LINK_EAS_CHAIN_ID }) + if (chainId !== PROFILE_LINK_EAS_CHAIN_ID) { + throw new Error(`Please switch to the supported network before saving links.`) } const profileLinkChainId = diff --git a/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx b/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx index caf89bca2..c5e03c18a 100644 --- a/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx +++ b/apps/web/src/components/profile/ProfileWalletScannerMenu.tsx @@ -1,5 +1,6 @@ import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' import { ETHERSCAN_BASE_URL } from '@buildeross/constants/etherscan' +import { useAuthStore } from '@buildeross/stores' import type { AddressType } from '@buildeross/types' import { Icon, Text } from '@buildeross/zord' import React from 'react' @@ -12,7 +13,6 @@ import { } from 'src/styles/profile.css' import { isOwnProfileAddress } from 'src/utils/profileDashboard' import type { ProfileIdentity } from 'src/utils/profileIdentity' -import { useAccount } from 'wagmi' import { DelegateToProfileModal } from './DelegateToProfileModal' import { ProfileLinksEditModal } from './ProfileLinksEditModal' @@ -50,7 +50,7 @@ export const ProfileWalletScannerMenu: React.FC = const menuRootRef = React.useRef(null) const menuButtonRef = React.useRef(null) const [isOpen, setIsOpen] = React.useState(false) - const { address: connectedAddress } = useAccount() + const { address: connectedAddress } = useAuthStore() const isOwnProfile = isOwnProfileAddress(connectedAddress, profileAddress) const [isDelegateModalOpen, setIsDelegateModalOpen] = React.useState(false) const [isEditLinksModalOpen, setIsEditLinksModalOpen] = React.useState(false) diff --git a/apps/web/src/components/wallet/WalletListView.tsx b/apps/web/src/components/wallet/WalletListView.tsx index acd36039f..3f56c3279 100644 --- a/apps/web/src/components/wallet/WalletListView.tsx +++ b/apps/web/src/components/wallet/WalletListView.tsx @@ -1,6 +1,6 @@ 'use client' -import { Stack, Text } from '@buildeross/zord' +import { Button, Icon, Stack, Text } from '@buildeross/zord' import type { WalletInfo } from '../../types/auth' import { WalletOption } from '../WalletOption' @@ -10,6 +10,8 @@ interface WalletListViewProps { onSelectWallet: (walletId: string) => void onSelectSafe: () => void showSafeOption?: boolean + title?: string + description?: string } export function WalletListView({ @@ -17,13 +19,22 @@ export function WalletListView({ onSelectWallet, onSelectSafe, showSafeOption = true, + title = 'Connect Wallet', + description, }: WalletListViewProps) { const installedWallets = wallets.filter((w) => !w.isRainbowKitConnector) const popularWallets = wallets.filter((w) => w.isRainbowKitConnector) return ( - Connect Wallet + + {title} + {description && ( + + {description} + + )} + {installedWallets.length > 0 && ( @@ -100,6 +111,24 @@ export function WalletListView({ . + + ) } diff --git a/apps/web/src/connectors/safeOwnerConnector.ts b/apps/web/src/connectors/safeOwnerConnector.ts index b36ca2ac6..4ca5cc4bc 100644 --- a/apps/web/src/connectors/safeOwnerConnector.ts +++ b/apps/web/src/connectors/safeOwnerConnector.ts @@ -202,7 +202,7 @@ export function createSafeOwnerConnector(): CreateConnectorFn { async disconnect() { // Clean up provider if (provider_) { - provider_.removeAllListeners() + provider_.destroy() } // Clear cached state @@ -317,6 +317,9 @@ export function createSafeOwnerConnector(): CreateConnectorFn { }, onDisconnect() { + if (provider_) { + provider_.destroy() + } clearCache() clearSafeInfo() config.emitter.emit('disconnect') diff --git a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx index a75af5886..6335d3427 100644 --- a/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx +++ b/apps/web/src/layouts/DefaultLayout/ConnectButton.tsx @@ -1,8 +1,8 @@ +import { useAuthStore } from '@buildeross/stores' import { Button, Flex } from '@buildeross/zord' import { ConnectButton as RKConnectButton } from '@rainbow-me/rainbowkit' import dynamic from 'next/dynamic' import React, { useState } from 'react' -import { useAccount } from 'wagmi' import { connectButtonWrapper } from './Nav.styles.css' @@ -17,8 +17,7 @@ const WalletConnectDialog = dynamic( export const ConnectButton = () => { const [showModal, setShowModal] = useState(false) - const { connector } = useAccount() - const isSafeMode = connector?.id === 'safeOwner' + const { isSafeMode } = useAuthStore() return ( = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [router, hasHydrated]) - // Separate effect to handle chain switching when needed - useEffect(() => { - if (!hasHydrated || !isChainInitialized) return - - // Only switch if connected and on wrong chain - if (isConnected && wagmiChain?.id !== selectedChain?.id) { - switchChain( - { chainId: selectedChain.id }, - { - onError(error) { - console.error(`Failed to automatically switch chain:`, error) - }, - } - ) - } - }, [ - hasHydrated, - isChainInitialized, - isConnected, - wagmiChain?.id, - selectedChain?.id, - switchChain, - ]) - if (!hasHydrated || !isChainInitialized) { return null } diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx index 12059b846..25d70288c 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx @@ -19,7 +19,7 @@ import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' import { useAppDisconnect } from 'src/hooks/useAppDisconnect' import { useDaoListPreferences } from 'src/hooks/useDaoListPreferences' import { profileStatBadge } from 'src/styles/profile.css' -import { type Address, formatUnits } from 'viem' +import { formatUnits } from 'viem' import { useAccount, useBalance } from 'wagmi' import { ConnectButton } from '../ConnectButton' @@ -131,34 +131,10 @@ export const ProfileMenu: React.FC = ({ onOpenMenu, onSetActiveDropdown, }) => { - const { address, isAuthenticated } = useAuthStore() + const { address, isAuthenticated, isSafeMode, eoaAddress, safeChainId } = useAuthStore() const { chain: selectedChain } = useChainStore() const { connector } = useAccount() - // Detect Safe mode and get EOA address + Safe chainId - const isSafeMode = connector?.id === 'safeOwner' - const [eoaAddress, setEoaAddress] = React.useState
(null) - const [safeChainId, setSafeChainId] = React.useState(null) - - React.useEffect(() => { - if (isSafeMode && connector) { - // Fetch EOA address - if ('getEOAAddress' in connector) { - ;(connector as any).getEOAAddress().then((addr: Address) => setEoaAddress(addr)) - } - - // Fetch Safe's actual chainId - if ('getChainId' in connector) { - ;(connector as any) - .getChainId() - .then((chainId: number) => setSafeChainId(chainId as CHAIN_ID)) - } - } else { - setEoaAddress(null) - setSafeChainId(null) - } - }, [isSafeMode, connector]) - const { displayName, ensAvatar } = useEnsData(address || '') const eoaEnsData = useEnsData(eoaAddress || '') diff --git a/apps/web/src/modules/home/GetStarted.tsx b/apps/web/src/modules/home/GetStarted.tsx index 47dd5f877..01aec345c 100644 --- a/apps/web/src/modules/home/GetStarted.tsx +++ b/apps/web/src/modules/home/GetStarted.tsx @@ -1,22 +1,12 @@ -import { useAuthStore, useChainStore } from '@buildeross/stores' -import { Button } from '@buildeross/zord' -import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useChainStore } from '@buildeross/stores' +import { ContractButton } from '@buildeross/ui/ContractButton' import { useRouter } from 'next/router' -import React from 'react' -import { useAccount, useSwitchChain } from 'wagmi' import { marqueeButton } from './Home.css' export const GetStarted = () => { - const { address } = useAuthStore() - const { chain: wagmiChain } = useAccount() const chain = useChainStore((x) => x.chain) - const { openConnectModal } = useConnectModal() - const { switchChain } = useSwitchChain() - - const handleSwitchChain = () => switchChain({ chainId: chain.id }) - const { push } = useRouter() const handleClick = (): void => { @@ -24,14 +14,9 @@ export const GetStarted = () => { } return ( - + ) } diff --git a/apps/web/src/pages/api/safe/propose.ts b/apps/web/src/pages/api/safe/propose.ts new file mode 100644 index 000000000..88d305d06 --- /dev/null +++ b/apps/web/src/pages/api/safe/propose.ts @@ -0,0 +1,76 @@ +import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' +import SafeApiKit from '@safe-global/api-kit' +import type { NextApiRequest, NextApiResponse } from 'next' +import { getAddress, isAddress } from 'viem' + +interface ProposalRequest { + chainId: number + safeAddress: string + safeTransactionData: { + to: string + value: string + data: string + operation?: number + safeTxGas?: string + baseGas?: string + gasPrice?: string + gasToken?: string + refundReceiver?: string + nonce: number + } + safeTxHash: string + senderAddress: string + senderSignature: string +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST') + return res.status(405).json({ error: 'Method not allowed' }) + } + + const apiKey = process.env.SAFE_API_KEY + if (!apiKey) { + return res.status(500).json({ error: 'Safe API is not configured' }) + } + + const body = req.body as Partial + if ( + !body || + typeof body.chainId !== 'number' || + typeof body.safeAddress !== 'string' || + typeof body.senderAddress !== 'string' || + typeof body.safeTxHash !== 'string' || + typeof body.senderSignature !== 'string' || + !body.safeTransactionData || + !isAddress(body.safeAddress) || + !isAddress(body.senderAddress) + ) { + return res.status(400).json({ error: 'Invalid Safe proposal' }) + } + + const serviceUrl = SAFE_SERVICE_URL[body.chainId as keyof typeof SAFE_SERVICE_URL] + if (!serviceUrl) { + return res.status(400).json({ error: 'Unsupported Safe chain' }) + } + + try { + const apiKit = new SafeApiKit({ chainId: BigInt(body.chainId), apiKey }) + await apiKit.proposeTransaction({ + safeAddress: getAddress(body.safeAddress), + safeTransactionData: body.safeTransactionData as Parameters< + SafeApiKit['proposeTransaction'] + >[0]['safeTransactionData'], + safeTxHash: body.safeTxHash as `0x${string}`, + senderAddress: getAddress(body.senderAddress), + senderSignature: body.senderSignature, + }) + + return res.status(200).json({ safeTxHash: body.safeTxHash }) + } catch (error) { + console.error('Failed to propose transaction to Safe Service', error) + return res + .status(502) + .json({ error: 'Failed to propose transaction to Safe Service' }) + } +} diff --git a/apps/web/src/pages/create.tsx b/apps/web/src/pages/create.tsx index bf4d50dad..11230e848 100644 --- a/apps/web/src/pages/create.tsx +++ b/apps/web/src/pages/create.tsx @@ -19,18 +19,13 @@ import React from 'react' import { Meta } from 'src/components/Meta' import { getCreateDaoLayout } from 'src/layouts/CreateDaoLayout' import { createWrapperHalf, formWrapper, pageGrid } from 'src/styles/create.css' -import { useAccount } from 'wagmi' import { NextPageWithLayout } from './_app' const CreatePage: NextPageWithLayout = () => { const { activeSection, isUploadingToIPFS, ipfsUploadProgress } = useFormStore() - const { address } = useAuthStore() + const { address, isSafeMode } = useAuthStore() const chain = useChainStore((x) => x.chain) - const { connector } = useAccount() - - // Detect Safe mode - Safes cannot create DAOs due to multi-transaction flow requirements - const isSafeMode = connector?.id === 'safeOwner' const { push } = useRouter() diff --git a/apps/web/src/pages/dao/[network]/[token]/migrate.tsx b/apps/web/src/pages/dao/[network]/[token]/migrate.tsx index 147ac98a7..9c33eeede 100644 --- a/apps/web/src/pages/dao/[network]/[token]/migrate.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/migrate.tsx @@ -14,7 +14,7 @@ import { Box, Flex, Stack, Text } from '@buildeross/zord' import { GetServerSideProps } from 'next' import { useRouter } from 'next/router' import React, { useCallback } from 'react' -import { useAccount, useReadContracts } from 'wagmi' +import { useReadContracts } from 'wagmi' import { getDaoLayout } from '../../../../layouts/DaoLayout' import { NextPageWithLayout } from '../../../_app' @@ -39,8 +39,7 @@ const MigratePage: NextPageWithLayout = ({ addresses, chainId, }) => { - const { address: walletAddress, isConnected } = useAuthStore() - const { connector } = useAccount() + const { address: walletAddress, isConnected, isSafeMode } = useAuthStore() const router = useRouter() const onNavigateToReview = useCallback(() => { @@ -104,7 +103,7 @@ const MigratePage: NextPageWithLayout = ({ return false }, [walletAddress, founders, builderBalance]) - if (isConnected && connector?.id === 'safeOwner') { + if (isConnected && isSafeMode) { return ( diff --git a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx index 8d85c6a18..7377f402d 100644 --- a/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx +++ b/packages/auction-ui/src/components/CurrentAuction/PlaceBid.test.tsx @@ -25,7 +25,7 @@ vi.mock('wagmi', async (importOriginal) => { chain: { id: CHAIN_ID.ETHEREUM }, }), useBalance: () => ({ data: { value: mockBalanceValue } }), - useConfig: () => ({}), + useConfig: () => ({ state: { current: null, connections: new Map() } }), useReadContracts: () => ({ data: [parseEther('1'), 10n] }), } }) diff --git a/packages/sdk/src/transaction/executeAppTransaction.test.ts b/packages/sdk/src/transaction/executeAppTransaction.test.ts index a1dd8233a..ce88edb75 100644 --- a/packages/sdk/src/transaction/executeAppTransaction.test.ts +++ b/packages/sdk/src/transaction/executeAppTransaction.test.ts @@ -1,4 +1,3 @@ -import { recordSafeProposalHash } from '@buildeross/utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import { executeAppTransaction, executeAppTransactions } from './executeAppTransaction' @@ -28,7 +27,11 @@ const request = (index: number) => args: [], }) as any -const config = (connector?: { id: string; getProvider: () => Promise }) => +const config = (connector?: { + id: string + safeInfo?: { threshold?: number } + getProvider: () => Promise +}) => ({ state: { current: connector ? 'connection' : null, @@ -52,10 +55,13 @@ describe('executeAppTransactions', () => { it('proposes all requests as one Safe transaction', async () => { const hash = `0x${'a'.repeat(64)}` as `0x${string}` - recordSafeProposalHash(hash) const provider = { request: vi.fn().mockResolvedValue(hash) } const result = await executeAppTransactions({ - config: config({ id: 'safeOwner', getProvider: async () => provider }), + config: config({ + id: 'safeOwner', + safeInfo: { threshold: 2 }, + getProvider: async () => provider, + }), requests: [request(1), request(2)], chainId: 1 as any, }) @@ -67,6 +73,24 @@ describe('executeAppTransactions', () => { expect(writeContract).not.toHaveBeenCalled() }) + it('treats multisig Safe writes as proposals even without hash tracking', async () => { + const hash = `0x${'d'.repeat(64)}` as `0x${string}` + const provider = { request: vi.fn().mockResolvedValue(hash) } + + const result = await executeAppTransactions({ + config: config({ + id: 'safeOwner', + safeInfo: { threshold: 2 }, + getProvider: async () => provider, + }), + requests: [request(1)], + chainId: 1 as any, + }) + + expect(result).toEqual({ kind: 'safe-proposed', hash }) + expect(waitForTransactionReceipt).not.toHaveBeenCalled() + }) + it('sends EOA requests sequentially and waits for each receipt', async () => { writeContract .mockResolvedValueOnce(`0x${'1'.repeat(64)}`) @@ -113,13 +137,43 @@ describe('executeAppTransactions', () => { }) describe('executeAppTransaction', () => { + beforeEach(() => { + vi.clearAllMocks() + waitForTransactionReceipt.mockResolvedValue({ status: 'success', blockNumber: 10n }) + }) + it('does not wait for a receipt for a Safe proposal', async () => { const hash = `0x${'c'.repeat(64)}` as `0x${string}` - recordSafeProposalHash(hash) writeContract.mockResolvedValue(hash) await expect( - executeAppTransaction({ config: config(), request: request(1), chainId: 1 as any }) + executeAppTransaction({ + config: config({ + id: 'safeOwner', + safeInfo: { threshold: 2 }, + getProvider: async () => ({ request: vi.fn() }), + }), + request: request(1), + chainId: 1 as any, + }) + ).resolves.toEqual({ kind: 'safe-proposed', hash }) + expect(waitForTransactionReceipt).not.toHaveBeenCalled() + }) + + it('treats multisig Safe writes as proposals even without hash tracking', async () => { + const hash = `0x${'e'.repeat(64)}` as `0x${string}` + writeContract.mockResolvedValue(hash) + + await expect( + executeAppTransaction({ + config: config({ + id: 'safeOwner', + safeInfo: { threshold: 2 }, + getProvider: async () => ({ request: vi.fn() }), + }), + request: request(1), + chainId: 1 as any, + }) ).resolves.toEqual({ kind: 'safe-proposed', hash }) expect(waitForTransactionReceipt).not.toHaveBeenCalled() }) diff --git a/packages/sdk/src/transaction/executeAppTransaction.ts b/packages/sdk/src/transaction/executeAppTransaction.ts index ff80dd060..692443752 100644 --- a/packages/sdk/src/transaction/executeAppTransaction.ts +++ b/packages/sdk/src/transaction/executeAppTransaction.ts @@ -1,5 +1,4 @@ import type { CHAIN_ID } from '@buildeross/types' -import { isSafeProposalHash } from '@buildeross/utils' import { encodeFunctionData, type TransactionReceipt, @@ -69,6 +68,16 @@ export async function executeAppTransactions({ ? config.state.connections.get(config.state.current) : undefined const connector = connection?.connector + const safeConnector = connector as + | { + id?: string + safeInfo?: { + threshold?: number + } | null + } + | undefined + const isMultiSigSafe = + safeConnector?.id === 'safeOwner' && (safeConnector.safeInfo?.threshold ?? 0) > 1 if (connector?.id === 'safeOwner') { const provider = (await connector.getProvider()) as { request: (args: { method: string; params?: unknown[] }) => Promise @@ -86,7 +95,7 @@ export async function executeAppTransactions({ method: 'eth_sendTransaction', params: [{ ...transactions[0], safeTransactions: transactions }], })) as `0x${string}` - if (isSafeProposalHash(hash)) return { kind: 'safe-proposed', hash } + if (isMultiSigSafe) return { kind: 'safe-proposed', hash } const receipt = await waitForTransactionReceipt(config, { hash, chainId }) if (receipt.status !== 'success') throw new Error(`Transaction reverted: ${hash}`) let subgraphSynced = false @@ -148,9 +157,24 @@ export async function executeAppTransaction({ chainId, waitForSubgraphSync = true, }: ExecuteAppTransactionParams): Promise { + const connection = config.state.current + ? config.state.connections.get(config.state.current) + : undefined + const connector = connection?.connector + const safeConnector = connector as + | { + id?: string + safeInfo?: { + threshold?: number + } | null + } + | undefined + const isMultiSigSafe = + safeConnector?.id === 'safeOwner' && (safeConnector.safeInfo?.threshold ?? 0) > 1 + const hash = await writeContract(config, request) - if (isSafeProposalHash(hash)) { + if (isMultiSigSafe) { return { kind: 'safe-proposed', hash } } diff --git a/packages/stores/src/createAuthStore.ts b/packages/stores/src/createAuthStore.ts index 74991ee03..ee9de04f3 100644 --- a/packages/stores/src/createAuthStore.ts +++ b/packages/stores/src/createAuthStore.ts @@ -1,4 +1,4 @@ -import type { AddressType } from '@buildeross/types' +import type { AddressType, CHAIN_ID } from '@buildeross/types' import { createContext, useContext } from 'react' import { useAccount } from 'wagmi' @@ -27,6 +27,7 @@ export type AuthStoreState = { // Safe-specific fields (when user authenticated via Safe wallet) eoaAddress?: AddressType safeAddress?: AddressType + safeChainId?: CHAIN_ID isSafeMode: boolean // State flags @@ -44,7 +45,7 @@ export interface SessionData { address?: AddressType eoaAddress?: AddressType safeAddress?: AddressType - safeChainId?: number + safeChainId?: CHAIN_ID } /** @@ -87,6 +88,7 @@ export function useAuthStore(): AuthStoreState { // Safe-specific fields eoaAddress: session?.eoaAddress as AddressType | undefined, safeAddress: session?.safeAddress as AddressType | undefined, + safeChainId: session?.safeChainId as CHAIN_ID | undefined, isSafeMode: !!session?.safeAddress, isConnected: walletConnected, diff --git a/packages/ui/src/ContractButton/ContractButton.test.tsx b/packages/ui/src/ContractButton/ContractButton.test.tsx index f7e08232a..c434b3cf8 100644 --- a/packages/ui/src/ContractButton/ContractButton.test.tsx +++ b/packages/ui/src/ContractButton/ContractButton.test.tsx @@ -117,4 +117,37 @@ describe('ContractButton', () => { expect(mockSwitchChain).toHaveBeenCalledTimes(1) }) }) + + it('shows safe reconnect popup action on wrong chain for safe wallets', async () => { + const user = userEvent.setup() + const handleClick = vi.fn() + + mockUseAccount.mockReturnValue({ + address: '0x1234', + chain: { id: CHAIN_ID.OPTIMISM }, + connector: { id: 'safeOwner' }, + }) + + render( + + Submit vote + + ) + + await user.click(screen.getByRole('button', { name: 'Submit vote' })) + + expect(handleClick).not.toHaveBeenCalled() + expect( + await screen.findByText( + 'Safe wallets cannot switch networks. Connect a Safe on Ethereum to continue.' + ) + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Connect Safe' })) + + await waitFor(() => { + expect(mockOpenConnectModal).toHaveBeenCalledTimes(1) + }) + expect(mockSwitchChain).not.toHaveBeenCalled() + }) }) diff --git a/packages/ui/src/ContractButton/ContractButton.tsx b/packages/ui/src/ContractButton/ContractButton.tsx index 66965d4ed..e0c9cc10c 100644 --- a/packages/ui/src/ContractButton/ContractButton.tsx +++ b/packages/ui/src/ContractButton/ContractButton.tsx @@ -15,7 +15,11 @@ export type ContractButtonProps = Omit & { onConnectWallet?: () => void } -type ErrorType = 'not_connected' | 'connector_invalid' | 'wrong_chain' +type ErrorType = + | 'not_connected' + | 'connector_invalid' + | 'wrong_chain' + | 'wrong_chain_safe' type ErrorState = { type: ErrorType @@ -81,6 +85,13 @@ export const ContractButton = React.forwardRef - {index + 1}. To: {truncateAddress(transaction.to)} + {transactions.length > 1 ? `${index + 1}. ` : ''}To:{' '} + {truncateAddress(transaction.to)} {transaction.data && transaction.data !== '0x' ? ` | Function: ${transaction.data.slice(0, 10)}` : ''} @@ -129,7 +130,7 @@ export function SafeTransactionModal({ {/* Proposing State */} {state === 'proposing' && ( - + Waiting for wallet signature... diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 47c196c9f..86156efc8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -32,7 +32,6 @@ export * from './safe' export * from './safe/errors' export * from './safe/handler' export * from './safe/proposeTransaction' -export * from './safe/transactionResult' export * from './safeService' export * from './safeStorage' export * from './sanitize' diff --git a/packages/utils/src/providers/SafeOwnerProvider.test.ts b/packages/utils/src/providers/SafeOwnerProvider.test.ts new file mode 100644 index 000000000..d5f1c2567 --- /dev/null +++ b/packages/utils/src/providers/SafeOwnerProvider.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' + +import { SafeOwnerProvider } from './SafeOwnerProvider' + +const safeInfo = { + safeAddress: '0x0000000000000000000000000000000000000001' as const, + chainId: 1, + threshold: 2, + owners: ['0x0000000000000000000000000000000000000002' as const], + isReadOnly: false, +} + +const createEoaProvider = () => { + const listeners = new Map void>>() + + return { + on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + const current = listeners.get(event) ?? new Set() + current.add(listener) + listeners.set(event, current) + }), + removeListener: vi.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(listener) + }), + request: vi.fn(), + emit(event: string, ...args: unknown[]) { + for (const listener of listeners.get(event) ?? []) { + listener(...args) + } + }, + } +} + +const publicClient = {} as any + +describe('SafeOwnerProvider', () => { + it('disconnects when the backing EOA account changes', () => { + const eoaProvider = createEoaProvider() + const safeProvider = new SafeOwnerProvider( + safeInfo as any, + eoaProvider as any, + publicClient + ) + const disconnect = vi.fn() + + safeProvider.on('disconnect', disconnect) + eoaProvider.emit('accountsChanged', ['0x0000000000000000000000000000000000000003']) + + expect(disconnect).toHaveBeenCalledTimes(1) + }) + + it('disconnects when the backing EOA chain changes', () => { + const eoaProvider = createEoaProvider() + const safeProvider = new SafeOwnerProvider( + safeInfo as any, + eoaProvider as any, + publicClient + ) + const disconnect = vi.fn() + + safeProvider.on('disconnect', disconnect) + eoaProvider.emit('chainChanged', '0x2') + + expect(disconnect).toHaveBeenCalledTimes(1) + }) + + it('disconnects when the backing EOA disconnects', () => { + const eoaProvider = createEoaProvider() + const safeProvider = new SafeOwnerProvider( + safeInfo as any, + eoaProvider as any, + publicClient + ) + const disconnect = vi.fn() + + safeProvider.on('disconnect', disconnect) + eoaProvider.emit('disconnect') + + expect(disconnect).toHaveBeenCalledTimes(1) + }) + + it('removes backing EOA listeners on destroy', () => { + const eoaProvider = createEoaProvider() + const safeProvider = new SafeOwnerProvider( + safeInfo as any, + eoaProvider as any, + publicClient + ) + const disconnect = vi.fn() + + safeProvider.on('disconnect', disconnect) + safeProvider.destroy() + eoaProvider.emit('accountsChanged', ['0x0000000000000000000000000000000000000003']) + + expect(eoaProvider.removeListener).toHaveBeenCalled() + expect(disconnect).not.toHaveBeenCalled() + }) +}) diff --git a/packages/utils/src/providers/SafeOwnerProvider.ts b/packages/utils/src/providers/SafeOwnerProvider.ts index 49745136c..0fb53495f 100644 --- a/packages/utils/src/providers/SafeOwnerProvider.ts +++ b/packages/utils/src/providers/SafeOwnerProvider.ts @@ -93,19 +93,14 @@ export class SafeOwnerProvider extends EventEmitter implements EIP1193Provider { const originalOff = this.eoaProvider.removeListener?.bind(this.eoaProvider) // Track event handlers for cleanup - const accountsHandler = (..._args: unknown[]) => { - const [accounts] = _args as [string[] | undefined] - // The Safe account is only usable while a backing owner EOA is connected. - this.emit( - 'accountsChanged', - accounts && accounts.length > 0 ? [this.safe.safeAddress] : [] - ) + const accountsHandler = () => { + // Any backing EOA account change invalidates the Safe session. + this.emit('disconnect') } - const chainHandler = (...args: unknown[]) => { - // Forward chain changes - const [chainId] = args - this.emit('chainChanged', chainId as string) + const chainHandler = () => { + // Safes are chain-specific. Any backing EOA chain change invalidates the session. + this.emit('disconnect') } const disconnectHandler = () => { diff --git a/packages/utils/src/safe/proposeTransaction.ts b/packages/utils/src/safe/proposeTransaction.ts index ed348efa7..883ba3e94 100644 --- a/packages/utils/src/safe/proposeTransaction.ts +++ b/packages/utils/src/safe/proposeTransaction.ts @@ -1,6 +1,5 @@ import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' import type { CHAIN_ID } from '@buildeross/types' -import SafeApiKit from '@safe-global/api-kit' import Safe from '@safe-global/protocol-kit' import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' import debug from 'debug' @@ -9,7 +8,6 @@ import { getAddress } from 'viem' import type { EIP1193Provider, SafeInfo, SendTransactionParams } from '../providers/types' import { ensureCorrectChain } from './ensureCorrectChain' import { SafeTransactionError, SafeTransactionErrorCode } from './errors' -import { recordSafeProposalHash } from './transactionResult' const debugSafeTx = debug('app:safe:tx') @@ -69,16 +67,6 @@ export async function proposeSafeTransaction( // Verify provider is on the correct chain, switch if necessary await ensureCorrectChain(eoaProvider, safeInfo.chainId) - const apiKey = process.env.NEXT_PUBLIC_SAFE_API_KEY - if (!apiKey) { - debugSafeTx('ERROR: NEXT_PUBLIC_SAFE_API_KEY is not set!') - throw new SafeTransactionError( - 'NEXT_PUBLIC_SAFE_API_KEY is required for Safe multi-sig transactions', - SafeTransactionErrorCode.API_ERROR - ) - } - debugSafeTx('API key present') - const serviceUrl = SAFE_SERVICE_URL[safeInfo.chainId as CHAIN_ID] if (!serviceUrl) { debugSafeTx('ERROR: No Safe Service URL for chain: %d', safeInfo.chainId) @@ -104,13 +92,6 @@ export async function proposeSafeTransaction( const senderAddress = getAddress(rawSenderAddress) debugSafeTx('EOA address (checksummed): %s', senderAddress) - // Create Safe API Kit with API key - debugSafeTx('Initializing Safe API Kit...') - const apiKit = new SafeApiKit({ - chainId: BigInt(safeInfo.chainId), - apiKey, - }) - // Checksum addresses for Safe API const safeAddress = getAddress(safeInfo.safeAddress) const transactions = Array.isArray(transaction) ? transaction : [transaction] @@ -184,16 +165,27 @@ export async function proposeSafeTransaction( const senderSignature = await protocolKit.signHash(safeTxHash) debugSafeTx('Transaction hash signed') - // Propose to Safe Service - debugSafeTx('Proposing transaction to Safe Service...') + // Submit the signed proposal through the server so the Safe API key stays private. + debugSafeTx('Proposing transaction through the server...') try { - await apiKit.proposeTransaction({ - safeAddress, - safeTransactionData: safeTx.data, - safeTxHash, - senderAddress, - senderSignature: senderSignature.data, + const response = await fetch('/api/safe/propose', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chainId: safeInfo.chainId, + safeAddress, + safeTransactionData: safeTx.data, + safeTxHash, + senderAddress, + senderSignature: senderSignature.data, + }), }) + if (!response.ok) { + const data = (await response.json().catch(() => null)) as { error?: string } | null + throw new Error( + data?.error || `Safe proposal failed with status ${response.status}` + ) + } debugSafeTx('Transaction successfully proposed to Safe Service') } catch (error) { debugSafeTx('ERROR: Failed to propose transaction to Safe Service: %O', error) @@ -203,8 +195,6 @@ export async function proposeSafeTransaction( ) } - recordSafeProposalHash(safeTxHash as `0x${string}`) - return safeTxHash } diff --git a/packages/utils/src/safe/transactionResult.test.ts b/packages/utils/src/safe/transactionResult.test.ts deleted file mode 100644 index 998e9c31b..000000000 --- a/packages/utils/src/safe/transactionResult.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { isSafeProposalHash, recordSafeProposalHash } from './transactionResult' - -describe('Safe transaction result registry', () => { - it('recognizes recorded Safe proposal hashes only', () => { - const hash = `0x${'1'.repeat(64)}` as `0x${string}` - - expect(isSafeProposalHash(hash)).toBe(false) - recordSafeProposalHash(hash) - expect(isSafeProposalHash(hash)).toBe(true) - }) - - it('maintains LRU cache with max size', () => { - // Clear any existing hashes by recording 101 new ones - const hashes = Array.from( - { length: 101 }, - (_, i) => `0x${i.toString(16).padStart(64, '0')}` as `0x${string}` - ) - - hashes.forEach(recordSafeProposalHash) - - // First hash should be evicted (LRU), last 100 should remain - expect(isSafeProposalHash(hashes[0])).toBe(false) - expect(isSafeProposalHash(hashes[1])).toBe(true) - expect(isSafeProposalHash(hashes[100])).toBe(true) - }) - - it('moves hash to end when recorded again', () => { - const hash1 = `0x${'1'.repeat(64)}` as `0x${string}` - const hash2 = `0x${'2'.repeat(64)}` as `0x${string}` - - recordSafeProposalHash(hash1) - recordSafeProposalHash(hash2) - - // Record hash1 again - should move to end - recordSafeProposalHash(hash1) - - // Both should still be recognized - expect(isSafeProposalHash(hash1)).toBe(true) - expect(isSafeProposalHash(hash2)).toBe(true) - }) -}) diff --git a/packages/utils/src/safe/transactionResult.ts b/packages/utils/src/safe/transactionResult.ts deleted file mode 100644 index 99493af54..000000000 --- a/packages/utils/src/safe/transactionResult.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Hex } from 'viem' - -export type SafeTransactionResultKind = 'onchain' | 'safe-proposed' - -// LRU cache for Safe proposal hashes to prevent unbounded memory growth -const MAX_SAFE_PROPOSAL_HASHES = 100 -const safeProposalHashes: Hex[] = [] - -export function recordSafeProposalHash(hash: Hex): void { - // Remove if already exists to move to end - const existingIndex = safeProposalHashes.indexOf(hash) - if (existingIndex !== -1) { - safeProposalHashes.splice(existingIndex, 1) - } - - // Add to end - safeProposalHashes.push(hash) - - // Remove oldest if over limit - if (safeProposalHashes.length > MAX_SAFE_PROPOSAL_HASHES) { - safeProposalHashes.shift() - } -} - -export function isSafeProposalHash(hash: Hex): boolean { - return safeProposalHashes.includes(hash) -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce3327d1e..e43079c4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: '@rainbow-me/rainbowkit': specifier: ^2.2.10 version: 2.2.10(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(wagmi@2.18.1(@tanstack/query-core@5.90.3)(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.8.1)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(zod@4.1.12)) + '@safe-global/api-kit': + specifier: ^5.0.2 + version: 5.0.2(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) '@sentry/nextjs': specifier: ^9.22.0 version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) diff --git a/turbo.json b/turbo.json index d8f291eae..d19d68869 100644 --- a/turbo.json +++ b/turbo.json @@ -28,7 +28,7 @@ "NEXT_PUBLIC_WALLET_CONNECT_METADATA", "NEXT_PUBLIC_BASE_BUILDER_CODE", "NEXT_PUBLIC_ZORA_COINS_SDK_API_KEY", - "NEXT_PUBLIC_SAFE_API_KEY", + "SAFE_API_KEY", "SENTRY_DSN", "SENTRY_ORG", "SENTRY_PROJECT", From 9d536105b27ef274b0c37db3c8e73ec5e99c7df6 Mon Sep 17 00:00:00 2001 From: dan13ram Date: Mon, 7 Sep 2026 19:15:46 +0530 Subject: [PATCH 5/6] feat: add recent Safe wallet recovery and connector polish --- apps/web/src/components/AsyncImage.tsx | 1 + apps/web/src/components/SafeAddressModal.tsx | 127 -------------- .../src/components/WalletConnectDialog.tsx | 147 +++++++++++++--- apps/web/src/components/WalletOption.test.tsx | 20 +++ apps/web/src/components/WalletOption.tsx | 4 +- .../src/components/wallet/SafeAddressView.tsx | 60 +++++++ .../src/components/wallet/SignatureView.tsx | 2 +- .../src/components/wallet/WalletListView.tsx | 17 +- apps/web/src/connectors/safeOwnerConnector.ts | 35 +++- .../src/machines/safeTransactionMachine.ts | 10 +- .../src/machines/walletModalMachine.test.ts | 161 ++++++++++++++++++ apps/web/src/machines/walletModalMachine.ts | 45 ++++- apps/web/src/pages/_app.tsx | 14 +- apps/web/src/pages/api/safe/propose.ts | 55 +++++- apps/web/src/pages/api/siwe/nonce.ts | 4 + apps/web/src/pages/api/siwe/verify.ts | 4 + apps/web/src/types/auth.ts | 2 + apps/web/src/utils/signing.test.ts | 78 +++++++++ apps/web/src/utils/signing.ts | 43 +++++ .../ui/src/Modal/SafeTransactionModal.tsx | 4 +- packages/utils/src/index.ts | 1 + packages/utils/src/providers/types.ts | 1 + packages/utils/src/recentSafeWallets.test.ts | 62 +++++++ packages/utils/src/recentSafeWallets.ts | 95 +++++++++++ packages/utils/src/safe/errors.test.ts | 35 ++++ packages/utils/src/safe/errors.ts | 14 +- packages/utils/src/safe/proposeTransaction.ts | 24 ++- packages/utils/src/safeStorage.test.ts | 68 ++++++++ packages/utils/src/safeStorage.ts | 38 ++++- 29 files changed, 977 insertions(+), 194 deletions(-) delete mode 100644 apps/web/src/components/SafeAddressModal.tsx create mode 100644 apps/web/src/components/WalletOption.test.tsx create mode 100644 apps/web/src/machines/walletModalMachine.test.ts create mode 100644 apps/web/src/utils/signing.test.ts create mode 100644 apps/web/src/utils/signing.ts create mode 100644 packages/utils/src/recentSafeWallets.test.ts create mode 100644 packages/utils/src/recentSafeWallets.ts create mode 100644 packages/utils/src/safe/errors.test.ts create mode 100644 packages/utils/src/safeStorage.test.ts diff --git a/apps/web/src/components/AsyncImage.tsx b/apps/web/src/components/AsyncImage.tsx index 30bc6a605..70ff544ed 100644 --- a/apps/web/src/components/AsyncImage.tsx +++ b/apps/web/src/components/AsyncImage.tsx @@ -37,6 +37,7 @@ export function AsyncImage({ height, width, borderRadius, + border: `1px solid ${background}`, }} className={className} > diff --git a/apps/web/src/components/SafeAddressModal.tsx b/apps/web/src/components/SafeAddressModal.tsx deleted file mode 100644 index df4534fbf..000000000 --- a/apps/web/src/components/SafeAddressModal.tsx +++ /dev/null @@ -1,127 +0,0 @@ -'use client' - -import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' -import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' -import { CHAIN_ID } from '@buildeross/types' -import { DropdownSelect, FIELD_TYPES, SmartInput } from '@buildeross/ui' -import { Button, Flex, Stack, Text } from '@buildeross/zord' -import { ChangeEvent, useState } from 'react' -import type { Address } from 'viem' -import { isAddress } from 'viem' - -interface SafeAddressModalProps { - onSubmit: (safeAddress: Address, chainId: CHAIN_ID) => Promise - onCancel: () => void - isValidating?: boolean - error?: string | null - initialSafeAddress?: Address - initialChainId?: CHAIN_ID -} - -// Dynamically build supported chains from SAFE_SERVICE_URL + PUBLIC_DEFAULT_CHAINS -const getSupportedChains = () => { - return PUBLIC_DEFAULT_CHAINS.filter( - (chain) => SAFE_SERVICE_URL[chain.id as CHAIN_ID] !== undefined - ).map((chain) => ({ - value: chain.id as CHAIN_ID, - label: chain.name, - })) -} - -const SUPPORTED_CHAINS = getSupportedChains() - -export function SafeAddressModal({ - onSubmit, - onCancel, - isValidating = false, - error, - initialSafeAddress, - initialChainId, -}: SafeAddressModalProps) { - const [safeAddress, setSafeAddress] = useState(initialSafeAddress || '') - const [chainId, setChainId] = useState(initialChainId || CHAIN_ID.ETHEREUM) - const [inputError, setInputError] = useState(null) - - const handleSubmit = async () => { - // Validate address - if (!safeAddress) { - setInputError('Please enter a Safe address') - return - } - - if (!isAddress(safeAddress)) { - setInputError('Invalid Ethereum address') - return - } - - setInputError(null) - await onSubmit(safeAddress as Address, chainId) - } - - return ( - - - Connect as Safe Owner - - Select the network and enter the Safe address. You'll need to connect with a - wallet that is one of the owners of this Safe. - - - - - setChainId(value)} - inputLabel="Network" - disabled={isValidating} - height="x12" - /> - - ) => { - setSafeAddress(e.target.value) - setInputError(null) - }} - inputLabel="Safe Address" - placeholder="0x..." - disabled={isValidating} - isAddress={true} - errorMessage={inputError} - /> - - {error && ( - - - {error} - - - )} - - - - - - - - ) -} diff --git a/apps/web/src/components/WalletConnectDialog.tsx b/apps/web/src/components/WalletConnectDialog.tsx index 7d8cc591a..ae76698b4 100644 --- a/apps/web/src/components/WalletConnectDialog.tsx +++ b/apps/web/src/components/WalletConnectDialog.tsx @@ -1,18 +1,19 @@ 'use client' import { AnimatedModal } from '@buildeross/ui' -import { getSafeInfo, setSafeInfo } from '@buildeross/utils' +import { addRecentSafeWallet, clearSafeInfo, getSafeInfo } from '@buildeross/utils' import { getConnectors } from '@wagmi/core' import { useMachine } from '@xstate/react' -import { useEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import type { Address } from 'viem' import { createSiweMessage } from 'viem/siwe' -import { useAccount, useConfig, useConnect, useSignMessage } from 'wagmi' +import { useAccount, useConfig, useConnect, useDisconnect, useSignMessage } from 'wagmi' import { useWalletConnectors } from '../hooks/useWalletConnectors' import { walletModalMachine } from '../machines/walletModalMachine' import type { WalletInfo } from '../types/auth' import { debugWallet } from '../utils/debug' +import { getCachedSigningAddress, resolveSigningAddress } from '../utils/signing' import { beginSiweAuthFlow, SIWE_NONCE_PATH } from '../utils/siweAuthFlow' import { ErrorView } from './wallet/ErrorView' import { LoadingView } from './wallet/LoadingView' @@ -27,13 +28,35 @@ interface WalletConnectDialogProps { export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProps) { const [state, send] = useMachine(walletModalMachine) - const { address, connector: activeConnector, isConnected, chainId } = useAccount() + const { connector: activeConnector, chainId } = useAccount() const { connectAsync } = useConnect() + const { disconnectAsync } = useDisconnect() const { signMessageAsync } = useSignMessage() const wagmiConfig = useConfig() // Track if we just authenticated to prevent immediate reopening const justAuthenticatedRef = useRef(false) + const safeFlowStartedRef = useRef(false) + const flowCancelledRef = useRef(false) + const authAttemptRef = useRef(0) + const recordedRecentSafeRef = useRef(null) + + const cleanupCancelledSafeFlow = useCallback(async () => { + flowCancelledRef.current = true + authAttemptRef.current += 1 + if (!safeFlowStartedRef.current) return + + safeFlowStartedRef.current = false + clearSafeInfo() + + if (activeConnector?.id === 'safeOwner') { + try { + await disconnectAsync() + } catch (error) { + debugWallet('Failed to disconnect cancelled Safe flow: %O', error) + } + } + }, [activeConnector, disconnectAsync]) // Use proper wallet connector hook (handles deduplication) const walletConnectors = useWalletConnectors() @@ -46,6 +69,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp name: wc.name, iconUrl: wc.iconUrl, isRainbowKitConnector: wc.isRainbowKitConnector, + recent: wc.recent, connector: wc.connector, })), [walletConnectors] @@ -54,6 +78,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp // Open modal handler useEffect(() => { if (isOpen && state.matches('closed') && !justAuthenticatedRef.current) { + flowCancelledRef.current = false send({ type: 'OPEN', wallets }) } // Only react to isOpen and state changes, not wallets @@ -64,24 +89,28 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp // Close modal handler useEffect(() => { if (!isOpen && !state.matches('closed')) { + void cleanupCancelledSafeFlow() send({ type: 'CLOSE' }) } - }, [isOpen, state, send]) + }, [cleanupCancelledSafeFlow, isOpen, state, send]) - // Handle wallet connection when wagmi connects + // Save recent Safe logins only after the flow fully succeeds. useEffect(() => { - const isWaitingForConnection = - state.matches('connectingWallet') || state.matches('switchingToSafe') + if (state.matches('authenticated') && state.context.safeInfo) { + const { safeAddress, chainId } = state.context.safeInfo + const recentKey = `${safeAddress.toLowerCase()}:${chainId}` - if (isWaitingForConnection && isConnected && address && activeConnector) { - debugWallet('Wallet connected: %s via %s', address, activeConnector.name) - send({ - type: 'WALLET_CONNECTED', - address, - connector: activeConnector, - }) + if (recordedRecentSafeRef.current !== recentKey) { + addRecentSafeWallet(safeAddress, chainId) + recordedRecentSafeRef.current = recentKey + } + return } - }, [state, isConnected, address, activeConnector, send]) + + if (state.matches('closed')) { + recordedRecentSafeRef.current = null + } + }, [state]) // Handle Safe address validation useEffect(() => { @@ -167,9 +196,6 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp return } - // Store Safe info with EOA connector ID for persistence - setSafeInfo(state.context.safeInfo!, state.context.connector!.id) - // Find SafeOwnerConnector from wagmi config const safeConnector = getConnectors(wagmiConfig).find( (c) => c.id === 'safeOwner' @@ -186,19 +212,35 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp } debugWallet('Connecting to SafeOwnerConnector...') - await connectAsync({ connector: safeConnector }) + const result = await connectAsync({ connector: safeConnector }) // Check abort immediately after async operation (before state updates) if (abortController.signal.aborted) { debugWallet( 'Safe connector switch aborted after connect (component unmounted)' ) + await disconnectAsync({ connector: safeConnector }) + return + } + + if (flowCancelledRef.current) { + await disconnectAsync({ connector: safeConnector }) return } debugWallet('Safe connector switch complete') + const connectedAddress = result.accounts?.[0] + const connectedConnector = safeConnector + + if (!connectedAddress) { + throw new Error('Safe connector did not return an address') + } - // The existing useEffect will detect the new connection and send WALLET_CONNECTED + send({ + type: 'WALLET_CONNECTED', + address: connectedAddress, + connector: connectedConnector, + }) } catch (error) { // Check abort in catch block (before state updates) if (abortController.signal.aborted) { @@ -206,6 +248,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp return } + clearSafeInfo() debugWallet('Safe connector switch error: %O', error) send({ type: 'ERROR', @@ -222,7 +265,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp abortController.abort() } } - }, [state, send, wagmiConfig, connectAsync]) + }, [state, send, wagmiConfig, connectAsync, disconnectAsync]) // Debug state changes useEffect(() => { @@ -233,6 +276,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp // Log when authenticated if (state.matches('authenticated')) { + safeFlowStartedRef.current = false debugWallet('AUTHENTICATED STATE REACHED! Modal should close in 500ms') } @@ -303,6 +347,8 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp return } + flowCancelledRef.current = false + const attemptId = ++authAttemptRef.current send({ type: 'SELECT_WALLET', walletId }) try { @@ -313,8 +359,22 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp wallet.connector ) const result = await connectAsync({ connector: wallet.connector }) + if (attemptId !== authAttemptRef.current || flowCancelledRef.current) return debugWallet('Connected successfully: %O', result) + const connectedAddress = result.accounts?.[0] + const connectedConnector = wallet.connector + + if (!connectedAddress) { + throw new Error('Wallet connection did not return an address') + } + + send({ + type: 'WALLET_CONNECTED', + address: connectedAddress, + connector: connectedConnector, + }) } catch (error) { + if (attemptId !== authAttemptRef.current || flowCancelledRef.current) return debugWallet('Connection error: %O', error) send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } }) } @@ -322,6 +382,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp // Handle Safe address submission const handleSubmitSafeAddress = (safeAddress: Address, chainId: number) => { + safeFlowStartedRef.current = true send({ type: 'SUBMIT_SAFE_ADDRESS', address: safeAddress, @@ -339,6 +400,8 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp ) send({ type: 'SIGN_MESSAGE' }) + flowCancelledRef.current = false + const attemptId = ++authAttemptRef.current // Create AbortController with 60s timeout for signature request const abortController = new AbortController() @@ -357,12 +420,20 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp throw new Error('No address available') } + const signingAddress = await resolveSigningAddress( + state.context.connector, + currentAddress + ) + + if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return + // Begin auth flow beginSiweAuthFlow() // Get nonce from API debugWallet('Fetching nonce from /api/siwe/nonce...') const nonceRes = await fetch(SIWE_NONCE_PATH) + if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return debugWallet('Nonce response status: %d', nonceRes.status) // Get response as text first to see what we're receiving @@ -399,7 +470,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp // Create SIWE message const message = createSiweMessage({ domain: window.location.host, - address: currentAddress, + address: signingAddress, statement: safeAddress ? `Sign in as owner of Safe ${safeAddress}` : 'Sign in with Ethereum to the app.', @@ -431,6 +502,8 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp return } + if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return + debugWallet('Signature received successfully: %s', signature) send({ @@ -457,6 +530,8 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp return } + if (flowCancelledRef.current || attemptId !== authAttemptRef.current) return + debugWallet('Signature error: %O', error) debugWallet( 'Error name: %s, message: %s', @@ -474,12 +549,18 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp } // Handle cancel - const handleCancel = () => { + const handleCancel = async () => { + flowCancelledRef.current = true + authAttemptRef.current += 1 + await cleanupCancelledSafeFlow() send({ type: 'CANCEL' }) } // Handle back navigation - const handleBack = () => { + const handleBack = async () => { + flowCancelledRef.current = true + authAttemptRef.current += 1 + await cleanupCancelledSafeFlow() send({ type: 'BACK' }) } @@ -489,7 +570,9 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp } // Handle close - const handleClose = () => { + const handleClose = async () => { + flowCancelledRef.current = true + await cleanupCancelledSafeFlow() if (!state.matches('closed')) { send({ type: 'CLOSE' }) } @@ -535,7 +618,7 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp onSelectSafe={() => {}} showSafeOption={false} title="Connect Safe Owner Wallet" - description="Choose a wallet that is an owner of this Safe. Your connected wallet cannot be used unless it has owner permissions for the Safe." + description="Choose a wallet that is an owner of this Safe to continue." /> ) } @@ -560,10 +643,18 @@ export function WalletConnectDialog({ isOpen, onClose }: WalletConnectDialogProp state.context.connector?.name || wallets.find((w) => w.id === state.context.selectedWalletId)?.name || 'wallet' + const signingAddress = + state.context.connector?.id === 'safeOwner' + ? getCachedSigningAddress(state.context.connector, state.context.address!) + : state.context.address! + + if (!signingAddress) { + return + } return ( { + it('shows a compact recent label when the wallet is recent', () => { + render( + {}} + /> + ) + + expect(screen.getByText('Rainbow')).toBeVisible() + expect(screen.getByText('Recent')).toBeVisible() + }) +}) diff --git a/apps/web/src/components/WalletOption.tsx b/apps/web/src/components/WalletOption.tsx index 06222120e..b6d1e06b5 100644 --- a/apps/web/src/components/WalletOption.tsx +++ b/apps/web/src/components/WalletOption.tsx @@ -50,7 +50,7 @@ export function WalletOption({ width={28} height={28} background={iconBackground} - borderRadius="6px" + borderRadius="8px" /> Recent diff --git a/apps/web/src/components/wallet/SafeAddressView.tsx b/apps/web/src/components/wallet/SafeAddressView.tsx index 03f75e25a..060851152 100644 --- a/apps/web/src/components/wallet/SafeAddressView.tsx +++ b/apps/web/src/components/wallet/SafeAddressView.tsx @@ -4,7 +4,9 @@ import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' import { SAFE_SERVICE_URL } from '@buildeross/constants/safe' import { CHAIN_ID } from '@buildeross/types' import { DropdownSelect, FIELD_TYPES, SmartInput } from '@buildeross/ui' +import { getRecentSafeWallets } from '@buildeross/utils' import { Box, Button, Flex, Stack, Text } from '@buildeross/zord' +import Image from 'next/image' import { ChangeEvent, useState } from 'react' import type { Address } from 'viem' import { isAddress } from 'viem' @@ -26,11 +28,18 @@ const getSupportedChains = () => { } const SUPPORTED_CHAINS = getSupportedChains() +const CHAINS_BY_ID = new Map(PUBLIC_DEFAULT_CHAINS.map((chain) => [chain.id, chain])) + +const formatSafeAddress = (address: Address) => + `${address.slice(0, 6)}...${address.slice(-4)}` export function SafeAddressView({ onSubmit, onBack, error }: SafeAddressViewProps) { const [address, setAddress] = useState('') const [chainId, setChainId] = useState(CHAIN_ID.ETHEREUM) const [validationError, setValidationError] = useState() + const recentSafeWallets = getRecentSafeWallets() + .filter((wallet) => CHAINS_BY_ID.has(wallet.chainId)) + .slice(0, 3) const handleSubmit = () => { if (!address) { @@ -80,6 +89,57 @@ export function SafeAddressView({ onSubmit, onBack, error }: SafeAddressViewProp /> + {recentSafeWallets.length > 0 && ( + + + Recent safes + + + + {recentSafeWallets.map((wallet) => { + const chain = CHAINS_BY_ID.get(wallet.chainId) + if (!chain) return null + + return ( + + ) + })} + + + )} + {error && ( diff --git a/apps/web/src/components/wallet/SignatureView.tsx b/apps/web/src/components/wallet/SignatureView.tsx index 5f9cbc6c7..4b45cd173 100644 --- a/apps/web/src/components/wallet/SignatureView.tsx +++ b/apps/web/src/components/wallet/SignatureView.tsx @@ -31,7 +31,7 @@ export function SignatureView({ - Wallet + {mode === 'safe' ? 'Signing wallet' : 'Wallet'} {walletName} diff --git a/apps/web/src/components/wallet/WalletListView.tsx b/apps/web/src/components/wallet/WalletListView.tsx index 3f56c3279..d927ed88c 100644 --- a/apps/web/src/components/wallet/WalletListView.tsx +++ b/apps/web/src/components/wallet/WalletListView.tsx @@ -27,7 +27,7 @@ export function WalletListView({ return ( - + {title} {description && ( @@ -37,7 +37,7 @@ export function WalletListView({ {installedWallets.length > 0 && ( - + Installed @@ -47,13 +47,14 @@ export function WalletListView({ name={wallet.name} icon={wallet.iconUrl || ''} iconBackground="#ffffff" + recent={wallet.recent} onClick={() => onSelectWallet(wallet.id)} /> ))} )} - + Popular @@ -63,13 +64,14 @@ export function WalletListView({ name={wallet.name} icon={wallet.iconUrl || ''} iconBackground="#ffffff" + recent={wallet.recent} onClick={() => onSelectWallet(wallet.id)} /> ))} {showSafeOption && ( - + Multi-Sig @@ -123,11 +125,12 @@ export function WalletListView({ p="x3" style={{ textDecoration: 'none' }} variant="secondary" + size="sm" > - - - I don't have a wallet? + + I don't have a wallet + ) diff --git a/apps/web/src/connectors/safeOwnerConnector.ts b/apps/web/src/connectors/safeOwnerConnector.ts index 4ca5cc4bc..da496b7d8 100644 --- a/apps/web/src/connectors/safeOwnerConnector.ts +++ b/apps/web/src/connectors/safeOwnerConnector.ts @@ -5,6 +5,7 @@ import { isOwnerOfSafe, type SafeInfo, SafeOwnerProvider, + setSafeInfo, } from '@buildeross/utils' import { getConnectors } from '@wagmi/core' import type { PublicClient } from 'viem' @@ -178,6 +179,21 @@ export function createSafeOwnerConnector(): CreateConnectorFn { const eoaAccounts = await eoaConnector_.getAccounts() eoaAddress_ = eoaAccounts?.[0] || null + // Persist the resolved EOA address so the signing UI can read it back later. + setSafeInfo( + { + safeAddress: saved.safeAddress, + chainId: saved.chainId, + threshold: saved.threshold, + owners: saved.owners, + isReadOnly: false, + nonce: saved.nonce, + version: saved.version, + }, + saved.eoaConnectorId, + eoaAddress_ + ) + // Load Safe info from cache if not already loaded if (!safeInfo_) { // Use cached SafeInfo from localStorage (already fetched during validation) @@ -224,6 +240,18 @@ export function createSafeOwnerConnector(): CreateConnectorFn { throw new Error('No Safe configuration found') } + if ( + provider_ && + safeInfo_ && + (safeInfo_.safeAddress.toLowerCase() !== saved.safeAddress.toLowerCase() || + safeInfo_.chainId !== saved.chainId || + !eoaConnector_ || + eoaConnector_.id !== saved.eoaConnectorId) + ) { + provider_.destroy() + clearCache() + } + if (!provider_) { // Get EOA connector if (!eoaConnector_) { @@ -352,7 +380,7 @@ export function createSafeOwnerConnector(): CreateConnectorFn { // Synchronous getter for cached EOA address get cachedEOAAddress(): `0x${string}` | null { - return eoaAddress_ + return eoaAddress_ ?? loadSafeConfig()?.eoaAddress ?? null }, // Custom method to get EOA connector @@ -372,6 +400,11 @@ export function createSafeOwnerConnector(): CreateConnectorFn { const saved = loadSafeConfig() if (!saved) return null + if (saved.eoaAddress) { + eoaAddress_ = saved.eoaAddress + return saved.eoaAddress + } + if (!eoaConnector_) { eoaConnector_ = findEOAConnector(saved.eoaConnectorId) } diff --git a/apps/web/src/machines/safeTransactionMachine.ts b/apps/web/src/machines/safeTransactionMachine.ts index a7576b7b7..f59438c8d 100644 --- a/apps/web/src/machines/safeTransactionMachine.ts +++ b/apps/web/src/machines/safeTransactionMachine.ts @@ -5,6 +5,7 @@ import type { SendTransactionParams, } from '@buildeross/utils' import { + getSafeErrorMessage, proposeSafeTransaction, SafeTransactionError, SafeTransactionErrorCode, @@ -92,7 +93,7 @@ export const safeTransactionMachine = createMachine( }, onError: { target: 'error', - actions: 'setError', + actions: ['setError', 'rejectConfirmation'], }, }, }, @@ -165,13 +166,18 @@ export const safeTransactionMachine = createMachine( error: ({ event }) => { if ('error' in event) { const err = event.error as Error - const message = err?.message || 'Failed to propose transaction' + const message = getSafeErrorMessage(err) debugSafeTx('Transaction error: %s', message) return message } return 'Failed to propose transaction' }, }), + rejectConfirmation: ({ context, event }) => { + if ('error' in event) { + context.confirmReject?.(event.error as Error) + } + }, clearError: assign({ error: null }), logSuccess: () => { debugSafeTx('State: success') diff --git a/apps/web/src/machines/walletModalMachine.test.ts b/apps/web/src/machines/walletModalMachine.test.ts new file mode 100644 index 000000000..dcee9ca3d --- /dev/null +++ b/apps/web/src/machines/walletModalMachine.test.ts @@ -0,0 +1,161 @@ +import { clearSafeInfo, getSavedSafeInfo } from '@buildeross/utils' +import type { Address } from 'viem' +import { describe, expect, it } from 'vitest' +import { createActor } from 'xstate' + +import { walletModalMachine } from './walletModalMachine' + +describe('walletModalMachine', () => { + const safeAddress = '0x0000000000000000000000000000000000000001' as Address + + it('resets transient Safe state when the modal closes', () => { + const actor = createActor(walletModalMachine) + actor.start() + + actor.send({ type: 'OPEN', wallets: [] }) + actor.send({ type: 'SELECT_SAFE' }) + actor.send({ + type: 'SUBMIT_SAFE_ADDRESS', + address: safeAddress, + chainId: 1, + }) + actor.send({ + type: 'SAFE_VALIDATED', + safeInfo: { + safeAddress, + chainId: 1, + owners: [safeAddress], + threshold: 1, + isReadOnly: false, + }, + }) + actor.send({ type: 'CLOSE' }) + + const snapshot = actor.getSnapshot() + + expect(snapshot.matches('closed')).toBe(true) + expect(snapshot.context.pendingSafeInfo).toBeNull() + expect(snapshot.context.pendingSafeAddress).toBeNull() + expect(snapshot.context.pendingSafeChainId).toBeNull() + expect(snapshot.context.safeInfo).toBeNull() + expect(snapshot.context.isSafeMode).toBe(false) + expect(snapshot.context.error).toBeNull() + }) + + it('resets context when reopening after a cancelled wallet flow', () => { + const actor = createActor(walletModalMachine) + actor.start() + + actor.send({ type: 'OPEN', wallets: [] }) + actor.send({ type: 'SELECT_WALLET', walletId: 'injected' }) + actor.send({ type: 'ERROR', error: { code: 'WALLET_NOT_CONNECTED' } }) + actor.send({ type: 'CLOSE' }) + actor.send({ type: 'OPEN', wallets: [] }) + + const snapshot = actor.getSnapshot() + + expect(snapshot.matches('selectingWallet')).toBe(true) + expect(snapshot.context.selectedWalletId).toBeNull() + expect(snapshot.context.address).toBeNull() + expect(snapshot.context.connector).toBeNull() + expect(snapshot.context.error).toBeNull() + }) + + it('returns to wallet selection when signing is cancelled', () => { + const actor = createActor(walletModalMachine) + actor.start() + + actor.send({ type: 'OPEN', wallets: [] }) + actor.send({ type: 'SELECT_WALLET', walletId: 'injected' }) + actor.send({ + type: 'WALLET_CONNECTED', + address: safeAddress, + connector: { id: 'injected', name: 'Injected' } as any, + }) + actor.send({ type: 'SIGN_MESSAGE' }) + actor.send({ type: 'CANCEL' }) + + const snapshot = actor.getSnapshot() + expect(snapshot.matches('selectingWallet')).toBe(true) + expect(snapshot.context.address).toBeNull() + expect(snapshot.context.connector).toBeNull() + expect(snapshot.context.message).toBeNull() + expect(snapshot.context.signature).toBeNull() + }) + + it('persists Safe info before switching to the Safe connector', async () => { + clearSafeInfo() + + const actor = createActor(walletModalMachine) + actor.start() + + actor.send({ type: 'OPEN', wallets: [] }) + actor.send({ type: 'SELECT_SAFE' }) + actor.send({ type: 'SUBMIT_SAFE_ADDRESS', address: safeAddress, chainId: 1 }) + actor.send({ + type: 'SAFE_VALIDATED', + safeInfo: { + safeAddress, + chainId: 1, + owners: [safeAddress], + threshold: 1, + isReadOnly: false, + }, + }) + actor.send({ type: 'SELECT_WALLET', walletId: 'injected' }) + actor.send({ + type: 'WALLET_CONNECTED', + address: safeAddress, + connector: { id: 'injected', name: 'Injected' } as any, + }) + + await Promise.resolve() + + expect(getSavedSafeInfo()).toMatchObject({ + safeAddress, + chainId: 1, + eoaConnectorId: 'injected', + }) + + clearSafeInfo() + }) + + it('clears Safe attempt state if the modal closes during Safe handoff', async () => { + const actor = createActor(walletModalMachine) + actor.start() + + actor.send({ type: 'OPEN', wallets: [] }) + actor.send({ type: 'SELECT_SAFE' }) + actor.send({ type: 'SUBMIT_SAFE_ADDRESS', address: safeAddress, chainId: 1 }) + actor.send({ + type: 'SAFE_VALIDATED', + safeInfo: { + safeAddress, + chainId: 1, + owners: [safeAddress], + threshold: 1, + isReadOnly: false, + }, + }) + actor.send({ type: 'SELECT_WALLET', walletId: 'injected' }) + actor.send({ + type: 'WALLET_CONNECTED', + address: safeAddress, + connector: { id: 'injected', name: 'Injected' } as any, + }) + + await Promise.resolve() + + actor.send({ type: 'CLOSE' }) + + const snapshot = actor.getSnapshot() + expect(snapshot.matches('closed')).toBe(true) + expect(snapshot.context.selectedWalletId).toBeNull() + expect(snapshot.context.address).toBeNull() + expect(snapshot.context.connector).toBeNull() + expect(snapshot.context.safeInfo).toBeNull() + expect(snapshot.context.pendingSafeInfo).toBeNull() + expect(snapshot.context.error).toBeNull() + expect(snapshot.context.isSafeMode).toBe(false) + }) +}) diff --git a/apps/web/src/machines/walletModalMachine.ts b/apps/web/src/machines/walletModalMachine.ts index 8c41bf480..951b8eebc 100644 --- a/apps/web/src/machines/walletModalMachine.ts +++ b/apps/web/src/machines/walletModalMachine.ts @@ -72,6 +72,7 @@ export const walletModalMachine = createMachine( }, states: { closed: { + entry: 'resetContext', on: { OPEN: { target: 'selectingWallet', @@ -197,6 +198,13 @@ export const walletModalMachine = createMachine( switchingToSafe: { entry: () => debugWallet('State: switchingToSafe (waiting for wagmi connector switch)'), + invoke: { + src: 'switchToSafeConnector', + input: ({ context }) => ({ + safeInfo: context.safeInfo!, + eoaConnectorId: context.connector!.id, + }), + }, on: { WALLET_CONNECTED: { target: 'awaitingSignature', @@ -214,7 +222,7 @@ export const walletModalMachine = createMachine( on: { SIGN_MESSAGE: 'signingMessage', CLOSE: 'disconnecting', - CANCEL: 'disconnecting', + CANCEL: { target: 'selectingWallet', actions: 'resetAttemptContext' }, }, }, signingMessage: { @@ -234,8 +242,8 @@ export const walletModalMachine = createMachine( target: 'awaitingSignature', actions: 'setSignatureError', }, - CANCEL: 'awaitingSignature', - CLOSE: 'disconnecting', + CANCEL: { target: 'selectingWallet', actions: 'resetAttemptContext' }, + CLOSE: { target: 'closed', actions: 'resetAttemptContext' }, }, }, authenticating: { @@ -262,6 +270,9 @@ export const walletModalMachine = createMachine( actions: 'setVerificationError', }, }, + on: { + CLOSE: { target: 'closed', actions: 'resetAttemptContext' }, + }, }, authenticated: { entry: ['notifyAuthComplete', () => debugWallet('State: authenticated')], @@ -296,6 +307,32 @@ export const walletModalMachine = createMachine( return [] }, }), + resetContext: assign({ + selectedWalletId: null, + address: null, + connector: null, + safeInfo: null, + pendingSafeInfo: null, + pendingSafeAddress: null, + pendingSafeChainId: null, + message: null, + signature: null, + error: null, + isSafeMode: false, + }), + resetAttemptContext: assign({ + selectedWalletId: null, + address: null, + connector: null, + safeInfo: null, + pendingSafeInfo: null, + pendingSafeAddress: null, + pendingSafeChainId: null, + message: null, + signature: null, + error: null, + isSafeMode: false, + }), setSelectedWallet: assign({ selectedWalletId: ({ event }) => { if (event.type === 'SELECT_WALLET') { @@ -464,7 +501,7 @@ export const walletModalMachine = createMachine( void, { safeInfo: SafeInfo; eoaConnectorId: string } >(async ({ input }) => { - // Save Safe info to localStorage + // Save Safe info before the Safe connector switch begins. saveSafeInfo(input.safeInfo, input.eoaConnectorId) // Actual connector switch handled by component }), diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index df0140eb3..950605ca7 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -57,6 +57,7 @@ import { sessionMachine } from 'src/machines/sessionMachine' import { AppThemeProvider } from 'src/theme/AppThemeProvider' import { clientConfig } from 'src/utils/clientConfig' import { debugSession } from 'src/utils/debug' +import { resolveSigningAddress } from 'src/utils/signing' import { beginSiweLogout, cancelSiweAuthFlow, @@ -71,7 +72,7 @@ import { } from 'src/utils/siweAuthFlow' import { SWRConfig } from 'swr' import { createSiweMessage } from 'viem/siwe' -import { useConfig, WagmiProvider } from 'wagmi' +import { useAccount, useConfig, WagmiProvider } from 'wagmi' // Cross-tab storage event debounce interval const CROSS_TAB_DEBOUNCE_MS = 100 @@ -102,6 +103,7 @@ function AppContent({ Component, pageProps, err }: AppPropsWithLayout) { const { state: safeState, clearSafe } = useSafeAuth() const resetApp = useAppDisconnect() const config = useConfig() + const { connector } = useAccount() const logoutInProgress = isSiweLogoutInProgress() // Use ref to always get latest safeState in auth adapter callbacks @@ -227,12 +229,13 @@ function AppContent({ Component, pageProps, err }: AppPropsWithLayout) { return nonce }, - createMessage: ({ nonce, address, chainId: msgChainId }) => { + createMessage: async ({ nonce, address, chainId: msgChainId }) => { // Use ref to get latest safeState (prevents stale closure) const currentSafeState = safeStateRef.current - const message = createSiweMessage({ + const signingAddress = await resolveSigningAddress(connector, address) + return createSiweMessage({ domain: window.location.host, - address, + address: signingAddress, statement: currentSafeState.safeAddress ? `Sign in as owner for Safe ${currentSafeState.safeAddress}` : 'Sign in with Ethereum to Nouns Builder', @@ -241,7 +244,6 @@ function AppContent({ Component, pageProps, err }: AppPropsWithLayout) { chainId: msgChainId, nonce, }) - return message }, verify: async ({ message, signature }) => { @@ -289,7 +291,7 @@ function AppContent({ Component, pageProps, err }: AppPropsWithLayout) { cancelSiweAuthFlow() }, }), - [sendSession, clearSafe, refresh] + [sendSession, clearSafe, refresh, connector] ) // RainbowKit auth status derived from XState machine diff --git a/apps/web/src/pages/api/safe/propose.ts b/apps/web/src/pages/api/safe/propose.ts index 88d305d06..0da2b158d 100644 --- a/apps/web/src/pages/api/safe/propose.ts +++ b/apps/web/src/pages/api/safe/propose.ts @@ -23,6 +23,47 @@ interface ProposalRequest { senderSignature: string } +type ProposalErrorCode = + | 'SAFE_SERVICE_REJECTED' + | 'SAFE_SERVICE_UNAVAILABLE' + | 'SAFE_SERVICE_CONFIG_ERROR' + +function getUpstreamError(error: unknown): { code: ProposalErrorCode; message: string } { + const status = + typeof error === 'object' && error !== null && 'response' in error + ? (error.response as { status?: number } | undefined)?.status + : undefined + + if (status === 401 || status === 403) { + return { + code: 'SAFE_SERVICE_CONFIG_ERROR', + message: 'Safe Service is not configured correctly. Please try again later.', + } + } + + if (status === 408 || status === 429 || !status || status >= 500) { + return { + code: 'SAFE_SERVICE_UNAVAILABLE', + message: + 'Safe Service is temporarily unavailable. Check your connection and try again.', + } + } + + if (status && status >= 400 && status < 500) { + return { + code: 'SAFE_SERVICE_REJECTED', + message: + 'Safe Service rejected this proposal. Check the transaction details and try again.', + } + } + + return { + code: 'SAFE_SERVICE_UNAVAILABLE', + message: + 'Safe Service is temporarily unavailable. Check your connection and try again.', + } +} + export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { res.setHeader('Allow', 'POST') @@ -69,8 +110,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(200).json({ safeTxHash: body.safeTxHash }) } catch (error) { console.error('Failed to propose transaction to Safe Service', error) - return res - .status(502) - .json({ error: 'Failed to propose transaction to Safe Service' }) + const upstreamError = getUpstreamError(error) + const statusCode = + upstreamError.code === 'SAFE_SERVICE_REJECTED' + ? 422 + : upstreamError.code === 'SAFE_SERVICE_CONFIG_ERROR' + ? 500 + : 502 + return res.status(statusCode).json({ + code: upstreamError.code, + error: upstreamError.message, + }) } } diff --git a/apps/web/src/pages/api/siwe/nonce.ts b/apps/web/src/pages/api/siwe/nonce.ts index 7fdadcd49..b35d81d0d 100644 --- a/apps/web/src/pages/api/siwe/nonce.ts +++ b/apps/web/src/pages/api/siwe/nonce.ts @@ -5,6 +5,10 @@ import { ironOptions, type IronSessionData } from 'src/utils/iron' import { SIWE_NONCE_RATE_LIMIT_KEY_PREFIX } from 'src/utils/siweAuthFlow' import { generateSiweNonce } from 'viem/siwe' +if (!process.env.IRON_PASSWORD) { + throw new Error('IRON_PASSWORD environment variable is required') +} + const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { method } = req switch (method) { diff --git a/apps/web/src/pages/api/siwe/verify.ts b/apps/web/src/pages/api/siwe/verify.ts index 6baf827eb..261cec997 100644 --- a/apps/web/src/pages/api/siwe/verify.ts +++ b/apps/web/src/pages/api/siwe/verify.ts @@ -55,9 +55,13 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { session.siwe = siweMessage } else { // Normal EOA authentication + delete session.eoaAddress + delete session.safeAddress + delete session.safeChainId session.siwe = siweMessage } + delete session.nonce await session.save() res.json({ ok: true }) } catch (error) { diff --git a/apps/web/src/types/auth.ts b/apps/web/src/types/auth.ts index d5bc52fce..b23c31d5e 100644 --- a/apps/web/src/types/auth.ts +++ b/apps/web/src/types/auth.ts @@ -26,6 +26,7 @@ export interface SafeInfo { threshold: number owners: Address[] isReadOnly: boolean + eoaAddress?: Address nonce?: number version?: string } @@ -36,5 +37,6 @@ export interface WalletInfo { name: string iconUrl?: string isRainbowKitConnector: boolean + recent?: boolean connector?: Connector } diff --git a/apps/web/src/utils/signing.test.ts b/apps/web/src/utils/signing.test.ts new file mode 100644 index 000000000..abb1e7413 --- /dev/null +++ b/apps/web/src/utils/signing.test.ts @@ -0,0 +1,78 @@ +import { setSafeInfo } from '@buildeross/utils' +import type { Address } from 'viem' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { resolveSigningAddress } from './signing' + +function createStorage() { + const store = new Map() + + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + } +} + +describe('resolveSigningAddress', () => { + beforeEach(() => { + const storage = createStorage() + vi.stubGlobal('window', { localStorage: storage }) + vi.stubGlobal('localStorage', storage) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('prefers the saved Safe EOA address when the cache is empty', async () => { + const fallback = '0x0000000000000000000000000000000000000001' as Address + const eoa = '0x0000000000000000000000000000000000000002' as Address + + setSafeInfo( + { + safeAddress: '0x0000000000000000000000000000000000000003' as Address, + chainId: 1, + threshold: 1, + owners: [eoa], + isReadOnly: false, + }, + 'injected', + eoa + ) + + const connector = { + id: 'safeOwner', + cachedEOAAddress: null, + getEOAAddress: vi.fn().mockResolvedValue(null), + } as any + + await expect(resolveSigningAddress(connector, fallback)).resolves.toBe(eoa) + }) + + it('returns the fallback address for normal wallets', async () => { + const fallback = '0x0000000000000000000000000000000000000001' as Address + + await expect( + resolveSigningAddress({ id: 'injected' } as any, fallback) + ).resolves.toBe(fallback) + }) + + it('prefers the cached EOA for Safe wallets', async () => { + const fallback = '0x0000000000000000000000000000000000000001' as Address + const eoa = '0x0000000000000000000000000000000000000002' as Address + + const connector = { + id: 'safeOwner', + cachedEOAAddress: eoa, + getEOAAddress: vi.fn(), + } as any + + await expect(resolveSigningAddress(connector, fallback)).resolves.toBe(eoa) + expect(connector.getEOAAddress).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/utils/signing.ts b/apps/web/src/utils/signing.ts new file mode 100644 index 000000000..f26f66b5e --- /dev/null +++ b/apps/web/src/utils/signing.ts @@ -0,0 +1,43 @@ +import { getSavedSafeInfo } from '@buildeross/utils' +import type { Address } from 'viem' +import type { Connector } from 'wagmi' + +import type { SafeOwnerConnectorType } from '../types/connectors' + +export async function resolveSigningAddress( + connector: Connector | null | undefined, + fallbackAddress: Address +): Promise
{ + if (connector?.id !== 'safeOwner') { + return fallbackAddress + } + + const safeConnector = connector as SafeOwnerConnectorType + if (safeConnector.cachedEOAAddress) { + return safeConnector.cachedEOAAddress + } + + const eoaAddress = await safeConnector.getEOAAddress() + if (eoaAddress) { + return eoaAddress + } + + const savedEOAAddress = getSavedSafeInfo()?.eoaAddress + if (savedEOAAddress) { + return savedEOAAddress + } + + throw new Error('Safe EOA address not available') +} + +export function getCachedSigningAddress( + connector: Connector | null | undefined, + fallbackAddress: Address +): Address | null { + if (connector?.id !== 'safeOwner') { + return fallbackAddress + } + + const safeConnector = connector as SafeOwnerConnectorType + return safeConnector.cachedEOAAddress ?? getSavedSafeInfo()?.eoaAddress ?? null +} diff --git a/packages/ui/src/Modal/SafeTransactionModal.tsx b/packages/ui/src/Modal/SafeTransactionModal.tsx index e083c3f6d..bb91d763b 100644 --- a/packages/ui/src/Modal/SafeTransactionModal.tsx +++ b/packages/ui/src/Modal/SafeTransactionModal.tsx @@ -100,7 +100,7 @@ export function SafeTransactionModal({ borderRadius="curved" backgroundColor="background2" > - + {transactions.length > 1 ? `${index + 1}. ` : ''}To:{' '} {truncateAddress(transaction.to)} {transaction.data && transaction.data !== '0x' @@ -193,7 +193,7 @@ export function SafeTransactionModal({ () + + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + } +} + +describe('recentSafeWallets', () => { + beforeEach(() => { + vi.stubGlobal('window', { localStorage: createStorage() }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('dedupes by safe address and chain and keeps the most recent entries', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')) + + addRecentSafeWallet('0x0000000000000000000000000000000000000001', 1) + vi.setSystemTime(new Date('2026-01-01T00:01:00.000Z')) + addRecentSafeWallet('0x0000000000000000000000000000000000000002', 8453) + vi.setSystemTime(new Date('2026-01-01T00:02:00.000Z')) + addRecentSafeWallet('0x0000000000000000000000000000000000000001', 1) + vi.setSystemTime(new Date('2026-01-01T00:03:00.000Z')) + addRecentSafeWallet('0x0000000000000000000000000000000000000003', 10) + vi.setSystemTime(new Date('2026-01-01T00:04:00.000Z')) + addRecentSafeWallet('0x0000000000000000000000000000000000000004', 137) + + const recent = getRecentSafeWallets() + + expect(recent).toHaveLength(3) + expect(recent.map((wallet) => wallet.safeAddress)).toEqual([ + '0x0000000000000000000000000000000000000004', + '0x0000000000000000000000000000000000000003', + '0x0000000000000000000000000000000000000001', + ]) + + vi.useRealTimers() + }) + + it('ignores invalid stored data', () => { + window.localStorage.setItem( + 'recent-safe-wallets', + JSON.stringify([{ safeAddress: 'not-an-address', chainId: 1, timestamp: 1 }]) + ) + + expect(getRecentSafeWallets()).toEqual([]) + }) +}) diff --git a/packages/utils/src/recentSafeWallets.ts b/packages/utils/src/recentSafeWallets.ts new file mode 100644 index 000000000..6f1cfd467 --- /dev/null +++ b/packages/utils/src/recentSafeWallets.ts @@ -0,0 +1,95 @@ +import type { Address } from 'viem' +import { getAddress, isAddress } from 'viem' + +const STORAGE_KEY = 'recent-safe-wallets' +const MAX_RECENT_SAFE_WALLETS = 3 + +export interface RecentSafeWallet { + safeAddress: Address + chainId: number + timestamp: number +} + +const getLocalStorage = () => + typeof window === 'undefined' ? undefined : window.localStorage + +const getStorageKey = (safeAddress: Address, chainId: number) => + `${safeAddress.toLowerCase()}:${chainId}` + +const isRecentSafeWallet = (value: unknown): value is RecentSafeWallet => { + if (!value || typeof value !== 'object') return false + + const candidate = value as Partial + + return ( + typeof candidate.safeAddress === 'string' && + isAddress(candidate.safeAddress) && + typeof candidate.chainId === 'number' && + Number.isInteger(candidate.chainId) && + typeof candidate.timestamp === 'number' && + Number.isFinite(candidate.timestamp) + ) +} + +const dedupeRecentSafeWallets = (wallets: RecentSafeWallet[]) => { + const seen = new Set() + + return wallets.filter((wallet) => { + const key = getStorageKey(wallet.safeAddress, wallet.chainId) + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +export function getRecentSafeWallets(): RecentSafeWallet[] { + const storage = getLocalStorage() + if (!storage) return [] + + try { + const stored = storage.getItem(STORAGE_KEY) + if (!stored) return [] + + const parsed = JSON.parse(stored) as unknown + if (!Array.isArray(parsed)) return [] + + return dedupeRecentSafeWallets( + parsed + .filter(isRecentSafeWallet) + .map((wallet) => ({ + safeAddress: getAddress(wallet.safeAddress), + chainId: wallet.chainId, + timestamp: wallet.timestamp, + })) + .sort((a, b) => b.timestamp - a.timestamp) + ).slice(0, MAX_RECENT_SAFE_WALLETS) + } catch { + return [] + } +} + +export function addRecentSafeWallet(safeAddress: Address, chainId: number): void { + const storage = getLocalStorage() + if (!storage || !isAddress(safeAddress) || !Number.isInteger(chainId)) return + + const nextWallet: RecentSafeWallet = { + safeAddress: getAddress(safeAddress), + chainId, + timestamp: Date.now(), + } + + const nextWallets = [ + nextWallet, + ...getRecentSafeWallets().filter( + (wallet) => + getStorageKey(wallet.safeAddress, wallet.chainId) !== + getStorageKey(nextWallet.safeAddress, nextWallet.chainId) + ), + ].slice(0, MAX_RECENT_SAFE_WALLETS) + + try { + storage.setItem(STORAGE_KEY, JSON.stringify(nextWallets)) + } catch { + // Ignore storage failures. + } +} diff --git a/packages/utils/src/safe/errors.test.ts b/packages/utils/src/safe/errors.test.ts new file mode 100644 index 000000000..5fdc7462b --- /dev/null +++ b/packages/utils/src/safe/errors.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { + getSafeErrorMessage, + SafeTransactionError, + SafeTransactionErrorCode, +} from './errors' + +describe('Safe transaction errors', () => { + it('returns a retryable message for unavailable Safe Service', () => { + expect( + getSafeErrorMessage( + new SafeTransactionError( + 'internal details', + SafeTransactionErrorCode.API_UNAVAILABLE + ) + ) + ).toBe( + 'Safe Service is temporarily unavailable. Check your connection and try again.' + ) + }) + + it('uses the safe rejection message without exposing unrelated errors', () => { + expect( + getSafeErrorMessage( + new SafeTransactionError( + 'Safe Service rejected this proposal. Check the transaction details and try again.', + SafeTransactionErrorCode.API_REJECTED + ) + ) + ).toBe( + 'Safe Service rejected this proposal. Check the transaction details and try again.' + ) + }) +}) diff --git a/packages/utils/src/safe/errors.ts b/packages/utils/src/safe/errors.ts index 528e2d7b3..0394104de 100644 --- a/packages/utils/src/safe/errors.ts +++ b/packages/utils/src/safe/errors.ts @@ -16,6 +16,12 @@ export enum SafeTransactionErrorCode { NOT_SAFE_OWNER = 'NOT_SAFE_OWNER', /** Safe Service API error */ API_ERROR = 'API_ERROR', + /** Safe Service rejected the proposal */ + API_REJECTED = 'API_REJECTED', + /** Safe Service could not be reached */ + API_UNAVAILABLE = 'API_UNAVAILABLE', + /** Safe Service credentials or configuration are invalid */ + API_CONFIG_ERROR = 'API_CONFIG_ERROR', /** Transaction handler not initialized */ HANDLER_NOT_INITIALIZED = 'HANDLER_NOT_INITIALIZED', /** Another transaction is already in progress */ @@ -76,7 +82,13 @@ export function getSafeErrorMessage(error: unknown): string { case SafeTransactionErrorCode.NOT_SAFE_OWNER: return 'Your connected wallet is not an owner of this Safe.' case SafeTransactionErrorCode.API_ERROR: - return 'Unable to connect to Safe Service. Please try again.' + return 'Safe Service returned an unexpected error. Please try again.' + case SafeTransactionErrorCode.API_REJECTED: + return error.message || 'Safe Service rejected this proposal.' + case SafeTransactionErrorCode.API_UNAVAILABLE: + return 'Safe Service is temporarily unavailable. Check your connection and try again.' + case SafeTransactionErrorCode.API_CONFIG_ERROR: + return 'Safe Service is not configured correctly. Please try again later.' case SafeTransactionErrorCode.HANDLER_NOT_INITIALIZED: return 'Safe transaction handler not initialized. Please refresh the page.' case SafeTransactionErrorCode.TRANSACTION_IN_PROGRESS: diff --git a/packages/utils/src/safe/proposeTransaction.ts b/packages/utils/src/safe/proposeTransaction.ts index 883ba3e94..f7331f372 100644 --- a/packages/utils/src/safe/proposeTransaction.ts +++ b/packages/utils/src/safe/proposeTransaction.ts @@ -141,7 +141,7 @@ export async function proposeSafeTransaction( // Build Safe transaction with checksummed address const safeTransactions: MetaTransactionData[] = transactions.map((tx) => ({ to: getAddress(tx.to), - value: tx.value?.toString() || '0', + value: tx.value ? BigInt(tx.value).toString(10) : '0', data: tx.data || '0x', })) debugSafeTx('Creating Safe transaction...') @@ -181,17 +181,27 @@ export async function proposeSafeTransaction( }), }) if (!response.ok) { - const data = (await response.json().catch(() => null)) as { error?: string } | null - throw new Error( - data?.error || `Safe proposal failed with status ${response.status}` + const data = (await response.json().catch(() => null)) as { + code?: string + error?: string + } | null + const error = new SafeTransactionError( + data?.error || 'Safe proposal failed. Please try again.', + data?.code === 'SAFE_SERVICE_REJECTED' + ? SafeTransactionErrorCode.API_REJECTED + : data?.code === 'SAFE_SERVICE_CONFIG_ERROR' + ? SafeTransactionErrorCode.API_CONFIG_ERROR + : SafeTransactionErrorCode.API_UNAVAILABLE ) + throw error } debugSafeTx('Transaction successfully proposed to Safe Service') } catch (error) { debugSafeTx('ERROR: Failed to propose transaction to Safe Service: %O', error) + if (error instanceof SafeTransactionError) throw error throw new SafeTransactionError( - `Failed to propose transaction to Safe Service: ${error instanceof Error ? error.message : 'Unknown error'}`, - SafeTransactionErrorCode.API_ERROR + 'Safe Service is temporarily unavailable. Check your connection and try again.', + SafeTransactionErrorCode.API_UNAVAILABLE ) } @@ -294,7 +304,7 @@ export async function executeSafeTransaction( // Build Safe transaction with checksummed address const safeTransactions: MetaTransactionData[] = transactions.map((tx) => ({ to: getAddress(tx.to), - value: tx.value?.toString() || '0', + value: tx.value ? BigInt(tx.value).toString(10) : '0', data: tx.data || '0x', })) debugSafeTx('Creating Safe transaction...') diff --git a/packages/utils/src/safeStorage.test.ts b/packages/utils/src/safeStorage.test.ts new file mode 100644 index 000000000..b4cbf5058 --- /dev/null +++ b/packages/utils/src/safeStorage.test.ts @@ -0,0 +1,68 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { clearSafeInfo, getSavedSafeInfo, setSafeInfo } from './safeStorage' + +function createStorage() { + const store = new Map() + + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value) + }, + removeItem: (key: string) => { + store.delete(key) + }, + } +} + +describe('safeStorage', () => { + beforeEach(() => { + const storage = createStorage() + vi.stubGlobal('window', { localStorage: storage }) + vi.stubGlobal('localStorage', storage) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('persists the Safe EOA address alongside safe info', () => { + setSafeInfo( + { + safeAddress: '0x0000000000000000000000000000000000000001' as any, + chainId: 1, + threshold: 1, + owners: ['0x0000000000000000000000000000000000000002' as any], + isReadOnly: false, + }, + 'injected', + '0x0000000000000000000000000000000000000003' as any + ) + + expect(getSavedSafeInfo()).toMatchObject({ + safeAddress: '0x0000000000000000000000000000000000000001', + chainId: 1, + eoaConnectorId: 'injected', + eoaAddress: '0x0000000000000000000000000000000000000003', + }) + }) + + it('clears saved safe info', () => { + setSafeInfo( + { + safeAddress: '0x0000000000000000000000000000000000000001' as any, + chainId: 1, + threshold: 1, + owners: ['0x0000000000000000000000000000000000000002' as any], + isReadOnly: false, + }, + 'injected', + '0x0000000000000000000000000000000000000003' as any + ) + + clearSafeInfo() + + expect(getSavedSafeInfo()).toBeNull() + }) +}) diff --git a/packages/utils/src/safeStorage.ts b/packages/utils/src/safeStorage.ts index 8a4f0412f..bfe97e3dc 100644 --- a/packages/utils/src/safeStorage.ts +++ b/packages/utils/src/safeStorage.ts @@ -1,4 +1,5 @@ import type { Address } from 'viem' +import { getAddress, isAddress } from 'viem' import type { SafeInfo } from './providers/types' @@ -8,6 +9,7 @@ export interface SavedSafeInfo { safeAddress: Address chainId: number eoaConnectorId: string + eoaAddress?: Address threshold: number owners: Address[] nonce?: number @@ -25,8 +27,32 @@ export function getSavedSafeInfo(): SavedSafeInfo | null { const stored = localStorage.getItem(STORAGE_KEY) if (!stored) return null - const parsed: SavedSafeInfo = JSON.parse(stored) - return parsed + const parsed = JSON.parse(stored) as Partial + const safeAddress = parsed.safeAddress + const owners = parsed.owners + if ( + typeof safeAddress !== 'string' || + !isAddress(safeAddress) || + typeof parsed.chainId !== 'number' || + !Number.isInteger(parsed.chainId) || + typeof parsed.eoaConnectorId !== 'string' || + !parsed.eoaConnectorId || + (parsed.eoaAddress !== undefined && + (typeof parsed.eoaAddress !== 'string' || !isAddress(parsed.eoaAddress))) || + typeof parsed.threshold !== 'number' || + !Array.isArray(owners) || + !owners.every((owner) => typeof owner === 'string' && isAddress(owner)) || + typeof parsed.timestamp !== 'number' + ) { + localStorage.removeItem(STORAGE_KEY) + return null + } + return { + ...parsed, + safeAddress: getAddress(safeAddress), + eoaAddress: parsed.eoaAddress ? getAddress(parsed.eoaAddress) : undefined, + owners: owners.map((owner) => getAddress(owner as string)), + } as SavedSafeInfo } catch (error) { console.error('Error reading saved Safe info:', error) return null @@ -36,7 +62,11 @@ export function getSavedSafeInfo(): SavedSafeInfo | null { /** * Save Safe information to localStorage */ -export function setSafeInfo(safeInfo: SafeInfo, eoaConnectorId: string): void { +export function setSafeInfo( + safeInfo: SafeInfo, + eoaConnectorId: string, + eoaAddress?: Address | null +): void { if (typeof window === 'undefined') return try { @@ -44,6 +74,8 @@ export function setSafeInfo(safeInfo: SafeInfo, eoaConnectorId: string): void { safeAddress: safeInfo.safeAddress, chainId: safeInfo.chainId, eoaConnectorId, + eoaAddress: + eoaAddress && isAddress(eoaAddress) ? getAddress(eoaAddress) : undefined, threshold: safeInfo.threshold, owners: safeInfo.owners, nonce: safeInfo.nonce, From 2fe17b7f96e4282b8cbd95a3a1a97cd22407b9e1 Mon Sep 17 00:00:00 2001 From: dan13ram Date: Wed, 9 Sep 2026 11:43:17 +0530 Subject: [PATCH 6/6] fix: add session security hardening for iron-session --- apps/web/src/utils/iron.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/utils/iron.ts b/apps/web/src/utils/iron.ts index 5f75b53d0..5ee6c8489 100644 --- a/apps/web/src/utils/iron.ts +++ b/apps/web/src/utils/iron.ts @@ -2,12 +2,33 @@ import type { SessionOptions } from 'iron-session' import type { Address } from 'viem' import type { SiweMessage } from 'viem/siwe' +// Validate IRON_PASSWORD at module load time +const IRON_PASSWORD = process.env.IRON_PASSWORD + +if (!IRON_PASSWORD) { + throw new Error( + 'IRON_PASSWORD environment variable is required for session encryption. ' + + 'Generate one with: openssl rand -base64 32' + ) +} + +if (IRON_PASSWORD.length < 32) { + throw new Error( + 'IRON_PASSWORD must be at least 32 characters long for secure encryption. ' + + 'Current length: ' + + IRON_PASSWORD.length + ) +} + export const ironOptions: SessionOptions = { cookieName: 'siwe', cookieOptions: { secure: process.env.NODE_ENV === 'production', + httpOnly: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7, // 7 days }, - password: process.env.IRON_PASSWORD as string, + password: IRON_PASSWORD, } export interface IronSessionData {