diff --git a/package.json b/package.json index ad68186..01112bc 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "devwallet", - "version": "0.2.0", + "version": "0.3.0", "private": true, "extension": { "name": "DW: DevWallet", - "description": "Dev Wallet for EVM & Forge" + "description": "DevWallet helps your Solidity development workflow. Works great with Forge" }, "scripts": { "anvil": "anvil --fork-url https://cloudflare-eth.com", diff --git a/src/actions/getAccountTokens.ts b/src/actions/getAccountTokens.ts index 7157a7b..daf2d89 100644 --- a/src/actions/getAccountTokens.ts +++ b/src/actions/getAccountTokens.ts @@ -1,9 +1,7 @@ -import { type Address, parseAbiItem } from 'abitype' +import type { Address } from 'abitype' import type { GetLogsParameters } from 'viem' import { erc20Abi } from 'viem' -import { getLogsQueryOptions } from '~/hooks/useGetLogs' -import { queryClient } from '~/react-query' import type { Client } from '~/viem' export async function getAccountTokens( @@ -18,40 +16,49 @@ export async function getAccountTokens( toBlock: GetLogsParameters['toBlock'] }, ) { - console.log('getAccountTokens params:', { address, fromBlock, toBlock }) + const effectiveFromBlock = 0n + const effectiveToBlock = toBlock ?? 'latest' + console.log(fromBlock) + // Transfer event signature hash (keccak256 of "Transfer(address,address,uint256)") + const transferEventSignature = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' as const + + // Pad address to 32 bytes for topic matching + const paddedAddress = `0x000000000000000000000000${address + .slice(2) + .toLowerCase()}` as const const [transfersFrom, transfersTo] = await Promise.all([ - queryClient.fetchQuery( - getLogsQueryOptions(client, { - event: parseAbiItem( - 'event Transfer(address indexed from, address indexed to, uint256)', - ), - args: { - from: address, - }, - fromBlock, - toBlock, + // Transfers FROM this address: topic[1] = address + client + .getLogs({ + fromBlock: effectiveFromBlock, + toBlock: effectiveToBlock, + topics: [transferEventSignature, paddedAddress], + } as any) // Type assertion needed for raw topics API + .catch((err) => { + console.error('Error fetching transfersFrom:', err) + return [] }), - ), - queryClient.fetchQuery( - getLogsQueryOptions(client, { - event: parseAbiItem( - 'event Transfer(address indexed from, address indexed to, uint256)', - ), - args: { - to: address, - }, - fromBlock, - toBlock, + // Transfers TO this address: topic[2] = address + client + .getLogs({ + fromBlock: effectiveFromBlock, + toBlock: effectiveToBlock, + topics: [transferEventSignature, null, paddedAddress], + } as any) // Type assertion needed for raw topics API + .catch((err) => { + console.error('Error fetching transfersTo:', err) + return [] }), - ), ]) - // console.log(transfersFrom, transfersTo, 'transfers'); + + const relevantTransfers = [...transfersFrom, ...transfersTo] + const potentialTokens = [ - ...new Set([ - ...(transfersFrom?.map((t) => t.address) || []), - ...(transfersTo?.map((t) => t.address) || []), - ]), + ...new Set( + relevantTransfers.map((t) => t.address.toLowerCase() as Address), + ), ] const erc20Tokens = await Promise.all( @@ -62,10 +69,15 @@ export async function getAccountTokens( abi: erc20Abi, functionName: 'decimals', }) - return typeof decimals === 'number' && decimals >= 0 && decimals <= 255 - ? tokenAddress - : null - } catch { + const isValid = + typeof decimals === 'number' && decimals >= 0 && decimals <= 255 + if (!isValid) { + console.warn( + `Token ${tokenAddress} has invalid decimals: ${decimals}. Skipping.`, + ) + } + return isValid ? tokenAddress : null + } catch (_err) { return null } }), diff --git a/src/hooks/useAccountTokens.ts b/src/hooks/useAccountTokens.ts index e04c91f..e414740 100644 --- a/src/hooks/useAccountTokens.ts +++ b/src/hooks/useAccountTokens.ts @@ -29,6 +29,9 @@ export function useAccountTokensQueryOptions(args: UseAccountTokensParameters) { return queryOptions({ enabled: Boolean(address && network.forkBlockNumber), queryKey: getAccountTokensQueryKey([client.key, address, stringify(args)]), + // Refetch tokens periodically to detect new transfers + refetchInterval: 3000, + staleTime: 2000, async queryFn() { if (!address) throw new Error('address is required') const tokens = await getAccountTokens(client, { @@ -110,7 +113,16 @@ export function useAccountTokens({ address }: UseAccountTokensParameters) { const tokens = useMemo(() => { if (!tokensKey) return [] - return tokensStore.tokens[tokensKey] ?? [] + const tokensList = tokensStore.tokens[tokensKey] ?? [] + + // Deduplicate tokens by address (case-insensitive) + const seen = new Set() + return tokensList.filter((token) => { + const lowerAddress = token.address.toLowerCase() + if (seen.has(lowerAddress)) return false + seen.add(lowerAddress) + return true + }) }, [tokensKey, tokensStore.tokens]) return Object.assign(useQuery(queryOptions), { diff --git a/src/screens/account-details.tsx b/src/screens/account-details.tsx index 32bea7b..b285c5d 100644 --- a/src/screens/account-details.tsx +++ b/src/screens/account-details.tsx @@ -205,7 +205,7 @@ function TokenRow({ accountAddress, tokenAddress }: TokenRowProps) { address: accountAddress, }) - const { removeToken } = useAccountTokens({ + const { removeToken, hideToken } = useAccountTokens({ address: accountAddress, }) @@ -282,6 +282,16 @@ function TokenRow({ accountAddress, tokenAddress }: TokenRowProps) { text={tokenAddress!} variant="ghost primary" /> + { + e.stopPropagation() + hideToken({ tokenAddress: tokenAddress! }) + }} + /> )} diff --git a/src/screens/token-transfer.tsx b/src/screens/token-transfer.tsx index 3482fc8..fbb8271 100644 --- a/src/screens/token-transfer.tsx +++ b/src/screens/token-transfer.tsx @@ -2,34 +2,19 @@ import { useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate, useParams } from 'react-router-dom' import { toast } from 'sonner' -import { - type Address, - type BaseError, - type Hex, - formatUnits, - isAddress, - parseUnits, -} from 'viem' +import { type Address, type BaseError, formatUnits, type Hex, isAddress, parseUnits, } from 'viem' import { Container } from '~/components' import * as Form from '~/components/form' import { Spinner } from '~/components/svgs' import { erc20Abi } from '~/constants/abi' -import { - Box, - Button, - Column, - Columns, - Inline, - Separator, - Stack, - Text, -} from '~/design-system' +import { Box, Button, Column, Columns, Inline, Separator, Stack, Text, } from '~/design-system' import { useBalance } from '~/hooks/useBalance' import { useClient } from '~/hooks/useClient' import { useErc20Balance } from '~/hooks/useErc20Balance' import { useErc20Metadata } from '~/hooks/useErc20Metadata' import { useWriteContract } from '~/hooks/useWriteContract' +import { queryClient } from '~/react-query' type TransferFormData = { recipient: string @@ -113,6 +98,20 @@ export default function TokenTransfer() { if (receipt.status === 'success') { toast.success(`Transfer successful! TX: ${txHash.slice(0, 10)}...`) + // invalidate tokens to fetch new ones + await queryClient.invalidateQueries({ + predicate: (query) => { + return query.queryKey[0] === 'account-tokens' + }, + }) + + // invalidate balances to update + await queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === 'balance' || + query.queryKey[0] === 'erc20-balance', + }) + navigate(-1) } else { toast.error('Transaction reverted') diff --git a/src/zustand/tokens.ts b/src/zustand/tokens.ts index 3238bc6..a7593f3 100644 --- a/src/zustand/tokens.ts +++ b/src/zustand/tokens.ts @@ -118,10 +118,12 @@ export const tokensStore = createStore( const tokens = { ...state.tokens } for (const tokenAddress of tokenAddresses) { - const exists = (tokens[serializedKey] || []).some( - (x) => x.address === tokenAddress, + const existingToken = (tokens[serializedKey] || []).find( + (x) => x.address.toLowerCase() === tokenAddress.toLowerCase(), ) - if (!exists) + // Only add if token doesn't exist yet + // If it exists but is hidden, respect the user's choice and don't modify it + if (!existingToken) tokens[serializedKey] = [ { address: tokenAddress,