0xtrails
Version:
SDK for Trails
1,131 lines (1,024 loc) • 33.1 kB
text/typescript
import type { TokenPrice } from "@0xsequence/trails-api"
import type React from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
type Account,
getAddress,
isAddress,
parseUnits,
type WalletClient,
zeroAddress,
} from "viem"
import { useAPIClient } from "../../apiClient.js"
import { getChainInfo, useSupportedChains } from "../../chains.js"
import { getFullErrorMessage, getPrettifiedErrorMessage } from "../../error.js"
import {
prepareSend,
TradeType,
type PrepareSendReturn,
type PrepareSendQuote,
} from "../../prepareSend.js"
import type { TransactionState } from "../../transactions.js"
import { getTokenPrice, useTokenPrices, normalizeNumber } from "../../prices.js"
import { useQueryParams } from "../../queryParams.js"
import { getRelayer } from "../../relayer.js"
import {
formatRawAmount,
formatUsdAmountDisplay,
formatAmount,
formatAmountDisplay,
} from "../../tokenBalances.js"
import type { SupportedToken } from "../../tokens.js"
import {
useSupportedTokens,
useTokenAddress,
useTokenInfo,
} from "../../tokens.js"
import type { CheckoutOnHandlers } from "./useCheckout.js"
import { useResolveEnsAddress } from "../../ens.js"
import { etherlink } from "viem/chains"
import { logger } from "../../logger.js"
import { getIsContract } from "../../contractUtils.js"
export interface Token {
id: number
name: string
symbol: string
balance: string
imageUrl: string
chainId: number
contractAddress: string
tokenPriceUsd?: number
balanceUsdFormatted?: string
contractInfo?: {
decimals: number
symbol: string
name: string
}
}
export type TokenInfo = {
symbol: string
name: string
imageUrl: string
decimals: number
}
type ChainInfo = {
id: number
name: string
imageUrl?: string
}
type PaymasterUrl = {
chainId: number
url: string
}
export type OnCompleteProps = {
transactionStates: TransactionState[]
}
export type UseSendProps = {
account: Account
toAmount?: string
toRecipient?: string
toChainId?: number
toToken?: string
toCalldata?: string
refundAddress?: string
walletClient: WalletClient
onTransactionStateChange: (transactionStates: TransactionState[]) => void
onError: (error: Error | string | null) => void
onWaitingForWalletConfirm: (quote: PrepareSendQuote) => void
paymasterUrls?: PaymasterUrl[]
gasless?: boolean
onSend: (amount: string, recipient: string) => void
onConfirm: () => void
onComplete: (result: OnCompleteProps) => void
selectedToken?: Token
setWalletConfirmRetryHandler: (handler: () => Promise<void>) => void
tradeType?: TradeType
quoteProvider?: string
fundMethod?: string
mode?: "pay" | "fund" | "earn" | "swap" | "receive"
onNavigateToMeshConnect?: (
props: {
toTokenSymbol: string
toTokenAmount: string
toChainId: number
toRecipientAddress: string
},
quote?: PrepareSendQuote | null,
) => void
checkoutOnHandlers?: CheckoutOnHandlers
refetchTrigger?: number
}
export type FeeOption = {
token: {
chainId: number
name: string
symbol: string
type: string
decimals: number
logoURL: string
contractAddress: string | null
tokenID: string | null
}
to: string
value: string
gasLimit: number
}
export type UseSendReturn = {
amount: string
amountRaw: string
amountUsdDisplay: string
balanceUsdDisplay: string
chainInfo: ChainInfo | null
error: string | null
toChainId: number | undefined
balanceFormatted: string
handleRecipientInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleSubmit: (e: React.FormEvent) => Promise<void>
isChainDropdownOpen: boolean
isSubmitting: boolean
isLoadingQuote: boolean
isTokenDropdownOpen: boolean
recipient: string
recipientInput: string
selectedDestinationChain: ChainInfo | null
selectedDestToken: TokenInfo
setAmount: (amount: string) => void
setRecipient: (recipient: string) => void
setRecipientInput: (recipientInput: string) => void
setSelectedDestinationChain: (chain: ChainInfo) => void
setSelectedDestToken: (token: TokenInfo) => void
setSelectedFeeToken: (token: FeeOption) => void
feeOptions: FeeOption[]
supportedTokens: SupportedToken[]
supportedChains: ChainInfo[]
ensAddress: string | null
isWaitingForWalletConfirm: boolean
buttonText: string
isValidRecipient: boolean
destTokenPrices: TokenPrice[] | null
sourceTokenPrices: TokenPrice[] | null
selectedToken?: Token
selectedFeeToken: FeeOption | null
setIsChainDropdownOpen: (isOpen: boolean) => void
setIsTokenDropdownOpen: (isOpen: boolean) => void
toAmountFormatted: string
destinationTokenAddress: string | null
isValidCustomToken: boolean
prepareSendQuote: PrepareSendQuote | null
toAmountDisplay: string
quoteError: string | null
quoteErrorPrettified: string | null
isSameTokenWithoutCustomCalldata: boolean
isRecipientContract: boolean
}
export function useSendForm({
account,
toAmount, // Custom specified amount
toRecipient, // Custom specified recipient
toChainId, // Custom specified destination chain id
toToken, // Custom specified destination token address or symbol
toCalldata, // Custom specified destination calldata
refundAddress, // Custom specified refund address
walletClient,
onTransactionStateChange,
onError,
onWaitingForWalletConfirm,
paymasterUrls,
gasless,
selectedToken,
onSend,
onConfirm,
onComplete,
setWalletConfirmRetryHandler,
tradeType = TradeType.EXACT_OUTPUT,
quoteProvider,
fundMethod,
mode,
onNavigateToMeshConnect,
checkoutOnHandlers,
refetchTrigger = 0,
}: UseSendProps): UseSendReturn {
// Auto-set quoteProvider to "lifi" if either from or to chain is etherlink
const effectiveQuoteProvider = useMemo(() => {
if (!quoteProvider || quoteProvider === "auto") {
if (
selectedToken?.chainId === etherlink.id ||
toChainId === etherlink.id
) {
return "lifi"
}
}
return quoteProvider
}, [quoteProvider, selectedToken?.chainId, toChainId])
const [amount, setAmount] = useState(
tradeType === TradeType.EXACT_INPUT ? "" : (toAmount ?? ""),
)
const [recipientInput, setRecipientInput] = useState(toRecipient ?? "")
const [recipient, setRecipient] = useState(toRecipient ?? "")
const [error, setError] = useState<string | null>(null)
const [quoteError, setQuoteError] = useState<string | null>(null)
const [quoteErrorPrettified, setQuoteErrorPrettified] = useState<
string | null
>(null)
const { supportedChains } = useSupportedChains()
const { ensAddress } = useResolveEnsAddress({
textInput: recipientInput,
})
useEffect(() => {
if (ensAddress) {
setRecipient(ensAddress)
} else {
setRecipient(recipientInput)
}
}, [ensAddress, recipientInput])
useEffect(() => {
if (onError) {
onError(error)
}
}, [error, onError])
const handleRecipientInputChange = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
setRecipientInput(e.target.value.trim())
}
const [selectedDestinationChain, setSelectedDestinationChain] =
useState<ChainInfo>(() => {
const chain = supportedChains.find((chain) => chain.id === toChainId)
if (chain) {
return chain
}
return supportedChains[0]!
})
const { supportedTokens } = useSupportedTokens({
chainId: selectedDestinationChain?.id,
})
// Check if recipient is a contract address
useEffect(() => {
const checkRecipientContract = async () => {
if (recipient && isAddress(recipient) && selectedDestinationChain?.id) {
try {
const isContract = await getIsContract(
recipient as `0x${string}`,
selectedDestinationChain.id,
)
setIsRecipientContract(isContract)
} catch (error) {
logger.console.error(
"[trails-sdk] Error checking if recipient is contract:",
error,
)
setIsRecipientContract(false)
}
} else {
setIsRecipientContract(false)
}
}
checkRecipientContract()
}, [recipient, selectedDestinationChain?.id])
const isCustomToken = useMemo(() => toToken?.startsWith("0x"), [toToken])
const {
tokenInfo: customTokenInfo,
isLoading: isLoadingCustomToken,
error: errorCustomToken,
} = useTokenInfo({
address: isCustomToken ? toToken! : "",
chainId: toChainId,
})
const isValidCustomToken = useMemo(() => {
if (!isCustomToken) {
return true
}
return Boolean(
isCustomToken &&
!errorCustomToken &&
!isLoadingCustomToken &&
!!customTokenInfo,
)
}, [isCustomToken, errorCustomToken, isLoadingCustomToken, customTokenInfo])
useEffect(() => {
if (isCustomToken && customTokenInfo && !isLoadingCustomToken) {
setSelectedDestToken(customTokenInfo as TokenInfo)
}
}, [customTokenInfo, isCustomToken, isLoadingCustomToken])
useEffect(() => {
if (isCustomToken && errorCustomToken && !isLoadingCustomToken) {
logger.console.error("[trails-sdk] errorCustomToken", errorCustomToken)
setError(
`Invalid custom toToken address. Error: ${errorCustomToken.message}`,
)
}
}, [errorCustomToken, isCustomToken, isLoadingCustomToken])
const defaultDestToken = useMemo(() => {
if (mode === "swap") {
return null
}
if (selectedDestinationChain) {
return supportedTokens.find(
(token) => token.chainId === selectedDestinationChain.id,
)
}
return supportedTokens?.[0] as TokenInfo
}, [supportedTokens, selectedDestinationChain, mode])
const [isChainDropdownOpen, setIsChainDropdownOpen] = useState(false)
const [isTokenDropdownOpen, setIsTokenDropdownOpen] = useState(false)
const [selectedDestToken, setSelectedDestToken] = useState<TokenInfo>(() => {
let token = defaultDestToken
if (toToken && !isCustomToken) {
const isToTokenAddress = isAddress(toToken)
token = supportedTokens.find(
(token) =>
(isToTokenAddress // Match by specified destination token address or symbol
? token.contractAddress === toToken
: token.symbol === toToken) &&
(toChainId // Match by specified destination chain id
? token.chainId === toChainId
: selectedDestinationChain.id), // Select by selected destination chain id
)
}
return token as TokenInfo
})
useEffect(() => {
if (!selectedDestToken && defaultDestToken) {
setSelectedDestToken(defaultDestToken as TokenInfo)
}
}, [selectedDestToken, defaultDestToken])
const apiClient = useAPIClient()
const destTokenAddress = useTokenAddress({
chainId: selectedDestinationChain?.id,
tokenSymbol: selectedDestToken?.symbol,
})
const { tokenPrices: destTokenPrices } = useTokenPrices(
selectedDestToken && destTokenAddress
? [
{
tokenId: selectedDestToken.symbol,
contractAddress: destTokenAddress,
chainId: selectedDestinationChain.id,
},
]
: [],
apiClient,
)
const { tokenPrices: sourceTokenPrices } = useTokenPrices(
selectedToken
? [
{
tokenId: selectedToken.symbol,
contractAddress: selectedToken.contractAddress,
chainId: selectedToken.chainId,
},
]
: [],
apiClient,
)
// Update selectedChain when toChainId prop changes
useEffect(() => {
if (toChainId) {
const newChain = supportedChains.find((chain) => chain.id === toChainId)
if (newChain) {
setSelectedDestinationChain(newChain)
}
}
}, [toChainId, supportedChains])
// Update selectedDestToken when toToken prop changes
useEffect(() => {
if (toToken && !isCustomToken && selectedDestinationChain?.id) {
const isToTokenAddress = isAddress(toToken)
const newToken = supportedTokens.find(
(token) =>
(isToTokenAddress // Match by specified destination token address or symbol
? token.contractAddress === toToken
: token.symbol === toToken) &&
(toChainId // Match by specified destination chain id
? token.chainId === toChainId
: token.chainId === selectedDestinationChain.id),
)
if (newToken) {
setSelectedDestToken(newToken as TokenInfo)
}
}
}, [
toToken,
supportedTokens,
toChainId,
selectedDestinationChain?.id,
isCustomToken,
])
// Update amount when toAmount prop changes (only for EXACT_OUTPUT)
useEffect(() => {
if (tradeType === TradeType.EXACT_OUTPUT) {
setAmount(toAmount ?? "")
}
}, [toAmount, tradeType])
const toAmountFormatted = useMemo(() => {
return formatAmount(toAmount || 0)
}, [toAmount])
// Update recipient when toRecipient prop changes
useEffect(() => {
setRecipientInput(toRecipient ?? "")
setRecipient(toRecipient ?? "")
}, [toRecipient])
const chainInfo = getChainInfo(selectedToken?.chainId)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isWaitingForWalletConfirm, setIsWaitingForWalletConfirm] =
useState(false)
const [isLoadingQuote, setIsLoadingQuote] = useState(false)
const [prepareSendResult, setPrepareSendResult] =
useState<PrepareSendReturn | null>(null)
// Create a stable callback for transaction state changes
const handleTransactionStateChange = useCallback(
(transactionStates: TransactionState[]) => {
try {
// Pass transaction states to widget-level handler
onTransactionStateChange(transactionStates)
} catch (error) {
logger.console.error(
"[trails-sdk] Error calling onTransactionStateChange:",
error,
)
}
},
[onTransactionStateChange],
)
const balanceFormatted = selectedToken
? formatRawAmount(
selectedToken.balance,
selectedToken.contractInfo?.decimals,
)
: "0"
const balanceUsdDisplay = selectedToken?.balanceUsdFormatted ?? ""
const isValidRecipient = Boolean(recipient && isAddress(recipient))
// Calculate USD value based on trade type
const amountUsdDisplay = useMemo(() => {
const tokenPrice =
tradeType === TradeType.EXACT_INPUT
? (sourceTokenPrices?.[0]?.price?.value ?? 0) // For fund form, use source token price
: (destTokenPrices?.[0]?.price?.value ?? 0) // For payment form, use dest token price
const amountUsd = Number(amount) * tokenPrice
return formatUsdAmountDisplay(amountUsd)
}, [amount, destTokenPrices, sourceTokenPrices, tradeType])
const [selectedFeeToken, setSelectedFeeToken] = useState<FeeOption | null>(
null,
)
const [isRecipientContract, setIsRecipientContract] = useState(false)
const { hasParam } = useQueryParams()
const isDryMode = hasParam("dryMode", "true")
const destinationTokenAddressFromTokenSymbol = useTokenAddress({
chainId: selectedDestinationChain?.id,
tokenSymbol: selectedDestToken?.symbol,
})
const destinationTokenAddress = useMemo(() => {
if (isCustomToken) {
return toToken ?? null
}
return destinationTokenAddressFromTokenSymbol ?? null
}, [isCustomToken, toToken, destinationTokenAddressFromTokenSymbol])
// Calculate raw amount (in wei/smallest unit)
const amountRaw = useMemo(() => {
if (!amount || !selectedToken?.contractInfo?.decimals) {
return "0"
}
// For EXACT_INPUT: use source token decimals (user enters source amount)
// For EXACT_OUTPUT: use destination token decimals (user enters destination amount)
const decimals =
tradeType === TradeType.EXACT_INPUT
? selectedToken.contractInfo?.decimals
: selectedDestToken?.decimals
if (!decimals) {
logger.console.warn("[trails-sdk] Missing token decimals for quote", {
decimals,
selectedToken,
selectedDestToken,
tradeType,
})
return "0"
}
try {
return parseUnits(amount, decimals).toString()
} catch {
return "0"
}
}, [
amount,
selectedDestToken,
selectedToken,
selectedDestToken?.decimals,
selectedToken?.contractInfo?.decimals,
tradeType,
])
// Get quote automatically when inputs change
const getQuote = useCallback(async () => {
// Only get quote if all required inputs are present
if (
!amount ||
!destinationTokenAddress ||
!isValidRecipient ||
!selectedDestToken ||
!selectedDestinationChain ||
amount === "0" ||
!amountRaw ||
amountRaw === "0" ||
!selectedToken
) {
setQuoteError(null)
setPrepareSendResult(null)
return
}
try {
setIsLoadingQuote(true)
setError(null)
setQuoteError(null)
const originRelayer = getRelayer(undefined, selectedToken.chainId)
const destinationRelayer = getRelayer(
undefined,
selectedDestinationChain.id,
)
const sourceTokenDecimals = selectedToken.contractInfo?.decimals
const destinationTokenDecimals = selectedDestToken.decimals
if (!sourceTokenDecimals || !destinationTokenDecimals) {
logger.console.warn("[trails-sdk] Missing token decimals for quote")
setPrepareSendResult(null)
setIsLoadingQuote(false)
return
}
let sourceTokenPriceUsd = selectedToken.tokenPriceUsd ?? null
let destinationTokenPriceUsd = destTokenPrices?.[0]?.price?.value ?? null
if (!sourceTokenPriceUsd) {
try {
const price = await getTokenPrice(apiClient, selectedToken)
sourceTokenPriceUsd = price?.price?.value ?? null
} catch (error) {
logger.console.error(
"[trails-sdk] Error getting source token price:",
error,
)
}
}
if (!destinationTokenPriceUsd) {
try {
const price = await getTokenPrice(apiClient, {
tokenId: selectedDestToken.symbol,
contractAddress: destinationTokenAddress ?? "",
chainId: selectedDestinationChain.id,
})
destinationTokenPriceUsd = price?.price?.value ?? null
} catch (error) {
logger.console.error(
"[trails-sdk] Error getting destination token price:",
error,
)
}
}
if (
!destinationTokenPriceUsd &&
selectedToken.symbol === selectedDestToken.symbol
) {
destinationTokenPriceUsd = sourceTokenPriceUsd
}
if (
!sourceTokenPriceUsd &&
selectedToken.symbol === selectedDestToken.symbol
) {
sourceTokenPriceUsd = destinationTokenPriceUsd
}
if (!sourceTokenPriceUsd || !destinationTokenPriceUsd) {
logger.console.warn("[trails-sdk] Missing token prices for quote", {
sourceTokenPriceUsd,
destinationTokenPriceUsd,
})
}
let nativeTokenPriceUsd = 0
if (
selectedToken.contractAddress === zeroAddress &&
sourceTokenPriceUsd
) {
nativeTokenPriceUsd = sourceTokenPriceUsd
} else {
const originChain = getChainInfo(selectedToken.chainId)
const nativeTokenSymbol = originChain?.nativeCurrency?.symbol ?? ""
const nativePrice = await getTokenPrice(apiClient, {
tokenId: nativeTokenSymbol,
contractAddress: zeroAddress,
chainId: selectedToken.chainId,
})
nativeTokenPriceUsd = nativePrice?.price?.value ?? 0
}
const options = {
account,
originTokenAddress: selectedToken.contractAddress,
originChainId: selectedToken.chainId,
originTokenBalance:
fundMethod === "qr-code" || fundMethod === "exchange"
? "1"
: selectedToken.balance,
destinationChainId: selectedDestinationChain.id,
recipient,
destinationTokenAddress,
swapAmount: amountRaw,
tradeType,
originTokenSymbol: selectedToken.symbol,
destinationTokenSymbol: selectedDestToken.symbol,
fee: "0",
client: walletClient,
apiClient,
originRelayer,
destinationRelayer,
destinationCalldata: toCalldata,
refundAddress,
dryMode: isDryMode,
onTransactionStateChange: handleTransactionStateChange,
sourceTokenPriceUsd,
destinationTokenPriceUsd,
sourceTokenDecimals,
destinationTokenDecimals,
paymasterUrl:
paymasterUrls?.find(
(p) => p.chainId.toString() === selectedToken.chainId.toString(),
)?.url ?? undefined,
gasless,
originNativeTokenPriceUsd: nativeTokenPriceUsd,
quoteProvider: effectiveQuoteProvider,
mode,
fundMethod,
checkoutOnHandlers,
}
const result = await prepareSend(options)
logger.console.log("[trails-sdk] prepareSend quote:", result.quote)
setPrepareSendResult(result)
setIsLoadingQuote(false)
} catch (error) {
logger.console.error("[trails-sdk] Error getting quote:", error)
setQuoteError(getFullErrorMessage(error))
setPrepareSendResult(null)
setIsLoadingQuote(false)
}
}, [
tradeType,
isDryMode,
account,
walletClient,
apiClient,
selectedDestToken?.decimals,
recipient,
destinationTokenAddress,
selectedDestToken?.symbol,
selectedDestinationChain?.id,
selectedToken?.contractAddress,
selectedToken?.chainId,
selectedToken?.balance,
selectedToken?.tokenPriceUsd,
toCalldata,
refundAddress,
paymasterUrls,
gasless,
handleTransactionStateChange,
isValidRecipient,
destTokenPrices?.[0]?.price?.value,
amount,
selectedDestToken,
selectedDestinationChain,
selectedToken,
effectiveQuoteProvider,
fundMethod,
amountRaw,
checkoutOnHandlers,
mode,
])
// Auto-fetch quotes when inputs change (debounced)
// biome-ignore lint/correctness/useExhaustiveDependencies: getQuote is intentionally excluded to prevent infinite loop
useEffect(() => {
// Only trigger if we have the essential inputs
if (
!amount ||
!destinationTokenAddress ||
!isValidRecipient ||
!selectedDestToken?.symbol ||
!selectedDestinationChain?.id
) {
setPrepareSendResult(null)
return
}
const timeoutId = setTimeout(() => {
getQuote()
}, 500) // Debounce by 500ms
return () => clearTimeout(timeoutId)
}, [
amount,
destinationTokenAddress,
isValidRecipient,
selectedDestToken?.symbol,
selectedDestinationChain?.id,
toCalldata,
refetchTrigger,
])
// Calculate destination amount from quote if available
const quotedDestinationAmount = useMemo(() => {
if (prepareSendResult) {
return prepareSendResult.quote.destinationAmountFormatted
}
return toAmountFormatted
}, [prepareSendResult, toAmountFormatted])
const quotedDestinationAmountDisplay = useMemo(() => {
return formatAmountDisplay(quotedDestinationAmount || "0")
}, [quotedDestinationAmount])
const feeOptions = useMemo(() => {
return prepareSendResult?.feeOptions?.options ?? []
}, [prepareSendResult])
const processSend = useCallback(async () => {
try {
if (!prepareSendResult) {
setError("No quote available. Please wait for quote to load.")
return
}
setError(null)
setIsSubmitting(true)
const { quote, send } = prepareSendResult
logger.console.log(
"[trails-sdk] Using prepared send result quote:",
quote,
)
// Handle exchange fund method - navigate to mesh-connect
if (fundMethod === "exchange" && onNavigateToMeshConnect) {
const originChainId = quote?.originChain?.id
const destinationChainId = quote?.destinationChain?.id
const toTokenSymbol = quote?.originToken?.symbol // MeshConnect will deposit origin token
const toTokenAmount = normalizeNumber(
quote.originAmountFormatted,
).toString() // MeshConnect will deposit origin token amount
const toChainId = quote?.originChain?.id // MeshConnect will deposit to origin chain
const toRecipientAddress = quote.originDepositAddress // MeshConnect will deposit to origin address
logger.console.log(
"[trails-sdk] Navigating to mesh-connect with props:",
{
toTokenSymbol,
toTokenAmount,
toChainId,
toRecipientAddress,
},
)
if (originChainId === destinationChainId) {
throw new Error(
"[trails-sdk] Must be different chain than the origin chain to use mesh-connect",
)
}
if (
!toTokenSymbol ||
!toTokenAmount ||
!toChainId ||
!toRecipientAddress
) {
throw new Error(
"[trails-sdk] Missing required props for mesh-connect",
)
}
onNavigateToMeshConnect(
{
toTokenSymbol,
toTokenAmount,
toChainId,
toRecipientAddress,
},
prepareSendResult.quote,
)
await handleSend()
return
}
function onOriginSend() {
logger.console.log("[trails-sdk] onOriginSend called")
onConfirm()
setIsWaitingForWalletConfirm(false)
onSend(amount, recipient)
}
setIsWaitingForWalletConfirm(true)
onWaitingForWalletConfirm(prepareSendResult.quote)
async function handleSend() {
logger.console.log(
"[trails-sdk] handleRetry called, about to call send()",
)
// Wait for full send to complete
const {
originUserTxReceipt,
originMetaTxnReceipt,
destinationMetaTxnReceipt,
} = await send({
onOriginSend,
feeTokenAddress: selectedFeeToken?.token.contractAddress,
})
logger.console.log("[trails-sdk] send() completed, receipts:", {
originUserTxReceipt,
originMetaTxnReceipt,
destinationMetaTxnReceipt,
})
// Move to receipt screen
onComplete({
transactionStates: quote.transactionStates,
})
}
async function walletConfirmRetryHandler() {
logger.console.log("[trails-sdk] walletConfirmRetryHandler called")
try {
logger.console.log("[trails-sdk] About to call handleRetry")
await handleSend()
logger.console.log("[trails-sdk] handleRetry completed successfully")
} catch (error) {
logger.console.error(
"[trails-sdk] Error in prepareSend walletConfirmRetryHandler:",
error,
)
const errorMessage = getFullErrorMessage(error)
setError(errorMessage)
if (onError) {
onError(errorMessage)
}
}
}
setWalletConfirmRetryHandler(
() => walletConfirmRetryHandler as unknown as Promise<void>,
)
await handleSend()
} catch (error) {
logger.console.error("[trails-sdk] Error in prepareSend:", error)
const errorMessage = getFullErrorMessage(error)
setError(errorMessage)
if (onError) {
onError(errorMessage)
}
}
setIsSubmitting(false)
setIsWaitingForWalletConfirm(false)
}, [
prepareSendResult,
amount,
onSend,
onConfirm,
onComplete,
setWalletConfirmRetryHandler,
onWaitingForWalletConfirm,
recipient,
onError,
fundMethod,
onNavigateToMeshConnect,
selectedFeeToken?.token.contractAddress,
])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
processSend().catch((error) => {
logger.console.error("[trails-sdk] Error in processSend:", error)
setError(
error instanceof Error ? error.message : "An unexpected error occurred",
)
})
}
// Get button text based on recipient and calldata
const buttonText = useMemo(() => {
if (!selectedToken) return "Select a token"
if (!amount) return "Enter an amount"
if (!selectedDestToken?.symbol) return "Select a token"
if (isWaitingForWalletConfirm) return "Waiting for wallet..."
if (isSubmitting) return "Processing..."
if (!isValidRecipient && mode === "earn") return "Select a vault"
if (!isValidRecipient) return "Enter a recipient"
if (isLoadingQuote) return "Getting quote..."
if (!prepareSendResult) return "No quote available"
const amountFormatted =
prepareSendResult?.quote?.originAmountFormatted ?? formatAmount(amount)
const destinationAmountFormatted =
prepareSendResult?.quote?.destinationAmountFormatted ??
formatAmount(amount)
const tokenSymbol = selectedToken.symbol
const destinationTokenSymbol = selectedDestToken?.symbol
const amountDisplay = formatAmountDisplay(amountFormatted)
const destinationAmountDisplay = formatAmountDisplay(
destinationAmountFormatted,
)
try {
const isSameChain = selectedToken.chainId === selectedDestinationChain?.id
const isSameToken = selectedToken.symbol === selectedDestToken.symbol
const checksummedRecipient = getAddress(recipient)
const checksummedAccount = getAddress(account.address)
if (mode === "swap") {
return `Swap ${amountDisplay} ${tokenSymbol}`
}
if (fundMethod === "exchange") {
return `Continue to Exchange`
}
if (fundMethod === "qr-code") {
return `Continue to QR Code`
}
if (mode === "earn") {
return `Deposit ${destinationAmountDisplay} ${destinationTokenSymbol}`
}
if (tradeType === TradeType.EXACT_INPUT) {
return `Fund with ${amountDisplay} ${tokenSymbol}`
}
if (isSameChain && isSameToken) {
return `Execute`
}
if (isSameChain && !isSameToken) {
return `Swap ${amountDisplay} ${tokenSymbol}`
}
if (checksummedRecipient === checksummedAccount) {
return `Receive ${destinationAmountDisplay} ${destinationTokenSymbol}`
}
return `Pay with ${amountDisplay} ${tokenSymbol}`
} catch {
return `Pay with ${amountDisplay} ${tokenSymbol}`
}
}, [
amount,
isValidRecipient,
recipient,
account.address,
selectedDestToken?.symbol,
isWaitingForWalletConfirm,
isSubmitting,
isLoadingQuote,
prepareSendResult,
selectedToken,
tradeType,
prepareSendResult?.quote?.originAmountFormatted,
selectedDestinationChain?.id,
mode,
fundMethod,
])
useEffect(() => {
if (quoteError) {
setQuoteErrorPrettified(getPrettifiedErrorMessage(quoteError))
} else {
setQuoteErrorPrettified(null)
}
}, [quoteError])
// Check if origin and destination tokens are the same (same contract address on same chain)
// Only block same-token transactions when there's no custom calldata
const isSameTokenWithoutCustomCalldata = useMemo(() => {
if (
!selectedToken ||
!selectedToken.contractAddress ||
!destinationTokenAddress ||
!selectedToken?.chainId ||
!selectedDestinationChain?.id
) {
return false
}
const isSameChainAndToken =
selectedToken.contractAddress.toLowerCase() ===
destinationTokenAddress.toLowerCase() &&
selectedToken.chainId === selectedDestinationChain?.id
// Allow same-token transactions if there's custom calldata (e.g., NFT minting)
return isSameChainAndToken && !toCalldata
}, [
selectedToken,
destinationTokenAddress,
selectedDestinationChain,
toCalldata,
selectedToken?.chainId,
])
return {
amount,
amountRaw,
amountUsdDisplay,
balanceUsdDisplay,
chainInfo,
toChainId,
error,
balanceFormatted,
handleRecipientInputChange,
handleSubmit,
isChainDropdownOpen,
isSubmitting,
isLoadingQuote,
isTokenDropdownOpen,
recipient,
recipientInput,
selectedDestinationChain,
selectedDestToken,
setAmount,
setRecipient,
setRecipientInput,
setSelectedDestinationChain,
setSelectedDestToken,
setSelectedFeeToken,
feeOptions,
supportedTokens,
supportedChains,
ensAddress: ensAddress ?? null,
isWaitingForWalletConfirm,
buttonText,
isValidRecipient,
destTokenPrices: destTokenPrices ?? null,
sourceTokenPrices: sourceTokenPrices ?? null,
selectedToken,
selectedFeeToken,
setIsChainDropdownOpen,
setIsTokenDropdownOpen,
toAmountFormatted: quotedDestinationAmount,
toAmountDisplay: quotedDestinationAmountDisplay,
destinationTokenAddress,
isValidCustomToken,
prepareSendQuote: prepareSendResult?.quote ?? null,
quoteError,
quoteErrorPrettified,
isSameTokenWithoutCustomCalldata,
isRecipientContract,
}
}