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..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() ??
@@ -770,4 +780,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,
+ };
+}