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 "./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 (
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.
tsx
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 (
<>
value={query.errors}
render={(errors) => (
{errors.map((error, index) => (
{getErrorMessage(error)}
))}
)}
/>
{query.isLoading && Loading trades...}
value={query.data}
render={(trades) => (
{trades.map((trade) => (
))}
)}
/>
</>
)
}
### Handling Partial Data with EagerQuery
The `useTradesQuery` hook returns an `EagerQuery` object from [RadzionKit](https://github.com/radzionc/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.
tsx
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.
tsx
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.
tsx
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 = {
queries: Query[]
joinData: (items: T[]) => R
}
export function useQueriesToEagerQuery({
queries,
joinData,
}: ToEagerQueryInput): EagerQuery {
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.
tsx
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](https://github.com/radzionc/radzionkit) to handle conditional logic cleanly.
ts
export function match(
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.```
```tsx
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.
tsx
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.
tsx
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>
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(url)
return recordMap(result, (value) => value[fiatCurrency])
}
## Conclusion
By combining the Alchemy API for transaction data, CoinGecko for asset prices, and [RadzionKit](https://github.com/radzionc/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.
Top comments (0)