Build a React App to Track EVM Trades Using Alchemy and RadzionKit

Build a React App to Track EVM Trades Using Alchemy and RadzionKit

December 30, 2024

20 min read

Build a React App to Track EVM Trades Using Alchemy and RadzionKit

Introduction

In this post, we’ll build a React app for tracking trading history on EVM chains. We’ll use the Alchemy API to fetch transaction data and RadzionKit as a foundation to kickstart our TypeScript monorepo for this project. You can find the full source code here and a live demo here.

Trading History
Trading History

Defining the Networks

We’ll support the Ethereum and Polygon networks, defining them in a configuration file. While this setup could easily be extended to include a UI option for users to select their preferred networks, we’ll keep the scope of this project small to avoid feature creep and maintain focus.

import { Network } from "alchemy-sdk"

export const tradingHistoryConfig = {
  networks: [Network.ETH_MAINNET, Network.MATIC_MAINNET],
}

Selecting Trade Assets

We’ll also define the specific trades we want to track. Given that the most common trading pairs involve a stablecoin paired with Ethereum or wrapped Ethereum (WETH) on L2 chains, we’ll designate ETH and WETH as our trade assets and USDC and USDT as our cash assets.

import { TradeType } from "@lib/chain/types/TradeType"

export const tradeAssets = ["ETH", "WETH"] as const
export type TradeAsset = (typeof tradeAssets)[number]

export const cashAssets = ["USDC", "USDT"] as const
export type CashAsset = (typeof cashAssets)[number]

export const primaryTradeAssetPriceProviderId = "ethereum"

export type Trade = {
  amount: number
  asset: TradeAsset
  cashAsset: CashAsset
  price: number
  type: TradeType

  timestamp: number
  hash: string
}

Trade Object Structure

A trade will be represented as an object with the following properties:

  • amount: The quantity of the trade asset involved in the trade.
  • asset: The trade asset (e.g., ETH or WETH).
  • cashAsset: The cash asset used in the trade (e.g., USDC or USDT).
  • price: The price of the trade asset denominated in the cash asset.
  • type: The type of trade, either "buy" or "sell".
  • timestamp: The time the trade occurred, represented as a Unix timestamp.
  • hash: The transaction hash, serving as a unique identifier for the trade.

Single Page Application Overview

Our app will feature a single page where users can view their trading history and manage their wallet addresses.

import { ClientOnly } from "@lib/ui/base/ClientOnly"
import { PageMetaTags } from "@lib/next-ui/metadata/PageMetaTags"
import { AlchemyApiKeyGuard } from "../../alchemy/components/AlchemyApiKeyGuard"
import { WebsiteNavigation } from "@lib/ui/website/navigation/WebsiteNavigation"
import { ProductLogo } from "../../product/ProductLogo"
import { ExitAlchemy } from "../../alchemy/components/ExitAlchemy"
import { Trades } from "./Trades"
import styled from "styled-components"
import { centeredContentColumn } from "@lib/ui/css/centeredContentColumn"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import { websiteConfig } from "@lib/ui/website/config"
import { HStack, vStack } from "@lib/ui/css/stack"
import { ManageAddresses } from "../addresses/ManageAddresses"
import { AddressesOnly } from "../addresses/AddressesOnly"

export const PageContainer = styled.div`
  ${centeredContentColumn({
    contentMaxWidth: websiteConfig.contentMaxWidth,
  })}

  ${verticalPadding(80)}
`

const Content = styled.div`
  ${vStack({ gap: 20, fullWidth: true })}
  max-width: 720px;
`

export const TradingHistoryPage = () => (
  <>
    <PageMetaTags
      title="ETH & WETH Trading History"
      description="Track ETH and WETH trades on Ethereum and Polygon. Easily check your trading history and decide if it’s a good time to buy or sell."
    />
    <ClientOnly>
      <AlchemyApiKeyGuard>
        <WebsiteNavigation
          renderTopbarItems={() => (
            <>
              <div />
              <ExitAlchemy />
            </>
          )}
          renderOverlayItems={() => <ExitAlchemy />}
          logo={<ProductLogo />}
        >
          <PageContainer>
            <HStack fullWidth wrap="wrap" gap={60}>
              <Content>
                <AddressesOnly>
                  <Trades />
                </AddressesOnly>
              </Content>
              <ManageAddresses />
            </HStack>
          </PageContainer>
        </WebsiteNavigation>
      </AlchemyApiKeyGuard>
    </ClientOnly>
  </>
)

Setting Up the Alchemy API Key

Set API Key
Set API Key

Since our app relies on an Alchemy API key to fetch transaction data, we’ll ensure users set their API key if they haven’t already. To handle this, we’ll wrap the page content in an AlchemyApiKeyGuard component. This component checks whether the API key is set and, if not, prompts users to input it using the SetAlchemyApiKey component.

import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useAlchemyApiKey } from "../state/alchemyApiKey"
import { SetAlchemyApiKey } from "./SetAlchemyApiKey"

export const AlchemyApiKeyGuard = ({
  children,
}: ComponentWithChildrenProps) => {
  const [value] = useAlchemyApiKey()

  if (!value) {
    return <SetAlchemyApiKey />
  }

  return <>{children}</>
}

We’ll store the API key in local storage so that users won’t need to re-enter it the next time they visit the app. If you’re curious about the implementation of usePersistentState, check out this post.

import {
  PersistentStateKey,
  usePersistentState,
} from "../../state/persistentState"

export const useAlchemyApiKey = () => {
  return usePersistentState<string | null>(
    PersistentStateKey.AlchemyApiKey,
    null,
  )
}

Validating the Alchemy API Key

In the SetAlchemyApiKey component, we’ll display our app’s logo alongside an input field where users can enter their Alchemy API key. Instead of using a submit button, we’ll validate the API key dynamically as the user types. To minimize unnecessary API calls, we’ll incorporate the InputDebounce component to debounce input changes, ensuring the validation process triggers only when the user stops typing.

import { useEffect, useState } from "react"
import { Center } from "@lib/ui/layout/Center"
import { vStack } from "@lib/ui/css/stack"
import { Text } from "@lib/ui/text"
import { InputDebounce } from "@lib/ui/inputs/InputDebounce"
import { TextInput } from "@lib/ui/inputs/TextInput"
import { useMutation } from "@tanstack/react-query"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { getErrorMessage } from "@lib/utils/getErrorMessage"
import styled from "styled-components"
import { useAlchemyApiKey } from "../state/alchemyApiKey"
import { isWrongAlchemyApiKey } from "../utils/isWrongAlchemyApiKey"
import { ProductLogo } from "../../product/ProductLogo"
import { Alchemy, Network } from "alchemy-sdk"

const Content = styled.div`
  ${vStack({
    gap: 20,
    alignItems: "center",
    fullWidth: true,
  })}

  max-width: 320px;
`

const Status = styled.div`
  min-height: 20px;
  ${vStack({
    alignItems: "center",
  })}
`

export const SetAlchemyApiKey = () => {
  const [, setValue] = useAlchemyApiKey()

  const { mutate, ...mutationState } = useMutation({
    mutationFn: async (apiKey: string) => {
      const alchemy = new Alchemy({
        apiKey,
        network: Network.ETH_MAINNET,
      })

      await alchemy.core.getBlockNumber()

      return apiKey
    },
    onSuccess: setValue,
  })

  const [inputValue, setInputValue] = useState("")

  useEffect(() => {
    if (inputValue) {
      mutate(inputValue)
    }
  }, [inputValue, mutate])

  return (
    <Center>
      <Content>
        <ProductLogo />
        <InputDebounce
          value={inputValue}
          onChange={setInputValue}
          render={({ value, onChange }) => (
            <TextInput
              value={value}
              onValueChange={onChange}
              autoFocus
              placeholder="Enter your Alchemy API key to continue"
            />
          )}
        />
        <Status>
          <MatchQuery
            value={mutationState}
            error={(error) => (
              <Text color="alert">
                {isWrongAlchemyApiKey(error)
                  ? "Wrong API Key"
                  : getErrorMessage(error)}
              </Text>
            )}
            pending={() => <Text>Loading...</Text>}
          />
        </Status>
      </Content>
    </Center>
  )
}

To validate the API key, we’ll make an arbitrary API call and assume the key is valid if no error occurs. For handling and displaying the pending and error states, we’ll use the MatchQuery component from RadzionKit. This component simplifies the rendering process by displaying different content based on the state of a mutation or query.

Users can "log out" and clear their API key by clicking the "Exit" button, conveniently located in the top-right corner of the page's topbar.```

import { HStack } from "@lib/ui/css/stack"
import { Button } from "@lib/ui/buttons/Button"
import { LogOutIcon } from "@lib/ui/icons/LogOutIcon"
import { useAlchemyApiKey } from "../state/alchemyApiKey"

export const ExitAlchemy = () => {
  const [, setValue] = useAlchemyApiKey()

  return (
    <Button kind="secondary" onClick={() => setValue(null)}>
      <HStack alignItems="center" gap={8}>
        <LogOutIcon />
        Exit
      </HStack>
    </Button>
  )
}

Centered Page Layout

The content of our page is centered using the centeredContentColumn utility from RadzionKit. It consists of two sections displayed side by side: trading history and address management.

import { css } from "styled-components"
import { toSizeUnit } from "./toSizeUnit"

interface CenteredContentColumnParams {
  contentMaxWidth: number | string
  horizontalMinPadding?: number | string
}

export const centeredContentColumn = ({
  contentMaxWidth,
  horizontalMinPadding = 20,
}: CenteredContentColumnParams) => css`
  display: grid;
  grid-template-columns:
    1fr min(
      ${toSizeUnit(contentMaxWidth)},
      100% - calc(${toSizeUnit(horizontalMinPadding)} * 2)
    )
    1fr;
  grid-column-gap: ${toSizeUnit(horizontalMinPadding)};

  > * {
    grid-column: 2;
  }
`

Address Management

Since trading history requires at least one address to function, we wrap it with the AddressesOnly component. This component checks if the user has added any addresses and displays a message prompting them to add one if none are found.

import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useAddresses } from "../state/addresses"
import { isEmpty } from "@lib/utils/array/isEmpty"
import { Text } from "@lib/ui/text"

export const AddressesOnly = ({ children }: ComponentWithChildrenProps) => {
  const [addresses] = useAddresses()

  if (isEmpty(addresses)) {
    return (
      <Text color="contrast" size={18}>
        Add an address to continue 👉
      </Text>
    )
  }

  return <>{children}</>
}

We store the addresses in local storage, just like the Alchemy API key. This ensures that users won’t need to re-enter their addresses each time they visit the app, making the experience seamless and user-friendly.

import {
  PersistentStateKey,
  usePersistentState,
} from "../../state/persistentState"

export const useAddresses = () => {
  return usePersistentState<string[]>(PersistentStateKey.Addresses, [])
}

The component for managing addresses is structured into three main sections:

  1. A header that includes the title and a visibility toggle.
  2. A list displaying the current addresses.
  3. An input field for adding new addresses.
import { HStack, VStack, vStack } from "@lib/ui/css/stack"
import styled from "styled-components"
import { useAddresses } from "../state/addresses"
import { Text } from "@lib/ui/text"
import { ManageAddressesVisibility } from "./ManageAddressesVisibility"
import { ManageAddress } from "./ManageAddress"
import { AddAddress } from "./AddAddress"
import { panel } from "@lib/ui/css/panel"

const Container = styled.div`
  ${panel()};
  flex: 1;
  min-width: 360px;
  ${vStack({
    gap: 12,
  })}
  align-self: flex-start;
`

export const ManageAddresses = () => {
  const [value] = useAddresses()

  return (
    <Container>
      <HStack fullWidth alignItems="center" justifyContent="space-between">
        <Text color="contrast" size={18} weight={600}>
          Track Addresses
        </Text>
        <ManageAddressesVisibility />
      </HStack>
      <VStack gap={4}>
        {value.map((address) => (
          <ManageAddress key={address} value={address} />
        ))}
      </VStack>
      <AddAddress />
    </Container>
  )
}

Visibility Toggle for Addresses

To allow users to share their trading history while keeping their addresses private, we include a visibility toggle. The toggle’s state is stored in local storage, ensuring the user’s preference is remembered across sessions.

import { IconButton } from "@lib/ui/buttons/IconButton"
import { EyeOffIcon } from "@lib/ui/icons/EyeOffIcon"
import { EyeIcon } from "@lib/ui/icons/EyeIcon"
import { Tooltip } from "@lib/ui/tooltips/Tooltip"
import { useAreAddressesVisible } from "./state/areAddressesVisible"

export const ManageAddressesVisibility = () => {
  const [value, setValue] = useAreAddressesVisible()

  const title = value ? "Hide addresses" : "Show addresses"

  return (
    <Tooltip
      content={title}
      renderOpener={(props) => (
        <div {...props}>
          <IconButton
            size="l"
            kind="secondary"
            title={title}
            onClick={() => setValue(!value)}
            icon={value ? <EyeIcon /> : <EyeOffIcon />}
          />
        </div>
      )}
    />
  )
}

The ManageAddress component displays an address along with an option to remove it. When the visibility toggle is off, the address is obscured with asterisks for privacy.

import { IconButton } from "@lib/ui/buttons/IconButton"
import { HStack } from "@lib/ui/css/stack"
import { ComponentWithValueProps } from "@lib/ui/props"
import { Text } from "@lib/ui/text"
import { TrashBinIcon } from "@lib/ui/icons/TrashBinIcon"
import { useAddresses } from "../state/addresses"
import { without } from "@lib/utils/array/without"
import { useAreAddressesVisible } from "./state/areAddressesVisible"
import { range } from "@lib/utils/array/range"
import { AsteriskIcon } from "@lib/ui/icons/AsteriskIcon"

export const ManageAddress = ({ value }: ComponentWithValueProps<string>) => {
  const [, setItems] = useAddresses()
  const [isVisible] = useAreAddressesVisible()

  return (
    <HStack
      fullWidth
      alignItems="center"
      justifyContent="space-between"
      gap={8}
    >
      <Text cropped color="supporting">
        {isVisible
          ? value
          : range(value.length).map((key) => (
              <AsteriskIcon style={{ flexShrink: 0 }} key={key} />
            ))}
      </Text>
      <IconButton
        kind="secondary"
        size="l"
        title="Remove address"
        onClick={() => setItems((items) => without(items, value))}
        icon={<TrashBinIcon />}
      />
    </HStack>
  )
}

Adding a New Address

The AddAddress component enables users to input a new address. If the input is a valid address and isn’t already in the list, it is added to the list, and the input field is cleared automatically. The component also respects the visibility toggle, obscuring the input field if addresses are set to be hidden.

import { useEffect, useState } from "react"
import { TextInput } from "@lib/ui/inputs/TextInput"
import { useAddresses } from "../state/addresses"
import { isAddress } from "viem"
import { useAreAddressesVisible } from "./state/areAddressesVisible"

export const AddAddress = () => {
  const [addresses, setAddresses] = useAddresses()

  const [inputValue, setInputValue] = useState("")

  const [isVisible] = useAreAddressesVisible()

  useEffect(() => {
    if (isAddress(inputValue) && !addresses.includes(inputValue)) {
      setInputValue("")
      setAddresses([...addresses, inputValue])
    }
  }, [addresses, inputValue, setAddresses])

  return (
    <TextInput
      value={inputValue}
      onValueChange={setInputValue}
      type={isVisible ? "text" : "password"}
      autoFocus
      placeholder="Add an address"
    />
  )
}

Displaying and Managing Trades

To display trades, we use the useTradesQuery hook to fetch the trade data. While the data is being fetched, a loading message is shown. If the query encounters errors, a warning block displays the error messages.

import { VStack } from "@lib/ui/css/stack"
import { useTradesQuery } from "../queries/useTradesQuery"
import { TradeItem } from "./TradeItem"
import { ShyWarningBlock } from "@lib/ui/status/ShyWarningBlock"
import { NonEmptyOnly } from "@lib/ui/base/NonEmptyOnly"
import { Text } from "@lib/ui/text"
import { getErrorMessage } from "@lib/utils/getErrorMessage"
import { NextTrade } from "./NextTrade"
import { getLastItem } from "@lib/utils/array/getLastItem"
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"

export const Trades = () => {
  const query = useTradesQuery()

  return (
    <>
      <NonEmptyOnly
        value={query.errors}
        render={(errors) => (
          <ShyWarningBlock title="Failed to get some trades">
            {errors.map((error, index) => (
              <VStack gap={20}>
                <Text key={index}>{getErrorMessage(error)}</Text>
              </VStack>
            ))}
          </ShyWarningBlock>
        )}
      />
      {query.isLoading && <Text color="supporting">Loading trades...</Text>}
      <NonEmptyOnly
        value={query.data}
        render={(trades) => (
          <SeparatedByLine gap={20}>
            <NextTrade lastTrade={getLastItem(trades)} />
            <VStack gap={20}>
              {trades.map((trade) => (
                <TradeItem key={trade.hash} value={trade} />
              ))}
            </VStack>
          </SeparatedByLine>
        )}
      />
    </>
  )
}

Handling Partial Data with EagerQuery

The useTradesQuery hook returns an EagerQuery object from RadzionKit, allowing us to display trades even if some queries fail or are still loading. This ensures a more resilient user experience by showing partial data when available instead of withholding all results.

import { useQueries } from "@tanstack/react-query"
import { useAddresses } from "../state/addresses"
import { useQueriesToEagerQuery } from "@lib/ui/query/hooks/useQueriesToEagerQuery"
import { tradingHistoryConfig } from "../config"
import { useAlchemyApiKey } from "../../alchemy/state/alchemyApiKey"
import { usePresentState } from "@lib/ui/state/usePresentState"
import { order } from "@lib/utils/array/order"
import { withoutDuplicates } from "@lib/utils/array/withoutDuplicates"
import { getAlchemyClient } from "../../alchemy/utils/getAlchemyClient"
import { noRefetchQueryOptions } from "@lib/ui/query/utils/options"
import { getTrades } from "../../alchemy/utils/getTrades"
import { Trade } from "../../entities/Trade"

const joinData = (items: Trade[][]) =>
  withoutDuplicates(
    order(items.flat(), ({ timestamp }) => timestamp, "desc"),
    (a, b) => a.hash === b.hash,
  )

export const useTradesQuery = () => {
  const [addresses] = useAddresses()

  const [apiKey] = usePresentState(useAlchemyApiKey())

  const queries = useQueries({
    queries: tradingHistoryConfig.networks.flatMap((network) => {
      const alchemy = getAlchemyClient({ network, apiKey })

      return addresses.map((address) => ({
        queryKey: ["txs", network, address],
        queryFn: async () => {
          return getTrades({
            alchemy,
            address,
          })
        },
        ...noRefetchQueryOptions,
      }))
    }),
  })

  return useQueriesToEagerQuery({
    queries,
    joinData,
  })
}

In the useTradesQuery hook, we iterate over the addresses and networks specified in the configuration file. For each address and network pair, we create a query that retrieves trades using the getTrades utility function. This function utilizes the Alchemy client to fetch trades for a given address and returns the data as an array of Trade objects.

Since the Alchemy client is used to fetch trades, we memoize its creation to prevent unnecessary client instances.

import { memoize } from "@lib/utils/memoize"
import { Alchemy, Network } from "alchemy-sdk"

type Input = {
  network: Network
  apiKey: string
}

export const getAlchemyClient = memoize(
  ({ network, apiKey }: Input) => new Alchemy({ apiKey, network }),
  ({ network, apiKey }) => `${network}-${apiKey}`,
)

By using the useQueriesToEagerQuery utility in the useTradesQuery hook, we aggregate the results of multiple queries into a single EagerQuery object. This object provides a unified source of data, combining the loading states, errors, and results from all queries. This ensures a consistent and reliable data structure for the Trades component, even when some queries are still loading or have failed.

import { EagerQuery, Query } from "../Query"
import { withoutUndefined } from "@lib/utils/array/withoutUndefined"
import { isEmpty } from "@lib/utils/array/isEmpty"
import { useMemo } from "react"

type ToEagerQueryInput<T, R, E = unknown> = {
  queries: Query<T, E>[]
  joinData: (items: T[]) => R
}

export function useQueriesToEagerQuery<T, R, E = unknown>({
  queries,
  joinData,
}: ToEagerQueryInput<T, R, E>): EagerQuery<R, E> {
  return useMemo(() => {
    const isPending = queries.some((query) => query.isPending)
    const isLoading = queries.some((query) => query.isLoading)
    const errors = queries.flatMap((query) => query.error ?? [])

    if (isEmpty(queries)) {
      return {
        isPending,
        isLoading,
        errors,
        data: joinData([]),
      }
    }

    try {
      const resolvedQueries = withoutUndefined(
        queries.map((query) => query.data),
      )
      return {
        isPending,
        isLoading,
        errors,
        data: isEmpty(resolvedQueries) ? undefined : joinData(resolvedQueries),
      }
    } catch (error: any) {
      return {
        isPending,
        isLoading,
        errors: [...errors, error],
        data: undefined,
      }
    }
  }, [joinData, queries])
}

Constructing Trades from Transfers

The Alchemy API provides options to fetch transfers either from or to an address, but it does not support retrieving both in a single request. As a result, we need to make two separate requests to gather all trades.

import { Alchemy, AssetTransfersCategory, SortingOrder } from "alchemy-sdk"
import { isEmpty } from "@lib/utils/array/isEmpty"
import {
  CashAsset,
  cashAssets,
  Trade,
  TradeAsset,
  tradeAssets,
} from "../../entities/Trade"
import { isOneOf } from "@lib/utils/array/isOneOf"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { match } from "@lib/utils/match"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { withoutUndefined } from "@lib/utils/array/withoutUndefined"
import { TradeType } from "@lib/chain/types/TradeType"

type Input = {
  address: string
  alchemy: Alchemy
}

const maxSwapTime = convertDuration(5, "min", "ms")

export const getTrades = async ({ address, alchemy }: Input) => {
  const { transfers: fromTransfers } = await alchemy.core.getAssetTransfers({
    fromAddress: address,
    category: [AssetTransfersCategory.EXTERNAL, AssetTransfersCategory.ERC20],
    withMetadata: true,
    order: SortingOrder.ASCENDING,
  })

  if (isEmpty(fromTransfers)) {
    return []
  }

  const { transfers: toTransfers } = await alchemy.core.getAssetTransfers({
    toAddress: address,
    category: [
      AssetTransfersCategory.EXTERNAL,
      AssetTransfersCategory.ERC20,
      AssetTransfersCategory.INTERNAL,
    ],
    withMetadata: true,
    order: SortingOrder.ASCENDING,
  })

  return withoutUndefined(
    fromTransfers.map(({ asset, metadata, value, hash }) => {
      const tradeAsset = isOneOf(asset, tradeAssets)
      const cashAsset = isOneOf(asset, cashAssets)

      if (!tradeAsset && !cashAsset) {
        return
      }

      const tradeType: TradeType = tradeAsset ? "sell" : "buy"

      const timestamp = new Date(metadata.blockTimestamp).getTime()

      const receiveTransfer = toTransfers.find((transfer) => {
        const time = new Date(transfer.metadata.blockTimestamp).getTime()
        if (time < timestamp || time - timestamp > maxSwapTime) {
          return false
        }

        return match(tradeType, {
          buy: () => isOneOf(transfer.asset, tradeAssets),
          sell: () => isOneOf(transfer.asset, cashAssets),
        })
      })

      if (!receiveTransfer) {
        return
      }

      const fromAmount = shouldBePresent(value)
      const toAmount = shouldBePresent(receiveTransfer.value)

      const trade: Trade = {
        asset: match(tradeType, {
          buy: () => receiveTransfer.asset as TradeAsset,
          sell: () => asset as TradeAsset,
        }),
        cashAsset: match(tradeType, {
          buy: () => asset as CashAsset,
          sell: () => receiveTransfer.asset as CashAsset,
        }),
        amount: match(tradeType, {
          buy: () => toAmount,
          sell: () => fromAmount,
        }),
        price: match(tradeType, {
          buy: () => fromAmount / toAmount,
          sell: () => toAmount / fromAmount,
        }),
        type: tradeType,

        timestamp,
        hash,
      }

      return trade
    }),
  )
}

To construct trades, we iterate over each transfer originating from the address. For each transfer, we first check whether the asset is a trade or cash asset. If it’s neither, we skip the transfer. Next, we determine the trade type based on the asset category. If the asset is a trade asset, the trade type is classified as a "sell"; otherwise, it is a "buy".

Matching Transfers

Next, we look for a corresponding transfer that indicates the address received the opposite asset. If no such transfer exists, it means the asset was likely received from another source, so we skip the trade.

Some swaps can be quite complex, so to simplify the process, we make a few assumptions. First, we assume that a swap won’t take longer than five minutes. Second, we assume that during those five minutes, the user won’t receive the asset they are trading to from another source. While it’s possible to develop a more robust solution, we’ll stick to this approach to keep the project simple for now.

When searching for the corresponding receive transfer, we first check the timestamp to ensure it falls within the five-minute window. Then, we verify the asset type: it should be a stablecoin for a sell trade or a trade asset for a buy trade.

With the from and to transfers identified, we construct the Trade object. Most of the fields are set based on the trade type, leveraging the match helper from RadzionKit to handle conditional logic cleanly.

export function match<T extends string | number | symbol, V>(
  value: T,
  handlers: { [key in T]: () => V },
): V {
  const handler = handlers[value]

  return handler()
}

Rendering Trades with TradeItem

With the trades prepared, we render them using the TradeItem component. This component displays the trade date, type, amount, asset, and rate. The trade type determines the color of the amount: buys are displayed in a contrasting color, while sells use the primary color. We avoid using red and green to represent the trades, as a sell or buy doesn’t inherently indicate a loss or profit.```

import { ComponentWithValueProps } from "@lib/ui/props"
import { Text, TextColor } from "@lib/ui/text"
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { Trade } from "../../entities/Trade"
import { format } from "date-fns"
import { match } from "@lib/utils/match"
import { TradeItemFrame } from "./TradeItemFrame"
import { TradeType } from "@lib/chain/types/TradeType"

export const TradeItem = ({
  value: { asset, amount, price, type, timestamp, cashAsset },
}: ComponentWithValueProps<Trade>) => {
  const color = match<TradeType, TextColor>(type, {
    buy: () => "contrast",
    sell: () => "primary",
  })

  return (
    <TradeItemFrame>
      <Text>{format(timestamp, "dd MMM yyyy")}</Text>
      <Text color={color}>
        {capitalizeFirstLetter(type)} {amount.toFixed(2)} {asset}
      </Text>
      <Text>
        1 {asset} ={" "}
        <Text as="span" color="contrast">
          {price.toFixed(2)}
        </Text>{" "}
        {cashAsset}
      </Text>
    </TradeItemFrame>
  )
}

Displaying Current Asset Prices

Above the list of trades, we display the current price and inform the user if it’s a good time to make the opposite trade to their most recent one.

import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import {
  cashAssets,
  primaryTradeAssetPriceProviderId,
  Trade,
  tradeAssets,
} from "../../entities/Trade"
import { useAssetPriceQuery } from "@lib/chain-ui/queries/useAssetPriceQuery"
import { TradeItemFrame } from "./TradeItemFrame"
import { Text } from "@lib/ui/text"
import { match } from "@lib/utils/match"
import { format } from "date-fns"

type NextTradeProps = {
  lastTrade: Pick<Trade, "price" | "type">
}

export const NextTrade = ({ lastTrade }: NextTradeProps) => {
  const priceQuery = useAssetPriceQuery({
    id: primaryTradeAssetPriceProviderId,
  })

  const [tradeAsset] = tradeAssets
  const [cashAsset] = cashAssets

  return (
    <TradeItemFrame>
      <Text>{format(Date.now(), "dd MMM yyyy")}</Text>

      <MatchQuery
        value={priceQuery}
        error={() => <Text>Failed to load price</Text>}
        pending={() => <Text>Loading price...</Text>}
        success={(price) => {
          const isGoodPrice = match(lastTrade.type, {
            buy: () => price < lastTrade.price,
            sell: () => price > lastTrade.price,
          })

          const nextTrade = lastTrade.type === "buy" ? "sell" : "buy"

          return (
            <>
              <Text>{`${isGoodPrice ? "Good" : "Bad"} price to ${nextTrade}`}</Text>
              <Text>
                1 {tradeAsset} ={" "}
                <Text as="span" color={isGoodPrice ? "success" : "alert"}>
                  {price.toFixed(2)}
                </Text>{" "}
                {cashAsset}
              </Text>
            </>
          )
        }}
      />
    </TradeItemFrame>
  )
}

To fetch the current price of the trade asset, we use the useAssetPriceQuery hook. We query the price of ETH, as both ETH and its wrapped versions on other chains should have a similar price.```

import { useQuery } from "@tanstack/react-query"
import {
  getAssetPrice,
  GetAssetPriceInput,
} from "../../chain/price/utils/getAssetPrice"

export const useAssetPriceQuery = (input: GetAssetPriceInput) => {
  return useQuery({
    queryKey: ["asset-price", input],
    queryFn: () => getAssetPrice(input),
  })
}

Fetching Prices with CoinGecko

The getAssetPrices utility function retrieves the prices of multiple assets from the CoinGecko API. It takes an array of asset IDs and an optional fiat currency as inputs. The function returns a record that maps each asset ID to its price in the specified fiat currency.

import { addQueryParams } from "@lib/utils/query/addQueryParams"
import { FiatCurrency } from "../FiatCurrency"
import { queryUrl } from "@lib/utils/query/queryUrl"
import { recordMap } from "@lib/utils/record/recordMap"

type Input = {
  ids: string[]
  fiatCurrency?: FiatCurrency
}

type Response = Record<string, Record<FiatCurrency, number>>

const baseUrl = "https://api.coingecko.com/api/v3/simple/price"

export const getAssetPrices = async ({ ids, fiatCurrency = "usd" }: Input) => {
  const url = addQueryParams(baseUrl, {
    ids: ids.join(","),
    vs_currencies: fiatCurrency,
  })

  const result = await queryUrl<Response>(url)

  return recordMap(result, (value) => value[fiatCurrency])
}

Conclusion

By combining the Alchemy API for transaction data, CoinGecko for asset prices, and RadzionKit for utility functions, we’ve built a streamlined app to track trading history on EVM chains. While this implementation makes simplifying assumptions, it provides a solid foundation for managing trades and exploring more advanced features in the future.