diff --git a/app/components/trade/DepositTrigger.tsx b/app/components/trade/DepositTrigger.tsx index d53df779..281a8434 100644 --- a/app/components/trade/DepositTrigger.tsx +++ b/app/components/trade/DepositTrigger.tsx @@ -5,6 +5,7 @@ import { useWalletCompat } from "@/hooks/useWalletCompat"; import { useUserAccount } from "@/hooks/useUserAccount"; import { useSlabState } from "@/components/providers/SlabProvider"; import { useTokenMeta } from "@/hooks/useTokenMeta"; +import { useWalletAtaBalance } from "@/hooks/useWalletAtaBalance"; import { formatTokenAmount } from "@/lib/format"; import { DepositWithdrawCard } from "./DepositWithdrawCard"; import { isMockMode } from "@/lib/mock-mode"; @@ -31,6 +32,23 @@ export const DepositTrigger: FC<{ slabAddress: string }> = ({ slabAddress }) => const capital = userAccount?.account.capital ?? 0n; const prevCapitalRef = useRef(capital); + // The bar at the top of the trade panel now shows the user's PHANTOM + // (or whichever wallet) USDC balance — what they have available to + // deposit — NOT what's already deposited into this market. Account + // balance (capital) is shown alongside the order-form Size row as + // "Account Bal:" instead. + // + // Pass `capital` as the refresh trigger so a deposit (which decreases + // wallet ATA balance and increases capital) re-fetches the wallet + // balance. Without this the bar would keep showing the pre-deposit + // number until the user reloaded the page. + const { balance: walletBalance, decimals: walletDecimals } = useWalletAtaBalance( + config?.collateralMint ?? null, + capital, + ); + const displayBalance = walletBalance ?? 0n; + const displayDecimals = walletDecimals ?? decimals; + // Read localStorage on mount useEffect(() => { const stored = localStorage.getItem(lsKey(slabAddress)); @@ -65,9 +83,12 @@ export const DepositTrigger: FC<{ slabAddress: string }> = ({ slabAddress }) => onClick={() => setExpanded(!expanded)} >
- Account + {/* Label and value share the same brightness so the whole + "Wallet Balance: X USDC" line reads as one unit. The + earlier "Account" label was at --text-dim, which made + users squint to read which balance this was. */} - {formatTokenAmount(capital, decimals)} {symbol} + Wallet Balance: {formatTokenAmount(displayBalance, displayDecimals)} {symbol}
diff --git a/app/components/trade/TradeForm.tsx b/app/components/trade/TradeForm.tsx index fa3a3a61..00fe8047 100644 --- a/app/components/trade/TradeForm.tsx +++ b/app/components/trade/TradeForm.tsx @@ -704,7 +704,7 @@ export const TradeForm: FC<{ slabAddress: string }> = ({ slabAddress }) => {
- Bal: {userAccount ? formatPerc(capital, decimals) : (walletAtaBalance !== null ? formatPerc(walletAtaBalance, decimals) : "—")} {collateralSymbol} + Account Bal: {userAccount ? formatPerc(capital, decimals) : "0"} {collateralSymbol}
diff --git a/app/hooks/useWalletAtaBalance.ts b/app/hooks/useWalletAtaBalance.ts new file mode 100644 index 00000000..ec326d70 --- /dev/null +++ b/app/hooks/useWalletAtaBalance.ts @@ -0,0 +1,76 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { PublicKey } from "@solana/web3.js"; +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; +import { useWalletCompat, useConnectionCompat } from "@/hooks/useWalletCompat"; + +export interface WalletAtaBalance { + /** Raw atomic balance from the user's ATA, or null if no ATA / not connected. */ + balance: bigint | null; + /** On-chain decimals from the ATA, or null if unavailable. Useful for + * tokens where TokenMetadata fails (cross-network, missing). */ + decimals: number | null; +} + +/** Fetches the user's associated token account balance for a given mint. + * Returns `{ balance: null }` when the wallet is disconnected, the mint + * is null, or the ATA doesn't exist yet. + * + * Re-fetch trigger: pass any value as `refreshTrigger` whose identity + * changes when an external event invalidates the cached balance. + * The canonical use is the in-market `capital` value — a deposit + * decreases the wallet ATA balance and increases capital; passing + * capital as the trigger forces a re-read of the ATA so the bar + * reflects the post-deposit state instead of the stale pre-deposit + * number. Without this, only publicKey/mint/connection changes would + * trigger a re-read, none of which fire after a deposit/withdraw. */ +export function useWalletAtaBalance( + mint: PublicKey | null | undefined, + refreshTrigger?: unknown, +): WalletAtaBalance { + const { publicKey } = useWalletCompat(); + const { connection } = useConnectionCompat(); + const [state, setState] = useState({ + balance: null, + decimals: null, + }); + + useEffect(() => { + if (!publicKey || !mint) { + setState({ balance: null, decimals: null }); + return; + } + let cancelled = false; + (async () => { + try { + const ata = getAssociatedTokenAddressSync(mint, publicKey); + const info = await connection.getTokenAccountBalance(ata); + if (cancelled) return; + // RPC returns amount as a string. The happy-path zero balance + // is "0" (truthy in JS). The else branch only fires for the + // unusual empty-string / falsy case from a malformed response; + // even then we want to preserve any decimals the RPC did + // hand back rather than silently drop them. + const onChainDecimals = + info.value.decimals !== undefined ? info.value.decimals : null; + if (info.value.amount) { + setState({ + balance: BigInt(info.value.amount), + decimals: onChainDecimals, + }); + } else { + setState({ balance: null, decimals: onChainDecimals }); + } + } catch { + // ATA may not exist yet (user hasn't received this token), keep null. + if (!cancelled) setState({ balance: null, decimals: null }); + } + })(); + return () => { + cancelled = true; + }; + }, [publicKey, mint, connection, refreshTrigger]); + + return state; +}