In this post, we'll build a decentralized React application that enables users to purchase Bitcoin using EVM-compatible blockchain assets. The live demo is available here, and you can explore the complete source code here. We'll leverage RadzionKit as our foundation, providing a robust TypeScript monorepo setup equipped with extensive utilities and reusable components for efficient development.
To enable the swap from EVM-compatible assets to Bitcoin, we’ll use THORChain, a cross-chain liquidity protocol. Simply send your token to a THORChain address with a specific memo indicating the swap details. THORChain then automatically executes the trade through its multi-chain liquidity pools, returning native BTC to your wallet—no intermediaries or wrapped tokens required.
Working with ERC20 tokens adds complexity since it requires obtaining user approval for token spending. To keep this tutorial focused and manageable, we'll concentrate solely on native EVM assets.
import { findBy } from "@lib/utils/array/findBy"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { mainnet, bsc, avalanche } from "viem/chains"
export const chains = [mainnet, bsc, avalanche] as const
export type ChainId = (typeof chains)[number]["id"]
export const getChain = (chainId: number) =>
shouldBePresent(findBy(chains, "id", chainId))
THORChain's supported networks can be determined by querying its pools endpoint. Currently, THORChain supports three EVM-compatible chains: Ethereum Mainnet, Binance Smart Chain (BSC), and Avalanche. We define these chains in our application and create a ChainId
type from this array. The getChain
utility function allows us to retrieve chain information using its numeric identifier.
import { getDefaultConfig, RainbowKitProvider } from "@rainbow-me/rainbowkit"
import "@rainbow-me/rainbowkit/styles.css"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { WagmiProvider, http } from "wagmi"
import React from "react"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { productName } from "../../product/config"
import { recordFromKeys } from "@lib/utils/record/recordFromKeys"
import { chains } from "../../swap/core/chains"
const config = getDefaultConfig({
appName: productName,
projectId: shouldBePresent(process.env.NEXT_PUBLIC_REOWN_PROJECT_ID),
chains,
transports: recordFromKeys(
chains.map((chain) => chain.id),
() => http(),
),
})
export const WalletProvider = ({ children }: ComponentWithChildrenProps) => {
return (
<WagmiProvider config={config}>
<RainbowKitProvider>{children}</RainbowKitProvider>
</WagmiProvider>
)
}
To enable users to swap their assets for BTC, they first need to connect their wallet to our application. We'll use RainbowKit, a popular wallet connection library that provides a polished user interface and seamless integration with Wagmi - the leading library for interacting with EVM-compatible chains in React applications. This combination offers a robust foundation for handling wallet connections and blockchain interactions.
The Wagmi provider configuration includes several key components: our application name, a project ID obtained from WalletConnect for enabling mobile wallet connections, and the list of supported blockchain networks. For each supported chain, we configure a transport layer using HTTP clients that facilitate communication between our application and the respective blockchain networks.
Our application is wrapped in a QueryClientProvider
for two key purposes: First, it supports Wagmi's internal data fetching needs since Wagmi is built on top of React Query. Second, we'll leverage the same query client to efficiently fetch and manage data from the THORChain API. Additionally, we include the WalletProvider
component within this wrapper to handle wallet connectivity throughout our application.
import type { AppProps } from "next/app"
import { ReactNode, useState } from "react"
import { GlobalStyle } from "@lib/ui/css/GlobalStyle"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Inter } from "next/font/google"
import { DarkLightThemeProvider } from "@lib/ui/theme/DarkLightThemeProvider"
import { Page } from "@lib/next-ui/Page"
import { WalletProvider } from "../wallet/components/WalletProvider"
const inter = Inter({
subsets: ["latin"],
weight: ["400", "500", "600", "800"],
})
interface MyAppProps extends AppProps {
Component: Page
}
function MyApp({ Component, pageProps }: MyAppProps) {
const [queryClient] = useState(() => new QueryClient())
const getLayout = Component.getLayout || ((page: ReactNode) => page)
const component = getLayout(<Component {...pageProps} />)
return (
<QueryClientProvider client={queryClient}>
<DarkLightThemeProvider value="dark">
<GlobalStyle fontFamily={inter.style.fontFamily} />
<WalletProvider>{component}</WalletProvider>
</DarkLightThemeProvider>
</QueryClientProvider>
)
}
export default MyApp
Our application consists of a single page that serves as the main interface for the Bitcoin swap functionality. We configure SEO-friendly meta tags for the title and description to improve discoverability. Since the page content is dependent on the user's wallet connection state, we wrap it in a ClientOnly
component to ensure proper client-side rendering and prevent hydration mismatches that could occur with server-side rendering.
import { ClientOnly } from "@lib/ui/base/ClientOnly"
import { PageMetaTags } from "@lib/next-ui/metadata/PageMetaTags"
import { WalletGuard } from "../../wallet/components/WalletGuard"
import { WebsiteNavigation } from "@lib/ui/website/navigation/WebsiteNavigation"
import { ProductLogo } from "../../product/ProductLogo"
import { ExitWallet } from "../../wallet/components/ExitWallet"
import styled from "styled-components"
import { centeredContentColumn } from "@lib/ui/css/centeredContentColumn"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import { chains } from "../core/chains"
import { AddressProvider } from "../state/address"
import { AmountProvider } from "../state/amount"
import { VStack } from "@lib/ui/css/stack"
import { ManageChain } from "./ManageChain"
import { ManageAmount } from "./ManageAmount"
import { SwapQuote } from "./SwapQuote"
import { ManageAddress } from "./ManageAddress"
import { SourceChainIdProvider } from "../state/sourceChainId"
export const PageContainer = styled.div`
${centeredContentColumn({
contentMaxWidth: 480,
})}
${verticalPadding(80)}
`
export const SwapPage = () => (
<>
<PageMetaTags
title="Buy Bitcoin with ETH, BNB, or AVAX | THORChain Swap"
description="Easily swap your ETH, BNB, or AVAX for Bitcoin using THORChain. Get real-time quotes and secure cross-chain swaps with minimal fees."
/>
<ClientOnly>
<WalletGuard>
<WebsiteNavigation
renderTopbarItems={() => (
<>
<div />
<ExitWallet />
</>
)}
renderOverlayItems={() => <ExitWallet />}
logo={<ProductLogo />}
>
<PageContainer>
<SourceChainIdProvider initialValue={chains[0].id}>
<AddressProvider initialValue="">
<AmountProvider initialValue={null}>
<VStack gap={20}>
<ManageChain />
<ManageAmount />
<ManageAddress />
<SwapQuote />
</VStack>
</AmountProvider>
</AddressProvider>
</SourceChainIdProvider>
</PageContainer>
</WebsiteNavigation>
</WalletGuard>
</ClientOnly>
</>
)
The WalletGuard
component ensures users are connected to their Web3 wallet before accessing protected content. It automatically displays a ConnectWallet
prompt if no wallet connection is detected, providing a seamless authentication flow. Once connected, the guarded content becomes accessible.
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { ConnectWallet } from "./ConnectWallet"
import { useAccount } from "wagmi"
export const WalletGuard = ({ children }: ComponentWithChildrenProps) => {
const { isConnected } = useAccount()
if (!isConnected) {
return <ConnectWallet />
}
return <>{children}</>
}
The ConnectWallet
component provides a user-friendly wallet connection interface. It displays the ProductLogo
at the top and a customized RainbowKit ConnectButton
below. By using ConnectButton.Custom
, we're able to maintain consistent styling with our application's design system through our own Button
component while leveraging RainbowKit's robust wallet connection functionality.
import { ConnectButton } from "@rainbow-me/rainbowkit"
import { ProductLogo } from "../../product/ProductLogo"
import { Center } from "@lib/ui/layout/Center"
import { VStack } from "@lib/ui/css/stack"
import { Button } from "@lib/ui/buttons/Button"
export const ConnectWallet = () => {
return (
<Center>
<VStack alignItems="center" gap={20}>
<ProductLogo />
<ConnectButton.Custom>
{({ openConnectModal }) => (
<Button onClick={openConnectModal} size="l" kind="primary">
Connect Wallet
</Button>
)}
</ConnectButton.Custom>
</VStack>
</Center>
)
}
Users can disconnect their wallet by clicking the "Exit" button located in the top-right corner of the navigation bar. This button leverages the useDisconnect
hook from Wagmi to safely terminate the wallet connection and reset the application state.
import { HStack } from "@lib/ui/css/stack"
import { Button } from "@lib/ui/buttons/Button"
import { LogOutIcon } from "@lib/ui/icons/LogOutIcon"
import { useDisconnect } from "wagmi"
export const ExitWallet = () => {
const { disconnect } = useDisconnect()
return (
<Button kind="secondary" onClick={() => disconnect()}>
<HStack alignItems="center" gap={8}>
<LogOutIcon />
Exit
</HStack>
</Button>
)
}
The centeredContentColumn
utility function from RadzionKit provides a flexible solution for centering content on a page. It creates a responsive three-column grid layout where the middle column contains your content and automatically adjusts its width based on the viewport size. The function wraps the styled-components css
helper and accepts parameters to customize the maximum content width and minimum horizontal padding, ensuring consistent spacing and optimal content presentation across different screen sizes.
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;
}
`
To manage the user's transaction details, we implement three state providers using React's Context API: SourceChainIdProvider
for storing the selected blockchain network, AddressProvider
for managing the destination address, and AmountProvider
for handling the transaction amount. These providers wrap our application content and make these states globally accessible to child components.
import { getStateProviderSetup } from "@lib/ui/state/getStateProviderSetup"
import { ChainId } from "../core/chains"
export const { provider: SourceChainIdProvider, useState: useSourceChainId } =
getStateProviderSetup<ChainId>("sourceChainId")
export const { provider: AmountProvider, useState: useAmount } =
getStateProviderSetup<number | null>("amount")
export const { provider: AddressProvider, useState: useAddress } =
getStateProviderSetup<string>("address")
RadzionKit includes a getStateProviderSetup
utility function that streamlines the creation of React context providers for managing single values. This utility eliminates boilerplate code by automatically generating a provider component and a custom hook for accessing and updating the state.
import { Dispatch, SetStateAction, createContext, useState } from "react"
import { ComponentWithChildrenProps } from "../props"
import { ContextState } from "./ContextState"
import { createContextHook } from "./createContextHook"
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
export function getStateProviderSetup<T>(name: string) {
const Context = createContext<ContextState<T> | undefined>(undefined)
type Props = ComponentWithChildrenProps & { initialValue: T }
const Provider = ({ children, initialValue }: Props) => {
const [value, setValue] = useState(initialValue)
return (
<Context.Provider value={{ value, setValue }}>
{children}
</Context.Provider>
)
}
return {
provider: Provider,
useState: createContextHook(
Context,
capitalizeFirstLetter(name),
(result): [T, Dispatch<SetStateAction<T>>] => [
result.value,
result.setValue,
],
),
}
}
The chain selection dropdown is implemented using RadzionKit's SelectOptionInput
component. This component renders a customizable dropdown menu that displays our supported blockchain networks. Each option in the dropdown combines a chain-specific icon (rendered using the NetworkIcon
component from @web3icons/react
) with the chain's name, creating a clear and intuitive selection interface.
import { SelectOptionInput } from "@lib/ui/inputs/dropdown/DropdownInput"
import { chains, getChain } from "../core/chains"
import { DropdownOptionContent } from "@lib/ui/inputs/dropdown/DropdownOptionContent"
import { NetworkIcon } from "@web3icons/react"
import { useSourceChainId } from "../state/sourceChainId"
export function ManageChain() {
const [sourceChainId, setSourceChainId] = useSourceChainId()
const chain = getChain(sourceChainId)
return (
<SelectOptionInput
label="Chain"
value={sourceChainId}
onChange={(id) => setSourceChainId(id)}
options={chains.map((chain) => chain.id)}
getOptionKey={(id) => getChain(id).name}
renderOption={(id) => (
<DropdownOptionContent
identifier={<NetworkIcon chainId={id} variant="branded" />}
name={getChain(id).name}
/>
)}
valueIdentifier={
<NetworkIcon
key={sourceChainId}
chainId={sourceChainId}
variant="branded"
/>
}
valueName={chain.name}
/>
)
}
For handling asset amount input, we utilize RadzionKit's AmountTextInput
component. This component provides a user-friendly interface for entering numerical values. The input field is enhanced with a clear label that dynamically displays the selected asset's name, making it immediately obvious to users what type of value they're entering.
import { AmountTextInput } from "@lib/ui/inputs/AmountTextInput"
import { useAmount } from "../state/amount"
import { MaxAmount } from "./MaxAmount"
import { getChain } from "../core/chains"
import { useSourceChainId } from "../state/sourceChainId"
export function ManageAmount() {
const [amount, setAmount] = useAmount()
const [sourceChainId] = useSourceChainId()
const { nativeCurrency } = getChain(sourceChainId)
return (
<AmountTextInput
value={amount}
onValueChange={setAmount}
shouldBePositive
label={`${nativeCurrency.name} amount`}
placeholder="Enter amount"
suggestion={<MaxAmount />}
/>
)
}
The MaxAmount
component provides users with a convenient way to view and utilize their maximum available balance for swaps. It integrates with the wallet through wagmi's useBalance
hook to fetch the current balance, then displays this information as an interactive suggestion within the AmountTextInput
. Users can click this suggestion to automatically populate the input field with their maximum available amount.
import { useAmount } from "../state/amount"
import { useBalance } from "wagmi"
import { useAccount } from "wagmi"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { formatUnits } from "viem"
import styled from "styled-components"
import { text } from "@lib/ui/text"
import { interactive } from "@lib/ui/css/interactive"
import { UnstyledButton } from "@lib/ui/buttons/UnstyledButton"
import { useSourceChainId } from "../state/sourceChainId"
import { getChain } from "../core/chains"
const Container = styled(UnstyledButton)`
${text({ color: "primary" })}
${interactive}
`
export function MaxAmount() {
const [, setAmount] = useAmount()
const account = useAccount()
const [sourceChainId] = useSourceChainId()
const address = shouldBePresent(account.address)
const balanceQuery = useBalance({
address,
chainId: sourceChainId,
})
const { nativeCurrency } = getChain(sourceChainId)
return (
<MatchQuery
value={balanceQuery}
success={({ value, decimals }) => {
const amount = Number(formatUnits(value, decimals))
return (
<Container
onClick={() => {
setAmount(amount)
}}
>
Max: {amount.toFixed(2)} {nativeCurrency.symbol}
</Container>
)
}}
/>
)
}
Currently, setting the maximum amount for native token transfers is not very practical since users need to reserve some balance for gas fees. However, this feature will become more useful when we implement ERC20 token swaps, as users can send their entire token balance without needing to reserve any for gas (since gas is paid in the native token).
The MatchQuery
component from RadzionKit allows us to conditionally render content based on different query states (success, error, pending, or inactive). In this implementation, we only render the max amount suggestion when the balance query succeeds, since displaying this feature is not critical to the core functionality. If the query is in an error or loading state, nothing will be shown, which is acceptable for this optional UI element.
import { ReactNode } from "react"
import { ComponentWithValueProps } from "../../props"
import { Query } from "../Query"
export type MatchQueryProps<T, E = unknown> = ComponentWithValueProps<
Query<T, E>
> & {
error?: (error: E) => ReactNode
pending?: () => ReactNode
success?: (data: T) => ReactNode
inactive?: () => ReactNode
}
export function MatchQuery<T, E = unknown>({
value,
error = () => null,
pending = () => null,
success = () => null,
inactive = () => null,
}: MatchQueryProps<T, E>) {
if (value.data !== undefined) {
return <>{success(value.data)}</>
}
if (value.error) {
return <>{error(value.error)}</>
}
if (value.isLoading === false) {
return <>{inactive()}</>
}
if (value.isPending) {
return <>{pending()}</>
}
return null
}
export type MatchQueryWrapperProps<T, E = unknown> = Pick<
MatchQueryProps<T>,
"success"
> &
Partial<Pick<MatchQueryProps<T, E>, "error" | "pending" | "inactive">>
The final input field allows users to enter a destination Bitcoin address using RadzionKit's TextInput
component. While Bitcoin address validation would enhance security and user experience, it has been intentionally omitted from this implementation to maintain a focused scope.
import { TextInput } from "@lib/ui/inputs/TextInput"
import { useAddress } from "../state/address"
export function ManageAddress() {
const [address, setAddress] = useAddress()
return (
<TextInput
label="Bitcoin Address"
placeholder="Enter Bitcoin address"
value={address}
onValueChange={setAddress}
/>
)
}
The final component of our form is the SwapQuote
component. This component leverages MatchQuery
to handle different states of the quote request. Unlike previous implementations, we explicitly handle both pending and error states to provide clear feedback to users. The pending state shows a loading message, while errors are displayed prominently to help users understand and resolve any issues that arise during the quote process.
import { useQuoteQuery } from "../queries/useQuoteQuery"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { Text } from "@lib/ui/Text"
import { ShyWarningBlock } from "@lib/ui/status/ShyWarningBlock"
import { getErrorMessage } from "@lib/utils/getErrorMessage"
import { ChainGuard } from "../../wallet/components/ChainGuard"
import { SwapInfo } from "./SwapInfo"
import { ExecuteSwap } from "./ExecuteSwap"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
export const SwapQuote = () => {
const quoteQuery = useQuoteQuery()
return (
<MatchQuery
value={quoteQuery}
pending={() => <Text color="supporting">Loading quote...</Text>}
error={(err) => (
<ShyWarningBlock title="Quote error">
{getErrorMessage(err)}
</ShyWarningBlock>
)}
success={(quote) => (
<>
<SwapInfo value={quote} />
<ChainGuard>
<ExecuteSwap
memo={quote.memo}
receiver={shouldBePresent(quote.inbound_address) as `0x${string}`}
/>
</ChainGuard>
</>
)}
/>
)
}
To optimize the quote fetching process, we utilize useStateDependentQuery
from RadzionKit. This hook creates a query that depends on both the address
and amount
states, ensuring that quotes are only fetched when both values are provided by the user. This approach prevents unnecessary API calls and provides a more efficient user experience.
import { useAddress } from "../state/address"
import { useAmount } from "../state/amount"
import { useStateDependentQuery } from "@lib/ui/query/hooks/useStateDependentQuery"
import { getQuote } from "../core/getQuote"
import { useSourceChainId } from "../state/sourceChainId"
export const useQuoteQuery = () => {
const [address] = useAddress()
const [amount] = useAmount()
const [chainId] = useSourceChainId()
return useStateDependentQuery({
state: {
address: address || undefined,
amount: amount || undefined,
},
getQuery: ({ address, amount }) => {
return {
queryKey: ["quote", address, amount],
queryFn: () => getQuote({ address, amount, chainId }),
}
},
})
}
The getQuote
function is responsible for fetching swap quotes from the THORChain network. It takes three parameters: address
(the destination address), amount
(the amount to swap), and chainId
(the source chain identifier). The function constructs a query URL with these parameters and makes a request to the THORChain node's quote endpoint. The response includes important swap details such as expected output amount, fees, inbound address, and memo required for the transaction.
import { addQueryParams } from "@lib/utils/query/addQueryParams"
import { toChainAmount } from "@lib/chain/utils/toChainAmount"
import { fromChainAmount } from "@lib/chain/utils/fromChainAmount"
import { ChainId, getChain } from "../core/chains"
import { mirrorRecord } from "@lib/utils/record/mirrorRecord"
import { queryUrl } from "@lib/utils/query/queryUrl"
import { formatAmount } from "@lib/utils/formatAmount"
import { thorChainRecord } from "./thor"
type GetQuoteInput = {
address: string
amount: number
chainId: ChainId
}
export type QuoteResponse = {
dust_threshold?: string
expected_amount_out: string
expiry: number
fees: {
affiliate: string
asset: string
outbound: string
total: string
}
inbound_address?: string
inbound_confirmation_blocks?: number
inbound_confirmation_seconds?: number
memo: string
notes: string
outbound_delay_blocks: number
outbound_delay_seconds: number
recommended_min_amount_in: string
slippage_bps?: number
total_swap_seconds?: number
warning: string
router?: string
}
type QuoteErrorResponse = {
error: string
}
const baseUrl = "https://thornode.ninerealms.com/thorchain/quote/swap"
const decimals = 8
export const getQuote = async ({ address, amount, chainId }: GetQuoteInput) => {
const chainPrefix = mirrorRecord(thorChainRecord)[chainId]
const chainAmount = toChainAmount(amount, decimals)
const url = addQueryParams(baseUrl, {
from_asset: `${chainPrefix}.${chainPrefix}`,
to_asset: "BTC.BTC",
amount: chainAmount.toString(),
destination: address,
streaming_interval: 1,
})
const result = await queryUrl<QuoteResponse | QuoteErrorResponse>(url)
if ("error" in result) {
throw new Error(result.error)
}
if (BigInt(result.recommended_min_amount_in) > chainAmount) {
const minAmount = fromChainAmount(
result.recommended_min_amount_in,
decimals,
)
const formattedMinAmount = formatAmount(minAmount)
const { nativeCurrency } = getChain(chainId)
const msg = `You need to swap at least ${formattedMinAmount} ${nativeCurrency.symbol}`
throw new Error(msg)
}
return result
}
If the response contains an error, we throw that error. Additionally, if the recommended minimum amount from THORChain is higher than the user's input amount, we throw an error with a formatted message showing the minimum required amount in the user's native currency.
Upon successful response from THORChain, we render the SwapInfo
component to display the estimated BTC amount the user will receive. While we could display additional data like network fees, exchange rates, and USD values, we'll focus on showing just the output amount to keep the UI clean and straightforward. The component formats the BTC amount using the correct decimal precision and adds the BTC symbol for clarity.
import { ComponentWithValueProps } from "@lib/ui/props"
import { QuoteResponse } from "../core/getQuote"
import { Text } from "@lib/ui/text"
import { btc } from "../core/btc"
import { formatAmount } from "@lib/utils/formatAmount"
import { fromChainAmount } from "@lib/chain/utils/fromChainAmount"
export const SwapInfo = ({ value }: ComponentWithValueProps<QuoteResponse>) => {
return (
<Text>
You should receive ~{" "}
<Text as="span" color="primary">
{formatAmount(fromChainAmount(value.expected_amount_out, btc.decimals))}{" "}
{btc.symbol}
</Text>
</Text>
)
}
For seamless network switching, we implement the ChainGuard
component. This component checks if the user's wallet is connected to the correct blockchain network. If there's a mismatch between the user's current chain and the source chain selected for the swap, it displays a button prompting the user to switch networks. This ensures transactions are always initiated from the correct blockchain network, preventing potential errors.
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useChainId, useSwitchChain } from "wagmi"
import { Button } from "@lib/ui/buttons/Button"
import { useSourceChainId } from "../../swap/state/sourceChainId"
import { getChain } from "../../swap/core/chains"
export const ChainGuard = ({ children }: ComponentWithChildrenProps) => {
const chainId = useChainId()
const { switchChain } = useSwitchChain()
const [sourceChainId] = useSourceChainId()
if (chainId !== sourceChainId) {
return (
<Button
onClick={() => {
switchChain({ chainId: sourceChainId })
}}
>
Switch to {getChain(sourceChainId).name}
</Button>
)
}
return <>{children}</>
}
The ExecuteSwap
component handles the critical task of executing the swap transaction between networks. It leverages Wagmi's useSendTransaction
hook to initiate and manage the transaction flow. The component provides a seamless user experience by displaying real-time feedback - showing a success message with a THORChain transaction link upon completion, or a clear error message if the transaction encounters any issues. The component accepts a memo (containing swap details) and a receiver address as props, which are essential for routing the transaction through THORChain's network.
import { useSendTransaction } from "wagmi"
import { Button } from "@lib/ui/buttons/Button"
import { useAmount } from "../state/amount"
import { toChainAmount } from "@lib/chain/utils/toChainAmount"
import { usePresentState } from "@lib/ui/state/usePresentState"
import { useSourceChainId } from "../state/sourceChainId"
import { getChain } from "../core/chains"
import { ShyWarningBlock } from "@lib/ui/status/ShyWarningBlock"
import { getErrorMessage } from "@lib/utils/getErrorMessage"
import { VStack } from "@lib/ui/css/stack"
import { text, Text } from "@lib/ui/text"
import styled from "styled-components"
import { ExternalLink } from "@lib/ui/navigation/Link/ExternalLink"
type ExecuteSwapProps = {
memo: string
receiver: `0x${string}`
}
const Link = styled(ExternalLink)`
${text({ color: "primary", weight: "bold" })}
`
export const ExecuteSwap = ({ memo, receiver }: ExecuteSwapProps) => {
const [sourceChainId] = useSourceChainId()
const [amount] = usePresentState(useAmount())
const { sendTransaction, isPending, error, data } = useSendTransaction()
const { nativeCurrency } = getChain(sourceChainId)
return (
<>
<Button
kind="primary"
onClick={() => {
sendTransaction({
to: receiver as `0x${string}`,
value: toChainAmount(amount, nativeCurrency.decimals),
data: `0x${Buffer.from(memo).toString("hex")}`,
})
}}
isLoading={isPending}
>
Buy Bitcoin
</Button>
{error && (
<ShyWarningBlock title="Failed to execute swap">
{getErrorMessage(error)}
</ShyWarningBlock>
)}
{data && (
<VStack>
<Text>
Transaction sent,{" "}
<Link to={`https://thorchain.net/tx/${data}`}>track it here</Link>
</Text>
</VStack>
)}
</>
)
}
This tutorial has shown how to build a decentralized application for swapping EVM assets to Bitcoin using THORChain. Using RadzionKit's components, we've created a user-friendly interface that handles wallet connections, network switching, and transactions. The application focuses on native token swaps while maintaining a clean UI that guides users through the process. Users can confidently exchange ETH, BNB, or AVAX for Bitcoin in a trustless way.