Skip to content

Commit

Permalink
Feat/input quotes (#1412)
Browse files Browse the repository at this point in the history
* Initialize fetchBridgeQuotes in utils, add BridgeQuoteRequest data type

* Basic useBridgeQuote that continuously provides back bridge quote amount

* Simple async fetchBridgeQuote function

* Adjust fetchBridgeQuote function to accept synapseSDK as a param

* fetchBridgeQuotes can return multiple bridge quotes via SDK in single function call

* static typing

* Init Bridge Updater component to allow for refreshing toTokens quotes

* Bridge Updater component can access bridge quotes based on current toTokens in store

* Remove test code in ToTokenListOverlay

* Return token Token type in fetchBridgeQuote() call to match possibleTokens in ToTokenListOverlay

* Update

* Add comment

* init fechAndStoreBridgeQuotes async thunk

* fetchAndStoreBridgeQuotes

* add fetchAndStoreBridgeQuote to use for current bridge selections

* Update BridgeQuoteRequest to include originToken

* ...

* port getAndSetBridgeQuote logic into fetchBridgeQuote

* Extend BridgeQuote type into BridgeQuoteResponse to include destinationToken to match token options by

* Add typing to thunks

* Add store state and reducer for fetchAndStoerBridgeQuotes

* Bridge Updater to dispatch fetched bridge quotes for toTokens when avail

* Update fetchBridgeQuotes to return array of objects

* Pass in formatted exchangeRate string into SelectSpecificTokenButton

* Add OptionDetails component that displays exchangeRate for now

* Prefetch exchange rates without fromValue

* Add state/reducer for fetchAndStoreBridgeQuotes status

* Show exchangeRates only after fetch status is valid

* Add action and reducer to resetFetchedBridgeQuotes

* Reset fetched bridge quotes if fromToken is reset or is null

* Reset fetched bridge quotes if no toChainId exists

* calculateEstimatedTransactionTime util function

* Pass in estimatedDuration prop to SelectSpecificTokenButton to populate token selection

* Add comments

* Add estimatedDurationInSeconds as prop in OptionDetails component, display duration in minute format

* Style estimated duration in token selection

* Add util function locateBestExchangeRateIndex

* Add isBestExchangeRate bool prop to SelectSpecificTokenButton

* ...

* Create OptionTag with BestOptionType interface to create multiple options

* Basic unstyled OptionTag is working

* Add gradient

* Style tag

* Render tag only if exchangeRate available

* Add destinationChainId in response for fetchBridgeQuotes

* Ensure quote does not show unless destinationChainId matches, solve for case when connected chain id is default toChainId

* Style OptionTag

* Match bridgeQuotes based on destinationToken and not array positioning

* Init getDefaultBridgeAmount util function

* Create required enums to construct respective getDefaultBridgeAmount func

* ...

* Update locateBestExchangeRateToken to match best rate by Token

* Proprogate bestExchangeRateToken changes to ToTokenListOverlay

* clean

* Fix NaN bug

* ...

* Clean

* Add maxConcurrentRequests and requestDelay to limit single overload + throttle fetchBridgeQuotes call

* Debounce user input in Bridge updater to prevent alternative quote fetching, initial 5000ms

* updateDebouncedFromValue action

* Add reducer

* Lift debouncedFromValue to store

* Utilize debouncedFromValue throughout bridge experience

* Create orderedPossibleTokens to create ordered list based on fetched bridge quotes

* Debounce 400

* Debounce 300ms

* 400ms debounce works

* Ensure loader activates when fromValue updates, not based on debouncedFromValue

* ..

* Sort Best Rate selection and place at top

* Add delay on bridge loading animation

* Add default case for getDefaultBridgeAmount switch statement

* Ensure loader not triggered until debouncedFromValue populated

* Add isLoadingExchangeRate prop to SelectSpecificTokenButton

* Show loading spinner when fetching bridge quote exchange rates

* ...

* Update name from LoadingSpinner  to LoadingDots to be more descriptive'
'
'

* Update ButtonLoadingSpinner to ButtonLoadingDots

* Add debouncedToTokensFromValue action and reducer

* Setup debounce for alternative bridge quotes

* Utilize debouncedToTokensFromValue to fetch alternate bridge quotes

* Separate debouncing for primary quote and alternate quotes

* Update semantic naming, add comments

* Update debounce times between primary/alternative

* Tweak debouncer for alternative quote

* Update debounce and maxConcurrentRequests to make alternative bridge quotes more reliable

* Tighten up alternative bridge quotes fetching conditions for stability

* update naming

* Clear quotes if user input does not exist

* Allow input to be zerod

* Update trigger for useEffect updating alternative bridge quotes

* hasOnlyZeroes shared utils function

* Add try catch around fetchBridgeQuote action

* clean

* clean

* Example with fetching with default values

* Only fetch alternative bridge quotes when user input exists and is not zero

* Increase bridge qutoe fetching reliability after setting default to selections to undefined

* Update loading status when fetching default exchange rates

* Only show best rate if more than one option

* Fix lint

* ..

* Disable integration tests for iniitally settting bridge origin and destination token

* Test max 2 concurrent requests

* Set loading to false in useEffect cleanup

* Add error handling for when fetchBridgeQuote does not have request or synapseSDK avail

---------

Co-authored-by: Jonah Lin <[email protected]>
  • Loading branch information
bigboydiamonds and bigboydiamonds authored Oct 10, 2023
1 parent 0757009 commit 1db681a
Show file tree
Hide file tree
Showing 28 changed files with 721 additions and 106 deletions.
6 changes: 3 additions & 3 deletions packages/synapse-interface/components/InteractiveInputRow.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react'
import Button from '@tw/Button'
import ButtonLoadingSpinner from '@components/buttons/ButtonLoadingSpinner'
import ButtonLoadingDots from '@/components/buttons/ButtonLoadingDots'
import { getMenuItemBgForCoin } from '@styles/tokens'
import { Token } from '@types'

Expand Down Expand Up @@ -155,11 +155,11 @@ const InteractiveInputRow = ({
<>
{loadingLabel ? (
<div className="flex items-center justify-center space-x-5 animate-pulse">
<ButtonLoadingSpinner className="mr-2" />
<ButtonLoadingDots className="mr-2" />
<span>{loadingLabel}</span>
</div>
) : (
<ButtonLoadingSpinner />
<ButtonLoadingDots />
)}
</>
) : (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useMemo, useCallback, useState } from 'react'
import { useAppDispatch } from '@/store/hooks'
import { RootState } from '@/store/store'
import { useAccount } from 'wagmi'
import {
setFromChainId,
Expand All @@ -24,10 +23,7 @@ import { useBridgeState } from '@/slices/bridge/hooks'
import { fetchAndStoreSingleTokenAllowance } from '@/slices/portfolio/hooks'
import { AVALANCHE, ETH, ARBITRUM } from '@/constants/chains/master'
import { USDC } from '@/constants/tokens/bridgeable'

function hasOnlyZeros(input: string): boolean {
return /^0+(\.0+)?$/.test(input)
}
import { hasOnlyZeroes } from '@/utils/hasOnlyZeroes'

const handleFocusOnInput = () => {
inputRef.current.focus()
Expand Down Expand Up @@ -97,7 +93,7 @@ export const PortfolioTokenAsset = ({
decimals[portfolioChainId],
3
)
return balance > 0n && hasOnlyZeros(formattedBalance)
return balance > 0n && hasOnlyZeroes(formattedBalance)
? '< 0.001'
: formattedBalance
}, [balance, portfolioChainId])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ export const InputContainer = () => {
useEffect(() => {
if (
fromToken &&
fromToken.decimals[fromChainId] &&
stringToBigInt(fromValue, fromToken.decimals[fromChainId]) !== 0n
fromToken.decimals[fromChainId]
// && stringToBigInt(fromValue, fromToken.decimals[fromChainId]) !== 0n
// stringToBigInt(fromValue, fromToken.decimals[fromChainId]) ===
// fromTokenBalance
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { Address, useAccount } from 'wagmi'

import LoadingSpinner from '../ui/tailwind/LoadingSpinner'
import LoadingDots from '../ui/tailwind/LoadingDots'
import { ToChainSelector } from './ToChainSelector'
import { shortenAddress } from '@/utils/shortenAddress'
import { ToTokenSelector } from './ToTokenSelector'
Expand Down Expand Up @@ -47,7 +47,7 @@ export const OutputContainer = ({}) => {
<ToTokenSelector />
<div className="flex ml-4">
{isLoading ? (
<LoadingSpinner className="opacity-50" />
<LoadingDots className="opacity-50" />
) : (
<input
pattern="[0-9.]+"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import _ from 'lodash'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useMemo } from 'react'
import { useDispatch } from 'react-redux'
import { Address } from 'viem'
import Fuse from 'fuse.js'

import { useKeyPress } from '@hooks/useKeyPress'
import SlideSearchBox from '@pages/bridge/SlideSearchBox'
import { Token } from '@/utils/types'
import { setToToken } from '@/slices/bridge/reducer'
import { BridgeState, setToToken } from '@/slices/bridge/reducer'
import { setShowToTokenListOverlay } from '@/slices/bridgeDisplaySlice'
import { segmentAnalyticsEvent } from '@/contexts/SegmentAnalyticsProvider'
import { useBridgeState } from '@/slices/bridge/hooks'
Expand All @@ -18,16 +19,31 @@ import { CHAINS_BY_ID } from '@/constants/chains'
import useCloseOnOutsideClick from '@/utils/hooks/useCloseOnOutsideClick'
import { CloseButton } from './components/CloseButton'
import { SearchResults } from './components/SearchResults'
import { formatBigIntToString } from '@/utils/bigint/format'
import { FetchState } from '@/slices/portfolio/actions'
import { calculateEstimatedTransactionTime } from '@/utils/calculateEstimatedTransactionTime'

interface TokenWithExchangeRate extends Token {
exchangeRate: bigint
}

export const ToTokenListOverlay = () => {
const { fromChainId, toTokens, toChainId, toToken } = useBridgeState()
const {
fromChainId,
fromToken,
toTokens,
toChainId,
toToken,
toTokensBridgeQuotes,
toTokensBridgeQuotesStatus,
}: BridgeState = useBridgeState()

const [currentIdx, setCurrentIdx] = useState(-1)
const [searchStr, setSearchStr] = useState('')
const dispatch = useDispatch()
const overlayRef = useRef(null)

let possibleTokens = sortByPriorityRank(toTokens)
let possibleTokens: Token[] = sortByPriorityRank(toTokens)

const { toTokens: allToChainTokens } = getRoutePossibilities({
fromChainId,
Expand Down Expand Up @@ -151,10 +167,71 @@ export const ToTokenListOverlay = () => {
onClose()
}

const isLoadingExchangeRate = useMemo(() => {
const hasRequiredUserInput: boolean = Boolean(
fromChainId && toChainId && fromToken && toToken
)
const isFetchLoading: boolean =
toTokensBridgeQuotesStatus === FetchState.IDLE ||
toTokensBridgeQuotesStatus === FetchState.LOADING

return hasRequiredUserInput && isFetchLoading
}, [fromChainId, toChainId, fromToken, toToken, toTokensBridgeQuotesStatus])

const bridgeQuotesMatchDestination: boolean = useMemo(() => {
return (
Array.isArray(toTokensBridgeQuotes) &&
toTokensBridgeQuotes[0]?.destinationChainId === toChainId
)
}, [toTokensBridgeQuotes, toChainId])

const orderedPossibleTokens: TokenWithExchangeRate[] | Token[] =
useMemo(() => {
if (
toTokensBridgeQuotesStatus === FetchState.VALID &&
bridgeQuotesMatchDestination &&
possibleTokens &&
possibleTokens.length > 0
) {
const bridgeQuotesMap = new Map(
toTokensBridgeQuotes.map((quote) => [quote.destinationToken, quote])
)

const tokensWithExchangeRates: TokenWithExchangeRate[] =
possibleTokens.map((token) => {
const bridgeQuote = bridgeQuotesMap.get(token)
if (bridgeQuote) {
return {
...token,
exchangeRate: bridgeQuote.exchangeRate,
}
} else {
return token as TokenWithExchangeRate
}
})

const sortedTokens = tokensWithExchangeRates.sort(
(a, b) => Number(b.exchangeRate) - Number(a.exchangeRate)
)

return sortedTokens
}
return possibleTokens
}, [
possibleTokens,
toTokensBridgeQuotes,
toTokensBridgeQuotesStatus,
bridgeQuotesMatchDestination,
])

const totalPossibleTokens: number = useMemo(() => {
return orderedPossibleTokens.length
}, [orderedPossibleTokens])

return (
<div
ref={overlayRef}
data-test-id="token-slide-over"
data-test-id="to-token-list-overlay"
className="max-h-full pb-4 mt-2 overflow-auto scrollbar-hide"
>
<div className="z-10 w-full px-2 ">
Expand All @@ -167,31 +244,50 @@ export const ToTokenListOverlay = () => {
<CloseButton onClick={onClose} />
</div>
</div>
{possibleTokens && possibleTokens.length > 0 && (
{orderedPossibleTokens && orderedPossibleTokens.length > 0 && (
<>
<div className="px-2 pt-2 pb-2 text-sm text-primaryTextColor ">
Receive…
</div>
<div className="px-2 pb-2 md:px-2">
{possibleTokens.map((token, idx) => {
return (
<SelectSpecificTokenButton
isOrigin={false}
key={idx}
token={token}
selectedToken={toToken}
active={idx === currentIdx}
showAllChains={false}
onClick={() => {
if (token === toToken) {
onClose()
} else {
handleSetToToken(toToken, token)
{orderedPossibleTokens.map(
(token: TokenWithExchangeRate, idx: number) => {
return (
<SelectSpecificTokenButton
isOrigin={false}
key={idx}
token={token}
selectedToken={toToken}
active={idx === currentIdx}
showAllChains={false}
isLoadingExchangeRate={isLoadingExchangeRate}
isBestExchangeRate={totalPossibleTokens > 1 && idx === 0}
exchangeRate={formatBigIntToString(
token?.exchangeRate,
18,
4
)}
estimatedDurationInSeconds={
toTokensBridgeQuotesStatus === FetchState.VALID &&
bridgeQuotesMatchDestination &&
calculateEstimatedTransactionTime({
originChainId: fromChainId,
originTokenAddress: fromToken.addresses[
fromChainId
] as Address,
})
}
}}
/>
)
})}
onClick={() => {
if (token === toToken) {
onClose()
} else {
handleSetToToken(toToken, token)
}
}}
/>
)
}
)}
</div>
</>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { usePortfolioBalances } from '@/slices/portfolio/hooks'
import { useBridgeState } from '@/slices/bridge/hooks'
import { CHAINS_BY_ID } from '@/constants/chains'
import { findChainIdsWithPausedToken } from '@/constants/tokens'
import LoadingDots from '@/components/ui/tailwind/LoadingDots'

const SelectSpecificTokenButton = ({
showAllChains,
Expand All @@ -21,6 +22,10 @@ const SelectSpecificTokenButton = ({
selectedToken,
onClick,
alternateBackground = false,
isLoadingExchangeRate = false,
exchangeRate,
isBestExchangeRate = false,
estimatedDurationInSeconds,
}: {
showAllChains?: boolean
isOrigin: boolean
Expand All @@ -29,6 +34,10 @@ const SelectSpecificTokenButton = ({
selectedToken: Token
onClick: () => void
alternateBackground?: boolean
isLoadingExchangeRate?: boolean
exchangeRate?: string
isBestExchangeRate?: boolean
estimatedDurationInSeconds?: number
}) => {
const ref = useRef<any>(null)
const isCurrentlySelected = selectedToken?.routeSymbol === token?.routeSymbol
Expand Down Expand Up @@ -56,6 +65,7 @@ const SelectSpecificTokenButton = ({

return (
<button
data-test-id="select-specific-token-button"
ref={ref}
tabIndex={active ? 1 : 0}
onClick={onClick}
Expand All @@ -78,10 +88,70 @@ const SelectSpecificTokenButton = ({
isOrigin={isOrigin}
showAllChains={showAllChains}
/>
{isLoadingExchangeRate ? (
<LoadingDots className="mr-8 opacity-50" />
) : (
<>
{exchangeRate && isBestExchangeRate && (
<OptionTag type={BestOptionType.RATE} />
)}

{exchangeRate && (
<OptionDetails
exchangeRate={exchangeRate}
estimatedDurationInSeconds={estimatedDurationInSeconds}
/>
)}
</>
)}
</button>
)
}

export enum BestOptionType {
RATE = 'Best rate',
SPEED = 'Fastest',
}

export const OptionTag = ({ type }: { type: BestOptionType }) => {
return (
<div
data-test-id="option-tag"
className="flex px-3 py-0.5 mr-3 text-sm whitespace-nowrap text-primary rounded-xl"
style={{
background:
'linear-gradient(to right, rgba(128, 0, 255, 0.2), rgba(255, 0, 191, 0.2))',
}}
>{`${type}`}</div>
)
}

export const OptionDetails = ({
exchangeRate,
estimatedDurationInSeconds,
}: {
exchangeRate: string
estimatedDurationInSeconds: number
}) => {
const estimatedDurationInMinutes: number = Math.floor(
estimatedDurationInSeconds / 60
)

return (
<div data-test-id="option-details" className="flex flex-col">
<div className="flex items-center font-normal">
<div className="flex text-sm text-secondary whitespace-nowrap">
1&nbsp;:&nbsp;
</div>
<div className="mb-[1px] text-primary">{exchangeRate}</div>
</div>
<div className="text-sm text-right text-secondary">
{estimatedDurationInMinutes} min
</div>
</div>
)
}

const ButtonContent = memo(
({
token,
Expand All @@ -101,7 +171,7 @@ const ButtonContent = memo(
)?.parsedBalance

return (
<div className="flex items-center w-full">
<div data-test-id="button-content" className="flex items-center w-full">
<img
alt="token image"
className="w-8 h-8 ml-2 mr-4 rounded-full"
Expand Down
Loading

0 comments on commit 1db681a

Please sign in to comment.