add received transaction history (#722)

This commit is contained in:
Jakub
2025-07-09 20:42:18 +09:00
committed by GitHub
parent adc8c21df8
commit 6e65af8eb4
20 changed files with 552 additions and 285 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@status-im/components": patch
"@status-im/wallet": patch
"wallet": patch
---
add received transaction history
+1 -1
View File
@@ -1,5 +1,5 @@
---
"@status-im/wallet": patch
'@status-im/wallet': patch
---
change feedback link
+2 -2
View File
@@ -1,6 +1,6 @@
---
"@status-im/wallet": patch
"wallet": patch
'@status-im/wallet': patch
'wallet': patch
---
Fixes onBack in import recovery phrase flow
+2 -2
View File
@@ -1,6 +1,6 @@
---
"@status-im/wallet": patch
"wallet": patch
'@status-im/wallet': patch
'wallet': patch
---
add link to feedback, change onboarding copy
+1 -1
View File
@@ -1,5 +1,5 @@
---
"wallet": patch
'wallet': patch
---
fix failing api calls due to inactive service worker
+10 -4
View File
@@ -1,11 +1,11 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import type { NetworkType } from '@status-im/wallet/data'
import type { Activity, NetworkType } from '@status-im/wallet/data'
const PAGE_LIMIT = 20
type Props = {
address: string
address: string | undefined
}
const getTransfers = async (
@@ -13,7 +13,9 @@ const getTransfers = async (
networks: NetworkType[],
pageKeys: Partial<Record<NetworkType, string>> = {},
) => {
const url = new URL('http://localhost:3030/api/trpc/activities.page')
const url = new URL(
`${import.meta.env.WXT_STATUS_API_URL}/api/trpc/activities.page`,
)
url.searchParams.set(
'input',
@@ -67,6 +69,9 @@ export const useActivities = ({ address }: Props) => {
return useInfiniteQuery({
queryKey: ['activities', address, networks],
queryFn: async ({ pageParam = {} }) => {
if (!address) {
return { activities: [], nextPage: {} }
}
const result = await getTransfers(
address,
networks as NetworkType[],
@@ -77,8 +82,9 @@ export const useActivities = ({ address }: Props) => {
nextPage: result.nextPageKeys,
}
},
enabled: !!address,
getNextPageParam: (lastPage: {
activities: []
activities: Activity[]
nextPage: Partial<Record<NetworkType, string | undefined>>
}) => {
const hasMore = Object.values(lastPage.nextPage).some(Boolean)
@@ -3,28 +3,39 @@ import { createFileRoute } from '@tanstack/react-router'
import SplittedLayout from '@/components/splitted-layout'
import { useActivities } from '@/hooks/use-activities'
import { useWallet } from '@/providers/wallet-context'
export const Route = createFileRoute('/portfolio/activity/')({
component: RouteComponent,
})
function RouteComponent() {
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const { currentWallet, isLoading: isWalletLoading } = useWallet()
const address = currentWallet?.activeAccounts[0].address
const { data, isLoading } = useActivities({ address })
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
useActivities({ address })
const activities = data?.pages.flatMap(page => page.activities) ?? []
return (
<SplittedLayout
list={
activities ? (
<ActivityList activities={activities} />
activities.length > 0 && address ? (
<ActivityList
activities={activities}
userAddress={address}
onLoadMore={fetchNextPage}
hasNextPage={hasNextPage}
isLoadingMore={isFetchingNextPage}
/>
) : (
<div className="mt-4 flex flex-col gap-3">Empty state</div>
<div className="mt-4 flex flex-col gap-3">
{!address ? 'No wallet selected' : 'No activity'}
</div>
)
}
detail={<FeedbackSection />}
isLoading={isLoading}
isLoading={isLoading || isWalletLoading}
/>
)
}
@@ -111,7 +111,7 @@ const ContextTag = (props: Props, ref: React.Ref<HTMLDivElement>) => {
<span className={iconStyles({ size, rounded: true, offset: size })}>
{cloneElement(icon)}
</span>
<span>{label}</span>
<span className="whitespace-nowrap">{label}</span>
</div>
)
})
@@ -0,0 +1,20 @@
import { ReceiveIcon, SendIcon } from '@status-im/icons/20'
type ActivityDirectionProps = {
direction: 'sent' | 'received'
}
const ActivityDirection = (props: ActivityDirectionProps) => {
const { direction } = props
const Icon = direction === 'sent' ? SendIcon : ReceiveIcon
const text = direction === 'sent' ? 'Sent' : 'Received'
return (
<div className="flex items-center gap-1 text-15 font-600 text-neutral-100">
<Icon className="text-neutral-50" />
{text}
</div>
)
}
export { ActivityDirection }
@@ -0,0 +1,124 @@
import { ContextTag } from '@status-im/components'
import { ExternalIcon } from '@status-im/icons/20'
import { cx } from 'class-variance-authority'
import { CurrencyAmount } from '../../currency-amount'
import { NetworkLogo } from '../../network-logo'
import { RelativeDate } from '../../relative-date'
import { shortenAddress } from '../../shorten-address'
import { formatTokenAmount } from '../../token-amount'
import { ActivityDirection } from './activity-direction'
import { ActivityStatus } from './activity-status'
import { ActivityTokenLogo } from './activity-token-logo'
import type { Activity } from '@status-im/wallet/data'
type ActivityItemProps = {
activity: Activity
userAddress: string
}
function getTokenActivityLabel(activity: Activity): string {
if (activity.category === 'erc721') {
return '1 NFT'
}
if (
activity.category === 'erc1155' &&
Array.isArray(activity.erc1155Metadata) &&
activity.erc1155Metadata.length > 0
) {
const total = activity.erc1155Metadata.reduce(
(sum, meta) => sum + parseInt(meta.value, 16),
0,
)
// ERC1155 can have multiple NFTs or FTs, so we show the total count
return `${total} Token${total > 1 ? 's' : ''}`
}
return '1 Asset'
}
const ActivityItem = (props: ActivityItemProps) => {
const { activity, userAddress } = props
const outgoingTransaction =
activity.from.toLowerCase() === userAddress.toLowerCase()
const assetSymbol =
activity.asset || (activity.category === 'external' ? 'ETH' : null)
const eurValue = Number(activity.eurRate) * Number(activity.value)
return (
<a
href={`https://etherscan.io/tx/${activity.hash}`}
target="_blank"
className="grid grid-cols-[2fr_1fr_1fr] gap-4 p-3 transition-colors focus-within:bg-neutral-5 hover:bg-neutral-5"
>
<div className="flex items-center gap-3">
<div className="relative">
<ActivityTokenLogo
symbol={assetSymbol || 'ETH'}
address={activity.rawContract?.address ?? ''}
/>
<div className="absolute bottom-[-4px] right-[-4px] size-[18px] rounded-full border border-white-100">
<NetworkLogo name={activity.network} size={16} />
</div>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2 text-15 font-600 sm:max-w-full">
<ActivityDirection
direction={outgoingTransaction ? 'sent' : 'received'}
/>
<RelativeDate
timestamp={activity.metadata.blockTimestamp}
className="text-13 font-400 text-neutral-40"
/>
</div>
<div className="flex items-end text-13 font-400 text-neutral-50">
<ContextTag type="label" size="24">
{shortenAddress(
outgoingTransaction ? activity.to : activity.from,
)}
</ContextTag>
</div>
</div>
</div>
<div className="flex flex-col items-end justify-center">
{assetSymbol ? (
<div className="flex flex-col items-end gap-1 truncate">
<ContextTag type="label" size="24">
{`${
outgoingTransaction ? '-' : '+'
} ${formatTokenAmount(activity.value, 'precise')} ${assetSymbol}`}
</ContextTag>
{eurValue > 0 && (
<div
className={cx(
'flex items-center gap-1 text-13 font-500',
outgoingTransaction ? 'text-danger-50' : 'text-success-50',
)}
>
{outgoingTransaction ? '-' : '+'}
<CurrencyAmount
value={eurValue}
format="standard"
className="text-13 font-500"
/>
</div>
)}
</div>
) : (
<ContextTag type="label" size="24">
{getTokenActivityLabel(activity)}
</ContextTag>
)}
</div>
<div className="flex items-center justify-end gap-1 text-13 font-400 text-neutral-50">
<ActivityStatus status={activity.status} />
<ExternalIcon className="text-neutral-50" />
</div>
</a>
)
}
export { ActivityItem }
@@ -0,0 +1,52 @@
import { CheckIcon, NegativeStateIcon, PendingIcon } from '@status-im/icons/20'
import { cx } from 'class-variance-authority'
import { match } from 'ts-pattern'
import type { Activity } from '@status-im/wallet/data'
type ActivityStatusProps = {
status: Activity['status']
}
const ActivityStatus = (props: ActivityStatusProps) => {
const { status } = props
const baseClass =
'flex items-center gap-1 rounded-20 border pl-[5px] pr-[8px] py-[3px] h-6'
return match(status)
.with('success', () => {
return (
<div className={cx(baseClass, 'border-success-50 bg-success-50/10')}>
<CheckIcon className="text-success-50" />
<div className="text-13 font-500 text-success-50">Success</div>
</div>
)
})
.with('failed', () => {
return (
<div className={cx(baseClass, 'border-danger-50/20 bg-danger-50/10')}>
<NegativeStateIcon className="text-danger-50" />
<div className="text-13 font-500 text-danger-50">Failed</div>
</div>
)
})
.with('pending', () => {
return (
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
<PendingIcon className="text-neutral-40" />
<div className="flex text-13 font-400 text-neutral-40">Pending</div>
</div>
)
})
.with('unknown', () => {
return (
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
<div className="flex text-13 font-400 text-neutral-50">Unknown</div>
</div>
)
})
.exhaustive()
}
export { ActivityStatus }
@@ -0,0 +1,45 @@
import erc20TokenList from '../../../constants/erc20.json'
type ActivityTokenLogoProps = {
symbol: string
address: string
}
const ActivityTokenLogo = (props: ActivityTokenLogoProps) => {
const { symbol, address } = props
const getActivityTokenLogo = (symbol: string, contractAddress?: string) => {
if (symbol === 'ETH') {
return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'
}
const token = erc20TokenList.tokens.find(
token =>
token.symbol === symbol ||
(contractAddress &&
token.address.toLowerCase() === contractAddress.toLowerCase()),
)
return token?.logoURI ?? ''
}
const src = getActivityTokenLogo(symbol, address)
if (!src) {
return (
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-10 text-11 font-600 text-neutral-50">
{symbol.slice(0, 4).toUpperCase()}
</div>
)
}
return (
<img
className="size-8 flex-shrink-0 rounded-full bg-neutral-10"
alt={symbol}
src={src}
/>
)
}
export { ActivityTokenLogo }
@@ -1,254 +1,66 @@
'use client'
import { ContextTag } from '@status-im/components'
import { CheckIcon, NegativeStateIcon, PendingIcon } from '@status-im/icons/12'
import { ExternalIcon, ReceiveIcon, SendIcon } from '@status-im/icons/20'
import { cx } from 'class-variance-authority'
import { formatRelative } from 'date-fns'
import { match } from 'ts-pattern'
import erc20TokenList from '../../constants/erc20.json'
import { CurrencyAmount } from '../currency-amount'
import { NetworkLogo } from '../network-logo'
import { shortenAddress } from '../shorten-address'
import { formatTokenAmount } from '../token-amount'
import { useInfiniteLoading } from '../../hooks/use-infinite-loading'
import { ActivityItem } from './components/activity-item'
import type { ApiOutput } from '../../data'
const fromAddress = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
type Activity = ApiOutput['activities']['activities']['activities'][0]
type Props = {
export type ActivityListProps = {
activities: Activity[]
userAddress: string
onLoadMore: () => void
hasNextPage: boolean
isLoadingMore: boolean
}
function getTokenActivityLabel(activity: Activity): string {
if (activity.category === 'erc721') {
return '1 NFT'
}
const ActivityList = (props: ActivityListProps) => {
const { activities, userAddress, onLoadMore, hasNextPage, isLoadingMore } =
props
if (
activity.category === 'erc1155' &&
Array.isArray(activity.erc1155Metadata) &&
activity.erc1155Metadata.length > 0
) {
const total = activity.erc1155Metadata.reduce(
(sum, meta) => sum + parseInt(meta.value, 16),
0,
)
// ERC1155 can have multiple NFTs or FTs, so we show the total count
return `${total} Token${total > 1 ? 's' : ''}`
}
return '1 Asset'
}
const ActivityList = (props: Props) => {
const { activities } = props
const { endOfPageRef, isLoading } = useInfiniteLoading({
rootMargin: '200px',
fetchNextPage: onLoadMore,
isFetchingNextPage: isLoadingMore,
hasNextPage: hasNextPage,
})
return (
<div className="pb-10">
<div className="flex min-h-[calc(100svh-362px)] w-full overflow-auto">
<div className="w-full">
{activities.map(activity => {
return <ActivityItem key={activity.uniqueId} activity={activity} />
return (
<ActivityItem
key={activity.uniqueId}
activity={activity}
userAddress={userAddress}
/>
)
})}
</div>
</div>
</div>
)
}
type ActivityItemProps = {
activity: Activity
}
const ActivityItem = (props: ActivityItemProps) => {
const { activity } = props
const outgoingTransaction = activity.from === fromAddress
const assetSymbol =
activity.asset || (activity.category === 'external' ? 'ETH' : null)
const eurValue = Number(activity.eurRate) * Number(activity.value)
return (
<div className="grid grid-cols-[2fr_1fr_1fr] gap-8 p-3 transition-colors focus-within:bg-neutral-5 hover:bg-neutral-5">
<div className="flex items-center gap-3">
<div className="relative">
<TokenLogo
symbol={assetSymbol || 'ETH'}
address={activity.rawContract?.address ?? ''}
/>
<div className="absolute bottom-[-4px] right-[-4px] size-[18px] rounded-full border border-white-100">
<NetworkLogo name={activity.network} size={16} />
</div>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2 text-15 font-600 sm:max-w-full">
<ActivityDirection
direction={outgoingTransaction ? 'sent' : 'received'}
/>
<span className="text-13 font-400 text-neutral-40">
{formatRelative(
new Date(activity.metadata.blockTimestamp),
new Date(),
{hasNextPage && (
<div
ref={endOfPageRef}
className="flex h-20 items-center justify-center"
>
{isLoading ? (
<div className="text-13 font-400 text-neutral-50">
Loading more activities...
</div>
) : (
<div className="h-1" />
)}
</span>
</div>
<div className="flex items-end text-13 font-400 text-neutral-50">
<ContextTag type="label" size="24">
{shortenAddress(
outgoingTransaction ? activity.to : activity.from,
)}
</ContextTag>
</div>
</div>
)}
{!hasNextPage && activities.length > 0 && (
<div className="py-8 text-center text-13 font-400 text-neutral-40">
No more activities to load
</div>
)}
</div>
</div>
<div className="flex flex-col items-end justify-center">
{assetSymbol ? (
<div className="flex flex-col items-end gap-1">
<ContextTag type="label" size="24">
{`${
outgoingTransaction ? '-' : '+'
} ${formatTokenAmount(activity.value, 'precise')} ${assetSymbol}`}
</ContextTag>
{eurValue > 0 && (
<div
className={cx(
'flex items-center gap-1 text-13 font-500',
outgoingTransaction ? 'text-danger-50' : 'text-success-50',
)}
>
{outgoingTransaction ? '-' : '+'}
<CurrencyAmount
value={eurValue}
format="standard"
className="text-13 font-500"
/>
</div>
)}
</div>
) : (
<ContextTag type="label" size="24">
{getTokenActivityLabel(activity)}
</ContextTag>
)}
</div>
<div className="flex items-center justify-end text-13 font-400 text-neutral-50">
<a
href={`https://etherscan.io/tx/${activity.hash}`}
target="_blank"
className="flex items-center gap-1"
>
<ActivityStatus status={activity.status} />
<ExternalIcon className="text-neutral-50" />
</a>
</div>
</div>
)
}
type ActivityStatusProps = {
status: Activity['status']
}
const ActivityStatus = (props: ActivityStatusProps) => {
const { status } = props
const baseClass =
'flex items-center gap-1 rounded-20 border pl-[5px] pr-[8px] py-[3px] h-6'
return match(status)
.with('success', () => {
return (
<div className={cx(baseClass, 'border-success-50 bg-success-50/10')}>
<CheckIcon className="text-success-50" />
<div className="text-13 font-500 text-success-50">Success</div>
</div>
)
})
.with('failed', () => {
return (
<div className={cx(baseClass, 'border-danger-50/20 bg-danger-50/10')}>
<NegativeStateIcon className="text-danger-50" />
<div className="text-13 font-500 text-danger-50">Failed</div>
</div>
)
})
.with('pending', () => {
return (
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
<PendingIcon className="text-neutral-40" />
<div className="flex text-13 font-400 text-neutral-40">Pending</div>
</div>
)
})
.with('unknown', () => {
return (
<div className={cx(baseClass, 'border-neutral-20 bg-neutral-10')}>
<div className="flex text-13 font-400 text-neutral-50">Unknown</div>
</div>
)
})
.exhaustive()
}
type TokenLogoProps = {
symbol: string
address: string
}
const TokenLogo = (props: TokenLogoProps) => {
const { symbol, address } = props
const getTokenLogo = (symbol: string, contractAddress?: string) => {
if (symbol === 'ETH') {
return 'https://assets.coingecko.com/coins/images/279/large/ethereum.png'
}
const token = erc20TokenList.tokens.find(
token =>
token.symbol === symbol ||
(contractAddress &&
token.address.toLowerCase() === contractAddress.toLowerCase()),
)
return token?.logoURI ?? ''
}
const src = getTokenLogo(symbol, address)
if (!src) {
return (
<div className="flex size-8 flex-shrink-0 items-center justify-center rounded-full bg-neutral-10 text-11 font-600 text-neutral-50">
{symbol.slice(0, 4).toUpperCase()}
</div>
)
}
return (
<img
className="size-8 flex-shrink-0 rounded-full bg-neutral-10"
alt={symbol}
src={src}
/>
)
}
type ActivityDirectionProps = {
direction: 'sent' | 'received'
}
const ActivityDirection = (props: ActivityDirectionProps) => {
const { direction } = props
const Icon = direction === 'sent' ? SendIcon : ReceiveIcon
const text = direction === 'sent' ? 'Sent' : 'Received'
return (
<div className="flex items-center gap-1 text-15 font-600 text-neutral-100">
<Icon className="text-neutral-50" />
{text}
</div>
)
}
@@ -16,6 +16,7 @@ type Props = {
// TODO: get this from the user's settings
const SYMBOL: 'EUR' | 'USD' = 'EUR'
const MIN_VALUE = 0.01
export const CurrencyAmount = (props: Props) => {
const { value, format = 'standard', className } = props
@@ -61,5 +62,9 @@ export const CurrencyAmount = (props: Props) => {
[format],
)
if (value > 0 && value < MIN_VALUE && format !== 'precise') {
return <div className={className}>{'< ' + formatter.format(MIN_VALUE)}</div>
}
return <div className={className}>{formatter.format(value)}</div>
}
+1 -1
View File
@@ -1,7 +1,7 @@
export type * from '../types'
export * from '../utils/variants'
export { AccountMenu } from './account-menu'
export { ActivityList } from './activity-list'
export { ActivityList, type ActivityListProps } from './activity-list'
export { type Account, Address, type AddressProps } from './address'
export { AssetsList } from './assets-list'
export { Balance } from './balance'
@@ -0,0 +1,23 @@
import { format, isToday, isYesterday } from 'date-fns'
type Props = {
timestamp: Date | string | number
className?: string
}
export const RelativeDate = (props: Props) => {
const { timestamp, className } = props
const date = new Date(timestamp)
const formatDate = () => {
if (isToday(date)) {
return `Today ${format(date, 'HH:mm')}`
}
if (isYesterday(date)) {
return `Yesterday ${format(date, 'HH:mm')}`
}
return format(date, 'dd MMMM HH:mm')
}
return <span className={className}>{formatDate()}</span>
}
@@ -41,6 +41,32 @@ const cachedPriceData = cache(async (key: string) => {
return await fetchTokensPriceForDate(symbols, timestamp)
})
type PriceDataResponse = Record<string, { EUR?: { PRICE: number } }>
const batchPriceRequests = async (
priceRequests: Array<{ symbols: string[]; timestamp: number }>,
): Promise<Record<string, PriceDataResponse>> => {
const BATCH_SIZE = 5
const results: Record<string, PriceDataResponse> = {}
for (let i = 0; i < priceRequests.length; i += BATCH_SIZE) {
const batch = priceRequests.slice(i, i + BATCH_SIZE)
const batchPromises = batch.map(async ({ symbols, timestamp }) => {
const key = JSON.stringify({ symbols, timestamp })
const data = await cachedPriceData(key)
return { key, data }
})
const batchResults = await Promise.all(batchPromises)
batchResults.forEach(({ key, data }) => {
results[key] = data
})
}
return results
}
const cachedActivity = cache(async (key: string) => {
const { address, network } = JSON.parse(key)
return await activity(address, network)
@@ -178,9 +204,13 @@ export async function page({
{},
)
const enhancedActivities: Activity[] = []
const priceRequests: Array<{
symbols: string[]
timestamp: number
date: string
}> = []
for (const dateActivities of Object.values(activityGroups)) {
for (const [date, dateActivities] of Object.entries(activityGroups)) {
const allAssetSymbols = [
...new Set(
dateActivities.map(activity => activity.asset).filter(Boolean),
@@ -204,9 +234,24 @@ export async function page({
new Date(dateActivities[0].metadata.blockTimestamp).getTime() / 1000,
)
const priceData = await cachedPriceData(
JSON.stringify({ symbols: validAssetSymbols, timestamp }),
)
if (validAssetSymbols.length > 0) {
priceRequests.push({ symbols: validAssetSymbols, timestamp, date })
}
}
const batchedPriceData = await batchPriceRequests(priceRequests)
const enhancedActivities: Activity[] = []
for (const [date, dateActivities] of Object.entries(activityGroups)) {
const requestKey = priceRequests.find(req => req.date === date)
const priceData = requestKey
? batchedPriceData[
JSON.stringify({
symbols: requestKey.symbols,
timestamp: requestKey.timestamp,
})
] || {}
: {}
const dateEnhancedActivities = dateActivities.map(activity => {
const assetSymbol =
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ApiRouter } from '.'
import type { Activity } from './routers/activity'
import type { Collectible } from './routers/collectibles'
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'
@@ -15,4 +16,4 @@ export type NetworkType =
| 'polygon'
| 'bsc'
export type { Collectible }
export type { Activity, Collectible }
+7 -1
View File
@@ -1,3 +1,9 @@
export { type ApiRouter, apiRouter } from './api'
export type { ApiInput, ApiOutput, Collectible, NetworkType } from './api/types'
export type {
Activity,
ApiInput,
ApiOutput,
Collectible,
NetworkType,
} from './api/types'
export { createAPI } from './trpc/api'
@@ -13,6 +13,7 @@ import { serverEnv } from '../../../config/env.server.mjs'
import type { NetworkType } from '../../api/types'
import type {
AssetTransfer,
deprecated_NFTSaleResponseBody,
ERC20TokenBalanceResponseBody,
NativeTokenBalanceResponseBody,
@@ -540,46 +541,155 @@ export async function getIncomingAssetTransfers(
}
}
type TransferBuffer = {
incoming: AssetTransfer[]
outgoing: AssetTransfer[]
incomingPageKey?: string
outgoingPageKey?: string
hasReachedEnd: {
incoming: boolean
outgoing: boolean
}
}
const transferBuffers = new Map<string, TransferBuffer>()
export async function getAssetTransfers(
address: string,
network: NetworkType,
pageKey?: string,
limit: number = 100,
limit: number = 20,
) {
const outgoingResult = await getOutgoingAssetTransfers(
address,
network,
pageKey,
Math.ceil(limit / 2),
)
try {
const cacheKey = `${address.toLowerCase()}-${network}`
let buffer = transferBuffers.get(cacheKey)
const incomingResult = await getIncomingAssetTransfers(
address,
network,
pageKey,
Math.ceil(limit / 2),
)
if (!pageKey || !buffer) {
buffer = {
incoming: [],
outgoing: [],
hasReachedEnd: { incoming: false, outgoing: false },
}
transferBuffers.set(cacheKey, buffer)
}
const allTransfers = [
...outgoingResult.transfers,
...incomingResult.transfers,
]
allTransfers.sort((a, b) => {
// First sort by block number (newest first)
const blockDiff = parseInt(b.blockNum, 16) - parseInt(a.blockNum, 16)
if (blockDiff !== 0) return blockDiff
let incomingPageKey: string | undefined
let outgoingPageKey: string | undefined
// If same block, sort by timestamp (newest first)
const timestampA = new Date(a.metadata.blockTimestamp).getTime()
const timestampB = new Date(b.metadata.blockTimestamp).getTime()
return timestampB - timestampA
})
if (pageKey) {
try {
const parsed = JSON.parse(pageKey)
if (parsed) {
incomingPageKey = parsed.incoming
outgoingPageKey = parsed.outgoing
}
} catch {
incomingPageKey = buffer.incomingPageKey
outgoingPageKey = buffer.outgoingPageKey
}
}
const transfers = allTransfers.slice(0, limit)
const needsFetch =
buffer.incoming.length < limit || buffer.outgoing.length < limit
return {
transfers,
pageKey: outgoingResult.pageKey || incomingResult.pageKey,
if (
needsFetch &&
(!buffer.hasReachedEnd.incoming || !buffer.hasReachedEnd.outgoing)
) {
const isOutgoingFetching =
!buffer.hasReachedEnd.outgoing && buffer.outgoing.length < limit
const isIncomingFetching =
!buffer.hasReachedEnd.incoming && buffer.incoming.length < limit
const [outgoingResult, incomingResult] = await Promise.all([
isOutgoingFetching
? getOutgoingAssetTransfers(
address,
network,
outgoingPageKey || buffer.outgoingPageKey,
limit,
)
: { transfers: [], pageKey: undefined },
isIncomingFetching
? getIncomingAssetTransfers(
address,
network,
incomingPageKey || buffer.incomingPageKey,
limit,
)
: { transfers: [], pageKey: undefined },
])
buffer.incoming.push(
...incomingResult.transfers.map(t => ({
...t,
tokenId: t.tokenId ?? undefined,
erc721TokenId: t.erc721TokenId ?? undefined,
erc1155Metadata: t.erc1155Metadata ?? undefined,
})),
)
buffer.outgoing.push(
...outgoingResult.transfers.map(t => ({
...t,
tokenId: t.tokenId ?? undefined,
erc721TokenId: t.erc721TokenId ?? undefined,
erc1155Metadata: t.erc1155Metadata ?? undefined,
})),
)
buffer.incomingPageKey = incomingResult.pageKey
buffer.outgoingPageKey = outgoingResult.pageKey
buffer.hasReachedEnd.incoming = incomingResult.pageKey === undefined
buffer.hasReachedEnd.outgoing = outgoingResult.pageKey === undefined
}
const allTransfers = [...buffer.incoming, ...buffer.outgoing]
const transfersMap = new Map<string, AssetTransfer>()
for (const transfer of allTransfers) {
transfersMap.set(transfer.uniqueId, transfer)
}
const uniqueTransfers = Array.from(transfersMap.values())
uniqueTransfers.sort((a, b) => {
const blockDiff = parseInt(b.blockNum, 16) - parseInt(a.blockNum, 16)
if (blockDiff !== 0) return blockDiff
const timestampA = new Date(a.metadata.blockTimestamp).getTime()
const timestampB = new Date(b.metadata.blockTimestamp).getTime()
return timestampB - timestampA
})
const transfers = uniqueTransfers.slice(0, limit)
const consumedIds = new Set(transfers.map(t => t.uniqueId))
buffer.incoming = buffer.incoming.filter(t => !consumedIds.has(t.uniqueId))
buffer.outgoing = buffer.outgoing.filter(t => !consumedIds.has(t.uniqueId))
const hasMoreTransfers =
!buffer.hasReachedEnd.incoming ||
!buffer.hasReachedEnd.outgoing ||
buffer.incoming.length > 0 ||
buffer.outgoing.length > 0
let nextPageKey: string | undefined
if (hasMoreTransfers) {
nextPageKey = JSON.stringify({
incoming: buffer.incomingPageKey,
outgoing: buffer.outgoingPageKey,
})
}
return {
transfers,
pageKey: nextPageKey,
}
} catch (error) {
console.error('getAssetTransfers error:', error)
return {
transfers: [],
pageKey: undefined,
}
}
}