From 88ca23fef2514bc5dc35bfbba92218ea6937e4b5 Mon Sep 17 00:00:00 2001 From: 0X-SquidSol Date: Thu, 7 May 2026 12:02:17 -0400 Subject: [PATCH 1/2] feat(trade): distinguish wallet balance from in-market account balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of data were being conflated in the trade panel UI: 1. The user's Phantom (or other wallet) USDC balance — what they have available to deposit. 2. The user's deposited capital on this market's slab account — what they can actually trade with. Previously the top of the trade panel showed "ACCOUNT 0 USDC" and the Size row showed "Bal: 0 USDC" — both reading from the same in-market capital figure (with a fallback to wallet balance when no account existed). Users had no way to tell what they had in their wallet vs what was on-chain in the market without doing mental math. Changes: app/hooks/useWalletAtaBalance.ts (new) Small hook that fetches the user's associated token account balance for a given mint via getTokenAccountBalance. Returns `{ balance, decimals }` or null when the wallet is disconnected, the mint is null, or the ATA doesn't exist yet. One-shot fetch on dependency change — no polling, callers can rebuild on slab capital changes if they want refresh-after- deposit semantics. DepositTrigger.tsx — top bar Renamed "ACCOUNT" to "Wallet Balance:" and switched the value from `capital` to the wallet's ATA balance via the new hook. Label and value now share the same brightness (--text), font, and weight so the whole "Wallet Balance: X USDC" line reads as one unit. Drops the small uppercase-tracking label that was at --text-dim and forced users to squint to read which balance they were looking at. TradeForm.tsx — Size row Renamed "Bal:" to "Account Bal:" so the label distinguishes itself from the wallet-balance line above. Drops the conditional fallback to walletAtaBalance when no userAccount exists — pre-account users now see "Account Bal: 0 USDC" (truthful: they have nothing deposited) rather than the misleading wallet balance which already shows above. The in-component walletAtaBalance state is retained for the margin / CTA logic that still depends on it. Tests: 2277 app tests passing. --- app/components/trade/DepositTrigger.tsx | 19 +++++++- app/components/trade/TradeForm.tsx | 9 +++- app/hooks/useWalletAtaBalance.ts | 63 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 app/hooks/useWalletAtaBalance.ts diff --git a/app/components/trade/DepositTrigger.tsx b/app/components/trade/DepositTrigger.tsx index d53df779..9f80669a 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,17 @@ 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. + const { balance: walletBalance, decimals: walletDecimals } = useWalletAtaBalance( + config?.collateralMint ?? null, + ); + const displayBalance = walletBalance ?? 0n; + const displayDecimals = walletDecimals ?? decimals; + // Read localStorage on mount useEffect(() => { const stored = localStorage.getItem(lsKey(slabAddress)); @@ -65,9 +77,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 5c8bacbe..dc6ba0eb 100644 --- a/app/components/trade/TradeForm.tsx +++ b/app/components/trade/TradeForm.tsx @@ -704,7 +704,14 @@ export const TradeForm: FC<{ slabAddress: string }> = ({ slabAddress }) => {
- Bal: {userAccount ? formatPerc(capital, decimals) : (walletAtaBalance !== null ? formatPerc(walletAtaBalance, decimals) : "—")} {collateralSymbol} + {/* "Account Bal" rather than "Bal" so users can't confuse this + with the wallet balance shown at the top of the trade + panel. This row is the in-market account capital — what + you have available to TRADE on this slab — not the + Phantom wallet's USDC balance. Pre-account users see + "0" because they haven't deposited yet; the wallet + balance row above tells them what they could deposit. */} + 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..a3c225dc --- /dev/null +++ b/app/hooks/useWalletAtaBalance.ts @@ -0,0 +1,63 @@ +"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. One-shot fetch on mount / + * dependency change — does NOT poll, so callers that want live + * updates after a deposit/withdraw should rebuild on the changing + * market state (slab capital change, etc.). */ +export function useWalletAtaBalance( + mint: PublicKey | null | undefined, +): 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; + if (info.value.amount) { + setState({ + balance: BigInt(info.value.amount), + decimals: + info.value.decimals !== undefined ? info.value.decimals : null, + }); + } else { + setState({ balance: null, decimals: null }); + } + } 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]); + + return state; +} From 7b276a7339a026d00ac24e01f64390993a432a39 Mon Sep 17 00:00:00 2001 From: 0X-SquidSol Date: Thu, 7 May 2026 12:12:19 -0400 Subject: [PATCH 2/2] fix(trade): refresh wallet balance when capital changes + preserve decimals on empty-amount edge case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings from the CodeRabbit pass on PR #2147. 1. (Major) Wallet balance went stale after every deposit / withdraw. useWalletAtaBalance only re-fetched on publicKey / mint / connection changes — none of which fire when the user deposits via the inline DepositWithdrawCard. So a deposit decreased the on-chain ATA balance but the bar kept showing the pre-deposit number until the page reloaded. Fix: hook now accepts an optional `refreshTrigger` arg and includes it in the effect's deps. DepositTrigger passes the in-market `capital` value as the trigger — capital changes on every settled deposit / withdraw / trade-fee event, which is exactly when the wallet ATA balance also changes on-chain. Capital also changes on trade settlement (fees), which causes an unnecessary re-fetch, but ATA fetches are cheap single RPC calls and the simpler "any capital change = refresh" trigger beats the alternative of plumbing explicit deposit/withdraw event signals through the component tree. 2. (Minor) The else branch in the success path of the fetch reset both balance AND decimals to null when info.value.amount was empty (an unusual malformed-RPC case), discarding any usable info.value.decimals from the same response. Now we compute on-chain decimals once at the top of the success block and preserve them across both branches. Tests: 11 TradeForm tests passing. --- app/components/trade/DepositTrigger.tsx | 6 +++++ app/hooks/useWalletAtaBalance.ts | 29 ++++++++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/app/components/trade/DepositTrigger.tsx b/app/components/trade/DepositTrigger.tsx index 9f80669a..281a8434 100644 --- a/app/components/trade/DepositTrigger.tsx +++ b/app/components/trade/DepositTrigger.tsx @@ -37,8 +37,14 @@ export const DepositTrigger: FC<{ slabAddress: string }> = ({ slabAddress }) => // 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; diff --git a/app/hooks/useWalletAtaBalance.ts b/app/hooks/useWalletAtaBalance.ts index a3c225dc..ec326d70 100644 --- a/app/hooks/useWalletAtaBalance.ts +++ b/app/hooks/useWalletAtaBalance.ts @@ -15,12 +15,19 @@ export interface WalletAtaBalance { /** 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. One-shot fetch on mount / - * dependency change — does NOT poll, so callers that want live - * updates after a deposit/withdraw should rebuild on the changing - * market state (slab capital change, etc.). */ + * 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(); @@ -40,14 +47,20 @@ export function useWalletAtaBalance( 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: - info.value.decimals !== undefined ? info.value.decimals : null, + decimals: onChainDecimals, }); } else { - setState({ balance: null, decimals: null }); + setState({ balance: null, decimals: onChainDecimals }); } } catch { // ATA may not exist yet (user hasn't received this token), keep null. @@ -57,7 +70,7 @@ export function useWalletAtaBalance( return () => { cancelled = true; }; - }, [publicKey, mint, connection]); + }, [publicKey, mint, connection, refreshTrigger]); return state; }