Skip to content

Commit

Permalink
feat: try to auto-populate destination address for cross-ecosystem tr…
Browse files Browse the repository at this point in the history
…ansfers
  • Loading branch information
chybisov committed Oct 28, 2024
1 parent 4d428ea commit 323d3a1
Show file tree
Hide file tree
Showing 17 changed files with 202 additions and 34 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ArrowDownward, ArrowForward } from '@mui/icons-material'
import { useAvailableChains } from '../../hooks/useAvailableChains.js'
import { useToAddressAutoPopulate } from '../../hooks/useToAddressAutoPopulate.js'
import { useToAddressReset } from '../../hooks/useToAddressReset.js'
import { useFieldActions } from '../../stores/form/useFieldActions.js'
import { IconCard, ReverseContainer } from './ReverseTokensButton.style.js'
Expand All @@ -10,20 +11,38 @@ export const ReverseTokensButton: React.FC<{ vertical?: boolean }> = ({
const { setFieldValue, getFieldValues } = useFieldActions()
const { getChainById } = useAvailableChains()
const { tryResetToAddress } = useToAddressReset()
const autoPopulateToAddress = useToAddressAutoPopulate()

const handleClick = () => {
const [fromChainId, fromToken, toChainId, toToken] = getFieldValues(
'fromChain',
'fromToken',
'toChain',
'toToken'
)
const [fromChainId, fromToken, toChainId, toToken, toAddress] =
getFieldValues(
'fromChain',
'fromToken',
'toChain',
'toToken',
'toAddress'
)
setFieldValue('fromAmount', '', { isTouched: true })
setFieldValue('fromChain', toChainId, { isTouched: true })
setFieldValue('fromToken', toToken, { isTouched: true })
setFieldValue('toChain', fromChainId, { isTouched: true })
setFieldValue('toToken', fromToken, { isTouched: true })

const autoPopulatedToAddress = autoPopulateToAddress({
formType: 'from',
selectedToAddress: toAddress,
selectedChainId: toChainId,
selectedOppositeChainId: fromChainId,
selectedOppositeTokenAddress: fromToken,
})

// Returning early as a compatible connected wallet was found, and toAddress has been populated
if (autoPopulatedToAddress) {
return
}

// Auto-population applies in certain scenarios, but otherwise,
// we attempt to reset `toAddress` when bridging across ecosystems
// fromChainId becomes toChainId after using reverse
const toChain = getChainById(fromChainId)
if (toChain) {
Expand Down
11 changes: 7 additions & 4 deletions packages/widget/src/components/TokenList/TokenList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ export const TokenList: FC<TokenListProps> = ({
'tokenSearchFilter'
)

const { chain, isLoading: isChainLoading } = useChain(selectedChainId)
const { account } = useAccount({ chainType: chain?.chainType })
const { chain: selectedChain, isLoading: isSelectedChainLoading } =
useChain(selectedChainId)
const { account } = useAccount({
chainType: selectedChain?.chainType,
})

const {
tokens: chainTokens,
Expand Down Expand Up @@ -64,7 +67,7 @@ export const TokenList: FC<TokenListProps> = ({

const isLoading =
isTokensLoading ||
isChainLoading ||
isSelectedChainLoading ||
(tokenSearchEnabled && isSearchedTokenLoading)

const tokens = filteredTokens.length
Expand All @@ -88,7 +91,7 @@ export const TokenList: FC<TokenListProps> = ({
tokens={tokens}
scrollElementRef={parentRef}
chainId={selectedChainId}
chain={chain}
chain={selectedChain}
isLoading={isLoading}
isBalanceLoading={isBalanceLoading}
showCategories={showCategories}
Expand Down
2 changes: 1 addition & 1 deletion packages/widget/src/components/TokenList/TokenListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const TokenListItem: React.FC<TokenListItemProps> = ({
}) => {
const handleClick: MouseEventHandler<HTMLDivElement> = (e) => {
e.stopPropagation()
onClick?.(token.address)
onClick?.(token.address, chain?.id)
}
return (
<ListItem
Expand Down
4 changes: 2 additions & 2 deletions packages/widget/src/components/TokenList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ export interface VirtualizedTokenListProps {
chainId?: number
chain?: ExtendedChain
showCategories?: boolean
onClick(tokenAddress: string): void
onClick(tokenAddress: string, chainId?: number): void
}

export interface TokenListItemBaseProps {
onClick?(tokenAddress: string): void
onClick?(tokenAddress: string, chainId?: number): void
size: number
start: number
}
Expand Down
49 changes: 38 additions & 11 deletions packages/widget/src/components/TokenList/useTokenSelect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback } from 'react'
import { useToAddressAutoPopulate } from '../../hooks/useToAddressAutoPopulate.js'
import { useWidgetEvents } from '../../hooks/useWidgetEvents.js'
import { useWidgetConfig } from '../../providers/WidgetProvider/WidgetProvider.js'
import { useChainOrderStoreContext } from '../../stores/chains/ChainOrderStore.js'
Expand All @@ -9,13 +10,20 @@ import { useFieldController } from '../../stores/form/useFieldController.js'
import { WidgetEvent } from '../../types/events.js'
import type { DisabledUI } from '../../types/widget.js'

export type UseTokenSelectArgs = {
formType: FormType
onClick?: () => void
}

export const useTokenSelect = (formType: FormType, onClick?: () => void) => {
const { subvariant, disabledUI } = useWidgetConfig()
const emitter = useWidgetEvents()
const { setFieldValue, getFieldValues } = useFieldActions()
const autoPopulateToAddress = useToAddressAutoPopulate()
const chainOrderStore = useChainOrderStoreContext()

const tokenKey = FormKeyHelper.getTokenKey(formType)
const { onChange } = useFieldController({ name: tokenKey })
const chainOrderStore = useChainOrderStoreContext()

return useCallback(
(tokenAddress: string, chainId?: number) => {
Expand All @@ -32,13 +40,18 @@ export const useTokenSelect = (formType: FormType, onClick?: () => void) => {
setFieldValue(amountKey, '')
}
const oppositeFormType = formType === 'from' ? 'to' : 'from'
const [selectedOppositeToken, selectedOppositeChainId] = getFieldValues(
const [
selectedOppositeTokenAddress,
selectedOppositeChainId,
selectedToAddress,
] = getFieldValues(
FormKeyHelper.getTokenKey(oppositeFormType),
FormKeyHelper.getChainKey(oppositeFormType)
FormKeyHelper.getChainKey(oppositeFormType),
'toAddress'
)
// TODO: remove when we enable same chain/token transfers
if (
selectedOppositeToken === tokenAddress &&
selectedOppositeTokenAddress === tokenAddress &&
selectedOppositeChainId === selectedChainId &&
subvariant !== 'custom'
) {
Expand All @@ -48,16 +61,29 @@ export const useTokenSelect = (formType: FormType, onClick?: () => void) => {
})
}

// If the destination token is not selected, update the destination chain to match the source one.
// If no opposite token is selected, synchronize the opposite chain to match the currently selected chain
const { setChain } = chainOrderStore.getState()
if (formType === 'from' && !selectedOppositeToken && selectedChainId) {
setFieldValue(FormKeyHelper.getChainKey('to'), selectedChainId, {
isDirty: true,
isTouched: true,
})
setChain(selectedChainId, 'to')
if (!selectedOppositeTokenAddress && selectedChainId) {
setFieldValue(
FormKeyHelper.getChainKey(oppositeFormType),
selectedChainId,
{
isDirty: true,
isTouched: true,
}
)
setChain(selectedChainId, oppositeFormType)
}

// Automatically populate toAddress field if bridging across ecosystems and compatible wallet is connected
autoPopulateToAddress({
formType,
selectedToAddress,
selectedChainId,
selectedOppositeChainId,
selectedOppositeTokenAddress,
})

const eventToEmit =
formType === 'from'
? WidgetEvent.SourceChainTokenSelected
Expand All @@ -73,6 +99,7 @@ export const useTokenSelect = (formType: FormType, onClick?: () => void) => {
onClick?.()
},
[
autoPopulateToAddress,
chainOrderStore,
disabledUI,
emitter,
Expand Down
7 changes: 6 additions & 1 deletion packages/widget/src/hooks/useAvailableChains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { useCallback } from 'react'
import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js'
import { isItemAllowed } from '../utils/item.js'

export type GetChainById = (
chainId?: number,
chains?: ExtendedChain[]
) => ExtendedChain | undefined

const supportedChainTypes = [ChainType.EVM, ChainType.SVM, ChainType.UTXO]

export const useAvailableChains = (chainTypes?: ChainType[]) => {
Expand Down Expand Up @@ -35,7 +40,7 @@ export const useAvailableChains = (chainTypes?: ChainType[]) => {
staleTime: 300_000,
})

const getChainById = useCallback(
const getChainById: GetChainById = useCallback(
(chainId?: number, chains: ExtendedChain[] | undefined = data) => {
if (!chainId) {
return
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/hooks/useChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export const useChain = (chainId?: number) => {
return {
chain,
isLoading,
getChainById,
}
}
95 changes: 95 additions & 0 deletions packages/widget/src/hooks/useToAddressAutoPopulate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useAccount } from '@lifi/wallet-management'
import { useCallback } from 'react'
import { useBookmarkActions } from '../stores/bookmarks/useBookmarkActions.js'
import type { FormType } from '../stores/form/types.js'
import { useFieldActions } from '../stores/form/useFieldActions.js'
import { useSendToWalletActions } from '../stores/settings/useSendToWalletStore.js'
import { getChainTypeFromAddress } from '../utils/chainType.js'
import { useAvailableChains } from './useAvailableChains.js'

export type UpdateToAddressArgs = {
formType: FormType
selectedToAddress?: string
selectedChainId?: number
selectedOppositeTokenAddress?: string
selectedOppositeChainId?: number
}

/**
* Automatically populates toAddress field if bridging across ecosystems and compatible wallet is connected
*/
export const useToAddressAutoPopulate = () => {
const { setFieldValue } = useFieldActions()
const { setSendToWallet } = useSendToWalletActions()
const { setSelectedBookmark } = useBookmarkActions()
const { getChainById } = useAvailableChains()
const { accounts } = useAccount()

return useCallback(
({
formType,
selectedToAddress,
selectedChainId,
selectedOppositeTokenAddress,
selectedOppositeChainId,
}: UpdateToAddressArgs) => {
if (
!selectedOppositeTokenAddress ||
!selectedOppositeChainId ||
!selectedChainId ||
!accounts?.length
) {
return
}
const selectedChain = getChainById?.(selectedChainId)
const selectedOppositeChain = getChainById?.(selectedOppositeChainId)
// Proceed if both chains are defined and of different ecosystem types (indicating cross-ecosystem bridging)
if (
!selectedChain ||
!selectedOppositeChain ||
selectedChain.chainType === selectedOppositeChain.chainType
) {
return
}
// Identify the destination chain type based on the bridge direction ('from' or 'to')
const destinationChainType =
formType === 'from'
? selectedOppositeChain.chainType
: selectedChain.chainType
// If toAddress is already selected, verify that it matches the destination chain type
if (selectedToAddress) {
const selectedToAddressChainType =
getChainTypeFromAddress(selectedToAddress)
if (destinationChainType === selectedToAddressChainType) {
return
}
}
// Find connected account compatible with the destination chain type
const destinationAccount = accounts?.find(
(account) => account.chainType === destinationChainType
)
// If a compatible destination account is found, set toAddress as if selecting it from the "Send to Wallet" connected wallets page
if (destinationAccount?.address) {
setFieldValue('toAddress', destinationAccount.address, {
isDirty: false,
isTouched: true,
})
setSelectedBookmark({
name: destinationAccount.connector?.name,
address: destinationAccount.address,
chainType: destinationAccount.chainType,
isConnectedAccount: true,
})
setSendToWallet(true)
return destinationAccount.address
}
},
[
accounts,
getChainById,
setFieldValue,
setSelectedBookmark,
setSendToWallet,
]
)
}
23 changes: 15 additions & 8 deletions packages/widget/src/hooks/useToAddressReset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { useWidgetConfig } from '../providers/WidgetProvider/WidgetProvider.js'
import { useBookmarkActions } from '../stores/bookmarks/useBookmarkActions.js'
import { useBookmarks } from '../stores/bookmarks/useBookmarks.js'
import { useFieldActions } from '../stores/form/useFieldActions.js'
import { useSendToWalletActions } from '../stores/settings/useSendToWalletStore.js'
import { RequiredUI } from '../types/widget.js'

export const useToAddressReset = () => {
const { requiredUI } = useWidgetConfig()
const { setFieldValue } = useFieldActions()
const { setFieldValue, isDirty } = useFieldActions()
const { selectedBookmark } = useBookmarks()
const { setSelectedBookmark } = useBookmarkActions()
const { setSendToWallet } = useSendToWalletActions()

const tryResetToAddress = (toChain: ExtendedChain) => {
const requiredToAddress = requiredUI?.includes(RequiredUI.ToAddress)
Expand All @@ -20,15 +22,20 @@ export const useToAddressReset = () => {
const shouldResetToAddress =
!requiredToAddress && !bookmarkSatisfiesToChainType

// toAddress field is required (always visible) when bridging between
// two ecosystems (fromChain and toChain have different chain types).
// We clean up toAddress on every chain change if toAddress is not required.
// This is used when we switch between different chain ecosystems (chain types) and
// prevents cases when after we switch the chain from one type to another "Send to wallet" field hides,
// but it keeps toAddress value set for the previous chain pair.
// The toAddress field is required and always visible when bridging between
// different ecosystems (fromChain and toChain have different chain types).
// We reset toAddress on each chain change if it's no longer required, ensuring that
// switching chain types doesn't leave a previously set toAddress value when
// the "Send to Wallet" field is hidden.
if (shouldResetToAddress) {
setFieldValue('toAddress', '')
setFieldValue('toAddress', '', { isTouched: true })
setSelectedBookmark()
// If toAddress was auto-filled (e.g., when making cross-ecosystem bridging and compatible destination wallet was connected)
// and not manually edited by the user, we need to hide "Send to Wallet".
const isToAddressDirty = isDirty('toAddress')
if (!isToAddressDirty) {
setSendToWallet(false)
}
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/pages/SendToWallet/BookmarksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const BookmarksPage = () => {
const handleBookmarkSelected = (bookmark: Bookmark) => {
setFieldValue('toAddress', bookmark.address, {
isTouched: true,
isDirty: true,
})
setSelectedBookmark(bookmark)
setSendToWallet(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const ConfirmAddressSheet = forwardRef<
if (validatedBookmark) {
setFieldValue('toAddress', validatedBookmark.address, {
isTouched: true,
isDirty: true,
})
onConfirm?.(validatedBookmark)
setSendToWallet(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const ConnectedWalletsPage = () => {
const handleWalletSelected = (account: Account) => {
setFieldValue('toAddress', account.address!, {
isTouched: true,
isDirty: true,
})
setSelectedBookmark({
name: account.connector?.name,
Expand Down
Loading

0 comments on commit 323d3a1

Please sign in to comment.