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