Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
652 changes: 0 additions & 652 deletions docs/TANSTACK_QUERY_OPPORTUNITIES.md

This file was deleted.

7 changes: 4 additions & 3 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHisto
import { loadingStateContext } from '@/context'
import { getCurrencyPrice } from '@/app/actions/currency'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
import { captureException } from '@sentry/nextjs'
import { isPaymentProcessorQR, parseSimpleFiQr, EQrType } from '@/components/Global/DirectSendQR/utils'
import type { SimpleFiQrData } from '@/components/Global/DirectSendQR/utils'
Expand Down Expand Up @@ -75,6 +76,7 @@ export default function QRPayPage() {
const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext)
const { shouldBlockPay, kycGateState } = useQrKycGate()
const queryClient = useQueryClient()
const { hasPendingTransactions } = usePendingTransactions()
const [isShaking, setIsShaking] = useState(false)
const [shakeIntensity, setShakeIntensity] = useState<ShakeIntensity>('none')
const [isClaimingPerk, setIsClaimingPerk] = useState(false)
Expand Down Expand Up @@ -715,8 +717,7 @@ export default function QRPayPage() {
// Check user balance
useEffect(() => {
// Skip balance check if transaction is being processed
// (balance has been optimistically updated in these states)
if (isLoading || isWaitingForWebSocket) {
if (hasPendingTransactions || isWaitingForWebSocket) {
return
}

Expand All @@ -732,7 +733,7 @@ export default function QRPayPage() {
} else {
setBalanceErrorMessage(null)
}
}, [usdAmount, balance, isLoading, isWaitingForWebSocket])
}, [usdAmount, balance, hasPendingTransactions, isWaitingForWebSocket])

useEffect(() => {
if (isSuccess) {
Expand Down
31 changes: 28 additions & 3 deletions src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import ErrorAlert from '@/components/Global/ErrorAlert'
import NavHeader from '@/components/Global/NavHeader'
import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants'
import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { useWallet } from '@/hooks/wallet/useWallet'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
import { AccountType, type Account } from '@/interfaces'
import { formatIban, shortenStringLong, isTxReverted } from '@/utils/general.utils'
import { useParams, useRouter } from 'next/navigation'
Expand All @@ -25,6 +26,7 @@ import { useQuery } from '@tanstack/react-query'
import { pointsApi } from '@/services/points'
import { PointsAction } from '@/services/services.types'
import { useSearchParams } from 'next/navigation'
import { parseUnits } from 'viem'

type View = 'INITIAL' | 'SUCCESS'

Expand All @@ -38,13 +40,15 @@ export default function WithdrawBankPage() {
setSelectedMethod,
} = useWithdrawFlow()
const { user, fetchUser } = useAuth()
const { address, sendMoney } = useWallet()
const { address, sendMoney, balance } = useWallet()
const router = useRouter()
const searchParams = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const [view, setView] = useState<View>('INITIAL')
const params = useParams()
const country = params.country as string
const [balanceErrorMessage, setBalanceErrorMessage] = useState<string | null>(null)
const { hasPendingTransactions } = usePendingTransactions()

// check if we came from send flow - using method param to detect (only bank goes through this page)
const methodParam = searchParams.get('method')
Expand Down Expand Up @@ -228,6 +232,26 @@ export default function WithdrawBankPage() {
fetchUser()
}, [])

// Balance validation
useEffect(() => {
// Skip balance check if transaction is pending
if (hasPendingTransactions) {
return
}

if (!amountToWithdraw || amountToWithdraw === '0' || isNaN(Number(amountToWithdraw)) || balance === undefined) {
setBalanceErrorMessage(null)
return
}

const withdrawAmount = parseUnits(amountToWithdraw, PEANUT_WALLET_TOKEN_DECIMALS)
if (withdrawAmount > balance) {
setBalanceErrorMessage('Not enough balance to complete withdrawal.')
} else {
setBalanceErrorMessage(null)
}
}, [amountToWithdraw, balance, hasPendingTransactions])

if (!bankAccount) {
return null
}
Expand Down Expand Up @@ -324,13 +348,14 @@ export default function WithdrawBankPage() {
iconSize={12}
shadowSize="4"
onClick={handleCreateAndInitiateOfframp}
disabled={isLoading || !bankAccount || isNonEuroSepaCountry}
disabled={isLoading || !bankAccount || isNonEuroSepaCountry || !!balanceErrorMessage}
className="w-full"
>
{isNonEuroSepaCountry ? 'Temporarily Unavailable' : 'Withdraw'}
</Button>
)}
{error.showError && <ErrorAlert description={error.errorMessage} />}
{balanceErrorMessage && <ErrorAlert description={balanceErrorMessage} />}
</div>
)}

Expand Down
8 changes: 5 additions & 3 deletions src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { SoundPlayer } from '@/components/Global/SoundPlayer'
import { useQueryClient } from '@tanstack/react-query'
import { captureException } from '@sentry/nextjs'
import useKycStatus from '@/hooks/useKycStatus'
import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'

type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure'

Expand Down Expand Up @@ -68,6 +69,7 @@ export default function MantecaWithdrawFlow() {
const { setIsSupportModalOpen } = useSupportModalContext()
const queryClient = useQueryClient()
const { isUserBridgeKycApproved } = useKycStatus()
const { hasPendingTransactions } = usePendingTransactions()

// Get method and country from URL parameters
const selectedMethodType = searchParams.get('method') // mercadopago, pix, bank-transfer, etc.
Expand Down Expand Up @@ -267,8 +269,8 @@ export default function MantecaWithdrawFlow() {

useEffect(() => {
// Skip balance check if transaction is being processed
// (balance has been optimistically updated in these states)
if (isLoading) {
// Use hasPendingTransactions to prevent race condition with optimistic updates
if (hasPendingTransactions) {
return
}

Expand All @@ -286,7 +288,7 @@ export default function MantecaWithdrawFlow() {
} else {
setBalanceErrorMessage(null)
}
}, [usdAmount, balance, isLoading])
}, [usdAmount, balance, hasPendingTransactions])

useEffect(() => {
if (step === 'success') {
Expand Down
6 changes: 6 additions & 0 deletions src/components/Create/useCreateLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ export const useCreateLink = () => {
}
}

// @dev TODO: Fix edge case - balance validation should also check loadingState.isLoading
// Current: NOT tracked by usePendingTransactions + validation doesn't check isLoading
// Edge case: If user rapidly creates links, insufficient balance error could briefly show
// Fix: Add isLoading check to Initial.link.send.view.tsx useEffect (line 98)
// Better: Wrap in useMutation with mutationKey: [BALANCE_DECREASE, SEND_LINK]
// Priority: Low (rare edge case in less common flow)
const createLink = useCallback(
async (amount: bigint) => {
setLoadingState('Generating details')
Expand Down
65 changes: 17 additions & 48 deletions src/components/Global/TokenSelector/TokenSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
TOKEN_SELECTOR_POPULAR_NETWORK_IDS,
TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS,
} from './TokenSelector.consts'
import { fetchWalletBalances } from '@/app/actions/tokens'
import { useWalletBalances } from '@/hooks/useWalletBalances'
import { Drawer, DrawerContent, DrawerTitle } from '../Drawer'

interface SectionProps {
Expand Down Expand Up @@ -62,12 +62,12 @@ const TokenSelector: React.FC<NewTokenSelectorProps> = ({ classNameButton, viewT
const { open: openAppkitModal } = useAppKit()
const { disconnect: disconnectWallet } = useDisconnect()
const { isConnected: isExternalWalletConnected, address: externalWalletAddress } = useAppKitAccount()
// external wallet balance states
const [externalBalances, setExternalBalances] = useState<IUserBalance[] | null>(null)
const [isLoadingExternalBalances, setIsLoadingExternalBalances] = useState(false)
// refs to track previous state for useEffect logic
const prevIsExternalConnected = useRef(isExternalWalletConnected)
const prevExternalAddress = useRef<string | null>(externalWalletAddress ?? null)

// Fetch external wallet balances using TanStack Query (replaces manual useEffect + refs + state)
const { data: externalBalances = [], isLoading: isLoadingExternalBalances } = useWalletBalances(
isExternalWalletConnected ? externalWalletAddress : undefined
)

// state for image loading errors
const [buttonImageError, setButtonImageError] = useState(false)
const {
Expand All @@ -86,50 +86,19 @@ const TokenSelector: React.FC<NewTokenSelectorProps> = ({ classNameButton, viewT
setTimeout(() => setSearchValue(''), 200)
}, [])

// external wallet balance fetching
useEffect(() => {
// this effect fetches balances when an external wallet connects,
// refetches when the address changes while connected,
// and clears them when it disconnects.
if (isExternalWalletConnected && externalWalletAddress) {
// wallet is connected with an address.
const justConnected = !prevIsExternalConnected.current
const addressChanged = externalWalletAddress !== prevExternalAddress.current
if (justConnected || addressChanged || externalBalances === null) {
// Fetch only if balances are null, not just empty array to prevent loops on 0 balance
setIsLoadingExternalBalances(true)
fetchWalletBalances(externalWalletAddress)
.then((balances) => {
setExternalBalances(balances.balances || [])
})
.catch((error) => {
console.error('Manual balance fetch failed:', error)
setExternalBalances([])
})
.finally(() => {
setIsLoadingExternalBalances(false)
})
}
} else {
// wallet is not connected
if (prevIsExternalConnected.current) {
// wallet was previously connected, now it's not: clear balances.
setExternalBalances(null)
setIsLoadingExternalBalances(false)
}
// else: wallet was already disconnected - do nothing.
}

// update refs for the next render
prevIsExternalConnected.current = isExternalWalletConnected
prevExternalAddress.current = externalWalletAddress ?? null
}, [isExternalWalletConnected, externalWalletAddress])
// Note: external wallet balance fetching is now handled by useWalletBalances hook
// It automatically:
// - Fetches when wallet connects (enabled guard)
// - Refetches when address changes (queryKey includes address)
// - Clears when wallet disconnects (enabled becomes false)
// - Auto-refreshes every 60 seconds
// No manual refs or state management needed!

const sourceBalances = useMemo(() => {
if (isExternalWalletConnected && externalBalances !== null) {
return externalBalances
if (isExternalWalletConnected) {
return externalBalances // Direct from query (auto-handles all cases)
} else {
return [] // return empty array if no source is available
return [] // return empty array if wallet not connected
}
}, [isExternalWalletConnected, externalBalances])

Expand Down
Loading
Loading