Build a Trustless React App to Swap EVM Assets for Bitcoin via THORChain

Build a Trustless React App to Swap EVM Assets for Bitcoin via THORChain

January 8, 2025

19 min read

Build a Trustless React App to Swap EVM Assets for Bitcoin via THORChain

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.

Buy Bitcoin
Buy Bitcoin

Using THORChain for Cross-Chain Liquidity

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.

Handling Native EVM Assets vs. ERC20 Tokens

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))

Defining Supported EVM Chains

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>
  )
}

Connecting Wallets with RainbowKit

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.

Configuring the Wagmi Provider

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.

Wrapping the Application in QueryClientProvider

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

Setting Up the Main Interface with SEO

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>
  </>
)

Ensuring Secure Access with WalletGuard

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}</>
}

Creating a User-Friendly ConnectWallet Component

Connect Wallet
Connect Wallet

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>
  )
}

Disconnecting the Wallet

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>
  )
}

Designing a Centered Layout with RadzionKit

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;
  }
`

Managing Application State with React Context

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")

Streamlining State Provider Creation

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,
      ],
    ),
  }
}

Implementing Chain Selection Dropdown

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}
    />
  )
}

Handling Asset Amount Input

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 />}
    />
  )
}

Adding a Max Amount Feature

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

Displaying Conditional UI with MatchQuery

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">>

Collecting a Destination Bitcoin Address

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}
    />
  )
}

Retrieving and Displaying Swap Quotes

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>
        </>
      )}
    />
  )
}

Optimizing Quote Fetching

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 }),
      }
    },
  })
}

Understanding the getQuote Function

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.

Displaying Swap Information

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>
  )
}

Ensuring Correct Network with ChainGuard

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}</>
}

Executing the Swap Transaction

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>
      )}
    </>
  )
}

Conclusion

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.