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
39 changes: 32 additions & 7 deletions src/components/AssetDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import {
PlusIcon,
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";
Expand All @@ -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();
Expand All @@ -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);
Expand All @@ -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 (
<div className="flex flex-col gap-5" aria-label={`${asset.symbol} details`}>
<header className="flex flex-col items-center gap-2 pt-2 text-center">
Expand Down Expand Up @@ -96,7 +121,7 @@ export function AssetDetails({ asset }: IAssetDetailsProps) {
label={copy.sendLabel}
variant="primary"
disabled={!canSend}
onClick={() => setSendOpen(true)}
onClick={openSend}
>
<SendIcon className="size-5" />
</ActionButton>
Expand All @@ -123,7 +148,7 @@ export function AssetDetails({ asset }: IAssetDetailsProps) {
asset={asset}
onClose={() => setSendOpen(false)}
onSuccess={() => {
requestBalanceRefresh(asset.id);
void requestBalanceRefresh(asset.id);
}}
/>
) : null}
Expand Down
41 changes: 4 additions & 37 deletions src/components/BalanceDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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,
Expand All @@ -46,38 +39,12 @@ export function BalanceDisplay({
}: IBalanceDisplayProps) {
const { style } = useStyle();
const unavailable = fallback ?? style.copy.balances.balanceUnavailable;
const [live, setLive] = useState<LiveBalance | null>(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 (
<span className={cn("font-mono tabular-nums", className)}>
{formatBalance(balance, decimals, unavailable)}
Expand Down
22 changes: 17 additions & 5 deletions src/components/modals/TransferTokensModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -189,7 +201,7 @@ export function TransferTokensModal({
owner: evmAddress,
to: recipient as EVMAccountAddress,
amount: parsed,
decimals: asset.decimals,
decimals,
hash,
});
onSuccess(hash);
Expand Down
26 changes: 18 additions & 8 deletions src/lib/implementations/business/TransactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -331,7 +333,7 @@ export class TransactionService implements ITransactionService {
// keep hardcoded fallback
}
upgradeNonce = await publicClient.getTransactionCount({
address: eoa,
address: getAddress(eoa),
blockTag: "pending",
});
}
Expand Down Expand Up @@ -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<LocalAccount> {
private async getViemAccount(
addressOverride?: EVMAccountAddress,
): Promise<LocalAccount> {
const signer = await this.options.owsProvider.getSigner();
const address =
signer.getCachedAddress?.() ?? loadCachedEvmAddress() ?? undefined;
addressOverride ??
signer.getCachedAddress?.() ??
loadCachedEvmAddress() ??
undefined;
const publicKey =
signer.getLastPublicKeyData?.()?.secp256k1PublicKey ??
loadCachedSecp256k1PublicKey() ??
Expand Down Expand Up @@ -770,4 +780,4 @@ function yParityFromSignedAuthorization(signed: {

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
49 changes: 49 additions & 0 deletions src/wallet/useLiveTrackedBalance.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}