Implement activity (#703)

Co-authored-by: Jakub Kotula <520927+jkbktl@users.noreply.github.com>
Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
Jinho Jang
2025-07-09 17:13:43 +09:00
committed by GitHub
co-authored by Jakub Kotula Felicio
parent 28715e88da
commit adc8c21df8
15 changed files with 1175 additions and 46 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@status-im/wallet': patch
'wallet': patch
---
add /activity to wallet
@@ -22,7 +22,6 @@ import { TabLink } from './tab-link'
// } as const
type Props = {
address: string
pathname: string
searchAndSortValues: {
inputValue: string
@@ -36,15 +35,16 @@ type Props = {
const ActionButtons = (props: Props) => {
// const { address, pathname, searchAndSortValues } = props
const { address, searchAndSortValues } = props
const { searchAndSortValues } = props
// const placeholder = placeholderText[checkPathnameAndReturnTabValue(pathname)]
return (
<div className="flex place-content-between">
<div className="flex gap-1.5">
<TabLink href={`/${address}/assets`}>Assets</TabLink>
<TabLink href={`/${address}/collectibles`}>Collectibles</TabLink>
<TabLink href={`/portfolio/assets`}>Assets</TabLink>
<TabLink href={`/portfolio/collectibles`}>Collectibles</TabLink>
<TabLink href={`/portfolio/activity`}>Activity</TabLink>
</div>
<div className="flex items-center gap-2">
{/* <Input
@@ -110,6 +110,7 @@ const SplittedLayout = (props: Props) => {
<TabLink href="/portfolio/collectibles">
Collectibles
</TabLink>
<TabLink href="/portfolio/activity">Activity</TabLink>
</div>
}
>
+95
View File
@@ -0,0 +1,95 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import type { NetworkType } from '@status-im/wallet/data'
const PAGE_LIMIT = 20
type Props = {
address: string
}
const getTransfers = async (
address: string,
networks: NetworkType[],
pageKeys: Partial<Record<NetworkType, string>> = {},
) => {
const url = new URL('http://localhost:3030/api/trpc/activities.page')
url.searchParams.set(
'input',
JSON.stringify({
json: {
address,
networks,
limit: PAGE_LIMIT,
pageKeys,
},
}),
)
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorBody = await response.text()
throw new Error(`Failed to fetch activities. ${errorBody}`)
}
const body = await response.json()
const data = body.result?.data?.json
if (!data) {
throw new Error('Unexpected response structure.')
}
return {
activities: data.activities,
nextPageKeys: data.nextPageKeys,
}
}
export const useActivities = ({ address }: Props) => {
const searchParams = new URLSearchParams(window.location.search)
const networks = searchParams.get('networks')?.split(',') ?? [
'ethereum',
// 'optimism',
// 'arbitrum',
// 'base',
// 'polygon',
// 'bsc',
]
return useInfiniteQuery({
queryKey: ['activities', address, networks],
queryFn: async ({ pageParam = {} }) => {
const result = await getTransfers(
address,
networks as NetworkType[],
pageParam,
)
return {
activities: result.activities,
nextPage: result.nextPageKeys,
}
},
getNextPageParam: (lastPage: {
activities: []
nextPage: Partial<Record<NetworkType, string | undefined>>
}) => {
const hasMore = Object.values(lastPage.nextPage).some(Boolean)
return hasMore ? lastPage.nextPage : undefined
},
initialPageParam: {},
staleTime: 1000 * 60 * 60,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
}
+26
View File
@@ -18,6 +18,7 @@ import { Route as OnboardingNewImport } from './routes/onboarding/new'
import { Route as OnboardingImportImport } from './routes/onboarding/import'
import { Route as PortfolioCollectiblesIndexImport } from './routes/portfolio/collectibles/index'
import { Route as PortfolioAssetsIndexImport } from './routes/portfolio/assets/index'
import { Route as PortfolioActivityIndexImport } from './routes/portfolio/activity/index'
import { Route as PortfolioAssetsTickerImport } from './routes/portfolio/assets/$ticker'
import { Route as PortfolioCollectiblesNetworkContractIdImport } from './routes/portfolio/collectibles/$network/$contract/$id'
@@ -67,6 +68,12 @@ const PortfolioAssetsIndexRoute = PortfolioAssetsIndexImport.update({
getParentRoute: () => rootRoute,
} as any)
const PortfolioActivityIndexRoute = PortfolioActivityIndexImport.update({
id: '/portfolio/activity/',
path: '/portfolio/activity/',
getParentRoute: () => rootRoute,
} as any)
const PortfolioAssetsTickerRoute = PortfolioAssetsTickerImport.update({
id: '/portfolio/assets/$ticker',
path: '/portfolio/assets/$ticker',
@@ -126,6 +133,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PortfolioAssetsTickerImport
parentRoute: typeof rootRoute
}
'/portfolio/activity/': {
id: '/portfolio/activity/'
path: '/portfolio/activity'
fullPath: '/portfolio/activity'
preLoaderRoute: typeof PortfolioActivityIndexImport
parentRoute: typeof rootRoute
}
'/portfolio/assets/': {
id: '/portfolio/assets/'
path: '/portfolio/assets'
@@ -174,6 +188,7 @@ export interface FileRoutesByFullPath {
'/onboarding/new': typeof OnboardingNewRoute
'/onboarding/': typeof OnboardingIndexRoute
'/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute
'/portfolio/activity': typeof PortfolioActivityIndexRoute
'/portfolio/assets': typeof PortfolioAssetsIndexRoute
'/portfolio/collectibles': typeof PortfolioCollectiblesIndexRoute
'/portfolio/collectibles/$network/$contract/$id': typeof PortfolioCollectiblesNetworkContractIdRoute
@@ -185,6 +200,7 @@ export interface FileRoutesByTo {
'/onboarding/new': typeof OnboardingNewRoute
'/onboarding': typeof OnboardingIndexRoute
'/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute
'/portfolio/activity': typeof PortfolioActivityIndexRoute
'/portfolio/assets': typeof PortfolioAssetsIndexRoute
'/portfolio/collectibles': typeof PortfolioCollectiblesIndexRoute
'/portfolio/collectibles/$network/$contract/$id': typeof PortfolioCollectiblesNetworkContractIdRoute
@@ -198,6 +214,7 @@ export interface FileRoutesById {
'/onboarding/new': typeof OnboardingNewRoute
'/onboarding/': typeof OnboardingIndexRoute
'/portfolio/assets/$ticker': typeof PortfolioAssetsTickerRoute
'/portfolio/activity/': typeof PortfolioActivityIndexRoute
'/portfolio/assets/': typeof PortfolioAssetsIndexRoute
'/portfolio/collectibles/': typeof PortfolioCollectiblesIndexRoute
'/portfolio/collectibles/$network/$contract/$id': typeof PortfolioCollectiblesNetworkContractIdRoute
@@ -212,6 +229,7 @@ export interface FileRouteTypes {
| '/onboarding/new'
| '/onboarding/'
| '/portfolio/assets/$ticker'
| '/portfolio/activity'
| '/portfolio/assets'
| '/portfolio/collectibles'
| '/portfolio/collectibles/$network/$contract/$id'
@@ -222,6 +240,7 @@ export interface FileRouteTypes {
| '/onboarding/new'
| '/onboarding'
| '/portfolio/assets/$ticker'
| '/portfolio/activity'
| '/portfolio/assets'
| '/portfolio/collectibles'
| '/portfolio/collectibles/$network/$contract/$id'
@@ -233,6 +252,7 @@ export interface FileRouteTypes {
| '/onboarding/new'
| '/onboarding/'
| '/portfolio/assets/$ticker'
| '/portfolio/activity/'
| '/portfolio/assets/'
| '/portfolio/collectibles/'
| '/portfolio/collectibles/$network/$contract/$id'
@@ -243,6 +263,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
OnboardingLayoutRoute: typeof OnboardingLayoutRouteWithChildren
PortfolioAssetsTickerRoute: typeof PortfolioAssetsTickerRoute
PortfolioActivityIndexRoute: typeof PortfolioActivityIndexRoute
PortfolioAssetsIndexRoute: typeof PortfolioAssetsIndexRoute
PortfolioCollectiblesIndexRoute: typeof PortfolioCollectiblesIndexRoute
PortfolioCollectiblesNetworkContractIdRoute: typeof PortfolioCollectiblesNetworkContractIdRoute
@@ -252,6 +273,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
OnboardingLayoutRoute: OnboardingLayoutRouteWithChildren,
PortfolioAssetsTickerRoute: PortfolioAssetsTickerRoute,
PortfolioActivityIndexRoute: PortfolioActivityIndexRoute,
PortfolioAssetsIndexRoute: PortfolioAssetsIndexRoute,
PortfolioCollectiblesIndexRoute: PortfolioCollectiblesIndexRoute,
PortfolioCollectiblesNetworkContractIdRoute:
@@ -271,6 +293,7 @@ export const routeTree = rootRoute
"/",
"/onboarding",
"/portfolio/assets/$ticker",
"/portfolio/activity/",
"/portfolio/assets/",
"/portfolio/collectibles/",
"/portfolio/collectibles/$network/$contract/$id"
@@ -302,6 +325,9 @@ export const routeTree = rootRoute
"/portfolio/assets/$ticker": {
"filePath": "portfolio/assets/$ticker.tsx"
},
"/portfolio/activity/": {
"filePath": "portfolio/activity/index.tsx"
},
"/portfolio/assets/": {
"filePath": "portfolio/assets/index.tsx"
},
@@ -0,0 +1,30 @@
import { ActivityList, FeedbackSection } from '@status-im/wallet/components'
import { createFileRoute } from '@tanstack/react-router'
import SplittedLayout from '@/components/splitted-layout'
import { useActivities } from '@/hooks/use-activities'
export const Route = createFileRoute('/portfolio/activity/')({
component: RouteComponent,
})
function RouteComponent() {
const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
const { data, isLoading } = useActivities({ address })
const activities = data?.pages.flatMap(page => page.activities) ?? []
return (
<SplittedLayout
list={
activities ? (
<ActivityList activities={activities} />
) : (
<div className="mt-4 flex flex-col gap-3">Empty state</div>
)
}
detail={<FeedbackSection />}
isLoading={isLoading}
/>
)
}
@@ -0,0 +1,256 @@
'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 type { ApiOutput } from '../../data'
const fromAddress = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
type Activity = ApiOutput['activities']['activities']['activities'][0]
type Props = {
activities: Activity[]
}
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 ActivityList = (props: Props) => {
const { activities } = props
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} />
})}
</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(),
)}
</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>
</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>
)
}
export { ActivityList }
+1
View File
@@ -1,6 +1,7 @@
export type * from '../types'
export * from '../utils/variants'
export { AccountMenu } from './account-menu'
export { ActivityList } from './activity-list'
export { type Account, Address, type AddressProps } from './address'
export { AssetsList } from './assets-list'
export { Balance } from './balance'
@@ -2,6 +2,50 @@ import { useMemo } from 'react'
import { match } from 'ts-pattern'
type TokenAmountFormat = 'compact' | 'standard' | 'precise'
const createTokenAmountFormatter = (format: TokenAmountFormat) => {
return match(format)
.with(
'compact',
() =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
notation: 'compact',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}),
)
.with(
'standard',
() =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
notation: 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}),
)
.with(
'precise',
() =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
notation: 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
minimumSignificantDigits: 1,
maximumSignificantDigits: 4,
roundingPriority: 'morePrecision',
}),
)
.exhaustive()
}
const formatTokenAmount = (value: number, format: TokenAmountFormat) => {
return createTokenAmountFormatter(format).format(value)
}
type Props = {
value: number
/**
@@ -10,52 +54,16 @@ type Props = {
* - 'standard': Regular format with 2 decimal places (e.g., 1,234.56, 789.00)
* - 'precise': Higher precision format with 4 significant digits, ideal for very small amounts (e.g., 0.0001234, 0.00006789)
*/
format: 'compact' | 'standard' | 'precise'
format: TokenAmountFormat
className?: string
}
export const TokenAmount = (props: Props) => {
const TokenAmount = (props: Props) => {
const { value, format, className } = props
const formatter = useMemo(
() =>
match(format)
.with(
'compact',
() =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
notation: 'compact',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}),
)
.with(
'standard',
() =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
notation: 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}),
)
.with(
'precise',
() =>
new Intl.NumberFormat('en-US', {
style: 'decimal',
notation: 'standard',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
minimumSignificantDigits: 1,
maximumSignificantDigits: 4,
roundingPriority: 'morePrecision',
}),
)
.exhaustive(),
[format],
)
const formatter = useMemo(() => createTokenAmountFormatter(format), [format])
return <div className={className}>{formatter.format(value)}</div>
}
export { formatTokenAmount, TokenAmount }
+2
View File
@@ -1,10 +1,12 @@
import { createCallerFactory, router } from './lib/trpc'
import { activitiesRouter as activities } from './routers/activity'
import { assetsRouter as assets } from './routers/assets'
import { collectiblesRouter as collectibles } from './routers/collectibles'
export const apiRouter = router({
assets,
collectibles,
activities,
})
export type ApiRouter = typeof apiRouter
@@ -0,0 +1,337 @@
import { cache } from 'react'
import { format } from 'date-fns'
import { z } from 'zod'
import erc20TokenList from '../../../constants/erc20.json'
import { groupBy } from '../../../utils/group-by'
import { getAssetTransfers, getTransactionStatus } from '../../services/alchemy'
import { fetchTokensPriceForDate } from '../../services/cryptocompare'
import { publicProcedure, router } from '../lib/trpc'
import type { AssetTransfer } from '../../services/alchemy/types'
import type { NetworkType } from '../types'
export type Activity = AssetTransfer & {
network: NetworkType
status: 'pending' | 'success' | 'failed' | 'unknown'
eurRate?: number
}
const MAX_CONCURRENT_REQUESTS = 5
function checkToken(symbol: string, contractAddress?: string | null): boolean {
if (symbol === 'ETH') return true
if (!contractAddress) return false
const knownToken = erc20TokenList.tokens.find(
token =>
token.symbol === symbol &&
token.address.toLowerCase() === contractAddress.toLowerCase(),
)
return Boolean(knownToken)
}
const cachedPriceData = cache(async (key: string) => {
const { symbols, timestamp } = JSON.parse(key)
if (symbols.length === 0) return {}
return await fetchTokensPriceForDate(symbols, timestamp)
})
const cachedActivity = cache(async (key: string) => {
const { address, network } = JSON.parse(key)
return await activity(address, network)
})
async function runWithConcurrency<T>(
tasks: (() => Promise<T>)[],
concurrency: number,
): Promise<T[]> {
const results: T[] = []
let index = 0
async function worker() {
while (index < tasks.length) {
const current = index++
results[current] = await tasks[current]()
}
}
const workers = Array.from({ length: concurrency }, () => worker())
await Promise.all(workers)
return results
}
export const activitiesRouter = router({
page: publicProcedure
.input(
z.object({
address: z.string(),
networks: z.array(
z.enum([
'ethereum',
'optimism',
'arbitrum',
'base',
'polygon',
'bsc',
]),
),
limit: z.number().optional(),
pageKeys: z.record(z.string()).optional(),
}),
)
.query(async ({ input }) => {
const key = JSON.stringify(input)
return await cachedPage(key)
}),
activities: publicProcedure
.input(
z.object({
address: z.string(),
network: z.enum([
'ethereum',
'optimism',
'arbitrum',
'base',
'polygon',
'bsc',
]),
}),
)
.query(async ({ input }) => {
const key = JSON.stringify(input)
return await cachedActivity(key)
}),
})
const cachedPage = cache(async (key: string) => {
const { address, networks, limit, pageKeys } = JSON.parse(key)
return await page({ address, networks, limit, pageKeys })
})
export async function page({
address,
networks,
limit = 20,
pageKeys = {},
}: {
address: string
networks: NetworkType[]
limit?: number
pageKeys?: Partial<Record<NetworkType, string>>
}) {
const activities: Activity[] = []
const nextPageKeys: Record<NetworkType, string | undefined> = {} as Record<
NetworkType,
string | undefined
>
for (const network of networks) {
try {
const { transfers, pageKey } = await getAssetTransfers(
address,
network,
pageKeys[network],
limit,
)
nextPageKeys[network] = pageKey
const tasks = transfers.map(tx => async () => {
const status = await getTransactionStatus(tx.hash, network)
return {
...tx,
status,
value: Number(tx.value),
tokenId: tx.tokenId ?? undefined,
erc721TokenId: tx.erc721TokenId ?? undefined,
erc1155Metadata: tx.erc1155Metadata ?? undefined,
rawContract: {
value: tx.rawContract?.value ?? '0',
address: tx.rawContract?.address ?? null,
decimal: tx.rawContract?.decimal ?? '0',
},
network,
} as Activity
})
const results = await runWithConcurrency(tasks, MAX_CONCURRENT_REQUESTS)
activities.push(...results)
} catch (err) {
console.error(`[alchemy] Error fetching transfers for ${network}:`, err)
throw new Error(`Failed to fetch transfers for ${network}`)
}
}
const activityGroups = groupBy(
activities,
activity => {
return format(new Date(activity.metadata.blockTimestamp), 'yyyy-MM-dd')
},
{},
)
const enhancedActivities: Activity[] = []
for (const dateActivities of Object.values(activityGroups)) {
const allAssetSymbols = [
...new Set(
dateActivities.map(activity => activity.asset).filter(Boolean),
),
]
const validAssetSymbols = allAssetSymbols.filter(symbol => {
const activity = dateActivities.find(a => a.asset === symbol)
const contractAddress = activity?.rawContract?.address
return checkToken(symbol, contractAddress)
})
const hasEthTransfers = dateActivities.some(
activity => !activity.asset && activity.category === 'external',
)
if (hasEthTransfers) {
validAssetSymbols.push('ETH')
}
const timestamp = Math.floor(
new Date(dateActivities[0].metadata.blockTimestamp).getTime() / 1000,
)
const priceData = await cachedPriceData(
JSON.stringify({ symbols: validAssetSymbols, timestamp }),
)
const dateEnhancedActivities = dateActivities.map(activity => {
const assetSymbol =
activity.asset || (activity.category === 'external' ? 'ETH' : null)
if (assetSymbol && priceData[assetSymbol]) {
const eurRate = priceData[assetSymbol].EUR?.PRICE ?? 0
return {
...activity,
eurRate,
}
}
return activity
})
enhancedActivities.push(...dateEnhancedActivities)
}
enhancedActivities.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
})
return {
activities: enhancedActivities,
nextPageKeys,
}
}
export async function activity(
address: string,
network: NetworkType,
pageKey?: string,
limit = 100,
): Promise<{ activities: Activity[]; nextPageKey?: string }> {
const { transfers, pageKey: nextPageKey } = await getAssetTransfers(
address,
network,
pageKey,
limit,
)
const tasks = transfers.map(tx => async () => {
const status = await getTransactionStatus(tx.hash, network)
return {
...tx,
status,
value: tx.value,
tokenId: tx.tokenId ?? undefined,
erc721TokenId: tx.erc721TokenId ?? undefined,
erc1155Metadata: tx.erc1155Metadata ?? undefined,
rawContract: {
value: tx.rawContract?.value ?? '0',
address: tx.rawContract?.address ?? null,
decimal: tx.rawContract?.decimal ?? '0',
},
network,
} as Activity
})
const activities = await runWithConcurrency(tasks, MAX_CONCURRENT_REQUESTS)
const activityGroups = groupBy(
activities,
activity => {
return format(new Date(activity.metadata.blockTimestamp), 'yyyy-MM-dd')
},
{},
)
const enhancedActivities: Activity[] = []
for (const dateActivities of Object.values(activityGroups)) {
const allAssetSymbols = [
...new Set(
dateActivities.map(activity => activity.asset).filter(Boolean),
),
]
const validAssetSymbols = allAssetSymbols.filter(symbol => {
const activity = dateActivities.find(a => a.asset === symbol)
const contractAddress = activity?.rawContract?.address
return checkToken(symbol, contractAddress)
})
const hasEthTransfers = dateActivities.some(
activity => !activity.asset && activity.category === 'external',
)
if (hasEthTransfers) {
validAssetSymbols.push('ETH')
}
const timestamp = Math.floor(
new Date(dateActivities[0].metadata.blockTimestamp).getTime() / 1000,
)
const priceData = await cachedPriceData(
JSON.stringify({ symbols: validAssetSymbols, timestamp }),
)
const dateEnhancedActivities = dateActivities.map(activity => {
const assetSymbol =
activity.asset || (activity.category === 'external' ? 'ETH' : null)
if (assetSymbol && priceData[assetSymbol]) {
const eurRate = priceData[assetSymbol].EUR?.PRICE ?? 0
return {
...activity,
eurRate,
}
}
return activity
})
enhancedActivities.push(...dateEnhancedActivities)
}
return {
activities: enhancedActivities,
nextPageKey,
}
}
@@ -32,6 +32,22 @@ const alchemyNetworks = {
bsc: 'bnb-mainnet',
}
const unsupportedCategoriesByNetwork: Partial<Record<NetworkType, string[]>> = {
bsc: ['internal'],
arbitrum: ['internal'],
base: ['internal'],
optimism: ['internal'],
}
const allCategories = [
'external',
'internal',
'erc20',
'erc721',
'erc1155',
'specialnft',
] as const
// todo: use `genesisTimestamp` for `all` days parame
// const networkConfigs = {
// ethereum: {
@@ -327,10 +343,276 @@ export async function getNFTMetadata(
return body
}
export async function getLatestBlockNumber(
network: NetworkType,
): Promise<number> {
const url = `https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_blockNumber',
params: [],
id: 1,
}),
})
const data = await res.json()
if (!data.result) {
throw new Error(`Failed to fetch latest block number for ${network}`)
}
return parseInt(data.result, 16)
}
/**
* @see https://www.alchemy.com/docs/data/transfers-api/transfers-endpoints/alchemy-get-asset-transfers
*
* 120 CU per request https://www.alchemy.com/docs/reference/compute-unit-costs#transfers-api
*/
export async function getOutgoingAssetTransfers(
fromAddress: string,
network: NetworkType,
pageKey?: string,
limit: number = 100,
) {
const supportedCategories = allCategories.filter(
category => !unsupportedCategoriesByNetwork[network]?.includes(category),
)
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
)
const params: {
category: (typeof allCategories)[number][]
fromAddress: string
excludeZeroValue: boolean
withMetadata: boolean
order: 'asc' | 'desc'
maxCount: string
pageKey?: string
} = {
category: supportedCategories,
fromAddress,
excludeZeroValue: true,
withMetadata: true,
order: 'desc',
maxCount: `0x${limit.toString(16)}`,
}
if (pageKey) {
params.pageKey = pageKey
}
const body = await _retry(async () =>
_fetch<
TokenBalanceHistoryResponseBody & {
result: {
transfers: TokenBalanceHistoryResponseBody['result']['transfers']
pageKey?: string
}
}
>(url, 'POST', 3600, {
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [params, 'latest'],
id: Date.now(),
}),
)
if ('error' in body) {
console.error('[Alchemy Error]', body.error)
throw new Error(`Alchemy API Error`)
}
const result = body.result
if (!result?.transfers) {
console.error('[Alchemy Warning] Missing transfers in response:', result)
return { transfers: [], pageKey: undefined }
}
result.transfers.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
// 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
})
return {
transfers: result.transfers,
pageKey: result.pageKey, // undefined if last page
}
}
/**
* Get incoming asset transfers (transactions sent TO an address)
*
* @see https://docs.alchemy.com/reference/alchemy-gettransfers
*/
export async function getIncomingAssetTransfers(
toAddress: string,
network: NetworkType,
pageKey?: string,
limit: number = 100,
) {
const supportedCategories = allCategories.filter(
category => !unsupportedCategoriesByNetwork[network]?.includes(category),
)
const url = new URL(
`https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`,
)
const params: {
category: (typeof allCategories)[number][]
toAddress: string
excludeZeroValue: boolean
withMetadata: boolean
maxCount: string
order: 'desc'
pageKey?: string
} = {
category: supportedCategories,
toAddress,
excludeZeroValue: true,
withMetadata: true,
maxCount: `0x${limit.toString(16)}`,
order: 'desc',
}
if (pageKey) {
params.pageKey = pageKey
}
const body = await _retry(async () =>
_fetch<
TokenBalanceHistoryResponseBody & {
result: {
transfers: TokenBalanceHistoryResponseBody['result']['transfers']
pageKey?: string
}
}
>(url, 'POST', 3600, {
jsonrpc: '2.0',
method: 'alchemy_getAssetTransfers',
params: [params, 'latest'],
id: Date.now(),
}),
)
if ('error' in body) {
console.error('[Alchemy Error]', body.error)
throw new Error(`Alchemy API Error`)
}
const result = body.result
if (!result?.transfers) {
console.error('[Alchemy Warning] Missing transfers in response:', result)
return { transfers: [], pageKey: undefined }
}
result.transfers.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
// 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
})
return {
transfers: result.transfers,
pageKey: result.pageKey,
}
}
export async function getAssetTransfers(
address: string,
network: NetworkType,
pageKey?: string,
limit: number = 100,
) {
const outgoingResult = await getOutgoingAssetTransfers(
address,
network,
pageKey,
Math.ceil(limit / 2),
)
const incomingResult = await getIncomingAssetTransfers(
address,
network,
pageKey,
Math.ceil(limit / 2),
)
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
// 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
})
const transfers = allTransfers.slice(0, limit)
return {
transfers,
pageKey: outgoingResult.pageKey || incomingResult.pageKey,
}
}
export async function getTransactionStatus(
txHash: string,
network: NetworkType,
): Promise<'pending' | 'success' | 'failed' | 'unknown'> {
const url = `https://${alchemyNetworks[network]}.g.alchemy.com/v2/${serverEnv.ALCHEMY_API_KEY}`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'eth_getTransactionReceipt',
params: [txHash],
id: 1,
}),
})
const data = await res.json()
if (data?.result == null) return 'pending'
if (data?.result?.status === '0x1') return 'success'
if (data?.result?.status === '0x0') return 'failed'
return 'unknown'
}
/**
* note: only available on Ethereum (Seaport, Wyvern, X2Y2, Blur, LooksRare, Cryptopunks), Polygon (Seaport) & Optimism (Seaport) mainnets
*
* important: We plan to release a new API that integrates NFT sales before turning off this endpoint (eta December 2024), so well keep you posted and let you know when that is scheduled!
* important: We plan to release a new API that integrates NFT sales before turning off this endpoint (eta December 2024), so we'll keep you posted and let you know when that is scheduled!
*
* @see https://docs.alchemy.com/reference/getnftsales-v3
*
@@ -246,6 +246,36 @@ export type NFTFloorPriceResponseBody = {
}
}
export type AssetTransfer = {
blockNum: string
category: string
from: string
to: string
value: number
hash: string
asset: string
tokenId?: string
uniqueId: string
erc1155Metadata?: { tokenId: string; value: string }[]
erc721TokenId?: string
metadata: {
blockTimestamp: string
}
rawContract: {
value: string
address: string | null
decimal: string
}
}
export type AssetTransfersResponseBody = {
jsonrpc: '2.0'
id: number
result: {
transfers: AssetTransfer[]
}
}
export type ResponseBody =
| ERC20TokenBalanceResponseBody
| NativeTokenBalanceResponseBody
@@ -253,3 +283,5 @@ export type ResponseBody =
| NFTMetadataResponseBody
| deprecated_NFTSaleResponseBody
| NFTFloorPriceResponseBody
| TokenBalanceHistoryResponseBody
| AssetTransfersResponseBody
@@ -146,6 +146,44 @@ export async function legacy_fetchTokensPrice(symbols: string[]) {
return data
}
/**
* Returns the price of a tokens at a specific timestamp.
* @see https://min-api.cryptocompare.com/documentation?key=Historical&cat=dataHistoday
*/
export async function fetchTokensPriceForDate(
symbols: string[],
timestamp: number,
) {
const data: Record<string, { EUR: { PRICE: number } }> = {}
for (const symbol of symbols) {
try {
const url = new URL('https://min-api.cryptocompare.com/data/v2/histoday')
url.searchParams.set('fsym', symbol)
url.searchParams.set('tsym', 'EUR')
url.searchParams.set('toTs', timestamp.toString())
url.searchParams.set('limit', '1')
url.searchParams.set('tryConversion', 'true') // tries to convert to EUR if specific market does not exist i.e. ETH <-> EUR
url.searchParams.set('api_key', serverEnv.CRYPTOCOMPARE_API_KEY)
const body = await _fetch<legacy_TokenPriceHistoryResponseBody>(url, 3600)
const prices = body.Data.Data
if (prices.length > 0) {
data[symbol] = {
EUR: {
PRICE: prices[0].close,
},
}
}
} catch (err) {
console.warn(`No price data for ${symbol}:`, String(err))
}
}
return data
}
async function _fetch<
T extends
| legacy_TokensPriceResponseBody
+15
View File
@@ -0,0 +1,15 @@
export function groupBy<T, K extends string | number>(
list: T[],
getter: (input: T) => K,
initialValue: Record<K, T[]>,
): Record<K, T[]> {
const grouped: Record<K, T[]> = initialValue
for (const item of list) {
const key = getter(item)
grouped[key] ??= []
grouped[key].push(item)
}
return grouped
}