From 71bad2729f4b5a6e6ffbc22fcd9328cd78015b2b Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Thu, 23 Jul 2026 23:56:55 -0700 Subject: [PATCH 1/2] Refactor AssetDetails and BalanceDisplay components for live balance integration - Updated AssetDetails to utilize live balance tracking, ensuring accurate asset information is displayed. - Refactored BalanceDisplay to remove unnecessary state management and leverage live balance updates. - Enhanced TransferTokensModal to validate against live balances, improving transaction accuracy. - Cleaned up imports and improved code clarity across affected components. --- src/components/AssetDetails.tsx | 39 ++++++++++++--- src/components/BalanceDisplay.tsx | 41 ++-------------- src/components/modals/TransferTokensModal.tsx | 22 +++++++-- .../business/TransactionService.ts | 2 +- src/wallet/useLiveTrackedBalance.ts | 49 +++++++++++++++++++ 5 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 src/wallet/useLiveTrackedBalance.ts diff --git a/src/components/AssetDetails.tsx b/src/components/AssetDetails.tsx index f57ff52..249accc 100644 --- a/src/components/AssetDetails.tsx +++ b/src/components/AssetDetails.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { ReactNode } from "react"; import { useShallow } from "zustand/react/shallow"; import { @@ -6,11 +6,12 @@ import { QrCodeIcon, SendIcon, } from "lucide-react"; -import type { TrackedAsset } from "../lib/types/domain"; +import { TrackedAsset } from "../lib/types/domain"; import { EAssetType } from "../lib/types/enum/EAssetType"; import { useStyle } from "../style/StyleProvider"; import { useWallet } from "../wallet/WalletProvider"; import { resolveActiveAddress } from "../wallet/activeAddress"; +import { useLiveTrackedBalance } from "../wallet/useLiveTrackedBalance"; import { useWalletSessionStore } from "../wallet/sessionStore"; import { BalanceDisplay } from "./BalanceDisplay"; import { TransactionHistory } from "./TransactionHistory"; @@ -26,7 +27,7 @@ export interface IAssetDetailsProps { * Shared focused-asset / asset-detail shell. * Balance and recent activity are live. */ -export function AssetDetails({ asset }: IAssetDetailsProps) { +export function AssetDetails({ asset: assetProp }: IAssetDetailsProps) { const { style } = useStyle(); const { balances: copy } = style.copy; const { requestBalanceRefresh, resolveChain } = useWallet(); @@ -40,9 +41,28 @@ export function AssetDetails({ asset }: IAssetDetailsProps) { const [sendOpen, setSendOpen] = useState(false); const [purchaseOpen, setPurchaseOpen] = useState(false); + const { balance, decimals } = useLiveTrackedBalance( + assetProp.id, + assetProp.balance, + assetProp.decimals, + ); + const asset = + balance === assetProp.balance && decimals === assetProp.decimals + ? assetProp + : new TrackedAsset( + assetProp.chainId, + assetProp.address, + assetProp.type, + assetProp.name, + assetProp.symbol, + decimals, + assetProp.id, + balance, + ); + useEffect(() => { - requestBalanceRefresh(asset.id); - }, [asset.id, requestBalanceRefresh]); + void requestBalanceRefresh(assetProp.id); + }, [assetProp.id, requestBalanceRefresh]); const network = resolveChain(asset.chainId)?.label ?? String(asset.chainId); @@ -53,6 +73,11 @@ export function AssetDetails({ asset }: IAssetDetailsProps) { }); const canSend = asset.type === EAssetType.Erc20; + const openSend = useCallback(() => { + void requestBalanceRefresh(asset.id); + setSendOpen(true); + }, [asset.id, requestBalanceRefresh]); + return (
@@ -96,7 +121,7 @@ export function AssetDetails({ asset }: IAssetDetailsProps) { label={copy.sendLabel} variant="primary" disabled={!canSend} - onClick={() => setSendOpen(true)} + onClick={openSend} > @@ -123,7 +148,7 @@ export function AssetDetails({ asset }: IAssetDetailsProps) { asset={asset} onClose={() => setSendOpen(false)} onSuccess={() => { - requestBalanceRefresh(asset.id); + void requestBalanceRefresh(asset.id); }} /> ) : null} diff --git a/src/components/BalanceDisplay.tsx b/src/components/BalanceDisplay.tsx index 956f50c..16b12cd 100644 --- a/src/components/BalanceDisplay.tsx +++ b/src/components/BalanceDisplay.tsx @@ -1,9 +1,8 @@ -import { useCallback, useState } from "react"; -import { formatUnits } from "viem"; import { cn } from "@/lib/utils"; import type { TrackedAssetId } from "../lib/types/primitives"; import { useStyle } from "../style/StyleProvider"; -import { useBalanceUpdated } from "../wallet/useWalletEvent"; +import { useLiveTrackedBalance } from "../wallet/useLiveTrackedBalance"; +import { formatUnits } from "viem"; export interface IBalanceDisplayProps { trackedAssetId: TrackedAssetId; @@ -27,15 +26,9 @@ function formatBalance( } } -type LiveBalance = { - balance: bigint | null; - decimals: number; -}; - /** * Formats a raw token balance and stays live via BalanceUpdated events. - * Props are the source of truth; event updates override until the asset or - * props change (reset during render — no prop→state sync effect). + * Props are the source of truth until an event overrides them. */ export function BalanceDisplay({ trackedAssetId, @@ -46,38 +39,12 @@ export function BalanceDisplay({ }: IBalanceDisplayProps) { const { style } = useStyle(); const unavailable = fallback ?? style.copy.balances.balanceUnavailable; - const [live, setLive] = useState(null); - const [prev, setPrev] = useState({ + const { balance, decimals } = useLiveTrackedBalance( trackedAssetId, propBalance, propDecimals, - }); - - if ( - trackedAssetId !== prev.trackedAssetId || - propBalance !== prev.propBalance || - propDecimals !== prev.propDecimals - ) { - setPrev({ trackedAssetId, propBalance, propDecimals }); - setLive(null); - } - - useBalanceUpdated( - useCallback( - (event) => { - const next = event.assets.find( - (asset) => asset.id === trackedAssetId, - ); - if (!next) return; - setLive({ balance: next.balance, decimals: next.decimals }); - }, - [trackedAssetId], - ), ); - const balance = live?.balance ?? propBalance; - const decimals = live?.decimals ?? propDecimals; - return ( {formatBalance(balance, decimals, unavailable)} diff --git a/src/components/modals/TransferTokensModal.tsx b/src/components/modals/TransferTokensModal.tsx index 0203a7d..51acd93 100644 --- a/src/components/modals/TransferTokensModal.tsx +++ b/src/components/modals/TransferTokensModal.tsx @@ -16,6 +16,7 @@ import { EAssetType } from "../../lib/types/enum/EAssetType"; import type { IPaymentQuote } from "../../lib/interfaces/business"; import { useStyle } from "../../style/StyleProvider"; import { chainTechnologyFor } from "../../wallet/activeAddress"; +import { useLiveTrackedBalance } from "../../wallet/useLiveTrackedBalance"; import { useWallet } from "../../wallet/WalletProvider"; import { useWalletSessionStore } from "../../wallet/sessionStore"; import { Modal } from "../Modal"; @@ -92,15 +93,22 @@ export function TransferTokensModal({ const chainMeta = resolveChain(asset.chainId); const useRelayer = chainMeta?.useRelayer === true; + // AssetDetails / list pass a snapshot; BalanceDisplay is live via events. + // Validate against the same live balance or Send always hits insufficient. + const { balance, decimals } = useLiveTrackedBalance( + asset.id, + asset.balance, + asset.decimals, + ); + const technology = useMemo( () => chainTechnologyFor(asset.chainId), [asset.chainId], ); const amountError = useMemo( - () => - amountValidationError(amount, asset.decimals, asset.balance, copy), - [amount, asset.balance, asset.decimals, copy], + () => amountValidationError(amount, decimals, balance, copy), + [amount, balance, decimals, copy], ); const onQuoteChange = useCallback( @@ -155,10 +163,14 @@ export function TransferTokensModal({ let parsed: bigint; try { - parsed = parseUnits(amount.trim(), asset.decimals); + parsed = parseUnits(amount.trim(), decimals); } catch { return; } + if (balance !== null && parsed > balance) { + setSubmitError(copy.insufficientBalanceError); + return; + } setBusy(true); try { @@ -189,7 +201,7 @@ export function TransferTokensModal({ owner: evmAddress, to: recipient as EVMAccountAddress, amount: parsed, - decimals: asset.decimals, + decimals, hash, }); onSuccess(hash); diff --git a/src/lib/implementations/business/TransactionService.ts b/src/lib/implementations/business/TransactionService.ts index 9a10db6..c7b061a 100644 --- a/src/lib/implementations/business/TransactionService.ts +++ b/src/lib/implementations/business/TransactionService.ts @@ -770,4 +770,4 @@ function yParityFromSignedAuthorization(signed: { function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); -} +} \ No newline at end of file diff --git a/src/wallet/useLiveTrackedBalance.ts b/src/wallet/useLiveTrackedBalance.ts new file mode 100644 index 0000000..c0051e4 --- /dev/null +++ b/src/wallet/useLiveTrackedBalance.ts @@ -0,0 +1,49 @@ +import { useCallback, useState } from "react"; +import type { TrackedAssetId } from "../lib/types/primitives"; +import { useBalanceUpdated } from "./useWalletEvent"; + +/** + * Keep a tracked-asset balance in sync with {@link BalanceUpdatedEvent}. + * List/detail props are often a snapshot; BalanceDisplay already uses this + * pattern — send validation must too or it rejects against a stale `0n`. + */ +export function useLiveTrackedBalance( + trackedAssetId: TrackedAssetId, + propBalance: bigint | null, + propDecimals: number, +): { balance: bigint | null; decimals: number } { + const [live, setLive] = useState<{ + balance: bigint | null; + decimals: number; + } | null>(null); + const [prev, setPrev] = useState({ + trackedAssetId, + propBalance, + propDecimals, + }); + + if ( + trackedAssetId !== prev.trackedAssetId || + propBalance !== prev.propBalance || + propDecimals !== prev.propDecimals + ) { + setPrev({ trackedAssetId, propBalance, propDecimals }); + setLive(null); + } + + useBalanceUpdated( + useCallback( + (event) => { + const next = event.assets.find((asset) => asset.id === trackedAssetId); + if (!next) return; + setLive({ balance: next.balance, decimals: next.decimals }); + }, + [trackedAssetId], + ), + ); + + return { + balance: live?.balance ?? propBalance, + decimals: live?.decimals ?? propDecimals, + }; +} From c93b0c78a676d556f192999bbc23d6205cef06cd Mon Sep 17 00:00:00 2001 From: Charlie Sibbach Date: Fri, 24 Jul 2026 00:01:02 -0700 Subject: [PATCH 2/2] Refactor TransactionService to improve account handling and display logic - Updated getViemAccount to accept an optional addressOverride parameter, enhancing flexibility in account resolution. - Modified transaction signing logic to ensure the correct EOA is used without unnecessary re-resolution. - Improved comments for clarity on display handling during signing processes, ensuring better understanding of UI interactions. --- .../business/TransactionService.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/lib/implementations/business/TransactionService.ts b/src/lib/implementations/business/TransactionService.ts index c7b061a..253aacd 100644 --- a/src/lib/implementations/business/TransactionService.ts +++ b/src/lib/implementations/business/TransactionService.ts @@ -299,7 +299,9 @@ export class TransactionService implements ITransactionService { await this.options.owsProvider.ensureDisplay(); try { const delegationSecret = await loadOrCreateDelegationBinding(); - const viemAccount = await this.getViemAccount(); + // Bind the LocalAccount to the same EOA used for upgrade checks / nonce / + // smartAccount — do not re-resolve address inside getViemAccount. + const viemAccount = await this.getViemAccount(eoa); const publicClient = this.options.blockchain.getPublicClient(chainId); const chainIdNumber = Number(BigInt(chainId)); @@ -331,7 +333,7 @@ export class TransactionService implements ITransactionService { // keep hardcoded fallback } upgradeNonce = await publicClient.getTransactionCount({ - address: eoa, + address: getAddress(eoa), blockTag: "pending", }); } @@ -539,17 +541,25 @@ export class TransactionService implements ITransactionService { }, }); - // Display is already held by sendViaRelayer / callers — do not re-enter - // ensureDisplay here; parallel awaits stagger signDigest and cancel the - // coalesced ceremony. + // Callers must already have the flyout open (SignHelper.withDisplay for + // eth_sendTransaction, plus sendViaRelayer.ensureDisplay for size). Do not + // call ensureDisplay here: parallel requestDisplay awaits stagger the two + // signDelegation → signDigest paths and the second signer RPC cancels the + // first Confirm UI (`ceremonyCancelled`). withCeremonyUiReason only sets + // Confirm copy — it does not open/close display and awaits this method. const signature = await smartAccount.signDelegation({ delegation }); return { ...delegation, signature }; } - private async getViemAccount(): Promise { + private async getViemAccount( + addressOverride?: EVMAccountAddress, + ): Promise { const signer = await this.options.owsProvider.getSigner(); const address = - signer.getCachedAddress?.() ?? loadCachedEvmAddress() ?? undefined; + addressOverride ?? + signer.getCachedAddress?.() ?? + loadCachedEvmAddress() ?? + undefined; const publicKey = signer.getLastPublicKeyData?.()?.secp256k1PublicKey ?? loadCachedSecp256k1PublicKey() ??