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.
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],
}
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
}
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.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>
</>
)
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,
)
}
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>
)
}
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;
}
`
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:
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>
)
}
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>
)
}
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"
/>
)
}
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>
)}
/>
</>
)
}
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])
}
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".
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()
}
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>
)
}
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),
})
}
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])
}
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.