🐙 GitHub | 🎮 Demo

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.

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

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.


tsx
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 (
setValue(null)}>


Exit


)
}


## Centered Page Layout

The content of our page is centered using the `centeredContentColumn` utility from [RadzionKit](https://github.com/radzionc/radzionkit). It consists of two sections displayed side by side: trading history and address management.


ts
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.


tsx
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 (

Add an address to continue 👉

)
}

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.


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

export const useAddresses = () => {
return usePersistentState(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.


tsx
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 (



Track Addresses




{value.map((address) => (

))}



)
}


### 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.


tsx
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 (
content={title}
renderOpener={(props) => (

size="l"
kind="secondary"
title={title}
onClick={() => setValue(!value)}
icon={value ? : }
/>

)}
/>
)
}


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.


tsx
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) => {
const [, setItems] = useAddresses()
const [isVisible] = useAreAddressesVisible()

return (
fullWidth
alignItems="center"
justifyContent="space-between"
gap={8}
>

{isVisible
? value
: range(value.length).map((key) => (

))}

kind="secondary"
size="l"
title="Remove address"
onClick={() => setItems((items) => without(items, value))}
icon={}
/>

)
}


### 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.


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

Author Of article : Radzion Chachura Read full article