diff --git a/.changeset/clever-rabbits-report.md b/.changeset/clever-rabbits-report.md new file mode 100644 index 000000000..09e9c06d3 --- /dev/null +++ b/.changeset/clever-rabbits-report.md @@ -0,0 +1,6 @@ +--- +'@reservoir0x/relay-kit-ui': minor +'@reservoir0x/relay-kit-hooks': patch +--- + +Deposit Address fallback functionality diff --git a/demo/pages/_app.tsx b/demo/pages/_app.tsx index a7852f2cd..14aec5c1c 100644 --- a/demo/pages/_app.tsx +++ b/demo/pages/_app.tsx @@ -169,10 +169,10 @@ const AppWrapper: FC = ({ children }) => { logLevel: 'INFO', environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENV_ID ?? '', walletConnectors: [ - EthereumWalletConnectors, - SolanaWalletConnectors, - BitcoinWalletConnectors, - EclipseWalletConnectors + // EthereumWalletConnectors + // SolanaWalletConnectors, + // BitcoinWalletConnectors, + // EclipseWalletConnectors ], cssOverrides: ` [data-testid="send-balance-button"] { diff --git a/demo/pages/index.tsx b/demo/pages/index.tsx index ec6312d4b..485f30994 100644 --- a/demo/pages/index.tsx +++ b/demo/pages/index.tsx @@ -22,6 +22,7 @@ const Index: NextPage = () => {

SDK Write Actions diff --git a/demo/pages/ui/chain.tsx b/demo/pages/ui/chain.tsx index d07b91378..4c6498e4d 100644 --- a/demo/pages/ui/chain.tsx +++ b/demo/pages/ui/chain.tsx @@ -128,6 +128,7 @@ const ChainWidgetPage: NextPage = () => { { + const { theme } = useTheme() + const [amount, setAmount] = useState() + const [refundAddress, setRefundAddress] = useState('') + const [currencyAddress, setCurrencyAddress] = useState(zeroAddress) + const [recipientAddress, setRecipientAddress] = useState('') + const [originChainId, setOriginChainId] = useState() + const [destinationChainId, setDestinationChainId] = useState< + number | undefined + >() + const [quote, setQuote] = useState() + const [depositAddress, setDepositAddress] = useState() + const [depositAmount, setDepositAmount] = useState() + return ( + +
+
+ + + setAmount(e.target.value ? Number(e.target.value) : undefined) + } + /> +
+
+ + setRefundAddress(e.target.value)} + /> +
+
+ + setCurrencyAddress(e.target.value)} + /> +
+
+ + + setOriginChainId( + e.target.value ? Number(e.target.value) : undefined + ) + } + /> +
+
+ + + setDestinationChainId( + e.target.value ? Number(e.target.value) : undefined + ) + } + /> +
+
+ + setRecipientAddress(e.target.value)} + /> +
+ + + + {quote ? ( +
+
+ Instructions to relay: +
+
+ Send {depositAmount} wei to this address: {depositAddress} +
+
+
Quote Response:
+
+ {JSON.stringify(quote, null, 2)} +
+
+ ) : undefined} +
+
+ ) +} + +export default DepositAddressesPage diff --git a/demo/pages/ui/swap.tsx b/demo/pages/ui/swap.tsx index 0b3fdfe21..1c632f547 100644 --- a/demo/pages/ui/swap.tsx +++ b/demo/pages/ui/swap.tsx @@ -137,6 +137,7 @@ const SwapWidgetPage: NextPage = () => { key={`swap-widget-${singleChainMode ? 'single' : 'multi'}-chain`} lockChainId={singleChainMode ? 8453 : undefined} singleChainMode={singleChainMode} + supportedWalletVMs={[]} defaultToToken={ singleChainMode ? { diff --git a/packages/hooks/src/hooks/useExecutionStatus.ts b/packages/hooks/src/hooks/useExecutionStatus.ts new file mode 100644 index 000000000..2498ee8c0 --- /dev/null +++ b/packages/hooks/src/hooks/useExecutionStatus.ts @@ -0,0 +1,103 @@ +import { + MAINNET_RELAY_API, + RelayClient, + setParams, + type Execute, + type paths, + type ProgressData +} from '@reservoir0x/relay-sdk' +import fetcher from '../fetcher.js' +import { + useQuery, + type DefaultError, + type QueryKey +} from '@tanstack/react-query' +import { useMemo } from 'react' +import type { AxiosRequestConfig } from 'axios' + +type ExecutionStatusParams = + paths['/intents/status/v2']['get']['parameters']['query'] + +export type ExecutionStatusResponse = + paths['/intents/status/v2']['get']['responses']['200']['content']['application/json'] + +type QueryType = typeof useQuery< + ExecutionStatusResponse, + DefaultError, + ExecutionStatusResponse, + QueryKey +> +type QueryOptions = Parameters['0'] + +export const queryExecutionStatus = function ( + baseApiUrl: string = MAINNET_RELAY_API, + options?: ExecutionStatusParams +): Promise { + return new Promise((resolve, reject) => { + const url = new URL(`${baseApiUrl}/intents/status/v2`) + let query: ExecutionStatusParams = { ...options } + setParams(url, query) + + fetcher(url.href) + .then((response) => { + const request: AxiosRequestConfig = { + url: url.href, + method: 'get' + } + resolve({ + ...response, + request + }) + }) + .catch((e) => { + reject(e) + }) + }) +} + +export type onProgress = (data: ProgressData) => void + +export default function ( + client?: RelayClient, + options?: ExecutionStatusParams, + onRequest?: () => void, + onResponse?: (data: Execute) => void, + queryOptions?: Partial +) { + const queryKey = ['useExecutionStatus', options] + const response = (useQuery as QueryType)({ + queryKey: queryKey, + queryFn: () => { + onRequest?.() + const promise = queryExecutionStatus(client?.baseApiUrl, options) + promise.then((response: any) => { + onResponse?.(response) + }) + return promise + }, + enabled: client !== undefined && options !== undefined, + retry: false, + ...queryOptions + }) + + return useMemo( + () => + ({ + ...response, + data: response.error ? undefined : response.data, + queryKey + }) as Omit, 'data'> & { + data?: ExecutionStatusResponse + queryKey: QueryKey + }, + [ + response.data, + response.error, + response.isLoading, + response.isFetching, + response.isRefetching, + response.dataUpdatedAt, + queryKey + ] + ) +} diff --git a/packages/hooks/src/hooks/useQuote.ts b/packages/hooks/src/hooks/useQuote.ts index 991ddbb16..0b33d53ce 100644 --- a/packages/hooks/src/hooks/useQuote.ts +++ b/packages/hooks/src/hooks/useQuote.ts @@ -64,8 +64,9 @@ export default function ( onResponse?: (data: Execute) => void, queryOptions?: Partial ) { + const queryKey = ['useQuote', options] const response = (useQuery as QueryType)({ - queryKey: ['useQuote', options], + queryKey: queryKey, queryFn: () => { onRequest?.() if (options && client?.source && !options.referrer) { @@ -108,9 +109,11 @@ export default function ( ({ ...response, data: response.error ? undefined : response.data, + queryKey, executeQuote }) as Omit, 'data'> & { data?: QuoteResponse + queryKey: QueryKey executeQuote: (onProgress: onProgress) => Promise | undefined }, [ @@ -120,7 +123,8 @@ export default function ( response.isFetching, response.isRefetching, response.dataUpdatedAt, - executeQuote + executeQuote, + queryKey ] ) } diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index d21f31628..da5817ad2 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -14,6 +14,10 @@ export { default as useRelayConfig, queryRelayConfig } from './hooks/useRelayConfig.js' +export { + default as useExecutionStatus, + queryExecutionStatus +} from './hooks/useExecutionStatus.js' //types export type { CurrencyList, Currency } from './hooks/useTokenList.js' diff --git a/packages/sdk/src/routes/index.ts b/packages/sdk/src/routes/index.ts index dbc2eb915..a516aad28 100644 --- a/packages/sdk/src/routes/index.ts +++ b/packages/sdk/src/routes/index.ts @@ -1,5 +1,6 @@ export const routes = [ "/chains", + "/chains/health", "/config", "/config/v2", "/execute/bridge", @@ -11,13 +12,13 @@ export const routes = [ "/execute/permits", "/quote", "/price", - "/execute/user-op/{chainId}", "/lives", "/intents/status", "/intents/status/v2", "/intents/quote", "/intents/quote/v2", "/requests/{requestId}/signature", + "/requests/{requestId}/signature/v2", "/requests", "/requests/v2", "/transactions/index", diff --git a/packages/sdk/src/types/Execute.ts b/packages/sdk/src/types/Execute.ts index 5adef436b..7e92e831e 100644 --- a/packages/sdk/src/types/Execute.ts +++ b/packages/sdk/src/types/Execute.ts @@ -42,6 +42,7 @@ export type Execute = { kind: 'transaction' | 'signature' id: string requestId?: string + depositAddress?: string items?: { status: 'complete' | 'incomplete' progressState?: TransactionStepState | SignatureStepState diff --git a/packages/sdk/src/types/api.ts b/packages/sdk/src/types/api.ts index c8aab21f8..b478df91b 100644 --- a/packages/sdk/src/types/api.ts +++ b/packages/sdk/src/types/api.ts @@ -96,6 +96,35 @@ export interface paths { }; }; }; + "/chains/health": { + get: { + parameters: { + query?: { + chainId?: string; + }; + }; + responses: { + /** @description Default Response */ + 200: { + content: { + "application/json": { + /** @description A boolean indicating if the chain is healthy (true) or not (false) */ + healthy?: boolean; + }; + }; + }; + /** @description Default Response */ + 400: { + content: { + "application/json": { + message?: string; + code?: string; + }; + }; + }; + }; + }; + }; "/config": { get: { parameters: { @@ -144,7 +173,7 @@ export interface paths { /** @description User address, when supplied returns user balance and max bridge amount */ user?: string; /** @description Restricts the user balance and capacity to a particular currency when supplied with a currency id. Defaults to the native currency of the destination chain. */ - currency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth"; + currency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth" | "ape"; }; }; responses: { @@ -197,7 +226,7 @@ export interface paths { originChainId: number; destinationChainId: number; /** @enum {string} */ - currency: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth"; + currency: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth" | "ape"; /** @description Amount to bridge as the base amount (can be switched to exact input using the dedicated flag), denoted in wei */ amount: string; /** @description App fees to be charged for execution */ @@ -307,10 +336,10 @@ export interface paths { * @description The currency for all relayer fees (gas and service) * @enum {string} */ - relayerCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth"; + relayerCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth" | "ape"; app?: string; /** @enum {string} */ - appCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth"; + appCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth" | "ape"; }; breakdown?: { /** @description Amount that will be bridged in the estimated time */ @@ -372,7 +401,7 @@ export interface paths { originChainId: number; destinationChainId: number; /** @enum {string} */ - currency: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth"; + currency: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth" | "ape"; /** @description Amount to bridge as the base amount (can be switched to exact input using the dedicated flag), denoted in wei */ amount: string; /** @description App fees to be charged for execution */ @@ -817,10 +846,10 @@ export interface paths { * @description The currency for all relayer fees (gas and service) * @enum {string} */ - relayerCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth"; + relayerCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth" | "ape"; app?: string; /** @enum {string} */ - appCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth"; + appCurrency?: "anime" | "btc" | "cgt" | "degen" | "eth" | "omi" | "pop" | "sipher" | "tg7" | "tia" | "topia" | "usdc" | "xai" | "weth" | "apeeth" | "ape"; }; /** * @example { @@ -2241,6 +2270,11 @@ export interface paths { /** @description App fees to be charged for execution in basis points, e.g. 100 = 1% */ fee?: string; }[]; + /** + * @description Enable this to use the Relay protocol for insuring the request - use with caution, this is an experimental flag + * @default true + */ + useCommitment?: boolean; }; }; }; @@ -2290,6 +2324,8 @@ export interface paths { kind?: string; /** @description A unique identifier for this step, tying all related transactions together */ requestId?: string; + /** @description The deposit address for the bridge request */ + depositAddress?: string; /** @description While uncommon it is possible for steps to contain multiple items of the same kind (transaction/signature) grouped together that can be executed simultaneously. */ items?: { /** @description Can either be complete or incomplete, this can be locally controlled once the step item is completed (depending on the kind) and the check object (if returned) has been verified. Once all step items are complete, the bridge is complete */ @@ -2624,6 +2660,7 @@ export interface paths { content: { "application/json": { message?: string; + errorCode?: string; }; }; }; @@ -2632,6 +2669,7 @@ export interface paths { content: { "application/json": { message?: string; + errorCode?: string; }; }; }; @@ -2640,6 +2678,7 @@ export interface paths { content: { "application/json": { message?: string; + errorCode?: string; }; }; }; @@ -3023,6 +3062,7 @@ export interface paths { content: { "application/json": { message?: string; + errorCode?: string; }; }; }; @@ -3031,6 +3071,7 @@ export interface paths { content: { "application/json": { message?: string; + errorCode?: string; }; }; }; @@ -3039,83 +3080,7 @@ export interface paths { content: { "application/json": { message?: string; - }; - }; - }; - }; - }; - }; - "/execute/user-op/{chainId}": { - post: { - parameters: { - path: { - chainId: string; - }; - }; - requestBody: { - content: { - "application/json": { - id: number; - jsonrpc: string; - method: string; - params: [{ - sender: string; - nonce: string; - factory?: string | null; - factoryData?: string | null; - callData: string; - callGasLimit: string; - verificationGasLimit: string; - preVerificationGas: string; - maxFeePerGas: string; - maxPriorityFeePerGas: string; - paymaster?: string | null; - paymasterVerificationGasLimit?: string | null; - paymasterPostOpGasLimit?: string | null; - paymasterData?: string | null; - signature: string; - }, string]; - }; - }; - }; - responses: { - /** @description Default Response */ - 200: { - content: { - "application/json": { - jsonrpc?: string; - id?: number; - result?: string; - }; - }; - }; - /** @description Default Response */ - 400: { - content: { - "application/json": { - jsonrpc?: string; - id?: number; - error?: unknown; - }; - }; - }; - /** @description Default Response */ - 401: { - content: { - "application/json": { - jsonrpc?: string; - id?: number; - error?: unknown; - }; - }; - }; - /** @description Default Response */ - 500: { - content: { - "application/json": { - jsonrpc?: string; - id?: number; - error?: unknown; + errorCode?: string; }; }; }; @@ -3397,6 +3362,42 @@ export interface paths { }; }; }; + "/requests/{requestId}/signature/v2": { + get: { + parameters: { + path: { + requestId: string; + }; + }; + responses: { + /** @description Default Response */ + 200: { + content: { + "application/json": { + requestData?: { + originChainId?: number; + originUser?: string; + originCurrency?: string; + destinationChainId?: number; + destinationUser?: string; + destinationCurrency?: string; + }; + signature?: string; + }; + }; + }; + /** @description Default Response */ + 400: { + content: { + "application/json": { + message?: string; + code?: string; + }; + }; + }; + }; + }; + }; "/requests": { get: { parameters: { @@ -4055,6 +4056,7 @@ export interface paths { OMI?: number; TOPIA?: number; ANIME?: number; + APE?: number; }; }; }; @@ -4086,6 +4088,8 @@ export interface paths { includeAllChains?: boolean; /** @description Uses 3rd party API's to search for a token, in case relay does not have it indexed */ useExternalSearch?: boolean; + /** @description Returns only currencies supported with deposit address bridging */ + depositAddressOnly?: boolean; }; }; }; @@ -4101,7 +4105,7 @@ export interface paths { name?: string; decimals?: number; /** @enum {string} */ - vmType?: "bvm" | "evm" | "svm"; + vmType?: "bvm" | "evm" | "svm" | "tvm"; metadata?: { logoURI?: string; verified?: boolean; diff --git a/packages/ui/package.json b/packages/ui/package.json index cf45c21a2..a57c92f20 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -80,6 +80,7 @@ "framer-motion": "^11.2.10", "fuse.js": "^7.0.0", "pandacss-preset-radix-colors": "^0.2.0", + "qrcode.react": "^4.1.0", "usehooks-ts": "^3.1.0" }, "keywords": [ diff --git a/packages/ui/src/components/common/MultiWalletDropdown.tsx b/packages/ui/src/components/common/MultiWalletDropdown.tsx index 464de8619..6aefa899c 100644 --- a/packages/ui/src/components/common/MultiWalletDropdown.tsx +++ b/packages/ui/src/components/common/MultiWalletDropdown.tsx @@ -15,7 +15,7 @@ import { ProviderOptionsContext } from '../../providers/RelayKitProvider.js' type MultiWalletDropdownProps = { context: 'origin' | 'destination' wallets: LinkedWallet[] - selectedWalletAddress: string + selectedWalletAddress?: string chain?: RelayChain onSelect: (wallet: LinkedWallet) => void onLinkNewWallet: () => void diff --git a/packages/ui/src/components/common/TokenSelector/SuggestedTokens.tsx b/packages/ui/src/components/common/TokenSelector/SuggestedTokens.tsx index 39345e70d..6ef3324a9 100644 --- a/packages/ui/src/components/common/TokenSelector/SuggestedTokens.tsx +++ b/packages/ui/src/components/common/TokenSelector/SuggestedTokens.tsx @@ -15,11 +15,13 @@ import { type Currency } from '@reservoir0x/relay-kit-hooks' type SuggestedTokensProps = { chainId: number + depositAddressOnly?: boolean onSelect: (token: Currency) => void } export const SuggestedTokens: FC = ({ chainId, + depositAddressOnly, onSelect }) => { const client = useRelayClient() @@ -36,25 +38,32 @@ export const SuggestedTokens: FC = ({ return chain.erc20Currencies .filter( (currency) => - currency.id?.toUpperCase().includes('USD') || - currency.id?.toUpperCase().includes('WETH') + (currency.id?.toUpperCase().includes('USD') || + currency.id?.toUpperCase().includes('WETH')) && + (depositAddressOnly ? currency.supportsBridging : true) ) .map((currency) => convertApiCurrencyToToken(currency, chainId)) }, [chain?.erc20Currencies, chainId]) // Get additional static suggested tokens for this chain - const staticSuggestedTokens = ChainSuggestedTokens[chainId] || [] + const staticSuggestedTokens = !depositAddressOnly + ? ChainSuggestedTokens[chainId] || [] + : [] // Combine all tokens and remove duplicates const allSuggestedTokens = useMemo(() => { const uniqueTokens: Record = {} ;[ - nativeCurrency, + depositAddressOnly && !chainCurrency?.supportsBridging + ? undefined + : nativeCurrency, ...suggestedErc20Tokens, ...staticSuggestedTokens ].forEach((token) => { - uniqueTokens[token.address] = token + if (token) { + uniqueTokens[token.address] = token + } }) return Object.values(uniqueTokens) diff --git a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx index 901a8fb3f..889d4c23f 100644 --- a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx +++ b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx @@ -42,12 +42,14 @@ export type TokenSelectorProps = { trigger: ReactNode restrictedTokensList?: Token[] chainIdsFilter?: number[] + lockedChainIds?: number[] context: 'from' | 'to' type?: 'token' | 'chain' size?: 'mobile' | 'desktop' address?: Address | string isValidAddress?: boolean multiWalletSupportEnabled?: boolean + depositAddressOnly?: boolean setToken: (token: Token) => void onAnalyticEvent?: (eventName: string, data?: any) => void } @@ -73,12 +75,14 @@ const TokenSelector: FC = ({ trigger, restrictedTokensList, chainIdsFilter, + lockedChainIds, context, type = 'token', size = 'mobile', address, isValidAddress, multiWalletSupportEnabled = false, + depositAddressOnly, setToken, onAnalyticEvent }) => { @@ -122,24 +126,29 @@ const TokenSelector: FC = ({ return chains }, [relayClient?.chains, multiWalletSupportEnabled]) + const configuredChainIds = useMemo(() => { + if (lockedChainIds) { + return lockedChainIds + } + let _chainIds = configuredChains.map((chain) => chain.id) + if (chainIdsFilter) { + _chainIds = _chainIds.filter((id) => !chainIdsFilter.includes(id)) + } + return _chainIds + }, [configuredChains, lockedChainIds, chainIdsFilter]) + const chainFilterOptions = context === 'from' ? configuredChains?.filter( (chain) => - chain.vmType === 'evm' || - chain.id === solana.id || - chain.id === eclipse.id || - chain.id === bitcoin.id + (chain.vmType === 'evm' || + chain.id === solana.id || + chain.id === eclipse.id || + chain.id === bitcoin.id) && + configuredChainIds.includes(chain.id) ) : configuredChains - const configuredChainIds = useMemo(() => { - if (chainIdsFilter) { - return chainIdsFilter - } - return configuredChains.map((chain) => chain.id) - }, [configuredChains, chainIdsFilter]) - const useDefaultTokenList = debouncedTokenSearchValue === '' && (!restrictedTokensList || !restrictedTokensList.length) @@ -165,8 +174,9 @@ const TokenSelector: FC = ({ term: !isAddress(debouncedTokenSearchValue) ? debouncedTokenSearchValue : undefined, - defaultList: useDefaultTokenList, + defaultList: useDefaultTokenList && !depositAddressOnly, limit: 20, + depositAddressOnly, ...(tokenListQuery ? { tokens: tokenListQuery } : {}) } ) @@ -185,7 +195,8 @@ const TokenSelector: FC = ({ defaultList: false, limit: 20, ...(tokenListQuery ? { tokens: tokenListQuery } : {}), - useExternalSearch: true + useExternalSearch: true, + depositAddressOnly }, { enabled: !!debouncedTokenSearchValue @@ -239,7 +250,8 @@ const TokenSelector: FC = ({ suggestedTokenQuery ? { tokens: suggestedTokenQuery, - limit: 20 + limit: 20, + depositAddressOnly } : undefined, { @@ -409,7 +421,8 @@ const TokenSelector: FC = ({ { chainIds: token?.chainId ? [token.chainId] : [], address: token?.address, - limit: 1 + limit: 1, + depositAddressOnly }, { enabled: @@ -538,15 +551,12 @@ const TokenSelector: FC = ({ sm: { minWidth: size === 'desktop' - ? !chainIdsFilter || chainIdsFilter.length > 1 + ? configuredChainIds.length > 1 ? 568 : 378 : 400, maxWidth: - size === 'desktop' && - (!chainIdsFilter || chainIdsFilter.length > 1) - ? 568 - : 378 + size === 'desktop' && configuredChainIds.length > 1 ? 568 : 378 } }} > @@ -567,7 +577,6 @@ const TokenSelector: FC = ({ setInputElement={setInputElement} tokenSearchInput={tokenSearchInput} setTokenSearchInput={setTokenSearchInput} - chainIdsFilter={chainIdsFilter} chainFilterOptions={chainFilterOptions} chainFilter={chainFilter} setChainFilter={setChainFilter} @@ -583,6 +592,7 @@ const TokenSelector: FC = ({ onAnalyticEvent={onAnalyticEvent} setUnverifiedToken={setUnverifiedToken} setUnverifiedTokenModalOpen={setUnverifiedTokenModalOpen} + depositAddressOnly={depositAddressOnly} /> ) : null} {tokenSelectorStep === TokenSelectorStep.SetChain ? ( @@ -600,6 +610,7 @@ const TokenSelector: FC = ({ type={type} size={size} multiWalletSupportEnabled={multiWalletSupportEnabled} + chainIdsFilter={chainIdsFilter} /> ) : null} diff --git a/packages/ui/src/components/common/TokenSelector/steps/SetChainStep.tsx b/packages/ui/src/components/common/TokenSelector/steps/SetChainStep.tsx index 855755ed9..4a6380204 100644 --- a/packages/ui/src/components/common/TokenSelector/steps/SetChainStep.tsx +++ b/packages/ui/src/components/common/TokenSelector/steps/SetChainStep.tsx @@ -44,6 +44,7 @@ type SetChainStepProps = { setChainSearchInput: React.Dispatch> selectToken: (currency: Currency, chainId?: number) => void selectedCurrencyList?: EnhancedCurrencyList + chainIdsFilter?: number[] } const fuseSearchOptions = { @@ -81,6 +82,7 @@ export const SetChainStep: FC = ({ chainSearchInput, setChainSearchInput, selectToken, + chainIdsFilter, selectedCurrencyList }) => { const client = useRelayClient() @@ -98,7 +100,8 @@ export const SetChainStep: FC = ({ chain.id === bitcoin.id) && (context !== 'from' || multiWalletSupportEnabled || - chain.vmType === 'evm') + chain.vmType === 'evm') && + (!chainIdsFilter || !chainIdsFilter.includes(chain.id)) ) || [] const combinedChains: NormalizedChain[] = [ diff --git a/packages/ui/src/components/common/TokenSelector/steps/SetCurrencyStep.tsx b/packages/ui/src/components/common/TokenSelector/steps/SetCurrencyStep.tsx index 2cef42f2e..4027dcfa0 100644 --- a/packages/ui/src/components/common/TokenSelector/steps/SetCurrencyStep.tsx +++ b/packages/ui/src/components/common/TokenSelector/steps/SetCurrencyStep.tsx @@ -44,7 +44,6 @@ type SetCurrencyProps = { ) => void tokenSearchInput: string setTokenSearchInput: (value: string) => void - chainIdsFilter: number[] | undefined chainFilterOptions: RelayChain[] chainFilter: ChainFilterValue setChainFilter: (value: React.SetStateAction) => void @@ -53,6 +52,7 @@ type SetCurrencyProps = { isLoadingDuneBalances: boolean enhancedCurrencyList?: EnhancedCurrencyList[] token?: Token + depositAddressOnly?: boolean selectToken: (currency: Currency, chainId?: number) => void setUnverifiedTokenModalOpen: React.Dispatch> setUnverifiedToken: React.Dispatch> @@ -73,7 +73,6 @@ export const SetCurrencyStep: FC = ({ setInputElement, tokenSearchInput, setTokenSearchInput, - chainIdsFilter, chainFilterOptions, chainFilter, setChainFilter, @@ -81,6 +80,7 @@ export const SetCurrencyStep: FC = ({ isLoadingExternalTokenList, isLoadingDuneBalances, enhancedCurrencyList, + depositAddressOnly, selectToken, setUnverifiedTokenModalOpen, setUnverifiedToken, @@ -158,7 +158,7 @@ export const SetCurrencyStep: FC = ({ Select Token - {isDesktop && (!chainIdsFilter || chainIdsFilter.length > 1) ? ( + {isDesktop && allChains.length > 2 ? ( <> = ({ } /> - {!isDesktop && (!chainIdsFilter || chainIdsFilter.length > 1) ? ( + {!isDesktop && allChains.length > 2 ? ( = ({ <> { selectToken(token, token.chainId) }} diff --git a/packages/ui/src/components/common/TransactionModal/DepositAddressModal.tsx b/packages/ui/src/components/common/TransactionModal/DepositAddressModal.tsx new file mode 100644 index 000000000..24fbd5cd1 --- /dev/null +++ b/packages/ui/src/components/common/TransactionModal/DepositAddressModal.tsx @@ -0,0 +1,246 @@ +import type { Execute, RelayChain } from '@reservoir0x/relay-sdk' +import { type Address } from 'viem' +import { type FC, useEffect } from 'react' +import { + type ChildrenProps, + DepositAddressModalRenderer, + TransactionProgressStep +} from './DepositAddressModalRenderer.js' +import { Modal } from '../Modal.js' +import { Flex, Text } from '../../primitives/index.js' +import { ErrorStep } from './steps/ErrorStep.js' +import { EventNames } from '../../../constants/events.js' +import { type Token } from '../../../types/index.js' +import { SwapSuccessStep } from './steps/SwapSuccessStep.js' +import { formatBN } from '../../../utils/numbers.js' +import { extractQuoteId } from '../../../utils/quote.js' +import { WaitingForDepositStep } from './steps/WaitingForDepositStep.js' +import { DepositAddressValidatingStep } from './steps/DepositAddressValidatingStep.js' + +type DepositAddressModalProps = { + open: boolean + fromChain?: RelayChain + fromToken?: Token + toToken?: Token + address?: Address | string + timeEstimate?: { time: number; formattedTime: string } + debouncedOutputAmountValue: string + debouncedInputAmountValue: string + amountInputValue: string + amountOutputValue: string + recipient?: Address | string + customToAddress?: Address | string + invalidateBalanceQueries: () => void + onAnalyticEvent?: (eventName: string, data?: any) => void + onOpenChange: (open: boolean) => void + onSuccess?: (data: Execute) => void +} + +export const DepositAddressModal: FC = ( + depositAddressModalProps +) => { + const { + open, + address, + fromChain, + fromToken, + toToken, + recipient, + debouncedInputAmountValue, + debouncedOutputAmountValue, + amountInputValue, + amountOutputValue, + timeEstimate, + invalidateBalanceQueries, + onAnalyticEvent, + onSuccess + } = depositAddressModalProps + return ( + { + const details = quote?.details + const fees = quote?.fees + + const extraData: { + gas_fee?: number + relayer_fee?: number + amount_in: number + amount_out: number + } = { + amount_in: parseFloat(`${details?.currencyIn?.amountFormatted}`), + amount_out: parseFloat(`${details?.currencyOut?.amountFormatted}`) + } + if (fees?.gas?.amountFormatted) { + extraData.gas_fee = parseFloat(fees.gas.amountFormatted) + } + if (fees?.relayer?.amountFormatted) { + extraData.relayer_fee = parseFloat(fees.relayer.amountFormatted) + } + const quoteId = quote + ? extractQuoteId(quote?.steps as Execute['steps']) + : undefined + onAnalyticEvent?.(EventNames.SWAP_SUCCESS, { + ...extraData, + chain_id_in: fromToken?.chainId, + currency_in: fromToken?.symbol, + chain_id_out: toToken?.chainId, + currency_out: toToken?.symbol, + quote_id: quoteId, + txHashes: [ + ...(executionStatus?.inTxHashes ?? []), + ...(executionStatus?.txHashes ?? []) + ] + }) + onSuccess?.({ + steps: quote?.steps as Execute['steps'], + fees: fees, + details: details + }) + }} + > + {(rendererProps) => { + return ( + + ) + }} + + ) +} + +type InnerDepositAddressModalProps = ChildrenProps & DepositAddressModalProps + +const InnerDepositAddressModal: FC = ({ + open, + onOpenChange, + fromToken, + toToken, + quote, + isFetchingQuote, + quoteError, + address, + swapError, + progressStep, + allTxHashes, + transaction, + timeEstimate, + fillTime, + seconds, + fromChain, + recipient, + depositAddress, + executionStatus, + isLoadingTransaction +}) => { + const details = quote?.details + + const fromAmountFormatted = details?.currencyIn?.amount + ? formatBN(details?.currencyIn?.amount, 6, fromToken?.decimals, false) + : '' + const toAmountFormatted = details?.currencyOut?.amount + ? formatBN(details?.currencyOut.amount, 6, toToken?.decimals, false) + : '' + + const isWaitingForDeposit = + progressStep === TransactionProgressStep.WaitingForDeposit + + return ( + { + const dynamicModalElements = Array.from( + document.querySelectorAll('#dynamic-send-transaction') + ) + const clickedInsideDynamicModal = dynamicModalElements.some((el) => + e.target ? el.contains(e.target as Node) : false + ) + + if (clickedInsideDynamicModal && dynamicModalElements.length > 0) { + e.preventDefault() + } + }} + > + + + {isWaitingForDeposit ? 'Manual Transfer' : 'Trade Details'} + + + {progressStep === TransactionProgressStep.WaitingForDeposit ? ( + + ) : null} + + {progressStep === TransactionProgressStep.Validating ? ( + + ) : null} + {progressStep === TransactionProgressStep.Success ? ( + + ) : null} + {progressStep === TransactionProgressStep.Error ? ( + + ) : null} + + + ) +} diff --git a/packages/ui/src/components/common/TransactionModal/DepositAddressModalRenderer.tsx b/packages/ui/src/components/common/TransactionModal/DepositAddressModalRenderer.tsx new file mode 100644 index 000000000..78e46fc2f --- /dev/null +++ b/packages/ui/src/components/common/TransactionModal/DepositAddressModalRenderer.tsx @@ -0,0 +1,344 @@ +import { + type FC, + useMemo, + useState, + useEffect, + type ReactNode, + type SetStateAction, + type Dispatch, + useContext +} from 'react' +import { parseUnits, type Address } from 'viem' +import { + type AdaptedWallet, + type Execute, + type RelayChain +} from '@reservoir0x/relay-sdk' +import { + calculateFillTime, + extractDepositRequestId +} from '../../../utils/relayTransaction.js' +import type { Token } from '../../../types/index.js' +import { + useQuote, + useRequests, + useExecutionStatus +} from '@reservoir0x/relay-kit-hooks' +import { useRelayClient } from '../../../hooks/index.js' +import { EventNames } from '../../../constants/events.js' +import { ProviderOptionsContext } from '../../../providers/RelayKitProvider.js' +import { useAccount } from 'wagmi' +import { extractDepositAddress, extractQuoteId } from '../../../utils/quote.js' +import { getDeadAddress } from '@reservoir0x/relay-sdk' +import { useQueryClient } from '@tanstack/react-query' +import { bitcoin } from '../../../utils/bitcoin.js' + +export enum TransactionProgressStep { + WaitingForDeposit, + Validating, + Success, + Error +} + +export type TxHashes = { txHash: string; chainId: number }[] + +export type ChildrenProps = { + progressStep: TransactionProgressStep + setProgressStep: Dispatch> + quote: ReturnType['data'] + isFetchingQuote: boolean + quoteError: Error | null + swapError: Error | null + setSwapError: Dispatch> + allTxHashes: TxHashes + transaction?: ReturnType['data']['0'] + seconds: number + fillTime: string + requestId: string | null + depositAddress?: string + executionStatus?: ReturnType['data'] + isLoadingTransaction: boolean +} + +type Props = { + open: boolean + address?: string + fromToken?: Token + fromChain?: RelayChain + toToken?: Token + debouncedOutputAmountValue: string + debouncedInputAmountValue: string + amountInputValue: string + amountOutputValue: string + recipient?: string + customToAddress?: Address + wallet?: AdaptedWallet + invalidateBalanceQueries: () => void + children: (props: ChildrenProps) => ReactNode + onSuccess?: ( + quote: ReturnType['data'], + executionStatus: ReturnType['data'] + ) => void + onAnalyticEvent?: (eventName: string, data?: any) => void + onSwapError?: (error: string, data?: Execute) => void +} + +export const DepositAddressModalRenderer: FC = ({ + open, + address, + fromChain, + fromToken, + toToken, + debouncedInputAmountValue, + debouncedOutputAmountValue, + recipient, + invalidateBalanceQueries, + children, + onSuccess, + onAnalyticEvent, + onSwapError +}) => { + const queryClient = useQueryClient() + const [progressStep, setProgressStep] = useState( + TransactionProgressStep.WaitingForDeposit + ) + const [swapError, setSwapError] = useState(null) + + const relayClient = useRelayClient() + const providerOptionsContext = useContext(ProviderOptionsContext) + const { connector } = useAccount() + const deadAddress = getDeadAddress(fromChain?.vmType, fromChain?.id) + + const { + data: quoteData, + isLoading: isFetchingQuote, + isRefetching, + error: quoteError, + queryKey + } = useQuote( + relayClient ? relayClient : undefined, + undefined, + fromToken && toToken + ? { + user: deadAddress, + originChainId: fromToken.chainId, + destinationChainId: toToken.chainId, + originCurrency: fromToken.address, + destinationCurrency: toToken.address, + recipient: recipient as string, + tradeType: 'EXACT_INPUT', + appFees: providerOptionsContext.appFees, + amount: parseUnits( + debouncedInputAmountValue, + fromToken.decimals + ).toString(), + referrer: relayClient?.source ?? undefined, + useDepositAddress: true + } + : undefined, + () => {}, + ({ steps, details }) => { + onAnalyticEvent?.(EventNames.SWAP_EXECUTE_QUOTE_RECEIVED, { + wallet_connector: connector?.name, + quote_id: steps ? extractQuoteId(steps) : undefined, + amount_in: details?.currencyIn?.amountFormatted, + currency_in: details?.currencyIn?.currency?.symbol, + chain_id_in: details?.currencyIn?.currency?.chainId, + amount_out: details?.currencyOut?.amountFormatted, + currency_out: details?.currencyOut?.currency?.symbol, + chain_id_out: details?.currencyOut?.currency?.chainId, + is_canonical: false, + is_deposit_address: true + }) + }, + { + enabled: Boolean( + open && + progressStep === TransactionProgressStep.WaitingForDeposit && + relayClient && + debouncedInputAmountValue && + debouncedInputAmountValue.length > 0 && + Number(debouncedInputAmountValue) !== 0 && + fromToken !== undefined && + toToken !== undefined + ), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchInterval: false, + refetchOnMount: false, + retryOnMount: false, + staleTime: Infinity + } + ) + + const quote = isFetchingQuote || isRefetching ? undefined : quoteData + + const requestId = useMemo( + () => extractDepositRequestId(quote?.steps as Execute['steps']), + [quote] + ) + + const depositAddress = useMemo( + () => extractDepositAddress(quote?.steps as Execute['steps']), + [quote] + ) + + useEffect(() => { + if (!open) { + if (quote) { + onAnalyticEvent?.(EventNames.DEPOSIT_ADDRESS_MODAL_CLOSED) + } + setSwapError(null) + queryClient.invalidateQueries({ queryKey }) + } else { + setProgressStep(TransactionProgressStep.WaitingForDeposit) + onAnalyticEvent?.(EventNames.DEPOSIT_ADDRESS_MODAL_OPEN) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const { data: executionStatus } = useExecutionStatus( + relayClient ? relayClient : undefined, + { + requestId: requestId ?? undefined + }, + undefined, + undefined, + { + enabled: requestId !== null && open, + refetchInterval(query) { + const observableStates = ['waiting', 'pending', 'delayed'] + + if ( + !query.state.data?.status || + (requestId && observableStates.includes(query.state.data?.status)) + ) { + return 1000 + } + return 0 + } + } + ) + + useEffect(() => { + if ( + executionStatus?.status === 'failure' || + executionStatus?.status === 'refund' || + quoteError + ) { + const swapError = new Error( + executionStatus?.details ?? + 'Oops! Something went wrong while processing your transaction.' + ) + if (progressStep !== TransactionProgressStep.Error) { + onSwapError?.(swapError.message, quote as Execute) + } + setProgressStep(TransactionProgressStep.Error) + onAnalyticEvent?.(EventNames.DEPOSIT_ADDRESS_SWAP_ERROR, { + error_message: executionStatus?.details ?? quoteError, + wallet_connector: connector?.name, + quote_id: requestId, + amount_in: parseFloat(`${debouncedInputAmountValue}`), + currency_in: fromToken?.symbol, + chain_id_in: fromToken?.chainId, + amount_out: parseFloat(`${debouncedOutputAmountValue}`), + currency_out: toToken?.symbol, + chain_id_out: toToken?.chainId, + txHashes: executionStatus?.txHashes ?? [] + }) + setSwapError(swapError) + invalidateBalanceQueries() + } else if (executionStatus?.status === 'success') { + if (progressStep !== TransactionProgressStep.Success) { + onSuccess?.(quote, executionStatus) + } + setProgressStep(TransactionProgressStep.Success) + invalidateBalanceQueries() + } else if (executionStatus?.status === 'pending') { + const timeEstimateMs = + ((quote?.details?.timeEstimate ?? 0) + + (fromChain && fromChain.id === bitcoin.id ? 600 : 0)) * + 1000 + const isDelayedTx = + timeEstimateMs > + (relayClient?.maxPollingAttemptsBeforeTimeout ?? 30) * + (relayClient?.pollingInterval ?? 5000) + if (isDelayedTx) { + setProgressStep(TransactionProgressStep.Success) + } else { + setProgressStep(TransactionProgressStep.Validating) + } + } + }, [executionStatus?.status, quoteError]) + + const allTxHashes = useMemo(() => { + const isRefund = executionStatus?.status === 'refund' + const _allTxHashes: TxHashes = [] + executionStatus?.txHashes?.forEach((txHash) => { + _allTxHashes.push({ + txHash, + chainId: isRefund + ? (fromToken?.chainId as number) + : (toToken?.chainId as number) + }) + }) + + executionStatus?.inTxHashes?.forEach((txHash) => { + _allTxHashes.push({ + txHash, + chainId: fromToken?.chainId as number + }) + }) + return _allTxHashes + }, [executionStatus?.txHashes, executionStatus?.inTxHashes]) + + const { data: transactions, isLoading: isLoadingTransaction } = useRequests( + (progressStep === TransactionProgressStep.Success || + progressStep === TransactionProgressStep.Error) && + allTxHashes[0] + ? { + user: address, + hash: allTxHashes[0]?.txHash + } + : undefined, + relayClient?.baseApiUrl, + { + enabled: + (progressStep === TransactionProgressStep.Success || + progressStep === TransactionProgressStep.Error) && + allTxHashes[0] + ? true + : false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchInterval: false, + retryOnMount: false + } + ) + + const transaction = transactions[0] + + const { fillTime, seconds } = calculateFillTime(transaction) + + return ( + <> + {children({ + progressStep, + setProgressStep, + quote, + isFetchingQuote: isFetchingQuote || isRefetching, + quoteError, + swapError, + setSwapError, + allTxHashes, + transaction, + fillTime, + seconds, + requestId, + depositAddress, + executionStatus, + isLoadingTransaction + })} + + ) +} diff --git a/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx b/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx index cb3688f06..80d202b3d 100644 --- a/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx +++ b/packages/ui/src/components/common/TransactionModal/TransactionModalRenderer.tsx @@ -406,7 +406,7 @@ export const TransactionModalRenderer: FC = ({ }, [steps, quoteError, swapError]) // Fetch Success Tx - const { data: transactions, isFetching: isLoadingTransaction } = useRequests( + const { data: transactions, isLoading: isLoadingTransaction } = useRequests( (progressStep === TransactionProgressStep.Success || progressStep === TransactionProgressStep.Error) && allTxHashes[0] diff --git a/packages/ui/src/components/common/TransactionModal/steps/DepositAddressValidatingStep.tsx b/packages/ui/src/components/common/TransactionModal/steps/DepositAddressValidatingStep.tsx new file mode 100644 index 000000000..fd88c3948 --- /dev/null +++ b/packages/ui/src/components/common/TransactionModal/steps/DepositAddressValidatingStep.tsx @@ -0,0 +1,78 @@ +import type { FC } from 'react' +import { Anchor, Button, Flex, Text } from '../../../primitives/index.js' +import { LoadingSpinner } from '../../LoadingSpinner.js' +import type { ExecuteStep, ExecuteStepItem } from '@reservoir0x/relay-sdk' +import { useExecutionStatus } from '@reservoir0x/relay-kit-hooks' +import { useRelayClient } from '../../../../hooks/index.js' +import getChainBlockExplorerUrl from '../../../../utils/getChainBlockExplorerUrl.js' +import { truncateAddress } from '../../../../utils/truncate.js' + +type DepositAddressValidatingStepProps = { + txHashes: string[] + status: NonNullable['data']>['status'] +} + +export const DepositAddressValidatingStep: FC< + DepositAddressValidatingStepProps +> = ({ txHashes, status }) => { + const relayClient = useRelayClient() + const transactionBaseUrl = + relayClient?.baseApiUrl && relayClient.baseApiUrl.includes('testnet') + ? 'https://testnets.relay.link' + : 'https://relay.link' + const txHash = txHashes && txHashes[0] ? txHashes[0] : undefined + + return ( + <> + + + + Funds received. Your transaction is now in progress. + + {status === 'delayed' ? ( + + + Your transaction is currently delayed. We apologize for the + inconvenience and appreciate your patience. After 5 minutes, your + transaction will either be processed or refunded. If this is + urgent, please contact support. + + + ) : null} + + Feel free to leave at any time, you can track your progress within the{' '} + + {' '} + transaction page + + . + + + + + ) +} diff --git a/packages/ui/src/components/common/TransactionModal/steps/WaitingForDepositStep.tsx b/packages/ui/src/components/common/TransactionModal/steps/WaitingForDepositStep.tsx new file mode 100644 index 000000000..7ac1358a1 --- /dev/null +++ b/packages/ui/src/components/common/TransactionModal/steps/WaitingForDepositStep.tsx @@ -0,0 +1,256 @@ +import { type FC } from 'react' +import { + Button, + ChainIcon, + Flex, + Pill, + Skeleton, + Text, + Box, + Anchor +} from '../../../primitives/index.js' +import { LoadingSpinner } from '../../LoadingSpinner.js' +import { truncateAddress } from '../../../../utils/truncate.js' +import { type Token } from '../../../../types/index.js' +import { getDeadAddress, type RelayChain } from '@reservoir0x/relay-sdk' +import { CopyToClipBoard } from '../../CopyToClipBoard.js' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faQrcode } from '@fortawesome/free-solid-svg-icons' +import { QRCodeCanvas } from 'qrcode.react' +import { generateQrWalletDeeplink } from '../../../../utils/qrcode.js' +import { + Popover, + PopoverContent, + PopoverPortal, + PopoverTrigger +} from '@radix-ui/react-popover' + +type WaitingForDepositStepProps = { + fromToken?: Token + fromChain?: RelayChain + fromAmountFormatted?: string + isFetchingQuote?: boolean + depositAddress?: string +} + +export const WaitingForDepositStep: FC = ({ + fromToken, + fromChain, + fromAmountFormatted, + isFetchingQuote, + depositAddress +}) => { + const qrcodeUrl = generateQrWalletDeeplink( + fromChain?.vmType, + fromAmountFormatted, + fromToken && + fromChain && + fromToken.address !== getDeadAddress(fromChain?.vmType) + ? fromToken.address + : undefined, + depositAddress, + fromChain?.id + ) + + return ( + <> + + + Transfer funds manually from your {fromChain?.displayName} wallet to + Relay’s deposit address to complete the bridge. + + + Learn More + + + + + Network + + + {fromChain?.displayName} + + + + Amount to transfer + + {fromAmountFormatted ? ( + <> + {' '} + + {fromAmountFormatted} + + {fromToken?.name} + {fromToken?.symbol} + + ) : ( + + )}{' '} + + + + Relay's Deposit Address + + {isFetchingQuote ? ( + + ) : ( + <> + + {truncateAddress(depositAddress, '...', 28, 4)} + + + {qrcodeUrl ? ( + + + + + + + + + + + + + + + {fromToken?.name} + + + + + + + + + + ) : null} + + )} + + + + ) +} diff --git a/packages/ui/src/components/widgets/FeeBreakdown.tsx b/packages/ui/src/components/widgets/FeeBreakdown.tsx index 69195db1b..f70d0c52a 100644 --- a/packages/ui/src/components/widgets/FeeBreakdown.tsx +++ b/packages/ui/src/components/widgets/FeeBreakdown.tsx @@ -24,6 +24,7 @@ type Props = Pick< > & { toChain?: RelayChain isSingleChainLocked?: boolean + fromChainWalletVMSupported?: boolean } const formatSwapRate = (rate: number) => { @@ -42,7 +43,8 @@ const FeeBreakdown: FC = ({ setUseExternalLiquidity, timeEstimate, canonicalTimeEstimate, - isSingleChainLocked + isSingleChainLocked, + fromChainWalletVMSupported }) => { const swapRate = price?.details?.rate const originGasFee = feeBreakdown?.breakdown?.find( @@ -92,7 +94,7 @@ const FeeBreakdown: FC = ({ }} id={'fee-breakdown-section'} > - {!isSingleChainLocked ? ( + {!isSingleChainLocked && fromChainWalletVMSupported ? ( <> void onAnalyticEvent?: (eventName: string, data?: any) => void onClick: () => void @@ -22,10 +23,12 @@ type SwapButtonProps = { | 'ctaCopy' | 'isValidFromAddress' | 'isValidToAddress' + | 'fromChainWalletVMSupported' > const SwapButton: FC = ({ transactionModalOpen, + depositAddressModalOpen, isValidFromAddress, isValidToAddress, context, @@ -37,26 +40,31 @@ const SwapButton: FC = ({ debouncedInputAmountValue, debouncedOutputAmountValue, isSameCurrencySameRecipientSwap, + fromChainWalletVMSupported, onClick, ctaCopy, onAnalyticEvent }) => { const isMounted = useMounted() - if (isMounted && address) { + if (isMounted && (address || !fromChainWalletVMSupported)) { + const invalidAmount = + !price || + Number(debouncedInputAmountValue) === 0 || + Number(debouncedOutputAmountValue) === 0 + return ( ) : null} {!isSingleChainLocked && ( @@ -699,8 +776,8 @@ const SwapWidget: FC = ({ tradeType === 'EXPECTED_OUTPUT' ? amountOutputValue : amountOutputValue - ? formatFixedLength(amountOutputValue, 8) - : amountOutputValue + ? formatFixedLength(amountOutputValue, 8) + : amountOutputValue } setValue={(e) => { setAmountOutputValue(e) @@ -710,7 +787,7 @@ const SwapWidget: FC = ({ debouncedAmountOutputControls.flush() } }} - disabled={!toToken} + disabled={!toToken || !fromChainWalletVMSupported} onFocus={() => { onAnalyticEvent?.(EventNames.SWAP_OUTPUT_FOCUSED) }} @@ -731,7 +808,8 @@ const SwapWidget: FC = ({ cursor: 'not-allowed', _placeholder: { color: 'gray10' - } + }, + color: 'gray10' } }} /> @@ -741,6 +819,7 @@ const SwapWidget: FC = ({ address={recipient} isValidAddress={isValidToAddress} token={toToken} + depositAddressOnly={!fromChainWalletVMSupported} restrictedToken={fromToken} setToken={(token) => { onAnalyticEvent?.(EventNames.SWAP_TOKEN_SELECT, { @@ -783,12 +862,17 @@ const SwapWidget: FC = ({ } onAnalyticEvent={onAnalyticEvent} - chainIdsFilter={ + lockedChainIds={ isSingleChainLocked ? [lockChainId] : toToken?.chainId !== undefined && - toToken?.chainId === lockChainId - ? [toToken?.chainId] + toToken?.chainId === lockChainId + ? [toToken?.chainId] + : undefined + } + chainIdsFilter={ + !fromChainWalletVMSupported && fromToken + ? [fromToken.chainId] : undefined } restrictedTokensList={tokens?.filter( @@ -866,7 +950,10 @@ const SwapWidget: FC = ({ - {error && !isFetchingPrice && !isSingleChainLocked ? ( + {error && + !isFetchingPrice && + !isSingleChainLocked && + fromChainWalletVMSupported ? ( = ({ }} canonicalTimeEstimate={canonicalTimeEstimate} isSingleChainLocked={isSingleChainLocked} + fromChainWalletVMSupported={fromChainWalletVMSupported} /> = ({ ) : ( = ({ isSameCurrencySameRecipientSwap } onClick={() => { - // If either address is not valid, open the link wallet modal - if (!isValidToAddress || !isValidFromAddress) { - if (multiWalletSupportEnabled) { - const chain = !isValidFromAddress - ? fromChain - : toChain - onLinkNewWallet?.({ - chain: chain, - direction: !isValidFromAddress ? 'from' : 'to' - })?.then((wallet) => { - if (!isValidFromAddress) { - onSetPrimaryWallet?.(wallet.address) + if (fromChainWalletVMSupported) { + // If either address is not valid, open the link wallet modal + if (!isValidToAddress || !isValidFromAddress) { + if ( + multiWalletSupportEnabled && + (isValidToAddress || + (!isValidToAddress && toChainWalletVMSupported)) + ) { + const chain = !isValidFromAddress + ? fromChain + : toChain + if (!address) { + onConnectWallet?.() } else { - setCustomToAddress(wallet.address) + onLinkNewWallet?.({ + chain: chain, + direction: !isValidFromAddress ? 'from' : 'to' + })?.then((wallet) => { + if (!isValidFromAddress) { + onSetPrimaryWallet?.(wallet.address) + } else { + setCustomToAddress(wallet.address) + } + }) } - }) + } else { + setAddressModalOpen(true) + } } else { - setAddressModalOpen(true) + setTransactionModalOpen(true) } } else { - setTransactionModalOpen(true) + if (!isValidToAddress) { + if ( + multiWalletSupportEnabled && + toChainWalletVMSupported + ) { + if (!address) { + onConnectWallet?.() + } else { + onLinkNewWallet?.({ + chain: toChain, + direction: 'to' + })?.then((wallet) => { + setCustomToAddress(wallet.address) + }) + } + } else { + setAddressModalOpen(true) + } + } else { + setDepositAddressModalOpen(true) + } } }} ctaCopy={ctaCopy} diff --git a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx index cefed28b9..2b2072ef8 100644 --- a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx @@ -15,7 +15,7 @@ import { useAccount } from 'wagmi' import { useCapabilities } from 'wagmi/experimental' import type { BridgeFee, Token } from '../../types/index.js' import { useQueryClient } from '@tanstack/react-query' -import type { Execute } from '@reservoir0x/relay-sdk' +import type { ChainVM, Execute } from '@reservoir0x/relay-sdk' import { calculatePriceTimeEstimate, calculateRelayerFeeProportionUsd, @@ -30,7 +30,6 @@ import type { DebouncedState } from 'usehooks-ts' import type Text from '../../components/primitives/Text.js' import type { AdaptedWallet } from '@reservoir0x/relay-sdk' import type { LinkedWallet } from '../../types/index.js' -import { formatBN } from '../../utils/numbers.js' import { addressWithFallback, isValidAddress, @@ -42,6 +41,7 @@ export type TradeType = 'EXACT_INPUT' | 'EXPECTED_OUTPUT' type SwapWidgetRendererProps = { transactionModalOpen: boolean + depositAddressModalOpen: boolean children: (props: ChildrenProps) => ReactNode defaultFromToken?: Token defaultToToken?: Token @@ -53,6 +53,7 @@ type SwapWidgetRendererProps = { wallet?: AdaptedWallet linkedWallets?: LinkedWallet[] multiWalletSupportEnabled?: boolean + supportedWalletVMs: ChainVM[] onConnectWallet?: () => void onAnalyticEvent?: (eventName: string, data?: any) => void onSwapError?: (error: string, data?: Execute) => void @@ -122,6 +123,10 @@ export type ChildrenProps = { isBvmSwap: boolean isValidFromAddress: boolean isValidToAddress: boolean + supportedWalletVMs: ChainVM[] + fromChainWalletVMSupported: boolean + toChainWalletVMSupported: boolean + isRecipientLinked?: boolean invalidateBalanceQueries: () => void setUseExternalLiquidity: Dispatch> setDetails: Dispatch> @@ -130,6 +135,7 @@ export type ChildrenProps = { const SwapWidgetRenderer: FC = ({ transactionModalOpen, + depositAddressModalOpen, defaultFromToken, defaultToToken, defaultToAddress, @@ -140,6 +146,7 @@ const SwapWidgetRenderer: FC = ({ wallet, multiWalletSupportEnabled = false, linkedWallets, + supportedWalletVMs, children, onAnalyticEvent }) => { @@ -198,13 +205,18 @@ const SwapWidgetRenderer: FC = ({ (chain) => chain.id === fromToken?.chainId ) + const fromChainWalletVMSupported = + !fromChain?.vmType || supportedWalletVMs.includes(fromChain?.vmType) + const toChainWalletVMSupported = + !toChain?.vmType || supportedWalletVMs.includes(toChain?.vmType) + const defaultRecipient = useMemo(() => { const _linkedWallet = linkedWallets?.find( (linkedWallet) => address === linkedWallet.address ) const _isValidToAddress = isValidAddress( toChain?.vmType, - customToAddress ?? address ?? '', + customToAddress ?? '', toChain?.id, !customToAddress && _linkedWallet?.address === address ? _linkedWallet?.connector @@ -235,9 +247,11 @@ const SwapWidgetRenderer: FC = ({ setCustomToAddress ]) - const recipient = customToAddress ?? defaultRecipient ?? address + const recipient = customToAddress ?? defaultRecipient - const { displayName: toDisplayName } = useENSResolver(recipient) + const { displayName: toDisplayName } = useENSResolver(recipient, { + enabled: toChain?.vmType === 'evm' + }) const { value: fromBalance, @@ -324,6 +338,10 @@ const SwapWidgetRenderer: FC = ({ const linkedWallet = linkedWallets?.find( (linkedWallet) => address === linkedWallet.address ) + const isRecipientLinked = + (recipient + ? linkedWallets?.find((wallet) => wallet.address === recipient) + : undefined) !== undefined const isValidFromAddress = isValidAddress( fromChain?.vmType, @@ -377,7 +395,9 @@ const SwapWidgetRenderer: FC = ({ } ) const supportsExternalLiquidity = - tokenPairIsCanonical && externalLiquiditySupport.status === 'success' + tokenPairIsCanonical && + externalLiquiditySupport.status === 'success' && + fromChainWalletVMSupported ? true : false @@ -403,7 +423,8 @@ const SwapWidgetRenderer: FC = ({ toToken.decimals ).toString(), referrer: relayClient?.source ?? undefined, - useExternalLiquidity + useExternalLiquidity, + useDepositAddress: !fromChainWalletVMSupported } : undefined @@ -461,6 +482,7 @@ const SwapWidgetRenderer: FC = ({ enabled: quoteFetchingEnabled, refetchInterval: !transactionModalOpen && + !depositAddressModalOpen && debouncedInputAmountValue === amountInputValue && debouncedOutputAmountValue === amountOutputValue ? 12000 @@ -546,7 +568,8 @@ const SwapWidgetRenderer: FC = ({ totalAmount && address && (fromBalance ?? 0n) < totalAmount && - !hasAuxiliaryFundsSupport + !hasAuxiliaryFundsSupport && + fromChainWalletVMSupported ) const fetchQuoteErrorMessage = error @@ -620,10 +643,16 @@ const SwapWidgetRenderer: FC = ({ if (!fromToken || !toToken) { ctaCopy = 'Select a token' - } else if (multiWalletSupportEnabled && !isValidFromAddress) { + } else if ( + multiWalletSupportEnabled && + !isValidFromAddress && + fromChainWalletVMSupported + ) { ctaCopy = `Select ${fromChain?.displayName} Wallet` } else if (multiWalletSupportEnabled && !isValidToAddress) { - ctaCopy = `Select ${toChain?.displayName} Wallet` + ctaCopy = toChainWalletVMSupported + ? `Select ${toChain?.displayName} Wallet` + : `Enter ${toChain?.displayName} Address` } else if (toChain?.vmType !== 'evm' && !isValidToAddress) { ctaCopy = `Enter ${toChain?.displayName} Address` } else if (isSameCurrencySameRecipientSwap) { @@ -634,6 +663,8 @@ const SwapWidgetRenderer: FC = ({ ctaCopy = 'Insufficient Balance' } else if (isInsufficientLiquidityError) { ctaCopy = 'Insufficient Liquidity' + } else if (!toChainWalletVMSupported && !isValidToAddress) { + ctaCopy = `Enter ${toChain.displayName} Address` } else if (transactionModalOpen) { switch (operation) { case 'wrap': { @@ -651,7 +682,7 @@ const SwapWidgetRenderer: FC = ({ case 'swap': default: { if (context === 'Swap') { - ctaCopy = 'Trade' + ctaCopy = 'Swap' } else { ctaCopy = context === 'Deposit' ? 'Depositing' : 'Withdrawing' } @@ -728,6 +759,10 @@ const SwapWidgetRenderer: FC = ({ isBvmSwap, isValidFromAddress, isValidToAddress, + supportedWalletVMs, + fromChainWalletVMSupported, + toChainWalletVMSupported, + isRecipientLinked, invalidateBalanceQueries, setUseExternalLiquidity, setDetails, diff --git a/packages/ui/src/components/widgets/WidgetContainer.tsx b/packages/ui/src/components/widgets/WidgetContainer.tsx index fb83ca4ef..be7ad5291 100644 --- a/packages/ui/src/components/widgets/WidgetContainer.tsx +++ b/packages/ui/src/components/widgets/WidgetContainer.tsx @@ -1,6 +1,7 @@ import { type FC, type ReactNode } from 'react' import { CustomAddressModal } from '../common/CustomAddressModal.js' import { SwapModal } from '../common/TransactionModal/SwapModal.js' +import { DepositAddressModal } from '../common/TransactionModal/DepositAddressModal.js' import { useMounted } from '../../hooks/index.js' import type { ChildrenProps } from './SwapWidgetRenderer.js' import type { RelayChain, AdaptedWallet, Execute } from '@reservoir0x/relay-sdk' @@ -9,6 +10,7 @@ import type { LinkedWallet } from '../../types/index.js' export type WidgetContainerProps = { transactionModalOpen: boolean + depositAddressModalOpen: boolean addressModalOpen: boolean toChain?: RelayChain fromChain?: RelayChain @@ -16,9 +18,11 @@ export type WidgetContainerProps = { linkedWallets?: LinkedWallet[] multiWalletSupportEnabled?: boolean setTransactionModalOpen: React.Dispatch> + setDepositAddressModalOpen: React.Dispatch> setAddressModalOpen: React.Dispatch> children: () => ReactNode onSwapModalOpenChange: (open: boolean) => void + onDepositAddressModalOpenChange: (open: boolean) => void onAnalyticEvent?: (eventName: string, data?: any) => void onSwapSuccess?: (data: Execute) => void onSwapValidating?: (data: Execute) => void @@ -34,8 +38,6 @@ export type WidgetContainerProps = { | 'recipient' | 'customToAddress' | 'tradeType' - | 'swapError' - | 'price' | 'address' | 'setCustomToAddress' | 'useExternalLiquidity' @@ -45,6 +47,8 @@ export type WidgetContainerProps = { const WidgetContainer: FC = ({ transactionModalOpen, setTransactionModalOpen, + depositAddressModalOpen, + setDepositAddressModalOpen, addressModalOpen, setAddressModalOpen, children, @@ -57,8 +61,6 @@ const WidgetContainer: FC = ({ amountOutputValue, tradeType, customToAddress, - swapError, - price, address, useExternalLiquidity, timeEstimate, @@ -68,6 +70,7 @@ const WidgetContainer: FC = ({ linkedWallets, multiWalletSupportEnabled, onSwapModalOpenChange, + onDepositAddressModalOpenChange, onSwapSuccess, onSwapValidating, onAnalyticEvent, @@ -80,34 +83,57 @@ const WidgetContainer: FC = ({
{children()} {isMounted ? ( - { - onSwapModalOpenChange(open) - setTransactionModalOpen(open) - }} - fromChain={fromChain} - fromToken={fromToken} - toToken={toToken} - amountInputValue={amountInputValue} - amountOutputValue={amountOutputValue} - debouncedInputAmountValue={debouncedInputAmountValue} - debouncedOutputAmountValue={debouncedOutputAmountValue} - tradeType={tradeType} - useExternalLiquidity={useExternalLiquidity} - address={address} - recipient={recipient} - isCanonical={useExternalLiquidity} - timeEstimate={timeEstimate} - onAnalyticEvent={onAnalyticEvent} - onSuccess={onSwapSuccess} - onSwapValidating={onSwapValidating} - invalidateBalanceQueries={invalidateBalanceQueries} - wallet={wallet} - linkedWallets={linkedWallets} - multiWalletSupportEnabled={multiWalletSupportEnabled} - /> + <> + { + onSwapModalOpenChange(open) + setTransactionModalOpen(open) + }} + fromChain={fromChain} + fromToken={fromToken} + toToken={toToken} + amountInputValue={amountInputValue} + amountOutputValue={amountOutputValue} + debouncedInputAmountValue={debouncedInputAmountValue} + debouncedOutputAmountValue={debouncedOutputAmountValue} + tradeType={tradeType} + useExternalLiquidity={useExternalLiquidity} + address={address} + recipient={recipient} + isCanonical={useExternalLiquidity} + timeEstimate={timeEstimate} + onAnalyticEvent={onAnalyticEvent} + onSuccess={onSwapSuccess} + onSwapValidating={onSwapValidating} + invalidateBalanceQueries={invalidateBalanceQueries} + wallet={wallet} + linkedWallets={linkedWallets} + multiWalletSupportEnabled={multiWalletSupportEnabled} + /> + { + onDepositAddressModalOpenChange(open) + setDepositAddressModalOpen(open) + }} + fromChain={fromChain} + fromToken={fromToken} + toToken={toToken} + amountInputValue={amountInputValue} + amountOutputValue={amountOutputValue} + debouncedInputAmountValue={debouncedInputAmountValue} + debouncedOutputAmountValue={debouncedOutputAmountValue} + address={address} + recipient={recipient} + timeEstimate={timeEstimate} + onAnalyticEvent={onAnalyticEvent} + onSuccess={onSwapSuccess} + invalidateBalanceQueries={invalidateBalanceQueries} + /> + ) : null} + { + if (vm === 'evm') { + return `ethereum:${toAddress}@${chainId}?value=${tokenAddress ? 0 : amount}` + } else if (vm === 'svm') { + if (chainId === solana.id) { + return `solana:${toAddress}?amount=${tokenAddress ? 0 : amount}` + } + } else if (vm === 'bvm') { + return `bitcoin:${toAddress}?amount=${tokenAddress ? 0 : amount}` + } + return undefined +} diff --git a/packages/ui/src/utils/quote.ts b/packages/ui/src/utils/quote.ts index 3eafe8d4f..63067d102 100644 --- a/packages/ui/src/utils/quote.ts +++ b/packages/ui/src/utils/quote.ts @@ -233,6 +233,11 @@ export const extractQuoteId = (steps?: Execute['steps']) => { return '' } +export const extractDepositAddress = (steps?: Execute['steps']) => { + const depositStep = steps?.find((step) => step.id === 'deposit') + return depositStep?.depositAddress +} + export const calculatePriceTimeEstimate = ( details?: PriceResponse['details'] ) => { diff --git a/packages/ui/src/utils/tokens.ts b/packages/ui/src/utils/tokens.ts index bf2e6659a..cf5e14161 100644 --- a/packages/ui/src/utils/tokens.ts +++ b/packages/ui/src/utils/tokens.ts @@ -1,6 +1,6 @@ import type { Token } from '../types/index.js' import { ASSETS_RELAY_API } from '@reservoir0x/relay-sdk' -import type { paths } from '@reservoir0x/relay-sdk' +import type { paths, RelayChain } from '@reservoir0x/relay-sdk' type ApiCurrency = NonNullable< paths['/chains']['get']['responses']['200']['content']['application/json']['chains'] @@ -22,3 +22,23 @@ export const convertApiCurrencyToToken = ( verified: true } } + +export const findBridgableToken = (chain?: RelayChain, token?: Token) => { + if (chain && token && token.chainId === chain.id) { + const toCurrencies = [ + ...(chain?.erc20Currencies ?? []), + chain.currency ?? undefined + ] + const toCurrency = toCurrencies.find((c) => c?.address === token?.address) + + if (!toCurrency || !toCurrency.supportsBridging) { + const supportedToCurrency = toCurrencies.find((c) => c?.supportsBridging) + if (supportedToCurrency) { + return convertApiCurrencyToToken(supportedToCurrency, chain.id) + } + } else { + return token + } + } + return null +} diff --git a/packages/ui/src/utils/truncate.ts b/packages/ui/src/utils/truncate.ts index a4d1a522a..8056b363d 100644 --- a/packages/ui/src/utils/truncate.ts +++ b/packages/ui/src/utils/truncate.ts @@ -7,9 +7,16 @@ * @returns A shrinked version of the Ethereum address * with the middle characters removed. */ -function truncateAddress(address?: string, shrinkInidicator?: string) { +function truncateAddress( + address?: string, + shrinkInidicator?: string, + firstSectionLength?: number, + lastSectionLength?: number +) { return address - ? address.slice(0, 4) + (shrinkInidicator || '…') + address.slice(-4) + ? address.slice(0, firstSectionLength ?? 4) + + (shrinkInidicator || '…') + + address.slice(-(lastSectionLength ?? 4)) : address } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cde0bc6e..3336698b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,6 +331,9 @@ importers: pandacss-preset-radix-colors: specifier: ^0.2.0 version: 0.2.0(@pandacss/dev@0.40.0)(@radix-ui/colors@0.1.9) + qrcode.react: + specifier: ^4.1.0 + version: 4.1.0(react@18.2.0) react: specifier: ^18.0 version: 18.2.0 @@ -13783,6 +13786,14 @@ packages: hasBin: true dev: false + /qrcode.react@4.1.0(react@18.2.0): + resolution: {integrity: sha512-uqXVIIVD/IPgWLYxbOczCNAQw80XCM/LulYDADF+g2xDsPj5OoRwSWtIS4jGyp295wyjKstfG1qIv/I2/rNWpQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + /qrcode@1.5.1: resolution: {integrity: sha512-nS8NJ1Z3md8uTjKtP+SGGhfqmTCs5flU/xR623oI0JX+Wepz9R8UrRVCTBTJm3qGw3rH6jJ6MUHjkDx15cxSSg==} engines: {node: '>=10.13.0'}