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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
82 changes: 47 additions & 35 deletions src/actions/getAccountTokens.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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(
Expand 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
}
}),
Expand Down
14 changes: 13 additions & 1 deletion src/hooks/useAccountTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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<string>()
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), {
Expand Down
12 changes: 11 additions & 1 deletion src/screens/account-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function TokenRow({ accountAddress, tokenAddress }: TokenRowProps) {
address: accountAddress,
})

const { removeToken } = useAccountTokens({
const { removeToken, hideToken } = useAccountTokens({
address: accountAddress,
})

Expand Down Expand Up @@ -282,6 +282,16 @@ function TokenRow({ accountAddress, tokenAddress }: TokenRowProps) {
text={tokenAddress!}
variant="ghost primary"
/>
<Button.Symbol
label="Hide token"
symbol="trash"
height="16px"
variant="ghost red"
onClick={(e) => {
e.stopPropagation()
hideToken({ tokenAddress: tokenAddress! })
}}
/>
</Inline>
)}
</>
Expand Down
35 changes: 17 additions & 18 deletions src/screens/token-transfer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
8 changes: 5 additions & 3 deletions src/zustand/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,12 @@ export const tokensStore = createStore<TokensStore>(
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,
Expand Down
Loading