packages/widget/src/components/Widget.tsx
import {
useMemo,
useEffect,
useContext,
useCallback,
useRef,
useState,
} from 'react'
import {
type BridgeableToken,
type Chain,
type CustomThemeVariables,
} from 'types'
import { ZeroAddress } from 'ethers'
import { Web3Context } from '@/providers/Web3Provider'
import { formatBigIntToString } from '@/utils/formatBigIntToString'
import { stringToBigInt } from '@/utils/stringToBigInt'
import { cleanNumberInput } from '@/utils/cleanNumberInput'
import { Receipt } from '@/components/Receipt'
import { ChainSelect } from '@/components/ui/ChainSelect'
import { TokenSelect } from '@/components/ui/TokenSelect'
import { useAppDispatch } from '@/state/hooks'
import {
setDestinationChainId,
setOriginChainId,
setOriginToken,
setDestinationToken,
setTargetTokens,
setDebouncedInputAmount,
setTargetChainIds,
setProtocolName,
} from '@/state/slices/bridge/reducer'
import { useBridgeState } from '@/state/slices/bridge/hooks'
import { setIsWalletPending } from '@/state/slices/wallet/reducer'
import {
fetchAndStoreAllowance,
fetchAndStoreTokenBalances,
useWalletState,
} from '@/state/slices/wallet/hooks'
import { BridgeButton } from '@/components/BridgeButton'
import { AvailableBalance } from '@/components/AvailableBalance'
import { useValidations } from '@/hooks/useValidations'
import {
fetchBridgeQuote,
useBridgeQuoteState,
} from '@/state/slices/bridgeQuote/hooks'
import {
EMPTY_BRIDGE_QUOTE,
resetQuote,
} from '@/state/slices/bridgeQuote/reducer'
import {
executeBridgeTxn,
useBridgeTransactionState,
} from '@/state/slices/bridgeTransaction/hooks'
import { BridgeTransactionStatus } from '@/state/slices/bridgeTransaction/reducer'
import {
executeApproveTxn,
useApproveTransactionState,
} from '@/state/slices/approveTransaction/hooks'
import { ApproveTransactionStatus } from '@/state/slices/approveTransaction/reducer'
import { useThemeVariables } from '@/hooks/useThemeVariables'
import { Transactions } from '@/components/Transactions'
import { CHAINS_BY_ID } from '@/constants/chains'
import { useSynapseContext } from '@/providers/SynapseProvider'
import { getFromTokens } from '@/utils/routeMaker/getFromTokens'
import { getSymbol } from '@/utils/routeMaker/generateRoutePossibilities'
import { findTokenByRouteSymbol } from '@/utils/findTokenByRouteSymbol'
import { useMaintenance } from '@/components/Maintenance/Maintenance'
import { getTimeMinutesFromNow } from '@/utils/getTimeMinutesFromNow'
import { useBridgeQuoteUpdater } from '@/hooks/useBridgeQuoteUpdater'
import { SwitchButton } from '@/components/ui/SwitchButton'
interface WidgetProps {
customTheme: CustomThemeVariables
container?: Boolean
targetTokens?: BridgeableToken[]
targetChainIds?: number[]
protocolName?: string
}
export const Widget = ({
customTheme,
container = false,
targetChainIds,
targetTokens,
protocolName,
}: WidgetProps) => {
const dispatch = useAppDispatch()
const currentSDKRequestID = useRef(0)
const { synapseSDK, synapseProviders } = useSynapseContext()
const web3Context = useContext(Web3Context)
const { connectedAddress, signer, provider, networkId } =
web3Context.web3Provider
const [inputAmount, setInputAmount] = useState('')
const {
debouncedInputAmount,
originChainId,
originToken,
destinationChainId,
destinationToken,
} = useBridgeState()
const {
isBridgePaused,
pausedModulesList,
BridgeMaintenanceProgressBar,
BridgeMaintenanceWarningMessage,
} = useMaintenance()
const allTokens = useMemo(() => {
return getFromTokens({
fromChainId: originChainId,
fromTokenRouteSymbol: null,
toChainId: null,
toTokenRouteSymbol: null,
})
.map(getSymbol)
.map(findTokenByRouteSymbol)
}, [originChainId])
const { bridgeQuote, isLoading } = useBridgeQuoteState()
const { isInputValid, hasValidSelections } = useValidations()
const { isWalletPending } = useWalletState()
const { bridgeTxnStatus } = useBridgeTransactionState()
const { approveTxnStatus } = useApproveTransactionState()
const themeVariables = useThemeVariables(customTheme)
const originChainProvider = useMemo(() => {
return synapseProviders.find(
(p) => Number(p?._network?.chainId) === originChainId
)
}, [originChainId])
useEffect(() => {
dispatch(setOriginChainId(networkId))
}, [networkId])
useEffect(() => {
dispatch(setTargetTokens(targetTokens))
dispatch(setTargetChainIds(targetChainIds))
if (targetChainIds && targetChainIds.length > 0) {
dispatch(setDestinationChainId(targetChainIds[0]))
}
dispatch(setProtocolName(protocolName))
}, [targetTokens, targetChainIds, targetTokens, protocolName])
/** Debounce user input to fetch bridge quote (in ms) */
useEffect(() => {
const DEBOUNCE_DELAY = 300
const debounceTimer = setTimeout(() => {
dispatch(setDebouncedInputAmount(inputAmount))
}, DEBOUNCE_DELAY)
return () => {
clearTimeout(debounceTimer)
}
}, [dispatch, inputAmount])
/** Fetch token balances when signer/address connected */
useEffect(() => {
if (!signer && !originChainProvider) return
if (originChainId && allTokens && connectedAddress) {
dispatch(
fetchAndStoreTokenBalances({
address: connectedAddress,
chainId: originChainId,
tokens: allTokens,
signerOrProvider: originChainProvider ?? signer,
})
)
}
}, [originChainId, allTokens, connectedAddress, signer, originChainProvider])
/** Fetch and store token allowance */
useEffect(() => {
if (
originToken?.addresses[originChainId] !== ZeroAddress &&
bridgeQuote?.routerAddress
) {
dispatch(
fetchAndStoreAllowance({
spenderAddress: bridgeQuote?.routerAddress,
ownerAddress: connectedAddress,
chainId: originChainId,
token: originToken,
provider: originChainProvider ?? provider,
})
)
}
}, [
originToken?.routeSymbol,
originChainId,
connectedAddress,
bridgeQuote?.routerAddress,
])
const fetchAndStoreBridgeQuote = async () => {
currentSDKRequestID.current += 1
const thisRequestId = currentSDKRequestID.current
dispatch(resetQuote())
const currentTimestamp: number = getTimeMinutesFromNow(0)
if (thisRequestId === currentSDKRequestID.current) {
dispatch(
fetchBridgeQuote({
originChainId,
destinationChainId,
originToken,
destinationToken,
amount: stringToBigInt(
debouncedInputAmount,
originToken.decimals[originChainId]
),
debouncedInputAmount,
synapseSDK,
requestId: thisRequestId,
pausedModules: pausedModulesList,
timestamp: currentTimestamp,
})
)
}
}
/** Handle refreshing quotes */
useEffect(() => {
if (isInputValid && hasValidSelections) {
fetchAndStoreBridgeQuote()
} else {
dispatch(resetQuote())
}
}, [
debouncedInputAmount,
originToken?.routeSymbol,
destinationToken?.routeSymbol,
originChainId,
destinationChainId,
isInputValid,
hasValidSelections,
])
useBridgeQuoteUpdater(
bridgeQuote,
fetchAndStoreBridgeQuote,
isLoading,
isWalletPending
)
const handleUserInput = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const value = cleanNumberInput(event.target.value)
setInputAmount(value)
},
[]
)
const handleOriginChainSelection = useCallback(
(newOriginChain: Chain) => {
dispatch(setOriginChainId(newOriginChain.id))
},
[dispatch, provider]
)
const handleDestinationChainSelection = useCallback(
(newDestinationChain: Chain) => {
dispatch(setDestinationChainId(newDestinationChain.id))
},
[dispatch]
)
const handleOriginTokenSelection = useCallback(
(newOriginToken: BridgeableToken) => {
dispatch(setOriginToken(newOriginToken))
},
[dispatch]
)
const handleDestinationTokenSelection = useCallback(
(newDestinationToken: BridgeableToken) => {
dispatch(setDestinationToken(newDestinationToken))
},
[dispatch]
)
const executeApproval = async () => {
try {
dispatch(setIsWalletPending(true))
const tx = await dispatch(
executeApproveTxn({
spenderAddress: bridgeQuote?.routerAddress,
tokenAddress: originToken?.addresses[originChainId],
amount: stringToBigInt(
debouncedInputAmount,
originToken?.decimals[originChainId]
),
signer,
})
)
/** Fetch allowance on successful approval tx */
if (tx?.payload?.hash || tx?.payload?.transactionHash) {
dispatch(
fetchAndStoreAllowance({
spenderAddress: bridgeQuote?.routerAddress,
ownerAddress: connectedAddress,
chainId: originChainId,
token: originToken,
provider: originChainProvider ?? provider,
})
)
}
} catch (error) {
console.error(`[Synapse Widget] Error while approving token: `, error)
} finally {
dispatch(setIsWalletPending(false))
}
}
const executeBridge = async () => {
try {
dispatch(setIsWalletPending(true))
const action = await dispatch(
executeBridgeTxn({
destinationAddress: connectedAddress,
originRouterAddress: bridgeQuote?.routerAddress,
originChainId,
destinationChainId,
tokenAddress: originToken?.addresses[originChainId],
amount: stringToBigInt(
debouncedInputAmount,
originToken?.decimals[originChainId]
),
parsedOriginAmount: debouncedInputAmount,
originTokenSymbol: originToken?.symbol,
originQuery: bridgeQuote?.originQuery,
destQuery: bridgeQuote?.destQuery,
bridgeModuleName: bridgeQuote?.bridgeModuleName,
estimatedTime: bridgeQuote?.estimatedTime,
synapseSDK,
signer,
})
)
/** Check thunk action is fulfilled */
if (executeBridgeTxn.fulfilled.match(action)) {
const tx = action.payload
/** Fetch balance/allowance on successful bridge tx */
if (tx?.txHash) {
dispatch(
fetchAndStoreTokenBalances({
address: connectedAddress,
chainId: originChainId,
tokens: allTokens,
signerOrProvider: originChainProvider ?? signer,
})
)
dispatch(
fetchAndStoreAllowance({
spenderAddress: bridgeQuote?.routerAddress,
ownerAddress: connectedAddress,
chainId: originChainId,
token: originToken,
provider: originChainProvider ?? provider,
})
)
}
}
} catch (error) {
console.error('[Synapse Widget] Error bridging: ', error)
} finally {
dispatch(setIsWalletPending(false))
}
}
const containerStyle = `
${container === false ? 'p-2 rounded-[inherit]' : 'p-2 rounded-lg'}`
const cardStyle = `
grid grid-cols-[1fr_auto]
rounded-md p-2 gap-1
border border-solid border-[--synapse-border]
`
const inputStyle = `
text-3xl w-full font-regular bg-transparent border-none block
text-[--synapse-text] placeholder:text-[--synapse-secondary] focus:outline-none disabled:cursor-not-allowed font-sans
`
const isCurrentRequestedQuote =
bridgeQuote?.requestId === currentSDKRequestID.current
const destinationValue = useMemo(() => {
if (isLoading) {
return '...'
} else if (!hasValidSelections) {
return ''
} else if (!bridgeQuote) {
return ''
} else if (bridgeQuote.outputAmountString === '') {
return '0'
} else if (!isCurrentRequestedQuote) {
return '...'
}
return bridgeQuote.outputAmountString
}, [isLoading, bridgeQuote, isCurrentRequestedQuote, hasValidSelections])
return (
<div
style={themeVariables}
className={`synapse-widget ${container && 'max-w-400px'}`}
>
<div
className={`grid gap-2 text-[--synapse-text] w-full ${containerStyle}`}
style={{ background: 'var(--synapse-root)' }}
>
<BridgeMaintenanceProgressBar />
<Transactions connectedAddress={connectedAddress} />
<section className="grid gap-0.5">
<section
className={cardStyle}
style={{ background: 'var(--synapse-surface)' }}
>
<ChainSelect
label="From"
isOrigin={true}
chain={CHAINS_BY_ID[originChainId]}
onChange={handleOriginChainSelection}
/>
<input
className={inputStyle}
placeholder="0"
value={inputAmount}
onChange={handleUserInput}
/>
<div className="flex flex-col items-end justify-center gap-2">
<TokenSelect
label="In"
isOrigin={true}
token={originToken}
onChange={handleOriginTokenSelection}
/>
<AvailableBalance
connectedAddress={connectedAddress}
setInputAmount={setInputAmount}
/>
</div>
</section>
<SwitchButton
onClick={() => {
dispatch(setDestinationChainId(originChainId))
dispatch(setDestinationToken(originToken))
dispatch(setOriginChainId(destinationChainId))
dispatch(setOriginToken(destinationToken))
}}
/>
<section
className={`${cardStyle} gap-3 pb-2.5`}
style={{ background: 'var(--synapse-surface)' }}
>
<ChainSelect
label="To"
isOrigin={false}
chain={CHAINS_BY_ID[destinationChainId]}
onChange={handleDestinationChainSelection}
/>
<input
className={inputStyle}
disabled={true}
placeholder=""
value={destinationValue}
/>
<div className="flex flex-col items-end justify-center">
<TokenSelect
label="Out"
isOrigin={false}
token={destinationToken}
onChange={handleDestinationTokenSelection}
/>
</div>
</section>
</section>
<BridgeMaintenanceWarningMessage />
<Receipt
quote={bridgeQuote ?? null}
loading={isLoading}
send={formatBigIntToString(
stringToBigInt(
debouncedInputAmount,
originToken?.decimals[originChainId]
),
originToken?.decimals[originChainId],
4
)}
receive={formatBigIntToString(
bridgeQuote?.delta,
destinationToken?.decimals[destinationChainId],
4
)}
/>
<BridgeButton
originChain={CHAINS_BY_ID[originChainId]}
isValidQuote={
Boolean(bridgeQuote) && bridgeQuote !== EMPTY_BRIDGE_QUOTE
}
handleApprove={executeApproval}
handleBridge={executeBridge}
isApprovalPending={
approveTxnStatus === ApproveTransactionStatus.PENDING
}
isBridgePending={bridgeTxnStatus === BridgeTransactionStatus.PENDING}
isBridgePaused={isBridgePaused}
/>
</div>
</div>
)
}