mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 06:11:10 +00:00
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:
co-authored by
Jakub Kotula
Felicio
parent
28715e88da
commit
adc8c21df8
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,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 }
|
||||
|
||||
@@ -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 we’ll 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user