From 3b1985fd0e14d76d138fc66b276a73da0aeb3f0f Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Thu, 12 Dec 2024 21:51:43 -0800 Subject: [PATCH 01/65] initial commit --- src/internal/svg/appleSvg.tsx | 27 ++ src/internal/svg/cardSvg.tsx | 16 + src/internal/svg/checkmarkSvg.tsx | 4 +- src/internal/svg/coinbaseLogoSvg.tsx | 17 + src/swap/components/SwapLite.tsx | 75 +++ src/swap/components/SwapLiteAmountInput.tsx | 41 ++ src/swap/components/SwapLiteButton.tsx | 74 +++ src/swap/components/SwapLiteDropdown.tsx | 76 +++ src/swap/components/SwapLiteMessage.tsx | 18 + src/swap/components/SwapLiteOnrampItem.tsx | 56 +++ src/swap/components/SwapLiteProvider.tsx | 488 ++++++++++++++++++++ src/swap/components/SwapLiteTokenItem.tsx | 58 +++ src/swap/constants.ts | 21 + src/swap/index.ts | 1 + src/swap/types.ts | 61 ++- 15 files changed, 1030 insertions(+), 3 deletions(-) create mode 100644 src/internal/svg/appleSvg.tsx create mode 100644 src/internal/svg/cardSvg.tsx create mode 100644 src/internal/svg/coinbaseLogoSvg.tsx create mode 100644 src/swap/components/SwapLite.tsx create mode 100644 src/swap/components/SwapLiteAmountInput.tsx create mode 100644 src/swap/components/SwapLiteButton.tsx create mode 100644 src/swap/components/SwapLiteDropdown.tsx create mode 100644 src/swap/components/SwapLiteMessage.tsx create mode 100644 src/swap/components/SwapLiteOnrampItem.tsx create mode 100644 src/swap/components/SwapLiteProvider.tsx create mode 100644 src/swap/components/SwapLiteTokenItem.tsx diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx new file mode 100644 index 0000000000..5a9154db4b --- /dev/null +++ b/src/internal/svg/appleSvg.tsx @@ -0,0 +1,27 @@ +export const appleSvg = ( + + + + + + + + + + + + + +); diff --git a/src/internal/svg/cardSvg.tsx b/src/internal/svg/cardSvg.tsx new file mode 100644 index 0000000000..9ebf9fd56c --- /dev/null +++ b/src/internal/svg/cardSvg.tsx @@ -0,0 +1,16 @@ +export const cardSvg = ( + + + + +); diff --git a/src/internal/svg/checkmarkSvg.tsx b/src/internal/svg/checkmarkSvg.tsx index add48c0522..40c710d782 100644 --- a/src/internal/svg/checkmarkSvg.tsx +++ b/src/internal/svg/checkmarkSvg.tsx @@ -1,7 +1,7 @@ export const checkmarkSvg = ( + + +); diff --git a/src/swap/components/SwapLite.tsx b/src/swap/components/SwapLite.tsx new file mode 100644 index 0000000000..02ae969ad7 --- /dev/null +++ b/src/swap/components/SwapLite.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react'; +import { cn } from '../../styles/theme'; +import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../constants'; +import type { SwapLiteReact } from '../types'; +import { SwapLiteButton } from './SwapLiteButton'; +import { SwapLiteDropdown } from './SwapLiteDropdown'; +import { SwapLiteAmountInput } from './SwapLiteAmountInput'; +import { SwapLiteProvider, useSwapLiteContext } from './SwapLiteProvider'; +import { SwapLiteMessage } from './SwapLiteMessage'; + +export function SwapLiteContent({ className }: { className?: string }) { + const { isDropdownOpen, setIsDropdownOpen } = useSwapLiteContext(); + const fundSwapContainerRef = useRef(null); + + // Handle clicking outside the wallet component to close the dropdown. + useEffect(() => { + const handleClickOutsideComponent = (event: MouseEvent) => { + if ( + fundSwapContainerRef.current && + !fundSwapContainerRef.current.contains(event.target as Node) && + isDropdownOpen + ) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('click', handleClickOutsideComponent); + return () => + document.removeEventListener('click', handleClickOutsideComponent); + }, [isDropdownOpen, setIsDropdownOpen]); + + return ( +
+
+ + + {isDropdownOpen && } +
+ +
+ ); +} +export function SwapLite({ + config = { + maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, + }, + className, + experimental = { useAggregator: false }, + isSponsored = false, + onError, + onStatus, + onSuccess, + toToken, + fromToken, + projectId, +}: SwapLiteReact) { + return ( + + + + ); +} diff --git a/src/swap/components/SwapLiteAmountInput.tsx b/src/swap/components/SwapLiteAmountInput.tsx new file mode 100644 index 0000000000..9e56c37ee1 --- /dev/null +++ b/src/swap/components/SwapLiteAmountInput.tsx @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { TextInput } from '../../internal/components/TextInput'; +import { isValidAmount } from '../../core/utils/isValidAmount'; +import { cn, pressable } from '../../styles/theme'; +import { TokenChip } from '../../token'; +import { formatAmount } from '../utils/formatAmount'; +import { useSwapLiteContext } from './SwapLiteProvider'; + +export function SwapLiteAmountInput() { + const { to, handleAmountChange } = useSwapLiteContext(); + + const handleChange = useCallback( + (amount: string) => { + handleAmountChange(amount); + }, + [handleAmountChange], + ); + + if (!to?.token) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/src/swap/components/SwapLiteButton.tsx b/src/swap/components/SwapLiteButton.tsx new file mode 100644 index 0000000000..c0033b0175 --- /dev/null +++ b/src/swap/components/SwapLiteButton.tsx @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from 'react'; +import { Spinner } from '../../internal/components/Spinner'; +import { checkmarkSvg } from '../../internal/svg/checkmarkSvg'; +import { + background, + border, + cn, + color, + pressable, + text, +} from '../../styles/theme'; +import { useSwapLiteContext } from './SwapLiteProvider'; + +export function SwapLiteButton() { + const { + setIsDropdownOpen, + from, + fromETH, + fromUSDC, + to, + lifecycleStatus: { statusName }, + } = useSwapLiteContext(); + const isLoading = + to?.loading || + from?.loading || + fromETH.loading || + fromUSDC.loading || + statusName === 'transactionPending' || + statusName === 'transactionApproved'; + + const isDisabled = + !fromETH.amount || + !fromUSDC.amount || + !fromETH.token || + !fromUSDC.token || + !to?.amount || + !to?.token || + isLoading; + + const handleSubmit = useCallback(() => { + setIsDropdownOpen(true); + }, [setIsDropdownOpen]); + + const buttonContent = useMemo(() => { + if (statusName === 'success') { + return checkmarkSvg; + } + return 'Buy'; + }, [statusName]); + + return ( + + ); +} diff --git a/src/swap/components/SwapLiteDropdown.tsx b/src/swap/components/SwapLiteDropdown.tsx new file mode 100644 index 0000000000..3aa4deaf1d --- /dev/null +++ b/src/swap/components/SwapLiteDropdown.tsx @@ -0,0 +1,76 @@ +import { useCallback, useMemo } from 'react'; +import { background, cn, color, text } from '../../styles/theme'; +import { useSwapLiteContext } from './SwapLiteProvider'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { ONRAMP_PAYMENT_METHODS } from '../constants'; +import { useAccount } from 'wagmi'; +import { ONRAMP_BUY_URL } from '../../fund/constants'; +import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; +import { openPopup } from '../../internal/utils/openPopup'; +import { SwapLiteOnrampItem } from './SwapLiteOnrampItem'; +import { SwapLiteTokenItem } from './SwapLiteTokenItem'; + +export function SwapLiteDropdown() { + const { to, fromETH, fromUSDC, from, projectId } = useSwapLiteContext(); + const { address } = useAccount(); + + const handleOnrampClick = useCallback( + (paymentMethodId: string) => { + return () => { + const assetSymbol = to?.token?.symbol; + const fundAmount = to?.amount; + const fundingUrl = `${ONRAMP_BUY_URL}/one-click?appId=${projectId}&addresses={"${address}":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${paymentMethodId}`; + const { height, width } = getFundingPopupSize('md', fundingUrl); + openPopup({ + url: fundingUrl, + height, + width, + target: '_blank', + }); + }; + }, + [to, projectId], + ); + + const formattedAmountUSD = useMemo(() => { + if (!to?.amountUSD || to?.amountUSD === '0') { + return null; + } + const roundedAmount = Number(getRoundedAmount(to?.amountUSD, 2)); + return `$${roundedAmount.toFixed(2)}`; + }, [to?.amountUSD]); + + return ( +
+
Buy with
+ + + {from && } + + {ONRAMP_PAYMENT_METHODS.map((method) => { + return ( + + ); + })} + + {!!formattedAmountUSD && ( +
{`${to?.amount} ${to?.token?.name} ≈ ${formattedAmountUSD}`}
+ )} +
+ ); +} diff --git a/src/swap/components/SwapLiteMessage.tsx b/src/swap/components/SwapLiteMessage.tsx new file mode 100644 index 0000000000..f7127972aa --- /dev/null +++ b/src/swap/components/SwapLiteMessage.tsx @@ -0,0 +1,18 @@ +import { cn, color } from '../../styles/theme'; +import { useSwapLiteContext } from './SwapLiteProvider'; + +export function SwapLiteMessage() { + const { + lifecycleStatus: { statusName }, + } = useSwapLiteContext(); + + if (statusName !== 'error') { + return null; + } + + return ( +
+ Something went wrong. Please try again. +
+ ); +} diff --git a/src/swap/components/SwapLiteOnrampItem.tsx b/src/swap/components/SwapLiteOnrampItem.tsx new file mode 100644 index 0000000000..2f6e93c27e --- /dev/null +++ b/src/swap/components/SwapLiteOnrampItem.tsx @@ -0,0 +1,56 @@ +import { cn, color } from '../../styles/theme'; +import { appleSvg } from '../../internal/svg/appleSvg'; +import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg'; +import { cardSvg } from '../../internal/svg/cardSvg'; +import { useSwapLiteContext } from './SwapLiteProvider'; +import { useCallback } from 'react'; + +type OnrampItemReact = { + name: string; + description: string; + onClick: () => void; + svg?: React.ReactNode; + icon: string; +}; + +const ONRAMP_ICON_MAP: Record = { + applePay: appleSvg, + coinbasePay: coinbaseLogoSvg, + creditCard: cardSvg, +}; + +export function SwapLiteOnrampItem({ + name, + description, + onClick, + icon, +}: OnrampItemReact) { + const { setIsDropdownOpen } = useSwapLiteContext(); + + const handleClick = useCallback(() => { + setIsDropdownOpen(false); + onClick(); + }, [onClick, setIsDropdownOpen]); + + return ( + + ); +} +c; diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx new file mode 100644 index 0000000000..b9f9d0aa3d --- /dev/null +++ b/src/swap/components/SwapLiteProvider.tsx @@ -0,0 +1,488 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { base } from 'viem/chains'; +import { useAccount, useConfig, useSendTransaction } from 'wagmi'; +import { useSwitchChain } from 'wagmi'; +import { useSendCalls } from 'wagmi/experimental'; +import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import { getSwapQuote } from '../../core/api/getSwapQuote'; +import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import { formatTokenAmount } from '../../core/utils/formatTokenAmount'; +import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; +import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; +import { useOnchainKit } from '../../core-react/useOnchainKit'; +import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../constants'; +import { useAwaitCalls } from '../hooks/useAwaitCalls'; +import { useSwapLiteTokens } from '../hooks/useSwapLiteTokens'; +import { useLifecycleStatus } from '../hooks/useLifecycleStatus'; +import { useResetSwapLiteInputs } from '../hooks/useResetSwapLiteInputs'; +import type { + SwapLiteContextType, + SwapLiteProviderReact, + SwapUnit, +} from '../types'; +import { isSwapError } from '../utils/isSwapError'; +import { processSwapTransaction } from '../utils/processSwapTransaction'; +import { EventMetadata, OnrampError } from '../../fund/types'; +import { setupOnrampEventListeners } from '../../fund'; + +const emptyContext = {} as SwapLiteContextType; + +export const SwapLiteContext = createContext(emptyContext); + +export function useSwapLiteContext() { + const context = useContext(SwapLiteContext); + if (context === emptyContext) { + throw new Error( + 'useSwapLiteContext must be used within a SwapLite component', + ); + } + return context; +} + +export function SwapLiteProvider({ + children, + config = { + maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, + }, + experimental, + isSponsored, + onError, + onStatus, + onSuccess, + toToken, + fromToken, + projectId, +}: SwapLiteProviderReact) { + const { + config: { paymaster } = { paymaster: undefined }, + } = useOnchainKit(); + const { address, chainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + // Feature flags + const { useAggregator } = experimental; + // Core Hooks + const accountConfig = useConfig(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const walletCapabilities = useCapabilitiesSafe({ + chainId: base.id, + }); // Swap is only available on Base + const [lifecycleStatus, updateLifecycleStatus] = useLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + maxSlippage: config.maxSlippage, + }, + }); // Component lifecycle + + const [transactionHash, setTransactionHash] = useState(''); + const [hasHandledSuccess, setHasHandledSuccess] = useState(false); + const { from, fromETH, fromUSDC, to } = useSwapLiteTokens( + toToken, + fromToken, + address, + ); + const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) + const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable) + + // Refreshes balances and inputs post-swap + const resetInputs = useResetSwapLiteInputs({ fromETH, fromUSDC, from, to }); + // For batched transactions, listens to and awaits calls from the Wallet server + const awaitCallsStatus = useAwaitCalls({ + accountConfig, + lifecycleStatus, + updateLifecycleStatus, + }); + + const handleOnrampEvent = useCallback((data: EventMetadata) => { + console.log({ data }); + if (data.eventName === 'transition_view') { + updateLifecycleStatus({ + statusName: 'transactionPending', + }); + } + }, []); + + const handleOnrampExit = useCallback((error?: OnrampError) => { + console.log({ error }); + }, []); + + const handleOnrampSuccess = useCallback(() => { + console.log('ONRAMP SUCCESS'); + }, []); + + useEffect(() => { + const unsubscribe = setupOnrampEventListeners({ + onEvent: handleOnrampEvent, + onExit: handleOnrampExit, + onSuccess: handleOnrampSuccess, + }); + return () => { + unsubscribe(); + }; + }, [handleOnrampEvent, handleOnrampExit, handleOnrampSuccess]); + + // Component lifecycle emitters + useEffect(() => { + // Error + if (lifecycleStatus.statusName === 'error') { + onError?.(lifecycleStatus.statusData); + } + // Success + if (lifecycleStatus.statusName === 'success') { + onSuccess?.(lifecycleStatus.statusData.transactionReceipt); + setTransactionHash( + lifecycleStatus.statusData.transactionReceipt?.transactionHash, + ); + setHasHandledSuccess(true); + } + // Emit Status + onStatus?.(lifecycleStatus); + }, [ + onError, + onStatus, + onSuccess, + lifecycleStatus, + lifecycleStatus.statusData, // Keep statusData, so that the effect runs when it changes + lifecycleStatus.statusName, // Keep statusName, so that the effect runs when it changes + ]); + + useEffect(() => { + // Reset inputs after status reset. `resetInputs` is dependent + // on 'from' and 'to' so moved to separate useEffect to + // prevents multiple calls to `onStatus` + if (lifecycleStatus.statusName === 'init' && hasHandledSuccess) { + setHasHandledSuccess(false); + resetInputs(); + } + }, [hasHandledSuccess, lifecycleStatus.statusName, resetInputs]); + + useEffect(() => { + // For batched transactions, `transactionApproved` will contain the calls ID + // We'll use the `useAwaitCalls` hook to listen to the call status from the wallet server + // This will update the lifecycle status to `success` once the calls are confirmed + if ( + lifecycleStatus.statusName === 'transactionApproved' && + lifecycleStatus.statusData.transactionType === 'Batched' + ) { + awaitCallsStatus(); + } + }, [ + awaitCallsStatus, + lifecycleStatus, + lifecycleStatus.statusData, + lifecycleStatus.statusName, + ]); + + useEffect(() => { + let timer: NodeJS.Timeout; + // Reset status to init after success has been handled + if (lifecycleStatus.statusName === 'success' && hasHandledSuccess) { + timer = setTimeout(() => { + updateLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + maxSlippage: config.maxSlippage, + }, + }); + }, 3000); + } + return () => { + if (timer) { + return clearTimeout(timer); + } + }; + }, [ + config.maxSlippage, + hasHandledSuccess, + lifecycleStatus.statusName, + updateLifecycleStatus, + ]); + + const handleAmountChange = useCallback( + async ( + amount: string, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component + ) => { + if ( + to.token === undefined || + fromETH.token === undefined || + fromUSDC.token === undefined + ) { + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + amountETH: fromETH.amount, + amountUSDC: fromUSDC.amount, + amountTo: to.amount, + tokenTo: to.token, + isMissingRequiredField: true, + }, + }); + return; + } + + if (amount === '' || amount === '.' || Number.parseFloat(amount) === 0) { + to.setAmount(''); + to.setAmountUSD(''); + fromETH.setAmountUSD(''); + fromUSDC.setAmountUSD(''); + from.setAmountUSD(''); + return; + } + + fromETH.setLoading(true); + fromUSDC.setLoading(true); + from.setLoading(true); + + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + // when fetching quote, the previous + // amount is irrelevant + amountTo: amount, + amountETH: '', + amountUSDC: '', + amountFrom: '', + tokenFromETH: fromETH.token, + tokenFromUSDC: fromUSDC.token, + tokenFrom: from.token, + tokenTo: to.token, + // when fetching quote, the destination + // amount is missing + isMissingRequiredField: true, + }, + }); + + try { + const maxSlippage = lifecycleStatus.statusData.maxSlippage; + const responseETH = await getSwapQuote({ + amount, + amountReference: 'to', + from: fromETH.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + }); + const responseUSDC = await getSwapQuote({ + amount, + amountReference: 'to', + from: fromUSDC.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + }); + + let responseFrom; + if (from?.token) { + responseFrom = await getSwapQuote({ + amount, + amountReference: 'to', + from: from?.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + }); + } + + // If request resolves to error response set the quoteError + // property of error state to the SwapError response + if (isSwapError(responseETH)) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: responseETH.code, + error: responseETH.error, + message: '', + }, + }); + return; + } + if (isSwapError(responseUSDC)) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: responseUSDC.code, + error: responseUSDC.error, + message: '', + }, + }); + return; + } + const formattedAmount = formatTokenAmount( + responseETH.fromAmount, + responseETH.from.decimals, + ); + const formattedUSDCAmount = formatTokenAmount( + responseUSDC.fromAmount, + responseUSDC.from.decimals, + ); + + fromETH.setAmountUSD(responseETH.fromAmountUSD); + fromETH.setAmount(formattedAmount); + fromUSDC.setAmountUSD(responseUSDC.fromAmountUSD); + fromUSDC.setAmount(formattedUSDCAmount); + + // if error occurs, handle gracefully + // (display other payment options with fromToken disabled) + let formattedFromAmount; + if (responseFrom && !isSwapError(responseFrom)) { + formattedFromAmount = formatTokenAmount( + responseFrom.fromAmount, + responseFrom.from.decimals, + ); + + from.setAmountUSD(responseFrom?.fromAmountUSD || ''); + from.setAmount(formattedFromAmount || ''); + } + // TODO: revisit this + to.setAmountUSD(responseETH.toAmountUSD); + + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + amountETH: formattedAmount, + amountUSDC: formattedUSDCAmount, + amountFrom: formattedFromAmount || '', + amountTo: amount, + tokenFromETH: fromETH.token, + tokenFromUSDC: fromUSDC.token, + tokenFrom: from.token, + tokenTo: to.token, + // if quote was fetched successfully, we + // have all required fields + isMissingRequiredField: !formattedAmount, + }, + }); + } catch (err) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc01', // Transaction module SwapProvider component 01 error + error: JSON.stringify(err), + message: '', + }, + }); + } finally { + // reset loading state when quote request resolves + fromETH.setLoading(false); + fromUSDC.setLoading(false); + from.setLoading(false); + } + }, + [ + to, + fromETH, + fromUSDC, + useAggregator, + updateLifecycleStatus, + lifecycleStatus.statusData.maxSlippage, + ], + ); + + const handleSubmit = useCallback( + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component + async (from: SwapUnit) => { + if (!address || !from.token || !to.token || !from.amount) { + return; + } + + try { + const maxSlippage = lifecycleStatus.statusData.maxSlippage; + const response = await buildSwapTransaction({ + amount: from.amount, + fromAddress: address, + from: from.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + }); + if (isSwapError(response)) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: response.code, + error: response.error, + message: response.message, + }, + }); + return; + } + await processSwapTransaction({ + chainId, + config: accountConfig, + isSponsored, + paymaster: paymaster || '', + sendCallsAsync, + sendTransactionAsync, + swapTransaction: response, + switchChainAsync, + updateLifecycleStatus, + useAggregator, + walletCapabilities, + }); + } catch (err) { + const errorMessage = isUserRejectedRequestError(err) + ? 'Request denied.' + : GENERIC_ERROR_MESSAGE; + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc02', // Transaction module SwapProvider component 02 error + error: JSON.stringify(err), + message: errorMessage, + }, + }); + } + }, + [ + accountConfig, + address, + chainId, + isSponsored, + lifecycleStatus, + paymaster, + sendCallsAsync, + sendTransactionAsync, + switchChainAsync, + to.token, + updateLifecycleStatus, + useAggregator, + walletCapabilities, + ], + ); + + const value = useValue({ + address, + config, + from, + fromETH, + fromUSDC, + handleAmountChange, + handleSubmit, + lifecycleStatus, + updateLifecycleStatus, + to, + setTransactionHash, + transactionHash, + isDropdownOpen, + setIsDropdownOpen, + toToken, + fromToken, + projectId, + }); + + return ( + + {children} + + ); +} diff --git a/src/swap/components/SwapLiteTokenItem.tsx b/src/swap/components/SwapLiteTokenItem.tsx new file mode 100644 index 0000000000..918cbc1375 --- /dev/null +++ b/src/swap/components/SwapLiteTokenItem.tsx @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from 'react'; +import { cn, color } from '../../styles/theme'; +import { TokenImage } from '../../token'; +import type { SwapUnit } from '../types'; +import { useSwapLiteContext } from './SwapLiteProvider'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; + +export function SwapLiteTokenItem({ swapUnit }: { swapUnit: SwapUnit }) { + const { handleSubmit, setIsDropdownOpen } = useSwapLiteContext(); + + if (!swapUnit?.token) { + return null; + } + + const handleClick = useCallback(() => { + setIsDropdownOpen(false); + handleSubmit(swapUnit); + }, [handleSubmit, swapUnit, setIsDropdownOpen]); + + const hasInsufficientBalance = + !swapUnit.balance || + Number.parseFloat(swapUnit.balance) < Number.parseFloat(swapUnit.amount); + + const roundedAmount = useMemo(() => { + return getRoundedAmount(swapUnit.amount, 10); + }, [swapUnit.amount]); + + const roundedBalance = useMemo(() => { + return getRoundedAmount(swapUnit.balance || '0', 10); + }, [swapUnit.balance]); + + return ( + + ); +} diff --git a/src/swap/constants.ts b/src/swap/constants.ts index ad222a5704..e3f10af11b 100644 --- a/src/swap/constants.ts +++ b/src/swap/constants.ts @@ -23,3 +23,24 @@ export enum SwapMessage { TOO_MANY_REQUESTS = 'Too many requests. Please try again later.', USER_REJECTED = 'User rejected the transaction', } + +export const ONRAMP_PAYMENT_METHODS = [ + { + id: 'CRYPTO_ACCOUNT', + name: 'Coinbase', + description: 'Buy with your Coinbase account', + icon: 'coinbasePay', + }, + { + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500/week', + icon: 'applePay', + }, + { + id: 'CARD', + name: 'Debit Card', + description: 'Up to $500/week', + icon: 'creditCard', + }, +]; diff --git a/src/swap/index.ts b/src/swap/index.ts index 984438fa9d..a8b9abdccb 100644 --- a/src/swap/index.ts +++ b/src/swap/index.ts @@ -3,6 +3,7 @@ export { Swap } from './components/Swap'; export { SwapAmountInput } from './components/SwapAmountInput'; export { SwapButton } from './components/SwapButton'; export { SwapDefault } from './components/SwapDefault'; +export { SwapLite } from './components/SwapLite'; export { SwapMessage } from './components/SwapMessage'; export { SwapSettings } from './components/SwapSettings'; export { SwapSettingsSlippageDescription } from './components/SwapSettingsSlippageDescription'; diff --git a/src/swap/types.ts b/src/swap/types.ts index cc9dd656bc..5a89a3cecb 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -93,9 +93,13 @@ export type LifecycleStatus = | { statusName: 'amountChange'; statusData: { - amountFrom: string; + amountFrom?: string; + amountETH?: string; + amountUSDC?: string; amountTo: string; tokenFrom?: Token; + tokenFromETH?: Token; + tokenFromUSDC?: Token; tokenTo?: Token; } & LifecycleStatusDataShared; } @@ -173,6 +177,61 @@ export type ProcessSwapTransactionParams = { walletCapabilities: WalletCapabilities; // EIP-5792 wallet capabilities }; +/** + * Note: exported as public Type + */ +export type SwapLiteReact = { + className?: string; // Optional className override for top div element. + config?: SwapConfig; + experimental?: { + useAggregator: boolean; // Whether to use a DEX aggregator. (default: true) + }; + isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) + onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. + onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + fromToken?: Token; + toToken: Token; + projectId: string; // Your CDP project ID found at https://portal.cdp.coinbase.com/ +}; + +export type SwapLiteContextType = { + address?: Address; // Used to check if user is connected in SwapButton + config: SwapConfig; + fromETH: SwapUnit; + fromUSDC: SwapUnit; + lifecycleStatus: LifecycleStatus; + handleAmountChange: (amount: string) => void; + handleSubmit: (fromToken: SwapUnit) => void; + updateLifecycleStatus: (state: LifecycleStatusUpdate) => void; // A function to set the lifecycle status of the component + setTransactionHash: (hash: string) => void; + fromToken?: Token; + to?: SwapUnit; + from?: SwapUnit; + toToken: Token; + transactionHash: string; + isDropdownOpen: boolean; + setIsDropdownOpen: (open: boolean) => void; + projectId: string; +}; + +export type SwapLiteProviderReact = { + children: React.ReactNode; + config?: { + maxSlippage: number; // Maximum acceptable slippage for a swap. (default: 10) This is as a percent, not basis points + }; + experimental: { + useAggregator: boolean; // Whether to use a DEX aggregator. (default: true) + }; + isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) + onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. + onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + fromToken?: Token; + toToken: Token; + projectId: string; +}; + /** * Note: exported as public Type */ From 38b978def4ab1d7517e4d68f4e0b64e91dcbd208 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Thu, 12 Dec 2024 21:58:24 -0800 Subject: [PATCH 02/65] fix typo --- src/swap/components/SwapLiteOnrampItem.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/swap/components/SwapLiteOnrampItem.tsx b/src/swap/components/SwapLiteOnrampItem.tsx index 2f6e93c27e..ecb58a3cad 100644 --- a/src/swap/components/SwapLiteOnrampItem.tsx +++ b/src/swap/components/SwapLiteOnrampItem.tsx @@ -53,4 +53,3 @@ export function SwapLiteOnrampItem({ ); } -c; From 4d2accf0167bf24a7940afe92b0b33e40148cb1e Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Thu, 12 Dec 2024 22:01:00 -0800 Subject: [PATCH 03/65] lint --- src/internal/svg/appleSvg.tsx | 2 +- src/swap/components/SwapLiteDropdown.tsx | 2 +- src/swap/components/SwapLiteOnrampItem.tsx | 2 +- src/swap/components/SwapLiteProvider.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index 5a9154db4b..0d5283a01e 100644 --- a/src/internal/svg/appleSvg.tsx +++ b/src/internal/svg/appleSvg.tsx @@ -5,7 +5,7 @@ export const appleSvg = ( preserveAspectRatio="xMidYMid meet" id="Artwork" > - +
Buy with
diff --git a/src/swap/components/SwapLiteOnrampItem.tsx b/src/swap/components/SwapLiteOnrampItem.tsx index ecb58a3cad..435ac1954f 100644 --- a/src/swap/components/SwapLiteOnrampItem.tsx +++ b/src/swap/components/SwapLiteOnrampItem.tsx @@ -41,7 +41,7 @@ export function SwapLiteOnrampItem({ onClick={handleClick} type="button" > -
+
{ONRAMP_ICON_MAP[icon]}
diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index b9f9d0aa3d..a281aa7563 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -29,7 +29,7 @@ import type { } from '../types'; import { isSwapError } from '../utils/isSwapError'; import { processSwapTransaction } from '../utils/processSwapTransaction'; -import { EventMetadata, OnrampError } from '../../fund/types'; +import type { EventMetadata, OnrampError } from '../../fund/types'; import { setupOnrampEventListeners } from '../../fund'; const emptyContext = {} as SwapLiteContextType; From b3474a8d8fefa342f3c43e4624509088b0fc57e3 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Thu, 12 Dec 2024 22:11:50 -0800 Subject: [PATCH 04/65] fix lint --- src/internal/svg/appleSvg.tsx | 2 +- src/internal/svg/cardSvg.tsx | 1 + src/internal/svg/coinbaseLogoSvg.tsx | 1 + src/swap/components/SwapLiteDropdown.tsx | 2 +- src/swap/components/SwapLiteProvider.tsx | 25 ++++++++++++++---------- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index 0d5283a01e..56bf73880f 100644 --- a/src/internal/svg/appleSvg.tsx +++ b/src/internal/svg/appleSvg.tsx @@ -6,7 +6,7 @@ export const appleSvg = ( id="Artwork" > - + AppleSvg + CardSvg diff --git a/src/internal/svg/coinbaseLogoSvg.tsx b/src/internal/svg/coinbaseLogoSvg.tsx index e46d12fd2b..44d6e645bc 100644 --- a/src/internal/svg/coinbaseLogoSvg.tsx +++ b/src/internal/svg/coinbaseLogoSvg.tsx @@ -9,6 +9,7 @@ export const coinbaseLogoSvg = ( xmlns="http://www.w3.org/2000/svg" className={cn(icon.foreground)} > + CoinbaseLogoSvg { diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index a281aa7563..1a540057c4 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -31,6 +31,7 @@ import { isSwapError } from '../utils/isSwapError'; import { processSwapTransaction } from '../utils/processSwapTransaction'; import type { EventMetadata, OnrampError } from '../../fund/types'; import { setupOnrampEventListeners } from '../../fund'; +import type { GetSwapQuoteResponse } from '../../core/api/types'; const emptyContext = {} as SwapLiteContextType; @@ -101,14 +102,17 @@ export function SwapLiteProvider({ updateLifecycleStatus, }); - const handleOnrampEvent = useCallback((data: EventMetadata) => { - console.log({ data }); - if (data.eventName === 'transition_view') { - updateLifecycleStatus({ - statusName: 'transactionPending', - }); - } - }, []); + const handleOnrampEvent = useCallback( + (data: EventMetadata) => { + console.log({ data }); + if (data.eventName === 'transition_view') { + updateLifecycleStatus({ + statusName: 'transactionPending', + }); + } + }, + [updateLifecycleStatus], + ); const handleOnrampExit = useCallback((error?: OnrampError) => { console.log({ error }); @@ -281,7 +285,7 @@ export function SwapLiteProvider({ useAggregator, }); - let responseFrom; + let responseFrom: GetSwapQuoteResponse | undefined; if (from?.token) { responseFrom = await getSwapQuote({ amount, @@ -333,7 +337,7 @@ export function SwapLiteProvider({ // if error occurs, handle gracefully // (display other payment options with fromToken disabled) - let formattedFromAmount; + let formattedFromAmount = ''; if (responseFrom && !isSwapError(responseFrom)) { formattedFromAmount = formatTokenAmount( responseFrom.fromAmount, @@ -380,6 +384,7 @@ export function SwapLiteProvider({ }, [ to, + from, fromETH, fromUSDC, useAggregator, From 75a16d4d3ebc031241fc6631e65d2e09e0b1b5ef Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Thu, 12 Dec 2024 22:17:48 -0800 Subject: [PATCH 05/65] fix imports --- src/swap/components/SwapLite.tsx | 4 ++-- src/swap/components/SwapLiteAmountInput.tsx | 2 +- src/swap/components/SwapLiteOnrampItem.tsx | 6 +++--- src/swap/components/SwapLiteProvider.tsx | 14 +++++++------- src/swap/components/SwapLiteTokenItem.tsx | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/swap/components/SwapLite.tsx b/src/swap/components/SwapLite.tsx index 02ae969ad7..236e46644d 100644 --- a/src/swap/components/SwapLite.tsx +++ b/src/swap/components/SwapLite.tsx @@ -2,11 +2,11 @@ import { useEffect, useRef } from 'react'; import { cn } from '../../styles/theme'; import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../constants'; import type { SwapLiteReact } from '../types'; +import { SwapLiteAmountInput } from './SwapLiteAmountInput'; import { SwapLiteButton } from './SwapLiteButton'; import { SwapLiteDropdown } from './SwapLiteDropdown'; -import { SwapLiteAmountInput } from './SwapLiteAmountInput'; -import { SwapLiteProvider, useSwapLiteContext } from './SwapLiteProvider'; import { SwapLiteMessage } from './SwapLiteMessage'; +import { SwapLiteProvider, useSwapLiteContext } from './SwapLiteProvider'; export function SwapLiteContent({ className }: { className?: string }) { const { isDropdownOpen, setIsDropdownOpen } = useSwapLiteContext(); diff --git a/src/swap/components/SwapLiteAmountInput.tsx b/src/swap/components/SwapLiteAmountInput.tsx index 9e56c37ee1..65a75ce80b 100644 --- a/src/swap/components/SwapLiteAmountInput.tsx +++ b/src/swap/components/SwapLiteAmountInput.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { TextInput } from '../../internal/components/TextInput'; import { isValidAmount } from '../../core/utils/isValidAmount'; +import { TextInput } from '../../internal/components/TextInput'; import { cn, pressable } from '../../styles/theme'; import { TokenChip } from '../../token'; import { formatAmount } from '../utils/formatAmount'; diff --git a/src/swap/components/SwapLiteOnrampItem.tsx b/src/swap/components/SwapLiteOnrampItem.tsx index 435ac1954f..af07cfa395 100644 --- a/src/swap/components/SwapLiteOnrampItem.tsx +++ b/src/swap/components/SwapLiteOnrampItem.tsx @@ -1,9 +1,9 @@ -import { cn, color } from '../../styles/theme'; +import { useCallback } from 'react'; import { appleSvg } from '../../internal/svg/appleSvg'; -import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg'; import { cardSvg } from '../../internal/svg/cardSvg'; +import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg'; +import { cn, color } from '../../styles/theme'; import { useSwapLiteContext } from './SwapLiteProvider'; -import { useCallback } from 'react'; type OnrampItemReact = { name: string; diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index 1a540057c4..1bf867018f 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -9,19 +9,22 @@ import { base } from 'viem/chains'; import { useAccount, useConfig, useSendTransaction } from 'wagmi'; import { useSwitchChain } from 'wagmi'; import { useSendCalls } from 'wagmi/experimental'; -import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; -import { getSwapQuote } from '../../core/api/getSwapQuote'; import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; import { useValue } from '../../core-react/internal/hooks/useValue'; +import { useOnchainKit } from '../../core-react/useOnchainKit'; +import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import { getSwapQuote } from '../../core/api/getSwapQuote'; +import type { GetSwapQuoteResponse } from '../../core/api/types'; import { formatTokenAmount } from '../../core/utils/formatTokenAmount'; +import { setupOnrampEventListeners } from '../../fund'; +import type { EventMetadata, OnrampError } from '../../fund/types'; import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; -import { useOnchainKit } from '../../core-react/useOnchainKit'; import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../constants'; import { useAwaitCalls } from '../hooks/useAwaitCalls'; -import { useSwapLiteTokens } from '../hooks/useSwapLiteTokens'; import { useLifecycleStatus } from '../hooks/useLifecycleStatus'; import { useResetSwapLiteInputs } from '../hooks/useResetSwapLiteInputs'; +import { useSwapLiteTokens } from '../hooks/useSwapLiteTokens'; import type { SwapLiteContextType, SwapLiteProviderReact, @@ -29,9 +32,6 @@ import type { } from '../types'; import { isSwapError } from '../utils/isSwapError'; import { processSwapTransaction } from '../utils/processSwapTransaction'; -import type { EventMetadata, OnrampError } from '../../fund/types'; -import { setupOnrampEventListeners } from '../../fund'; -import type { GetSwapQuoteResponse } from '../../core/api/types'; const emptyContext = {} as SwapLiteContextType; diff --git a/src/swap/components/SwapLiteTokenItem.tsx b/src/swap/components/SwapLiteTokenItem.tsx index 918cbc1375..90799d002b 100644 --- a/src/swap/components/SwapLiteTokenItem.tsx +++ b/src/swap/components/SwapLiteTokenItem.tsx @@ -1,9 +1,9 @@ import { useCallback, useMemo } from 'react'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { cn, color } from '../../styles/theme'; import { TokenImage } from '../../token'; import type { SwapUnit } from '../types'; import { useSwapLiteContext } from './SwapLiteProvider'; -import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; export function SwapLiteTokenItem({ swapUnit }: { swapUnit: SwapUnit }) { const { handleSubmit, setIsDropdownOpen } = useSwapLiteContext(); From ee0f7bcd238efd09b4fb04d135105d890f78554e Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Fri, 13 Dec 2024 10:43:11 -0800 Subject: [PATCH 06/65] adjust swap quote functionality --- src/core/api/getSwapLiteQuote.ts | 68 +++++++++++++ src/swap/components/SwapLiteAmountInput.tsx | 5 +- src/swap/components/SwapLiteButton.tsx | 11 +-- src/swap/components/SwapLiteDropdown.tsx | 10 +- src/swap/components/SwapLiteProvider.tsx | 102 ++++++++------------ 5 files changed, 119 insertions(+), 77 deletions(-) create mode 100644 src/core/api/getSwapLiteQuote.ts diff --git a/src/core/api/getSwapLiteQuote.ts b/src/core/api/getSwapLiteQuote.ts new file mode 100644 index 0000000000..37f5743ef0 --- /dev/null +++ b/src/core/api/getSwapLiteQuote.ts @@ -0,0 +1,68 @@ +import type { SwapUnit } from '../../swap/types'; +import { isSwapError } from '../../swap/utils/isSwapError'; +import { Token } from '../../token'; +import { formatTokenAmount } from '../utils/formatTokenAmount'; +import { getSwapQuote } from './getSwapQuote'; +import type { + APIError, + GetSwapQuoteParams, + GetSwapQuoteResponse, +} from './types'; + +type GetSwapLiteQuoteResponse = { + response?: GetSwapQuoteResponse; + error?: APIError; + formattedFromAmount?: string; +}; + +type GetSwapLiteQuoteParams = Omit & { + fromSwapUnit: SwapUnit; + from?: Token; +}; + +export async function getSwapLiteQuote({ + amount, + amountReference, + from, + maxSlippage, + to, + useAggregator, + fromSwapUnit, +}: GetSwapLiteQuoteParams): Promise { + // only fetch quote if the from token is provided + if (!from) { + return { response: undefined, formattedFromAmount: '', error: undefined }; + } + + let response: GetSwapQuoteResponse | undefined; + // only fetch quote if the from and to tokens are different + if (to?.symbol !== from?.symbol) { + response = await getSwapQuote({ + amount, + amountReference, + from, + maxSlippage, + to, + useAggregator, + }); + } + + let formattedFromAmount = ''; + if (response && !isSwapError(response)) { + formattedFromAmount = formatTokenAmount( + response.fromAmount, + response.from.decimals, + ); + + fromSwapUnit.setAmountUSD(response?.fromAmountUSD || ''); + fromSwapUnit.setAmount(formattedFromAmount || ''); + } + + let error; + if (isSwapError(response)) { + error = response; + response = undefined; + } + + return { response, formattedFromAmount, error }; +} diff --git a/src/swap/components/SwapLiteAmountInput.tsx b/src/swap/components/SwapLiteAmountInput.tsx index 65a75ce80b..1cbe7bd084 100644 --- a/src/swap/components/SwapLiteAmountInput.tsx +++ b/src/swap/components/SwapLiteAmountInput.tsx @@ -35,7 +35,10 @@ export function SwapLiteAmountInput() { onChange={handleChange} inputValidator={isValidAmount} /> - +
); } diff --git a/src/swap/components/SwapLiteButton.tsx b/src/swap/components/SwapLiteButton.tsx index c0033b0175..2d426d8eb9 100644 --- a/src/swap/components/SwapLiteButton.tsx +++ b/src/swap/components/SwapLiteButton.tsx @@ -28,14 +28,7 @@ export function SwapLiteButton() { statusName === 'transactionPending' || statusName === 'transactionApproved'; - const isDisabled = - !fromETH.amount || - !fromUSDC.amount || - !fromETH.token || - !fromUSDC.token || - !to?.amount || - !to?.token || - isLoading; + const isDisabled = !to?.amount || !to?.token || isLoading; const handleSubmit = useCallback(() => { setIsDropdownOpen(true); @@ -65,7 +58,7 @@ export function SwapLiteButton() { {isLoading ? ( ) : ( - + {buttonContent} )} diff --git a/src/swap/components/SwapLiteDropdown.tsx b/src/swap/components/SwapLiteDropdown.tsx index 20ec2ded3a..f6deef87b2 100644 --- a/src/swap/components/SwapLiteDropdown.tsx +++ b/src/swap/components/SwapLiteDropdown.tsx @@ -40,6 +40,10 @@ export function SwapLiteDropdown() { return `$${roundedAmount.toFixed(2)}`; }, [to?.amountUSD]); + const isToETH = to?.token?.symbol === 'ETH'; + const isToUSDC = to?.token?.symbol === 'USDC'; + const isToFrom = to?.token?.symbol === from?.token?.symbol; + return (
Buy with
- - - {from && } + {!isToETH && } + {!isToUSDC && } + {from && !isToFrom && } {ONRAMP_PAYMENT_METHODS.map((method) => { return ( diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index 1bf867018f..a467166539 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -13,9 +13,6 @@ import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabili import { useValue } from '../../core-react/internal/hooks/useValue'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; -import { getSwapQuote } from '../../core/api/getSwapQuote'; -import type { GetSwapQuoteResponse } from '../../core/api/types'; -import { formatTokenAmount } from '../../core/utils/formatTokenAmount'; import { setupOnrampEventListeners } from '../../fund'; import type { EventMetadata, OnrampError } from '../../fund/types'; import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; @@ -32,6 +29,7 @@ import type { } from '../types'; import { isSwapError } from '../utils/isSwapError'; import { processSwapTransaction } from '../utils/processSwapTransaction'; +import { getSwapLiteQuote } from '../../core/api/getSwapLiteQuote'; const emptyContext = {} as SwapLiteContextType; @@ -268,94 +266,70 @@ export function SwapLiteProvider({ try { const maxSlippage = lifecycleStatus.statusData.maxSlippage; - const responseETH = await getSwapQuote({ + + const { + response: responseETH, + formattedFromAmount: formattedAmountETH, + } = await getSwapLiteQuote({ amount, amountReference: 'to', from: fromETH.token, maxSlippage: String(maxSlippage), to: to.token, useAggregator, + fromSwapUnit: fromETH, }); - const responseUSDC = await getSwapQuote({ + + const { + response: responseUSDC, + formattedFromAmount: formattedAmountUSDC, + } = await getSwapLiteQuote({ amount, amountReference: 'to', from: fromUSDC.token, maxSlippage: String(maxSlippage), to: to.token, useAggregator, + fromSwapUnit: fromUSDC, }); - let responseFrom: GetSwapQuoteResponse | undefined; - if (from?.token) { - responseFrom = await getSwapQuote({ - amount, - amountReference: 'to', - from: from?.token, - maxSlippage: String(maxSlippage), - to: to.token, - useAggregator, - }); - } + const { + response: responseFrom, + formattedFromAmount: formattedAmountFrom, + } = await getSwapLiteQuote({ + amount, + amountReference: 'to', + from: from?.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + fromSwapUnit: from, + }); - // If request resolves to error response set the quoteError - // property of error state to the SwapError response - if (isSwapError(responseETH)) { - updateLifecycleStatus({ - statusName: 'error', - statusData: { - code: responseETH.code, - error: responseETH.error, - message: '', - }, - }); - return; - } - if (isSwapError(responseUSDC)) { + if (!isSwapError(responseETH) && responseETH?.toAmountUSD) { + to.setAmountUSD(responseETH?.toAmountUSD); + } else if (!isSwapError(responseUSDC) && responseUSDC?.toAmountUSD) { + to.setAmountUSD(responseUSDC.toAmountUSD); + } else if (!isSwapError(responseFrom) && responseFrom?.toAmountUSD) { + to.setAmountUSD(responseFrom.toAmountUSD); + } else { updateLifecycleStatus({ statusName: 'error', statusData: { - code: responseUSDC.code, - error: responseUSDC.error, + code: 'TmSPc01', // Transaction module SwapProvider component 01 error + error: 'No valid quote found', message: '', }, }); return; } - const formattedAmount = formatTokenAmount( - responseETH.fromAmount, - responseETH.from.decimals, - ); - const formattedUSDCAmount = formatTokenAmount( - responseUSDC.fromAmount, - responseUSDC.from.decimals, - ); - - fromETH.setAmountUSD(responseETH.fromAmountUSD); - fromETH.setAmount(formattedAmount); - fromUSDC.setAmountUSD(responseUSDC.fromAmountUSD); - fromUSDC.setAmount(formattedUSDCAmount); - - // if error occurs, handle gracefully - // (display other payment options with fromToken disabled) - let formattedFromAmount = ''; - if (responseFrom && !isSwapError(responseFrom)) { - formattedFromAmount = formatTokenAmount( - responseFrom.fromAmount, - responseFrom.from.decimals, - ); - - from.setAmountUSD(responseFrom?.fromAmountUSD || ''); - from.setAmount(formattedFromAmount || ''); - } - // TODO: revisit this - to.setAmountUSD(responseETH.toAmountUSD); updateLifecycleStatus({ statusName: 'amountChange', statusData: { - amountETH: formattedAmount, - amountUSDC: formattedUSDCAmount, - amountFrom: formattedFromAmount || '', + amountETH: formattedAmountETH, + amountUSDC: formattedAmountUSDC, + amountFrom: formattedAmountFrom || '', amountTo: amount, tokenFromETH: fromETH.token, tokenFromUSDC: fromUSDC.token, @@ -363,7 +337,7 @@ export function SwapLiteProvider({ tokenTo: to.token, // if quote was fetched successfully, we // have all required fields - isMissingRequiredField: !formattedAmount, + isMissingRequiredField: !formattedAmountETH, }, }); } catch (err) { From de3805dc767cc4436f39d497612f1d5171c28e3a Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Fri, 13 Dec 2024 11:08:23 -0800 Subject: [PATCH 07/65] fix swap test --- src/swap/components/SwapProvider.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/swap/components/SwapProvider.test.tsx b/src/swap/components/SwapProvider.test.tsx index c2528b1b70..7ca9b34346 100644 --- a/src/swap/components/SwapProvider.test.tsx +++ b/src/swap/components/SwapProvider.test.tsx @@ -145,7 +145,7 @@ const TestSwapComponent = () => { const context = useSwapContext(); useEffect(() => { context.from.setToken?.(ETH_TOKEN); - context.from.setAmount('100'); + context.from.setAmount?.('100'); context.to.setToken?.(DEGEN_TOKEN); }, [context]); const handleStatusError = async () => { @@ -653,9 +653,9 @@ describe('SwapProvider', () => { const { result } = renderHook(() => useSwapContext(), { wrapper }); await act(async () => { result.current.from.setToken?.(ETH_TOKEN); - result.current.from.setAmount('10'); + result.current.from.setAmount?.('10'); result.current.to.setToken?.(DEGEN_TOKEN); - result.current.to.setAmount('1000'); + result.current.to.setAmount?.('1000'); }); await act(async () => { result.current.handleToggle(); From 9e9842525d3c51d75755641e020a9c470177d631 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Fri, 13 Dec 2024 11:10:34 -0800 Subject: [PATCH 08/65] adjust svgs --- src/internal/svg/appleSvg.tsx | 3 +-- src/internal/svg/cardSvg.tsx | 2 +- src/internal/svg/coinbaseLogoSvg.tsx | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index 56bf73880f..46986e59d7 100644 --- a/src/internal/svg/appleSvg.tsx +++ b/src/internal/svg/appleSvg.tsx @@ -5,7 +5,6 @@ export const appleSvg = ( preserveAspectRatio="xMidYMid meet" id="Artwork" > - AppleSvg diff --git a/src/internal/svg/cardSvg.tsx b/src/internal/svg/cardSvg.tsx index 8bc57253b5..cde31cc0d6 100644 --- a/src/internal/svg/cardSvg.tsx +++ b/src/internal/svg/cardSvg.tsx @@ -5,7 +5,7 @@ export const cardSvg = ( height="24" viewBox="0 0 24 24" fill="none" - stroke="#000000" + stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" diff --git a/src/internal/svg/coinbaseLogoSvg.tsx b/src/internal/svg/coinbaseLogoSvg.tsx index 44d6e645bc..437c7282a8 100644 --- a/src/internal/svg/coinbaseLogoSvg.tsx +++ b/src/internal/svg/coinbaseLogoSvg.tsx @@ -2,8 +2,8 @@ import { cn, icon } from '../../styles/theme'; export const coinbaseLogoSvg = ( Date: Fri, 13 Dec 2024 11:11:56 -0800 Subject: [PATCH 09/65] adjust imports --- src/core/api/getSwapLiteQuote.ts | 6 +++--- src/swap/components/SwapLiteDropdown.tsx | 8 ++++---- src/swap/components/SwapLiteProvider.tsx | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/api/getSwapLiteQuote.ts b/src/core/api/getSwapLiteQuote.ts index 37f5743ef0..6109bd8a59 100644 --- a/src/core/api/getSwapLiteQuote.ts +++ b/src/core/api/getSwapLiteQuote.ts @@ -1,6 +1,6 @@ -import type { SwapUnit } from '../../swap/types'; +import type { SwapError, SwapUnit } from '../../swap/types'; import { isSwapError } from '../../swap/utils/isSwapError'; -import { Token } from '../../token'; +import type { Token } from '../../token'; import { formatTokenAmount } from '../utils/formatTokenAmount'; import { getSwapQuote } from './getSwapQuote'; import type { @@ -58,7 +58,7 @@ export async function getSwapLiteQuote({ fromSwapUnit.setAmount(formattedFromAmount || ''); } - let error; + let error: SwapError | undefined; if (isSwapError(response)) { error = response; response = undefined; diff --git a/src/swap/components/SwapLiteDropdown.tsx b/src/swap/components/SwapLiteDropdown.tsx index f6deef87b2..aa7919e248 100644 --- a/src/swap/components/SwapLiteDropdown.tsx +++ b/src/swap/components/SwapLiteDropdown.tsx @@ -1,13 +1,13 @@ import { useCallback, useMemo } from 'react'; -import { background, cn, color, text } from '../../styles/theme'; -import { useSwapLiteContext } from './SwapLiteProvider'; -import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; -import { ONRAMP_PAYMENT_METHODS } from '../constants'; import { useAccount } from 'wagmi'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { ONRAMP_BUY_URL } from '../../fund/constants'; import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; import { openPopup } from '../../internal/utils/openPopup'; +import { background, cn, color, text } from '../../styles/theme'; +import { ONRAMP_PAYMENT_METHODS } from '../constants'; import { SwapLiteOnrampItem } from './SwapLiteOnrampItem'; +import { useSwapLiteContext } from './SwapLiteProvider'; import { SwapLiteTokenItem } from './SwapLiteTokenItem'; export function SwapLiteDropdown() { diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index a467166539..f5204eea27 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -13,6 +13,7 @@ import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabili import { useValue } from '../../core-react/internal/hooks/useValue'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import { getSwapLiteQuote } from '../../core/api/getSwapLiteQuote'; import { setupOnrampEventListeners } from '../../fund'; import type { EventMetadata, OnrampError } from '../../fund/types'; import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; @@ -29,7 +30,6 @@ import type { } from '../types'; import { isSwapError } from '../utils/isSwapError'; import { processSwapTransaction } from '../utils/processSwapTransaction'; -import { getSwapLiteQuote } from '../../core/api/getSwapLiteQuote'; const emptyContext = {} as SwapLiteContextType; From 1672671618e2ac58d094de2d4c8055c83e96dae4 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Fri, 13 Dec 2024 14:51:38 -0800 Subject: [PATCH 10/65] adjust hook behavior --- src/swap/components/SwapLiteDropdown.tsx | 20 +++++++++++++++++--- src/ui/react/internal/utils/openPopup.ts | 2 +- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/swap/components/SwapLiteDropdown.tsx b/src/swap/components/SwapLiteDropdown.tsx index aa7919e248..40276dd23b 100644 --- a/src/swap/components/SwapLiteDropdown.tsx +++ b/src/swap/components/SwapLiteDropdown.tsx @@ -21,12 +21,22 @@ export function SwapLiteDropdown() { const fundAmount = to?.amount; const fundingUrl = `${ONRAMP_BUY_URL}/one-click?appId=${projectId}&addresses={"${address}":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${paymentMethodId}`; const { height, width } = getFundingPopupSize('md', fundingUrl); - openPopup({ + const popupWindow = openPopup({ url: fundingUrl, height, width, target: '_blank', }); + + // if (!popupWindow) { + // return null; + // } + // const interval = setInterval(() => { + // if (popupWindow.closed) { + // clearInterval(interval); + // console.log('Popup closed'); + // } + // }, 500); }; }, [address, to, projectId], @@ -42,7 +52,11 @@ export function SwapLiteDropdown() { const isToETH = to?.token?.symbol === 'ETH'; const isToUSDC = to?.token?.symbol === 'USDC'; - const isToFrom = to?.token?.symbol === from?.token?.symbol; + const showFromToken = + to?.token?.symbol !== from?.token?.symbol && + from && + from?.token?.symbol !== 'ETH' && + from?.token?.symbol !== 'USDC'; return (
Buy with
{!isToETH && } {!isToUSDC && } - {from && !isToFrom && } + {showFromToken && } {ONRAMP_PAYMENT_METHODS.map((method) => { return ( diff --git a/src/ui/react/internal/utils/openPopup.ts b/src/ui/react/internal/utils/openPopup.ts index 4b5ff6a7ca..e90acd4d2d 100644 --- a/src/ui/react/internal/utils/openPopup.ts +++ b/src/ui/react/internal/utils/openPopup.ts @@ -14,5 +14,5 @@ export function openPopup({ url, target, height, width }: OpenPopupProps) { const top = Math.round((window.screen.height - height) / 2); const windowFeatures = `width=${width},height=${height},resizable,scrollbars=yes,status=1,left=${left},top=${top}`; - window.open(url, target, windowFeatures); + return window.open(url, target, windowFeatures); } From 7f8cdc4f4d7de1316f83de1991e3953904c66c80 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Sun, 15 Dec 2024 22:46:32 -0800 Subject: [PATCH 11/65] add popup monitor --- src/swap/components/SwapLiteDropdown.tsx | 17 +++++------ src/swap/components/SwapLiteProvider.tsx | 31 ++++++++++++++++---- src/swap/hooks/usePopupMonitor.ts | 37 ++++++++++++++++++++++++ src/swap/types.ts | 3 +- 4 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 src/swap/hooks/usePopupMonitor.ts diff --git a/src/swap/components/SwapLiteDropdown.tsx b/src/swap/components/SwapLiteDropdown.tsx index 40276dd23b..bc8fa772ed 100644 --- a/src/swap/components/SwapLiteDropdown.tsx +++ b/src/swap/components/SwapLiteDropdown.tsx @@ -11,7 +11,8 @@ import { useSwapLiteContext } from './SwapLiteProvider'; import { SwapLiteTokenItem } from './SwapLiteTokenItem'; export function SwapLiteDropdown() { - const { to, fromETH, fromUSDC, from, projectId } = useSwapLiteContext(); + const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } = + useSwapLiteContext(); const { address } = useAccount(); const handleOnrampClick = useCallback( @@ -28,15 +29,11 @@ export function SwapLiteDropdown() { target: '_blank', }); - // if (!popupWindow) { - // return null; - // } - // const interval = setInterval(() => { - // if (popupWindow.closed) { - // clearInterval(interval); - // console.log('Popup closed'); - // } - // }, 500); + if (popupWindow) { + // Detects when the popup is closed + // to stop loading state + startPopupMonitor(popupWindow); + } }; }, [address, to, projectId], diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index f5204eea27..4742259f5e 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -30,6 +30,7 @@ import type { } from '../types'; import { isSwapError } from '../utils/isSwapError'; import { processSwapTransaction } from '../utils/processSwapTransaction'; +import { usePopupMonitor } from '../hooks/usePopupMonitor'; const emptyContext = {} as SwapLiteContextType; @@ -102,7 +103,7 @@ export function SwapLiteProvider({ const handleOnrampEvent = useCallback( (data: EventMetadata) => { - console.log({ data }); + console.log('EVENT HANDLER', { data }); if (data.eventName === 'transition_view') { updateLifecycleStatus({ statusName: 'transactionPending', @@ -113,13 +114,27 @@ export function SwapLiteProvider({ ); const handleOnrampExit = useCallback((error?: OnrampError) => { - console.log({ error }); + console.log('EXIT HANDLER', { error }); }, []); const handleOnrampSuccess = useCallback(() => { - console.log('ONRAMP SUCCESS'); + console.log('SUCCESS HANDLER'); + updateLifecycleStatus({ + statusName: 'success', + statusData: {}, + }); }, []); + const onPopupClose = useCallback(() => { + updateLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: false, + maxSlippage: config.maxSlippage, + }, + }); + }, [updateLifecycleStatus]); + useEffect(() => { const unsubscribe = setupOnrampEventListeners({ onEvent: handleOnrampEvent, @@ -131,6 +146,8 @@ export function SwapLiteProvider({ }; }, [handleOnrampEvent, handleOnrampExit, handleOnrampSuccess]); + const { startPopupMonitor } = usePopupMonitor(onPopupClose); + // Component lifecycle emitters useEffect(() => { // Error @@ -138,8 +155,11 @@ export function SwapLiteProvider({ onError?.(lifecycleStatus.statusData); } // Success - if (lifecycleStatus.statusName === 'success') { - onSuccess?.(lifecycleStatus.statusData.transactionReceipt); + if ( + lifecycleStatus.statusName === 'success' && + lifecycleStatus?.statusData.transactionReceipt + ) { + onSuccess?.(lifecycleStatus?.statusData.transactionReceipt); setTransactionHash( lifecycleStatus.statusData.transactionReceipt?.transactionHash, ); @@ -457,6 +477,7 @@ export function SwapLiteProvider({ toToken, fromToken, projectId, + startPopupMonitor, }); return ( diff --git a/src/swap/hooks/usePopupMonitor.ts b/src/swap/hooks/usePopupMonitor.ts new file mode 100644 index 0000000000..c9414541be --- /dev/null +++ b/src/swap/hooks/usePopupMonitor.ts @@ -0,0 +1,37 @@ +import { useRef, useEffect, useCallback } from 'react'; + +export const usePopupMonitor = (onClose?: () => void) => { + const intervalRef = useRef(null); + + // Start monitoring the popup + const startPopupMonitor = useCallback((popupWindow: Window) => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + intervalRef.current = window.setInterval(() => { + if (popupWindow.closed) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + console.log('Popup closed'); + onClose?.(); + } + }, 500); + }, []); + + // Stop monitoring the popup + const stopPopupMonitor = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // Cleanup interval on unmount + useEffect(() => { + return () => stopPopupMonitor(); + }, [stopPopupMonitor]); + + return { startPopupMonitor }; +}; diff --git a/src/swap/types.ts b/src/swap/types.ts index 5a89a3cecb..93c6da66bd 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -122,7 +122,7 @@ export type LifecycleStatus = | { statusName: 'success'; statusData: { - transactionReceipt: TransactionReceipt; + transactionReceipt?: TransactionReceipt; } & LifecycleStatusDataShared; }; @@ -213,6 +213,7 @@ export type SwapLiteContextType = { isDropdownOpen: boolean; setIsDropdownOpen: (open: boolean) => void; projectId: string; + startPopupMonitor: (popupWindow: Window) => void; }; export type SwapLiteProviderReact = { From 9570480adb03555815544c071c460637b81d765d Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 08:54:41 -0800 Subject: [PATCH 12/65] fix optional params - revisit --- src/core/api/getSwapLiteQuote.ts | 4 ++-- src/swap/components/SwapLiteProvider.tsx | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/core/api/getSwapLiteQuote.ts b/src/core/api/getSwapLiteQuote.ts index 6109bd8a59..f6fd9c598b 100644 --- a/src/core/api/getSwapLiteQuote.ts +++ b/src/core/api/getSwapLiteQuote.ts @@ -16,7 +16,7 @@ type GetSwapLiteQuoteResponse = { }; type GetSwapLiteQuoteParams = Omit & { - fromSwapUnit: SwapUnit; + fromSwapUnit?: SwapUnit; from?: Token; }; @@ -48,7 +48,7 @@ export async function getSwapLiteQuote({ } let formattedFromAmount = ''; - if (response && !isSwapError(response)) { + if (response && !isSwapError(response) && fromSwapUnit) { formattedFromAmount = formatTokenAmount( response.fromAmount, response.from.decimals, diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index 4742259f5e..359cc03bf1 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -257,13 +257,13 @@ export function SwapLiteProvider({ to.setAmountUSD(''); fromETH.setAmountUSD(''); fromUSDC.setAmountUSD(''); - from.setAmountUSD(''); + from?.setAmountUSD(''); return; } fromETH.setLoading(true); fromUSDC.setLoading(true); - from.setLoading(true); + from?.setLoading(true); updateLifecycleStatus({ statusName: 'amountChange', @@ -276,7 +276,7 @@ export function SwapLiteProvider({ amountFrom: '', tokenFromETH: fromETH.token, tokenFromUSDC: fromUSDC.token, - tokenFrom: from.token, + tokenFrom: from?.token, tokenTo: to.token, // when fetching quote, the destination // amount is missing @@ -353,7 +353,7 @@ export function SwapLiteProvider({ amountTo: amount, tokenFromETH: fromETH.token, tokenFromUSDC: fromUSDC.token, - tokenFrom: from.token, + tokenFrom: from?.token, tokenTo: to.token, // if quote was fetched successfully, we // have all required fields @@ -373,7 +373,7 @@ export function SwapLiteProvider({ // reset loading state when quote request resolves fromETH.setLoading(false); fromUSDC.setLoading(false); - from.setLoading(false); + from?.setLoading(false); } }, [ From 2303671dbb3cc7174ebfc03700c11a950bc734a4 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 09:02:05 -0800 Subject: [PATCH 13/65] remove unused file --- src/core/api/getSwapLiteQuote.ts | 68 ------------------------ src/swap/components/SwapLiteProvider.tsx | 2 +- 2 files changed, 1 insertion(+), 69 deletions(-) delete mode 100644 src/core/api/getSwapLiteQuote.ts diff --git a/src/core/api/getSwapLiteQuote.ts b/src/core/api/getSwapLiteQuote.ts deleted file mode 100644 index f6fd9c598b..0000000000 --- a/src/core/api/getSwapLiteQuote.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { SwapError, SwapUnit } from '../../swap/types'; -import { isSwapError } from '../../swap/utils/isSwapError'; -import type { Token } from '../../token'; -import { formatTokenAmount } from '../utils/formatTokenAmount'; -import { getSwapQuote } from './getSwapQuote'; -import type { - APIError, - GetSwapQuoteParams, - GetSwapQuoteResponse, -} from './types'; - -type GetSwapLiteQuoteResponse = { - response?: GetSwapQuoteResponse; - error?: APIError; - formattedFromAmount?: string; -}; - -type GetSwapLiteQuoteParams = Omit & { - fromSwapUnit?: SwapUnit; - from?: Token; -}; - -export async function getSwapLiteQuote({ - amount, - amountReference, - from, - maxSlippage, - to, - useAggregator, - fromSwapUnit, -}: GetSwapLiteQuoteParams): Promise { - // only fetch quote if the from token is provided - if (!from) { - return { response: undefined, formattedFromAmount: '', error: undefined }; - } - - let response: GetSwapQuoteResponse | undefined; - // only fetch quote if the from and to tokens are different - if (to?.symbol !== from?.symbol) { - response = await getSwapQuote({ - amount, - amountReference, - from, - maxSlippage, - to, - useAggregator, - }); - } - - let formattedFromAmount = ''; - if (response && !isSwapError(response) && fromSwapUnit) { - formattedFromAmount = formatTokenAmount( - response.fromAmount, - response.from.decimals, - ); - - fromSwapUnit.setAmountUSD(response?.fromAmountUSD || ''); - fromSwapUnit.setAmount(formattedFromAmount || ''); - } - - let error: SwapError | undefined; - if (isSwapError(response)) { - error = response; - response = undefined; - } - - return { response, formattedFromAmount, error }; -} diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx index 359cc03bf1..df5f0580bf 100644 --- a/src/swap/components/SwapLiteProvider.tsx +++ b/src/swap/components/SwapLiteProvider.tsx @@ -13,7 +13,7 @@ import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabili import { useValue } from '../../core-react/internal/hooks/useValue'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; -import { getSwapLiteQuote } from '../../core/api/getSwapLiteQuote'; +import { getSwapLiteQuote } from '../utils/getSwapLiteQuote'; import { setupOnrampEventListeners } from '../../fund'; import type { EventMetadata, OnrampError } from '../../fund/types'; import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; From 1f5d208633f8584a6049decfe55a92696b64d725 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 09:38:12 -0800 Subject: [PATCH 14/65] add leading 0 --- src/swap/components/SwapLiteDropdown.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/swap/components/SwapLiteDropdown.tsx b/src/swap/components/SwapLiteDropdown.tsx index bc8fa772ed..38844d2ca6 100644 --- a/src/swap/components/SwapLiteDropdown.tsx +++ b/src/swap/components/SwapLiteDropdown.tsx @@ -19,7 +19,11 @@ export function SwapLiteDropdown() { (paymentMethodId: string) => { return () => { const assetSymbol = to?.token?.symbol; - const fundAmount = to?.amount; + let fundAmount = to?.amount; + // funding url requires a leading zero if the amount is less than 1 + if (fundAmount?.[0] === '.') { + fundAmount = `0${fundAmount}`; + } const fundingUrl = `${ONRAMP_BUY_URL}/one-click?appId=${projectId}&addresses={"${address}":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${paymentMethodId}`; const { height, width } = getFundingPopupSize('md', fundingUrl); const popupWindow = openPopup({ From ddf3654435694dcefa6caee2318d2e8e5d7fab0b Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 10:27:52 -0800 Subject: [PATCH 15/65] fix swap error --- src/swap/components/SwapProvider.tsx | 4 ++-- src/swap/types.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/swap/components/SwapProvider.tsx b/src/swap/components/SwapProvider.tsx index 7f8fc1824c..73fe73dcec 100644 --- a/src/swap/components/SwapProvider.tsx +++ b/src/swap/components/SwapProvider.tsx @@ -95,9 +95,9 @@ export function SwapProvider({ } // Success if (lifecycleStatus.statusName === 'success') { - onSuccess?.(lifecycleStatus.statusData.transactionReceipt); + onSuccess?.(lifecycleStatus.statusData?.transactionReceipt); setTransactionHash( - lifecycleStatus.statusData.transactionReceipt?.transactionHash, + lifecycleStatus.statusData?.transactionReceipt?.transactionHash ?? '', ); setHasHandledSuccess(true); setIsToastVisible(true); diff --git a/src/swap/types.ts b/src/swap/types.ts index 93c6da66bd..32051b2d35 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -261,7 +261,7 @@ export type SwapButtonReact = { disabled?: boolean; // Disables swap button }; -type SwapConfig = { +export type SwapConfig = { maxSlippage: number; // Maximum acceptable slippage for a swap. (default: 10) This is as a percent, not basis points; }; @@ -341,7 +341,7 @@ export type SwapProviderReact = { isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt }; /** @@ -357,7 +357,7 @@ export type SwapReact = { isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt title?: string; // Title for the Swap component. (default: "Swap") }; From 6b343264188589803c0c8c63d5fc7b2e276bdc45 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 10:57:31 -0800 Subject: [PATCH 16/65] remove SwapLite components --- src/swap/components/SwapLite.tsx | 75 --- src/swap/components/SwapLiteAmountInput.tsx | 44 -- src/swap/components/SwapLiteButton.tsx | 67 --- src/swap/components/SwapLiteDropdown.tsx | 95 ---- src/swap/components/SwapLiteMessage.tsx | 18 - src/swap/components/SwapLiteOnrampItem.tsx | 55 --- src/swap/components/SwapLiteProvider.tsx | 488 -------------------- src/swap/components/SwapLiteTokenItem.tsx | 58 --- 8 files changed, 900 deletions(-) delete mode 100644 src/swap/components/SwapLite.tsx delete mode 100644 src/swap/components/SwapLiteAmountInput.tsx delete mode 100644 src/swap/components/SwapLiteButton.tsx delete mode 100644 src/swap/components/SwapLiteDropdown.tsx delete mode 100644 src/swap/components/SwapLiteMessage.tsx delete mode 100644 src/swap/components/SwapLiteOnrampItem.tsx delete mode 100644 src/swap/components/SwapLiteProvider.tsx delete mode 100644 src/swap/components/SwapLiteTokenItem.tsx diff --git a/src/swap/components/SwapLite.tsx b/src/swap/components/SwapLite.tsx deleted file mode 100644 index 236e46644d..0000000000 --- a/src/swap/components/SwapLite.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { cn } from '../../styles/theme'; -import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../constants'; -import type { SwapLiteReact } from '../types'; -import { SwapLiteAmountInput } from './SwapLiteAmountInput'; -import { SwapLiteButton } from './SwapLiteButton'; -import { SwapLiteDropdown } from './SwapLiteDropdown'; -import { SwapLiteMessage } from './SwapLiteMessage'; -import { SwapLiteProvider, useSwapLiteContext } from './SwapLiteProvider'; - -export function SwapLiteContent({ className }: { className?: string }) { - const { isDropdownOpen, setIsDropdownOpen } = useSwapLiteContext(); - const fundSwapContainerRef = useRef(null); - - // Handle clicking outside the wallet component to close the dropdown. - useEffect(() => { - const handleClickOutsideComponent = (event: MouseEvent) => { - if ( - fundSwapContainerRef.current && - !fundSwapContainerRef.current.contains(event.target as Node) && - isDropdownOpen - ) { - setIsDropdownOpen(false); - } - }; - - document.addEventListener('click', handleClickOutsideComponent); - return () => - document.removeEventListener('click', handleClickOutsideComponent); - }, [isDropdownOpen, setIsDropdownOpen]); - - return ( -
-
- - - {isDropdownOpen && } -
- -
- ); -} -export function SwapLite({ - config = { - maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, - }, - className, - experimental = { useAggregator: false }, - isSponsored = false, - onError, - onStatus, - onSuccess, - toToken, - fromToken, - projectId, -}: SwapLiteReact) { - return ( - - - - ); -} diff --git a/src/swap/components/SwapLiteAmountInput.tsx b/src/swap/components/SwapLiteAmountInput.tsx deleted file mode 100644 index 1cbe7bd084..0000000000 --- a/src/swap/components/SwapLiteAmountInput.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useCallback } from 'react'; -import { isValidAmount } from '../../core/utils/isValidAmount'; -import { TextInput } from '../../internal/components/TextInput'; -import { cn, pressable } from '../../styles/theme'; -import { TokenChip } from '../../token'; -import { formatAmount } from '../utils/formatAmount'; -import { useSwapLiteContext } from './SwapLiteProvider'; - -export function SwapLiteAmountInput() { - const { to, handleAmountChange } = useSwapLiteContext(); - - const handleChange = useCallback( - (amount: string) => { - handleAmountChange(amount); - }, - [handleAmountChange], - ); - - if (!to?.token) { - return null; - } - - return ( -
- - -
- ); -} diff --git a/src/swap/components/SwapLiteButton.tsx b/src/swap/components/SwapLiteButton.tsx deleted file mode 100644 index 2d426d8eb9..0000000000 --- a/src/swap/components/SwapLiteButton.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { Spinner } from '../../internal/components/Spinner'; -import { checkmarkSvg } from '../../internal/svg/checkmarkSvg'; -import { - background, - border, - cn, - color, - pressable, - text, -} from '../../styles/theme'; -import { useSwapLiteContext } from './SwapLiteProvider'; - -export function SwapLiteButton() { - const { - setIsDropdownOpen, - from, - fromETH, - fromUSDC, - to, - lifecycleStatus: { statusName }, - } = useSwapLiteContext(); - const isLoading = - to?.loading || - from?.loading || - fromETH.loading || - fromUSDC.loading || - statusName === 'transactionPending' || - statusName === 'transactionApproved'; - - const isDisabled = !to?.amount || !to?.token || isLoading; - - const handleSubmit = useCallback(() => { - setIsDropdownOpen(true); - }, [setIsDropdownOpen]); - - const buttonContent = useMemo(() => { - if (statusName === 'success') { - return checkmarkSvg; - } - return 'Buy'; - }, [statusName]); - - return ( - - ); -} diff --git a/src/swap/components/SwapLiteDropdown.tsx b/src/swap/components/SwapLiteDropdown.tsx deleted file mode 100644 index 38844d2ca6..0000000000 --- a/src/swap/components/SwapLiteDropdown.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useAccount } from 'wagmi'; -import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; -import { ONRAMP_BUY_URL } from '../../fund/constants'; -import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; -import { openPopup } from '../../internal/utils/openPopup'; -import { background, cn, color, text } from '../../styles/theme'; -import { ONRAMP_PAYMENT_METHODS } from '../constants'; -import { SwapLiteOnrampItem } from './SwapLiteOnrampItem'; -import { useSwapLiteContext } from './SwapLiteProvider'; -import { SwapLiteTokenItem } from './SwapLiteTokenItem'; - -export function SwapLiteDropdown() { - const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } = - useSwapLiteContext(); - const { address } = useAccount(); - - const handleOnrampClick = useCallback( - (paymentMethodId: string) => { - return () => { - const assetSymbol = to?.token?.symbol; - let fundAmount = to?.amount; - // funding url requires a leading zero if the amount is less than 1 - if (fundAmount?.[0] === '.') { - fundAmount = `0${fundAmount}`; - } - const fundingUrl = `${ONRAMP_BUY_URL}/one-click?appId=${projectId}&addresses={"${address}":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${paymentMethodId}`; - const { height, width } = getFundingPopupSize('md', fundingUrl); - const popupWindow = openPopup({ - url: fundingUrl, - height, - width, - target: '_blank', - }); - - if (popupWindow) { - // Detects when the popup is closed - // to stop loading state - startPopupMonitor(popupWindow); - } - }; - }, - [address, to, projectId], - ); - - const formattedAmountUSD = useMemo(() => { - if (!to?.amountUSD || to?.amountUSD === '0') { - return null; - } - const roundedAmount = Number(getRoundedAmount(to?.amountUSD, 2)); - return `$${roundedAmount.toFixed(2)}`; - }, [to?.amountUSD]); - - const isToETH = to?.token?.symbol === 'ETH'; - const isToUSDC = to?.token?.symbol === 'USDC'; - const showFromToken = - to?.token?.symbol !== from?.token?.symbol && - from && - from?.token?.symbol !== 'ETH' && - from?.token?.symbol !== 'USDC'; - - return ( -
-
Buy with
- {!isToETH && } - {!isToUSDC && } - {showFromToken && } - - {ONRAMP_PAYMENT_METHODS.map((method) => { - return ( - - ); - })} - - {!!formattedAmountUSD && ( -
{`${to?.amount} ${to?.token?.name} ≈ ${formattedAmountUSD}`}
- )} -
- ); -} diff --git a/src/swap/components/SwapLiteMessage.tsx b/src/swap/components/SwapLiteMessage.tsx deleted file mode 100644 index f7127972aa..0000000000 --- a/src/swap/components/SwapLiteMessage.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { cn, color } from '../../styles/theme'; -import { useSwapLiteContext } from './SwapLiteProvider'; - -export function SwapLiteMessage() { - const { - lifecycleStatus: { statusName }, - } = useSwapLiteContext(); - - if (statusName !== 'error') { - return null; - } - - return ( -
- Something went wrong. Please try again. -
- ); -} diff --git a/src/swap/components/SwapLiteOnrampItem.tsx b/src/swap/components/SwapLiteOnrampItem.tsx deleted file mode 100644 index af07cfa395..0000000000 --- a/src/swap/components/SwapLiteOnrampItem.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useCallback } from 'react'; -import { appleSvg } from '../../internal/svg/appleSvg'; -import { cardSvg } from '../../internal/svg/cardSvg'; -import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg'; -import { cn, color } from '../../styles/theme'; -import { useSwapLiteContext } from './SwapLiteProvider'; - -type OnrampItemReact = { - name: string; - description: string; - onClick: () => void; - svg?: React.ReactNode; - icon: string; -}; - -const ONRAMP_ICON_MAP: Record = { - applePay: appleSvg, - coinbasePay: coinbaseLogoSvg, - creditCard: cardSvg, -}; - -export function SwapLiteOnrampItem({ - name, - description, - onClick, - icon, -}: OnrampItemReact) { - const { setIsDropdownOpen } = useSwapLiteContext(); - - const handleClick = useCallback(() => { - setIsDropdownOpen(false); - onClick(); - }, [onClick, setIsDropdownOpen]); - - return ( - - ); -} diff --git a/src/swap/components/SwapLiteProvider.tsx b/src/swap/components/SwapLiteProvider.tsx deleted file mode 100644 index df5f0580bf..0000000000 --- a/src/swap/components/SwapLiteProvider.tsx +++ /dev/null @@ -1,488 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import { base } from 'viem/chains'; -import { useAccount, useConfig, useSendTransaction } from 'wagmi'; -import { useSwitchChain } from 'wagmi'; -import { useSendCalls } from 'wagmi/experimental'; -import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; -import { useValue } from '../../core-react/internal/hooks/useValue'; -import { useOnchainKit } from '../../core-react/useOnchainKit'; -import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; -import { getSwapLiteQuote } from '../utils/getSwapLiteQuote'; -import { setupOnrampEventListeners } from '../../fund'; -import type { EventMetadata, OnrampError } from '../../fund/types'; -import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; -import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; -import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../constants'; -import { useAwaitCalls } from '../hooks/useAwaitCalls'; -import { useLifecycleStatus } from '../hooks/useLifecycleStatus'; -import { useResetSwapLiteInputs } from '../hooks/useResetSwapLiteInputs'; -import { useSwapLiteTokens } from '../hooks/useSwapLiteTokens'; -import type { - SwapLiteContextType, - SwapLiteProviderReact, - SwapUnit, -} from '../types'; -import { isSwapError } from '../utils/isSwapError'; -import { processSwapTransaction } from '../utils/processSwapTransaction'; -import { usePopupMonitor } from '../hooks/usePopupMonitor'; - -const emptyContext = {} as SwapLiteContextType; - -export const SwapLiteContext = createContext(emptyContext); - -export function useSwapLiteContext() { - const context = useContext(SwapLiteContext); - if (context === emptyContext) { - throw new Error( - 'useSwapLiteContext must be used within a SwapLite component', - ); - } - return context; -} - -export function SwapLiteProvider({ - children, - config = { - maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, - }, - experimental, - isSponsored, - onError, - onStatus, - onSuccess, - toToken, - fromToken, - projectId, -}: SwapLiteProviderReact) { - const { - config: { paymaster } = { paymaster: undefined }, - } = useOnchainKit(); - const { address, chainId } = useAccount(); - const { switchChainAsync } = useSwitchChain(); - // Feature flags - const { useAggregator } = experimental; - // Core Hooks - const accountConfig = useConfig(); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - - const walletCapabilities = useCapabilitiesSafe({ - chainId: base.id, - }); // Swap is only available on Base - const [lifecycleStatus, updateLifecycleStatus] = useLifecycleStatus({ - statusName: 'init', - statusData: { - isMissingRequiredField: true, - maxSlippage: config.maxSlippage, - }, - }); // Component lifecycle - - const [transactionHash, setTransactionHash] = useState(''); - const [hasHandledSuccess, setHasHandledSuccess] = useState(false); - const { from, fromETH, fromUSDC, to } = useSwapLiteTokens( - toToken, - fromToken, - address, - ); - const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) - const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable) - - // Refreshes balances and inputs post-swap - const resetInputs = useResetSwapLiteInputs({ fromETH, fromUSDC, from, to }); - // For batched transactions, listens to and awaits calls from the Wallet server - const awaitCallsStatus = useAwaitCalls({ - accountConfig, - lifecycleStatus, - updateLifecycleStatus, - }); - - const handleOnrampEvent = useCallback( - (data: EventMetadata) => { - console.log('EVENT HANDLER', { data }); - if (data.eventName === 'transition_view') { - updateLifecycleStatus({ - statusName: 'transactionPending', - }); - } - }, - [updateLifecycleStatus], - ); - - const handleOnrampExit = useCallback((error?: OnrampError) => { - console.log('EXIT HANDLER', { error }); - }, []); - - const handleOnrampSuccess = useCallback(() => { - console.log('SUCCESS HANDLER'); - updateLifecycleStatus({ - statusName: 'success', - statusData: {}, - }); - }, []); - - const onPopupClose = useCallback(() => { - updateLifecycleStatus({ - statusName: 'init', - statusData: { - isMissingRequiredField: false, - maxSlippage: config.maxSlippage, - }, - }); - }, [updateLifecycleStatus]); - - useEffect(() => { - const unsubscribe = setupOnrampEventListeners({ - onEvent: handleOnrampEvent, - onExit: handleOnrampExit, - onSuccess: handleOnrampSuccess, - }); - return () => { - unsubscribe(); - }; - }, [handleOnrampEvent, handleOnrampExit, handleOnrampSuccess]); - - const { startPopupMonitor } = usePopupMonitor(onPopupClose); - - // Component lifecycle emitters - useEffect(() => { - // Error - if (lifecycleStatus.statusName === 'error') { - onError?.(lifecycleStatus.statusData); - } - // Success - if ( - lifecycleStatus.statusName === 'success' && - lifecycleStatus?.statusData.transactionReceipt - ) { - onSuccess?.(lifecycleStatus?.statusData.transactionReceipt); - setTransactionHash( - lifecycleStatus.statusData.transactionReceipt?.transactionHash, - ); - setHasHandledSuccess(true); - } - // Emit Status - onStatus?.(lifecycleStatus); - }, [ - onError, - onStatus, - onSuccess, - lifecycleStatus, - lifecycleStatus.statusData, // Keep statusData, so that the effect runs when it changes - lifecycleStatus.statusName, // Keep statusName, so that the effect runs when it changes - ]); - - useEffect(() => { - // Reset inputs after status reset. `resetInputs` is dependent - // on 'from' and 'to' so moved to separate useEffect to - // prevents multiple calls to `onStatus` - if (lifecycleStatus.statusName === 'init' && hasHandledSuccess) { - setHasHandledSuccess(false); - resetInputs(); - } - }, [hasHandledSuccess, lifecycleStatus.statusName, resetInputs]); - - useEffect(() => { - // For batched transactions, `transactionApproved` will contain the calls ID - // We'll use the `useAwaitCalls` hook to listen to the call status from the wallet server - // This will update the lifecycle status to `success` once the calls are confirmed - if ( - lifecycleStatus.statusName === 'transactionApproved' && - lifecycleStatus.statusData.transactionType === 'Batched' - ) { - awaitCallsStatus(); - } - }, [ - awaitCallsStatus, - lifecycleStatus, - lifecycleStatus.statusData, - lifecycleStatus.statusName, - ]); - - useEffect(() => { - let timer: NodeJS.Timeout; - // Reset status to init after success has been handled - if (lifecycleStatus.statusName === 'success' && hasHandledSuccess) { - timer = setTimeout(() => { - updateLifecycleStatus({ - statusName: 'init', - statusData: { - isMissingRequiredField: true, - maxSlippage: config.maxSlippage, - }, - }); - }, 3000); - } - return () => { - if (timer) { - return clearTimeout(timer); - } - }; - }, [ - config.maxSlippage, - hasHandledSuccess, - lifecycleStatus.statusName, - updateLifecycleStatus, - ]); - - const handleAmountChange = useCallback( - async ( - amount: string, - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component - ) => { - if ( - to.token === undefined || - fromETH.token === undefined || - fromUSDC.token === undefined - ) { - updateLifecycleStatus({ - statusName: 'amountChange', - statusData: { - amountETH: fromETH.amount, - amountUSDC: fromUSDC.amount, - amountTo: to.amount, - tokenTo: to.token, - isMissingRequiredField: true, - }, - }); - return; - } - - if (amount === '' || amount === '.' || Number.parseFloat(amount) === 0) { - to.setAmount(''); - to.setAmountUSD(''); - fromETH.setAmountUSD(''); - fromUSDC.setAmountUSD(''); - from?.setAmountUSD(''); - return; - } - - fromETH.setLoading(true); - fromUSDC.setLoading(true); - from?.setLoading(true); - - updateLifecycleStatus({ - statusName: 'amountChange', - statusData: { - // when fetching quote, the previous - // amount is irrelevant - amountTo: amount, - amountETH: '', - amountUSDC: '', - amountFrom: '', - tokenFromETH: fromETH.token, - tokenFromUSDC: fromUSDC.token, - tokenFrom: from?.token, - tokenTo: to.token, - // when fetching quote, the destination - // amount is missing - isMissingRequiredField: true, - }, - }); - - try { - const maxSlippage = lifecycleStatus.statusData.maxSlippage; - - const { - response: responseETH, - formattedFromAmount: formattedAmountETH, - } = await getSwapLiteQuote({ - amount, - amountReference: 'to', - from: fromETH.token, - maxSlippage: String(maxSlippage), - to: to.token, - useAggregator, - fromSwapUnit: fromETH, - }); - - const { - response: responseUSDC, - formattedFromAmount: formattedAmountUSDC, - } = await getSwapLiteQuote({ - amount, - amountReference: 'to', - from: fromUSDC.token, - maxSlippage: String(maxSlippage), - to: to.token, - useAggregator, - fromSwapUnit: fromUSDC, - }); - - const { - response: responseFrom, - formattedFromAmount: formattedAmountFrom, - } = await getSwapLiteQuote({ - amount, - amountReference: 'to', - from: from?.token, - maxSlippage: String(maxSlippage), - to: to.token, - useAggregator, - fromSwapUnit: from, - }); - - if (!isSwapError(responseETH) && responseETH?.toAmountUSD) { - to.setAmountUSD(responseETH?.toAmountUSD); - } else if (!isSwapError(responseUSDC) && responseUSDC?.toAmountUSD) { - to.setAmountUSD(responseUSDC.toAmountUSD); - } else if (!isSwapError(responseFrom) && responseFrom?.toAmountUSD) { - to.setAmountUSD(responseFrom.toAmountUSD); - } else { - updateLifecycleStatus({ - statusName: 'error', - statusData: { - code: 'TmSPc01', // Transaction module SwapProvider component 01 error - error: 'No valid quote found', - message: '', - }, - }); - return; - } - - updateLifecycleStatus({ - statusName: 'amountChange', - statusData: { - amountETH: formattedAmountETH, - amountUSDC: formattedAmountUSDC, - amountFrom: formattedAmountFrom || '', - amountTo: amount, - tokenFromETH: fromETH.token, - tokenFromUSDC: fromUSDC.token, - tokenFrom: from?.token, - tokenTo: to.token, - // if quote was fetched successfully, we - // have all required fields - isMissingRequiredField: !formattedAmountETH, - }, - }); - } catch (err) { - updateLifecycleStatus({ - statusName: 'error', - statusData: { - code: 'TmSPc01', // Transaction module SwapProvider component 01 error - error: JSON.stringify(err), - message: '', - }, - }); - } finally { - // reset loading state when quote request resolves - fromETH.setLoading(false); - fromUSDC.setLoading(false); - from?.setLoading(false); - } - }, - [ - to, - from, - fromETH, - fromUSDC, - useAggregator, - updateLifecycleStatus, - lifecycleStatus.statusData.maxSlippage, - ], - ); - - const handleSubmit = useCallback( - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component - async (from: SwapUnit) => { - if (!address || !from.token || !to.token || !from.amount) { - return; - } - - try { - const maxSlippage = lifecycleStatus.statusData.maxSlippage; - const response = await buildSwapTransaction({ - amount: from.amount, - fromAddress: address, - from: from.token, - maxSlippage: String(maxSlippage), - to: to.token, - useAggregator, - }); - if (isSwapError(response)) { - updateLifecycleStatus({ - statusName: 'error', - statusData: { - code: response.code, - error: response.error, - message: response.message, - }, - }); - return; - } - await processSwapTransaction({ - chainId, - config: accountConfig, - isSponsored, - paymaster: paymaster || '', - sendCallsAsync, - sendTransactionAsync, - swapTransaction: response, - switchChainAsync, - updateLifecycleStatus, - useAggregator, - walletCapabilities, - }); - } catch (err) { - const errorMessage = isUserRejectedRequestError(err) - ? 'Request denied.' - : GENERIC_ERROR_MESSAGE; - updateLifecycleStatus({ - statusName: 'error', - statusData: { - code: 'TmSPc02', // Transaction module SwapProvider component 02 error - error: JSON.stringify(err), - message: errorMessage, - }, - }); - } - }, - [ - accountConfig, - address, - chainId, - isSponsored, - lifecycleStatus, - paymaster, - sendCallsAsync, - sendTransactionAsync, - switchChainAsync, - to.token, - updateLifecycleStatus, - useAggregator, - walletCapabilities, - ], - ); - - const value = useValue({ - address, - config, - from, - fromETH, - fromUSDC, - handleAmountChange, - handleSubmit, - lifecycleStatus, - updateLifecycleStatus, - to, - setTransactionHash, - transactionHash, - isDropdownOpen, - setIsDropdownOpen, - toToken, - fromToken, - projectId, - startPopupMonitor, - }); - - return ( - - {children} - - ); -} diff --git a/src/swap/components/SwapLiteTokenItem.tsx b/src/swap/components/SwapLiteTokenItem.tsx deleted file mode 100644 index 90799d002b..0000000000 --- a/src/swap/components/SwapLiteTokenItem.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; -import { cn, color } from '../../styles/theme'; -import { TokenImage } from '../../token'; -import type { SwapUnit } from '../types'; -import { useSwapLiteContext } from './SwapLiteProvider'; - -export function SwapLiteTokenItem({ swapUnit }: { swapUnit: SwapUnit }) { - const { handleSubmit, setIsDropdownOpen } = useSwapLiteContext(); - - if (!swapUnit?.token) { - return null; - } - - const handleClick = useCallback(() => { - setIsDropdownOpen(false); - handleSubmit(swapUnit); - }, [handleSubmit, swapUnit, setIsDropdownOpen]); - - const hasInsufficientBalance = - !swapUnit.balance || - Number.parseFloat(swapUnit.balance) < Number.parseFloat(swapUnit.amount); - - const roundedAmount = useMemo(() => { - return getRoundedAmount(swapUnit.amount, 10); - }, [swapUnit.amount]); - - const roundedBalance = useMemo(() => { - return getRoundedAmount(swapUnit.balance || '0', 10); - }, [swapUnit.balance]); - - return ( - - ); -} From 62f9586cbff4a5dd2cd85745816d3be996ee823e Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 10:58:36 -0800 Subject: [PATCH 17/65] add buy components --- src/buy/components/Buy.tsx | 75 ++++ src/buy/components/BuyAmountInput.tsx | 44 +++ src/buy/components/BuyButton.tsx | 67 ++++ src/buy/components/BuyDropdown.tsx | 95 +++++ src/buy/components/BuyMessage.tsx | 18 + src/buy/components/BuyOnrampItem.tsx | 55 +++ src/buy/components/BuyProvider.tsx | 479 ++++++++++++++++++++++++++ src/buy/components/BuyTokenItem.tsx | 58 ++++ src/buy/constants.ts | 20 ++ src/buy/hooks/usePopupMonitor.ts | 37 ++ src/buy/index.ts | 2 + src/buy/types.ts | 65 +++- src/swap/index.ts | 1 - 13 files changed, 1014 insertions(+), 2 deletions(-) create mode 100644 src/buy/components/Buy.tsx create mode 100644 src/buy/components/BuyAmountInput.tsx create mode 100644 src/buy/components/BuyButton.tsx create mode 100644 src/buy/components/BuyDropdown.tsx create mode 100644 src/buy/components/BuyMessage.tsx create mode 100644 src/buy/components/BuyOnrampItem.tsx create mode 100644 src/buy/components/BuyProvider.tsx create mode 100644 src/buy/components/BuyTokenItem.tsx create mode 100644 src/buy/constants.ts create mode 100644 src/buy/hooks/usePopupMonitor.ts create mode 100644 src/buy/index.ts diff --git a/src/buy/components/Buy.tsx b/src/buy/components/Buy.tsx new file mode 100644 index 0000000000..6c51c48aeb --- /dev/null +++ b/src/buy/components/Buy.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react'; +import { cn } from '../../styles/theme'; +import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; +import type { BuyReact } from '../types'; +import { BuyAmountInput } from './BuyAmountInput'; +import { BuyButton } from './BuyButton'; +import { BuyDropdown } from './BuyDropdown'; +import { BuyMessage } from './BuyMessage'; +import { BuyProvider, useBuyContext } from './BuyProvider'; + +export function BuyContent({ className }: { className?: string }) { + const { isDropdownOpen, setIsDropdownOpen } = useBuyContext(); + const fundSwapContainerRef = useRef(null); + + // Handle clicking outside the wallet component to close the dropdown. + useEffect(() => { + const handleClickOutsideComponent = (event: MouseEvent) => { + if ( + fundSwapContainerRef.current && + !fundSwapContainerRef.current.contains(event.target as Node) && + isDropdownOpen + ) { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('click', handleClickOutsideComponent); + return () => + document.removeEventListener('click', handleClickOutsideComponent); + }, [isDropdownOpen, setIsDropdownOpen]); + + return ( +
+
+ + + {isDropdownOpen && } +
+ +
+ ); +} +export function Buy({ + config = { + maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, + }, + className, + experimental = { useAggregator: false }, + isSponsored = false, + onError, + onStatus, + onSuccess, + toToken, + fromToken, + projectId, +}: BuyReact) { + return ( + + + + ); +} diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx new file mode 100644 index 0000000000..7a62d2862b --- /dev/null +++ b/src/buy/components/BuyAmountInput.tsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { isValidAmount } from '../../core/utils/isValidAmount'; +import { TextInput } from '../../internal/components/TextInput'; +import { cn, pressable } from '../../styles/theme'; +import { TokenChip } from '../../token'; +import { formatAmount } from '../../swap/utils/formatAmount'; +import { useBuyContext } from './BuyProvider'; + +export function BuyAmountInput() { + const { to, handleAmountChange } = useBuyContext(); + + const handleChange = useCallback( + (amount: string) => { + handleAmountChange(amount); + }, + [handleAmountChange], + ); + + if (!to?.token) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/src/buy/components/BuyButton.tsx b/src/buy/components/BuyButton.tsx new file mode 100644 index 0000000000..24316c0c20 --- /dev/null +++ b/src/buy/components/BuyButton.tsx @@ -0,0 +1,67 @@ +import { useCallback, useMemo } from 'react'; +import { Spinner } from '../../internal/components/Spinner'; +import { checkmarkSvg } from '../../internal/svg/checkmarkSvg'; +import { + background, + border, + cn, + color, + pressable, + text, +} from '../../styles/theme'; +import { useBuyContext } from './BuyProvider'; + +export function BuyButton() { + const { + setIsDropdownOpen, + from, + fromETH, + fromUSDC, + to, + lifecycleStatus: { statusName }, + } = useBuyContext(); + const isLoading = + to?.loading || + from?.loading || + fromETH.loading || + fromUSDC.loading || + statusName === 'transactionPending' || + statusName === 'transactionApproved'; + + const isDisabled = !to?.amount || !to?.token || isLoading; + + const handleSubmit = useCallback(() => { + setIsDropdownOpen(true); + }, [setIsDropdownOpen]); + + const buttonContent = useMemo(() => { + if (statusName === 'success') { + return checkmarkSvg; + } + return 'Buy'; + }, [statusName]); + + return ( + + ); +} diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx new file mode 100644 index 0000000000..c6a14793dd --- /dev/null +++ b/src/buy/components/BuyDropdown.tsx @@ -0,0 +1,95 @@ +import { useCallback, useMemo } from 'react'; +import { useAccount } from 'wagmi'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { ONRAMP_BUY_URL } from '../../fund/constants'; +import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; +import { openPopup } from '../../internal/utils/openPopup'; +import { background, cn, color, text } from '../../styles/theme'; +import { ONRAMP_PAYMENT_METHODS } from '../constants'; +import { BuyOnrampItem } from './BuyOnrampItem'; +import { useBuyContext } from './BuyProvider'; +import { BuyTokenItem } from './BuyTokenItem'; + +export function BuyDropdown() { + const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } = + useBuyContext(); + const { address } = useAccount(); + + const handleOnrampClick = useCallback( + (paymentMethodId: string) => { + return () => { + const assetSymbol = to?.token?.symbol; + let fundAmount = to?.amount; + // funding url requires a leading zero if the amount is less than 1 + if (fundAmount?.[0] === '.') { + fundAmount = `0${fundAmount}`; + } + const fundingUrl = `${ONRAMP_BUY_URL}/one-click?appId=${projectId}&addresses={"${address}":["base"]}&assets=["${assetSymbol}"]&presetCryptoAmount=${fundAmount}&defaultPaymentMethod=${paymentMethodId}`; + const { height, width } = getFundingPopupSize('md', fundingUrl); + const popupWindow = openPopup({ + url: fundingUrl, + height, + width, + target: '_blank', + }); + + if (popupWindow) { + // Detects when the popup is closed + // to stop loading state + startPopupMonitor(popupWindow); + } + }; + }, + [address, to, projectId], + ); + + const formattedAmountUSD = useMemo(() => { + if (!to?.amountUSD || to?.amountUSD === '0') { + return null; + } + const roundedAmount = Number(getRoundedAmount(to?.amountUSD, 2)); + return `$${roundedAmount.toFixed(2)}`; + }, [to?.amountUSD]); + + const isToETH = to?.token?.symbol === 'ETH'; + const isToUSDC = to?.token?.symbol === 'USDC'; + const showFromToken = + to?.token?.symbol !== from?.token?.symbol && + from && + from?.token?.symbol !== 'ETH' && + from?.token?.symbol !== 'USDC'; + + return ( +
+
Buy with
+ {!isToETH && } + {!isToUSDC && } + {showFromToken && } + + {ONRAMP_PAYMENT_METHODS.map((method) => { + return ( + + ); + })} + + {!!formattedAmountUSD && ( +
{`${to?.amount} ${to?.token?.name} ≈ ${formattedAmountUSD}`}
+ )} +
+ ); +} diff --git a/src/buy/components/BuyMessage.tsx b/src/buy/components/BuyMessage.tsx new file mode 100644 index 0000000000..8aede245f6 --- /dev/null +++ b/src/buy/components/BuyMessage.tsx @@ -0,0 +1,18 @@ +import { cn, color } from '../../styles/theme'; +import { useBuyContext } from './BuyProvider'; + +export function BuyMessage() { + const { + lifecycleStatus: { statusName }, + } = useBuyContext(); + + if (statusName !== 'error') { + return null; + } + + return ( +
+ Something went wrong. Please try again. +
+ ); +} diff --git a/src/buy/components/BuyOnrampItem.tsx b/src/buy/components/BuyOnrampItem.tsx new file mode 100644 index 0000000000..84a2e3dd72 --- /dev/null +++ b/src/buy/components/BuyOnrampItem.tsx @@ -0,0 +1,55 @@ +import { useCallback } from 'react'; +import { appleSvg } from '../../internal/svg/appleSvg'; +import { cardSvg } from '../../internal/svg/cardSvg'; +import { coinbaseLogoSvg } from '../../internal/svg/coinbaseLogoSvg'; +import { cn, color } from '../../styles/theme'; +import { useBuyContext } from './BuyProvider'; + +type OnrampItemReact = { + name: string; + description: string; + onClick: () => void; + svg?: React.ReactNode; + icon: string; +}; + +const ONRAMP_ICON_MAP: Record = { + applePay: appleSvg, + coinbasePay: coinbaseLogoSvg, + creditCard: cardSvg, +}; + +export function BuyOnrampItem({ + name, + description, + onClick, + icon, +}: OnrampItemReact) { + const { setIsDropdownOpen } = useBuyContext(); + + const handleClick = useCallback(() => { + setIsDropdownOpen(false); + onClick(); + }, [onClick, setIsDropdownOpen]); + + return ( + + ); +} diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx new file mode 100644 index 0000000000..9b5da24d43 --- /dev/null +++ b/src/buy/components/BuyProvider.tsx @@ -0,0 +1,479 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { base } from 'viem/chains'; +import { useAccount, useConfig, useSendTransaction } from 'wagmi'; +import { useSwitchChain } from 'wagmi'; +import { useSendCalls } from 'wagmi/experimental'; +import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; +import { useValue } from '../../core-react/internal/hooks/useValue'; +import { useOnchainKit } from '../../core-react/useOnchainKit'; +import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import { getBuyQuote } from '../utils/getBuyQuote'; +import { setupOnrampEventListeners } from '../../fund'; +import type { EventMetadata, OnrampError } from '../../fund/types'; +import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; +import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; +import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; +import { useAwaitCalls } from '../../swap/hooks/useAwaitCalls'; +import { useLifecycleStatus } from '../../swap/hooks/useLifecycleStatus'; +import { useResetSwapLiteInputs } from '../../swap/hooks/useResetSwapLiteInputs'; +import { useSwapLiteTokens } from '../../swap/hooks/useSwapLiteTokens'; +import type { SwapUnit } from '../../swap/types'; +import { isSwapError } from '../../swap/utils/isSwapError'; +import { processSwapTransaction } from '../../swap/utils/processSwapTransaction'; +import { usePopupMonitor } from '../hooks/usePopupMonitor'; +import { BuyContextType, BuyProviderReact } from '../types'; + +const emptyContext = {} as BuyContextType; + +export const BuyContext = createContext(emptyContext); + +export function useBuyContext() { + const context = useContext(BuyContext); + if (context === emptyContext) { + throw new Error('useBuyContext must be used within a Buy component'); + } + return context; +} + +export function BuyProvider({ + children, + config = { + maxSlippage: FALLBACK_DEFAULT_MAX_SLIPPAGE, + }, + experimental, + isSponsored, + onError, + onStatus, + onSuccess, + toToken, + fromToken, + projectId, +}: BuyProviderReact) { + const { + config: { paymaster } = { paymaster: undefined }, + } = useOnchainKit(); + const { address, chainId } = useAccount(); + const { switchChainAsync } = useSwitchChain(); + // Feature flags + const { useAggregator } = experimental; + // Core Hooks + const accountConfig = useConfig(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + const walletCapabilities = useCapabilitiesSafe({ + chainId: base.id, + }); // Swap is only available on Base + const [lifecycleStatus, updateLifecycleStatus] = useLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + maxSlippage: config.maxSlippage, + }, + }); // Component lifecycle + + const [transactionHash, setTransactionHash] = useState(''); + const [hasHandledSuccess, setHasHandledSuccess] = useState(false); + const { from, fromETH, fromUSDC, to } = useSwapLiteTokens( + toToken, + fromToken, + address, + ); + const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) + const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable) + + // Refreshes balances and inputs post-swap + const resetInputs = useResetSwapLiteInputs({ fromETH, fromUSDC, from, to }); + // For batched transactions, listens to and awaits calls from the Wallet server + const awaitCallsStatus = useAwaitCalls({ + accountConfig, + lifecycleStatus, + updateLifecycleStatus, + }); + + const handleOnrampEvent = useCallback( + (data: EventMetadata) => { + console.log('EVENT HANDLER', { data }); + if (data.eventName === 'transition_view') { + updateLifecycleStatus({ + statusName: 'transactionPending', + }); + } + }, + [updateLifecycleStatus], + ); + + const handleOnrampExit = useCallback((error?: OnrampError) => { + console.log('EXIT HANDLER', { error }); + }, []); + + const handleOnrampSuccess = useCallback(() => { + console.log('SUCCESS HANDLER'); + updateLifecycleStatus({ + statusName: 'success', + statusData: {}, + }); + }, []); + + const onPopupClose = useCallback(() => { + updateLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: false, + maxSlippage: config.maxSlippage, + }, + }); + }, [updateLifecycleStatus]); + + useEffect(() => { + const unsubscribe = setupOnrampEventListeners({ + onEvent: handleOnrampEvent, + onExit: handleOnrampExit, + onSuccess: handleOnrampSuccess, + }); + return () => { + unsubscribe(); + }; + }, [handleOnrampEvent, handleOnrampExit, handleOnrampSuccess]); + + const { startPopupMonitor } = usePopupMonitor(onPopupClose); + + // Component lifecycle emitters + useEffect(() => { + // Error + if (lifecycleStatus.statusName === 'error') { + onError?.(lifecycleStatus.statusData); + } + // Success + if ( + lifecycleStatus.statusName === 'success' && + lifecycleStatus?.statusData.transactionReceipt + ) { + onSuccess?.(lifecycleStatus?.statusData.transactionReceipt); + setTransactionHash( + lifecycleStatus.statusData.transactionReceipt?.transactionHash, + ); + setHasHandledSuccess(true); + } + // Emit Status + onStatus?.(lifecycleStatus); + }, [ + onError, + onStatus, + onSuccess, + lifecycleStatus, + lifecycleStatus.statusData, // Keep statusData, so that the effect runs when it changes + lifecycleStatus.statusName, // Keep statusName, so that the effect runs when it changes + ]); + + useEffect(() => { + // Reset inputs after status reset. `resetInputs` is dependent + // on 'from' and 'to' so moved to separate useEffect to + // prevents multiple calls to `onStatus` + if (lifecycleStatus.statusName === 'init' && hasHandledSuccess) { + setHasHandledSuccess(false); + resetInputs(); + } + }, [hasHandledSuccess, lifecycleStatus.statusName, resetInputs]); + + useEffect(() => { + // For batched transactions, `transactionApproved` will contain the calls ID + // We'll use the `useAwaitCalls` hook to listen to the call status from the wallet server + // This will update the lifecycle status to `success` once the calls are confirmed + if ( + lifecycleStatus.statusName === 'transactionApproved' && + lifecycleStatus.statusData.transactionType === 'Batched' + ) { + awaitCallsStatus(); + } + }, [ + awaitCallsStatus, + lifecycleStatus, + lifecycleStatus.statusData, + lifecycleStatus.statusName, + ]); + + useEffect(() => { + let timer: NodeJS.Timeout; + // Reset status to init after success has been handled + if (lifecycleStatus.statusName === 'success' && hasHandledSuccess) { + timer = setTimeout(() => { + updateLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + maxSlippage: config.maxSlippage, + }, + }); + }, 3000); + } + return () => { + if (timer) { + return clearTimeout(timer); + } + }; + }, [ + config.maxSlippage, + hasHandledSuccess, + lifecycleStatus.statusName, + updateLifecycleStatus, + ]); + + const handleAmountChange = useCallback( + async ( + amount: string, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component + ) => { + if ( + to.token === undefined || + fromETH.token === undefined || + fromUSDC.token === undefined + ) { + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + amountETH: fromETH.amount, + amountUSDC: fromUSDC.amount, + amountTo: to.amount, + tokenTo: to.token, + isMissingRequiredField: true, + }, + }); + return; + } + + if (amount === '' || amount === '.' || Number.parseFloat(amount) === 0) { + to.setAmount(''); + to.setAmountUSD(''); + fromETH.setAmountUSD(''); + fromUSDC.setAmountUSD(''); + from?.setAmountUSD(''); + return; + } + + fromETH.setLoading(true); + fromUSDC.setLoading(true); + from?.setLoading(true); + + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + // when fetching quote, the previous + // amount is irrelevant + amountTo: amount, + amountETH: '', + amountUSDC: '', + amountFrom: '', + tokenFromETH: fromETH.token, + tokenFromUSDC: fromUSDC.token, + tokenFrom: from?.token, + tokenTo: to.token, + // when fetching quote, the destination + // amount is missing + isMissingRequiredField: true, + }, + }); + + try { + const maxSlippage = lifecycleStatus.statusData.maxSlippage; + + const { + response: responseETH, + formattedFromAmount: formattedAmountETH, + } = await getBuyQuote({ + amount, + amountReference: 'to', + from: fromETH.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + fromSwapUnit: fromETH, + }); + + const { + response: responseUSDC, + formattedFromAmount: formattedAmountUSDC, + } = await getBuyQuote({ + amount, + amountReference: 'to', + from: fromUSDC.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + fromSwapUnit: fromUSDC, + }); + + const { + response: responseFrom, + formattedFromAmount: formattedAmountFrom, + } = await getBuyQuote({ + amount, + amountReference: 'to', + from: from?.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + fromSwapUnit: from, + }); + + if (!isSwapError(responseETH) && responseETH?.toAmountUSD) { + to.setAmountUSD(responseETH?.toAmountUSD); + } else if (!isSwapError(responseUSDC) && responseUSDC?.toAmountUSD) { + to.setAmountUSD(responseUSDC.toAmountUSD); + } else if (!isSwapError(responseFrom) && responseFrom?.toAmountUSD) { + to.setAmountUSD(responseFrom.toAmountUSD); + } else { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc01', // Transaction module SwapProvider component 01 error + error: 'No valid quote found', + message: '', + }, + }); + return; + } + + updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + amountETH: formattedAmountETH, + amountUSDC: formattedAmountUSDC, + amountFrom: formattedAmountFrom || '', + amountTo: amount, + tokenFromETH: fromETH.token, + tokenFromUSDC: fromUSDC.token, + tokenFrom: from?.token, + tokenTo: to.token, + // if quote was fetched successfully, we + // have all required fields + isMissingRequiredField: !formattedAmountETH, + }, + }); + } catch (err) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc01', // Transaction module SwapProvider component 01 error + error: JSON.stringify(err), + message: '', + }, + }); + } finally { + // reset loading state when quote request resolves + fromETH.setLoading(false); + fromUSDC.setLoading(false); + from?.setLoading(false); + } + }, + [ + to, + from, + fromETH, + fromUSDC, + useAggregator, + updateLifecycleStatus, + lifecycleStatus.statusData.maxSlippage, + ], + ); + + const handleSubmit = useCallback( + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component + async (from: SwapUnit) => { + if (!address || !from.token || !to.token || !from.amount) { + return; + } + + try { + const maxSlippage = lifecycleStatus.statusData.maxSlippage; + const response = await buildSwapTransaction({ + amount: from.amount, + fromAddress: address, + from: from.token, + maxSlippage: String(maxSlippage), + to: to.token, + useAggregator, + }); + if (isSwapError(response)) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: response.code, + error: response.error, + message: response.message, + }, + }); + return; + } + await processSwapTransaction({ + chainId, + config: accountConfig, + isSponsored, + paymaster: paymaster || '', + sendCallsAsync, + sendTransactionAsync, + swapTransaction: response, + switchChainAsync, + updateLifecycleStatus, + useAggregator, + walletCapabilities, + }); + } catch (err) { + const errorMessage = isUserRejectedRequestError(err) + ? 'Request denied.' + : GENERIC_ERROR_MESSAGE; + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmSPc02', // Transaction module SwapProvider component 02 error + error: JSON.stringify(err), + message: errorMessage, + }, + }); + } + }, + [ + accountConfig, + address, + chainId, + isSponsored, + lifecycleStatus, + paymaster, + sendCallsAsync, + sendTransactionAsync, + switchChainAsync, + to.token, + updateLifecycleStatus, + useAggregator, + walletCapabilities, + ], + ); + + const value = useValue({ + address, + config, + from, + fromETH, + fromUSDC, + handleAmountChange, + handleSubmit, + lifecycleStatus, + updateLifecycleStatus, + to, + setTransactionHash, + transactionHash, + isDropdownOpen, + setIsDropdownOpen, + toToken, + fromToken, + projectId, + startPopupMonitor, + }); + + return {children}; +} diff --git a/src/buy/components/BuyTokenItem.tsx b/src/buy/components/BuyTokenItem.tsx new file mode 100644 index 0000000000..b130c73fa7 --- /dev/null +++ b/src/buy/components/BuyTokenItem.tsx @@ -0,0 +1,58 @@ +import { useCallback, useMemo } from 'react'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { cn, color } from '../../styles/theme'; +import { TokenImage } from '../../token'; +import type { SwapUnit } from '../../swap/types'; +import { useBuyContext } from './BuyProvider'; + +export function BuyTokenItem({ swapUnit }: { swapUnit?: SwapUnit }) { + const { handleSubmit, setIsDropdownOpen } = useBuyContext(); + + if (!swapUnit || !swapUnit.token) { + return null; + } + + const handleClick = useCallback(() => { + setIsDropdownOpen(false); + handleSubmit(swapUnit); + }, [handleSubmit, swapUnit, setIsDropdownOpen]); + + const hasInsufficientBalance = + !swapUnit.balance || + Number.parseFloat(swapUnit.balance) < Number.parseFloat(swapUnit.amount); + + const roundedAmount = useMemo(() => { + return getRoundedAmount(swapUnit.amount, 10); + }, [swapUnit.amount]); + + const roundedBalance = useMemo(() => { + return getRoundedAmount(swapUnit.balance || '0', 10); + }, [swapUnit.balance]); + + return ( + + ); +} diff --git a/src/buy/constants.ts b/src/buy/constants.ts new file mode 100644 index 0000000000..513ed97a2d --- /dev/null +++ b/src/buy/constants.ts @@ -0,0 +1,20 @@ +export const ONRAMP_PAYMENT_METHODS = [ + { + id: 'CRYPTO_ACCOUNT', + name: 'Coinbase', + description: 'Buy with your Coinbase account', + icon: 'coinbasePay', + }, + { + id: 'APPLE_PAY', + name: 'Apple Pay', + description: 'Up to $500/week', + icon: 'applePay', + }, + { + id: 'CARD', + name: 'Debit Card', + description: 'Up to $500/week', + icon: 'creditCard', + }, +]; diff --git a/src/buy/hooks/usePopupMonitor.ts b/src/buy/hooks/usePopupMonitor.ts new file mode 100644 index 0000000000..c9414541be --- /dev/null +++ b/src/buy/hooks/usePopupMonitor.ts @@ -0,0 +1,37 @@ +import { useRef, useEffect, useCallback } from 'react'; + +export const usePopupMonitor = (onClose?: () => void) => { + const intervalRef = useRef(null); + + // Start monitoring the popup + const startPopupMonitor = useCallback((popupWindow: Window) => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + intervalRef.current = window.setInterval(() => { + if (popupWindow.closed) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + console.log('Popup closed'); + onClose?.(); + } + }, 500); + }, []); + + // Stop monitoring the popup + const stopPopupMonitor = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // Cleanup interval on unmount + useEffect(() => { + return () => stopPopupMonitor(); + }, [stopPopupMonitor]); + + return { startPopupMonitor }; +}; diff --git a/src/buy/index.ts b/src/buy/index.ts new file mode 100644 index 0000000000..bed8f927a9 --- /dev/null +++ b/src/buy/index.ts @@ -0,0 +1,2 @@ +// 🌲☀🌲 +export { Buy } from './components/Buy'; diff --git a/src/buy/types.ts b/src/buy/types.ts index ac853e565e..ab55297f05 100644 --- a/src/buy/types.ts +++ b/src/buy/types.ts @@ -1,4 +1,66 @@ -import type { SwapUnit } from '@/swap/types'; + +import { + LifecycleStatus, + LifecycleStatusUpdate, + SwapConfig, + SwapError, + SwapUnit, +} from '@/swap/types'; +import { Token } from '@/token'; +import { Address, TransactionReceipt } from 'viem'; + +export type BuyReact = { + className?: string; // Optional className override for top div element. + config?: SwapConfig; + experimental?: { + useAggregator: boolean; // Whether to use a DEX aggregator. (default: true) + }; + isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) + onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. + onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + fromToken?: Token; + toToken: Token; + projectId: string; // Your CDP project ID found at https://portal.cdp.coinbase.com/ +}; + +export type BuyContextType = { + address?: Address; // Used to check if user is connected in SwapButton + config: SwapConfig; + fromETH: SwapUnit; + fromUSDC: SwapUnit; + lifecycleStatus: LifecycleStatus; + handleAmountChange: (amount: string) => void; + handleSubmit: (fromToken: SwapUnit) => void; + updateLifecycleStatus: (state: LifecycleStatusUpdate) => void; // A function to set the lifecycle status of the component + setTransactionHash: (hash: string) => void; + fromToken?: Token; + to?: SwapUnit; + from?: SwapUnit; + toToken: Token; + transactionHash: string; + isDropdownOpen: boolean; + setIsDropdownOpen: (open: boolean) => void; + projectId: string; + startPopupMonitor: (popupWindow: Window) => void; +}; + +export type BuyProviderReact = { + children: React.ReactNode; + config?: { + maxSlippage: number; // Maximum acceptable slippage for a swap. (default: 10) This is as a percent, not basis points + }; + experimental: { + useAggregator: boolean; // Whether to use a DEX aggregator. (default: true) + }; + isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) + onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. + onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + fromToken?: Token; + toToken: Token; + projectId: string; +}; export type BuyTokens = { fromETH: SwapUnit; @@ -6,3 +68,4 @@ export type BuyTokens = { to: SwapUnit; from?: SwapUnit; }; + \ No newline at end of file diff --git a/src/swap/index.ts b/src/swap/index.ts index a8b9abdccb..984438fa9d 100644 --- a/src/swap/index.ts +++ b/src/swap/index.ts @@ -3,7 +3,6 @@ export { Swap } from './components/Swap'; export { SwapAmountInput } from './components/SwapAmountInput'; export { SwapButton } from './components/SwapButton'; export { SwapDefault } from './components/SwapDefault'; -export { SwapLite } from './components/SwapLite'; export { SwapMessage } from './components/SwapMessage'; export { SwapSettings } from './components/SwapSettings'; export { SwapSettingsSlippageDescription } from './components/SwapSettingsSlippageDescription'; From 63f471773c9a7258c2a5e26b67b3ace5899d7f28 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 11:01:19 -0800 Subject: [PATCH 18/65] add test coverage --- src/buy/components/BuyOnrampItem.test.tsx | 87 +++++++++++++++++ src/buy/components/BuyTokenItem.test.tsx | 110 ++++++++++++++++++++++ src/internal/svg/appleSvg.tsx | 1 + src/internal/svg/cardSvg.tsx | 1 + src/internal/svg/coinbaseLogoSvg.tsx | 1 + 5 files changed, 200 insertions(+) create mode 100644 src/buy/components/BuyOnrampItem.test.tsx create mode 100644 src/buy/components/BuyTokenItem.test.tsx diff --git a/src/buy/components/BuyOnrampItem.test.tsx b/src/buy/components/BuyOnrampItem.test.tsx new file mode 100644 index 0000000000..aca971234e --- /dev/null +++ b/src/buy/components/BuyOnrampItem.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { BuyOnrampItem } from './BuyOnrampItem'; +import { useBuyContext } from './BuyProvider'; + +vi.mock('./BuyProvider', () => ({ + useBuyContext: vi.fn(), +})); + +vi.mock('../../internal/svg', () => ({ + appleSvg: , + cardSvg: , + coinbaseLogoSvg: , +})); + +describe('BuyOnrampItem', () => { + const mockSetIsDropdownOpen = vi.fn(); + const mockOnClick = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useBuyContext as Mock).mockReturnValue({ + setIsDropdownOpen: mockSetIsDropdownOpen, + }); + }); + + it('renders correctly with provided props', () => { + render( + , + ); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByText('Apple Pay')).toBeInTheDocument(); + expect(screen.getByText('Fast and secure payments.')).toBeInTheDocument(); + expect(screen.getByTestId('appleSvg')).toBeInTheDocument(); + }); + + it('handles icon rendering based on the icon prop', () => { + render( + , + ); + + expect(screen.getByTestId('cardSvg')).toBeInTheDocument(); + }); + + it('triggers onClick and closes dropdown on button click', () => { + render( + , + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(mockSetIsDropdownOpen).toHaveBeenCalledWith(false); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('applies correct styling and attributes to the button', () => { + render( + , + ); + + const button = screen.getByRole('button'); + expect(button).toHaveClass('flex items-center gap-2 rounded-lg p-2'); + expect(button).toHaveAttribute('type', 'button'); + }); +}); diff --git a/src/buy/components/BuyTokenItem.test.tsx b/src/buy/components/BuyTokenItem.test.tsx new file mode 100644 index 0000000000..7496138e48 --- /dev/null +++ b/src/buy/components/BuyTokenItem.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { BuyTokenItem } from './BuyTokenItem'; +import { useBuyContext } from './BuyProvider'; +import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; +import { ethToken } from '../../token/constants'; + +vi.mock('./BuyProvider', () => ({ + useBuyContext: vi.fn(), +})); + +vi.mock('../../core/utils/getRoundedAmount', () => ({ + getRoundedAmount: vi.fn((value) => value), +})); + +const ethSwapUnit = { + token: ethToken, + amount: '10.5', + balance: '20', + amountUSD: '10.5', + loading: false, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), +}; + +describe('BuyTokenItem', () => { + const mockHandleSubmit = vi.fn(); + const mockSetIsDropdownOpen = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useBuyContext as Mock).mockReturnValue({ + handleSubmit: mockHandleSubmit, + setIsDropdownOpen: mockSetIsDropdownOpen, + }); + }); + + it('renders null when swapUnit is undefined or has no token', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders correctly with valid swapUnit', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByText('10.5 ETH')).toBeInTheDocument(); + expect(screen.getByText('Balance: 20')).toBeInTheDocument(); + const button = screen.getByRole('button'); + expect(button).toHaveClass('hover:bg-[var(--ock-bg-inverse)]', { + exact: false, + }); + }); + + it('disables button and applies muted styling when balance is insufficient', () => { + const swapUnit = { + token: ethToken, + amount: '10.5', + balance: '5', + amountUSD: '10.5', + loading: false, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), + }; + + render(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(button).not.toHaveClass('hover:bg-[var(--ock-bg-inverse)]', { + exact: false, + }); + expect(screen.getByText('Balance: 5')).toHaveClass('text-xs'); + }); + + it('triggers handleSubmit and closes dropdown on click', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(mockSetIsDropdownOpen).toHaveBeenCalledWith(false); + expect(mockHandleSubmit).toHaveBeenCalledWith(ethSwapUnit); + }); + + it('formats amount and balance using getRoundedAmount', () => { + const swapUnit = { + token: ethToken, + amount: '10.5678', + balance: '20.1234', + amountUSD: '10.5', + loading: false, + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + setLoading: vi.fn(), + }; + + (getRoundedAmount as Mock).mockImplementation((value) => value.slice(0, 4)); + + render(); + + expect(getRoundedAmount).toHaveBeenCalledWith('10.5678', 10); + expect(getRoundedAmount).toHaveBeenCalledWith('20.1234', 10); + expect(screen.getByText('10.5 ETH')).toBeInTheDocument(); + expect(screen.getByText('Balance: 20.1')).toBeInTheDocument(); + }); +}); diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index 46986e59d7..274767452e 100644 --- a/src/internal/svg/appleSvg.tsx +++ b/src/internal/svg/appleSvg.tsx @@ -4,6 +4,7 @@ export const appleSvg = ( viewBox="0 -29.75 165.5 165.5" preserveAspectRatio="xMidYMid meet" id="Artwork" + data-testid="appleSvg" > AppleSvg CardSvg diff --git a/src/internal/svg/coinbaseLogoSvg.tsx b/src/internal/svg/coinbaseLogoSvg.tsx index 437c7282a8..01f8eb3e63 100644 --- a/src/internal/svg/coinbaseLogoSvg.tsx +++ b/src/internal/svg/coinbaseLogoSvg.tsx @@ -8,6 +8,7 @@ export const coinbaseLogoSvg = ( fill="none" xmlns="http://www.w3.org/2000/svg" className={cn(icon.foreground)} + data-testid="coinbaseLogoSvg" > CoinbaseLogoSvg Date: Mon, 16 Dec 2024 11:10:21 -0800 Subject: [PATCH 19/65] remove unused code --- src/swap/hooks/usePopupMonitor.ts | 37 ------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 src/swap/hooks/usePopupMonitor.ts diff --git a/src/swap/hooks/usePopupMonitor.ts b/src/swap/hooks/usePopupMonitor.ts deleted file mode 100644 index c9414541be..0000000000 --- a/src/swap/hooks/usePopupMonitor.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useRef, useEffect, useCallback } from 'react'; - -export const usePopupMonitor = (onClose?: () => void) => { - const intervalRef = useRef(null); - - // Start monitoring the popup - const startPopupMonitor = useCallback((popupWindow: Window) => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - intervalRef.current = window.setInterval(() => { - if (popupWindow.closed) { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - console.log('Popup closed'); - onClose?.(); - } - }, 500); - }, []); - - // Stop monitoring the popup - const stopPopupMonitor = useCallback(() => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }, []); - - // Cleanup interval on unmount - useEffect(() => { - return () => stopPopupMonitor(); - }, [stopPopupMonitor]); - - return { startPopupMonitor }; -}; From 64decc8ea25b51d2f33d46028f60cb844640f82b Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 11:10:54 -0800 Subject: [PATCH 20/65] remove exit handler --- src/buy/components/BuyProvider.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index 9b5da24d43..d8748f9d37 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -108,10 +108,6 @@ export function BuyProvider({ [updateLifecycleStatus], ); - const handleOnrampExit = useCallback((error?: OnrampError) => { - console.log('EXIT HANDLER', { error }); - }, []); - const handleOnrampSuccess = useCallback(() => { console.log('SUCCESS HANDLER'); updateLifecycleStatus({ @@ -133,14 +129,14 @@ export function BuyProvider({ useEffect(() => { const unsubscribe = setupOnrampEventListeners({ onEvent: handleOnrampEvent, - onExit: handleOnrampExit, onSuccess: handleOnrampSuccess, }); return () => { unsubscribe(); }; - }, [handleOnrampEvent, handleOnrampExit, handleOnrampSuccess]); + }, [handleOnrampEvent, handleOnrampSuccess]); + // used to detect when the popup is closed in order to stop loading state const { startPopupMonitor } = usePopupMonitor(onPopupClose); // Component lifecycle emitters @@ -331,7 +327,7 @@ export function BuyProvider({ updateLifecycleStatus({ statusName: 'error', statusData: { - code: 'TmSPc01', // Transaction module SwapProvider component 01 error + code: 'TmSPc01', error: 'No valid quote found', message: '', }, From 66545a6512fc52534a21c87dfbea99f2bafbb8cc Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 11:48:28 -0800 Subject: [PATCH 21/65] adjust imports --- src/buy/components/BuyProvider.tsx | 8 ++++---- src/buy/types.ts | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index d8748f9d37..f6c98b91c8 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -21,12 +21,12 @@ import { isUserRejectedRequestError } from '../../transaction/utils/isUserReject import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; import { useAwaitCalls } from '../../swap/hooks/useAwaitCalls'; import { useLifecycleStatus } from '../../swap/hooks/useLifecycleStatus'; -import { useResetSwapLiteInputs } from '../../swap/hooks/useResetSwapLiteInputs'; -import { useSwapLiteTokens } from '../../swap/hooks/useSwapLiteTokens'; import type { SwapUnit } from '../../swap/types'; import { isSwapError } from '../../swap/utils/isSwapError'; import { processSwapTransaction } from '../../swap/utils/processSwapTransaction'; +import { useBuyTokens } from '../hooks/useBuyTokens'; import { usePopupMonitor } from '../hooks/usePopupMonitor'; +import { useResetBuyInputs } from '../hooks/useResetBuyInputs'; import { BuyContextType, BuyProviderReact } from '../types'; const emptyContext = {} as BuyContextType; @@ -79,7 +79,7 @@ export function BuyProvider({ const [transactionHash, setTransactionHash] = useState(''); const [hasHandledSuccess, setHasHandledSuccess] = useState(false); - const { from, fromETH, fromUSDC, to } = useSwapLiteTokens( + const { from, fromETH, fromUSDC, to } = useBuyTokens( toToken, fromToken, address, @@ -88,7 +88,7 @@ export function BuyProvider({ const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable) // Refreshes balances and inputs post-swap - const resetInputs = useResetSwapLiteInputs({ fromETH, fromUSDC, from, to }); + const resetInputs = useResetBuyInputs({ fromETH, fromUSDC, from, to }); // For batched transactions, listens to and awaits calls from the Wallet server const awaitCallsStatus = useAwaitCalls({ accountConfig, diff --git a/src/buy/types.ts b/src/buy/types.ts index ab55297f05..18ca8a5a4b 100644 --- a/src/buy/types.ts +++ b/src/buy/types.ts @@ -1,4 +1,3 @@ - import { LifecycleStatus, LifecycleStatusUpdate, @@ -68,4 +67,3 @@ export type BuyTokens = { to: SwapUnit; from?: SwapUnit; }; - \ No newline at end of file From 1e65878725aef1183a62ec6085bd7941b27daf3a Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 12:01:16 -0800 Subject: [PATCH 22/65] add test coverage --- src/buy/components/BuyButton.test.tsx | 98 ++++++++++++++++++++++++++ src/buy/components/BuyButton.tsx | 1 + src/buy/components/BuyMessage.test.tsx | 38 ++++++++++ src/buy/components/BuyProvider.tsx | 9 +-- src/buy/types.ts | 4 +- 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 src/buy/components/BuyButton.test.tsx create mode 100644 src/buy/components/BuyMessage.test.tsx diff --git a/src/buy/components/BuyButton.test.tsx b/src/buy/components/BuyButton.test.tsx new file mode 100644 index 0000000000..23a2bc0fcd --- /dev/null +++ b/src/buy/components/BuyButton.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BuyButton } from './BuyButton'; +import { useBuyContext } from './BuyProvider'; +import { Spinner } from '../../internal/components/Spinner'; +import { checkmarkSvg } from '../../internal/svg/checkmarkSvg'; + +vi.mock('./BuyProvider', () => ({ + useBuyContext: vi.fn(), +})); + +vi.mock('../../internal/components/Spinner', () => ({ + Spinner: () =>
, +})); + +vi.mock('../../internal/svg/checkmarkSvg', () => ({ + checkmarkSvg: , +})); + +describe('BuyButton', () => { + const mockSetIsDropdownOpen = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useBuyContext as Mock).mockReturnValue({ + setIsDropdownOpen: mockSetIsDropdownOpen, + from: { loading: false }, + fromETH: { loading: false }, + fromUSDC: { loading: false }, + to: { loading: false, amount: 10, token: 'ETH' }, + lifecycleStatus: { statusName: 'idle' }, + }); + }); + + it('renders the button with default content', () => { + render(); + + const button = screen.getByTestId('ockBuyButton_Button'); + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Buy'); + expect(button).not.toBeDisabled(); + }); + + it('shows a spinner when loading', () => { + (useBuyContext as Mock).mockReturnValue({ + setIsDropdownOpen: mockSetIsDropdownOpen, + from: { loading: true }, + fromETH: { loading: false }, + fromUSDC: { loading: false }, + to: { loading: false, amount: 10, token: 'ETH' }, + lifecycleStatus: { statusName: 'idle' }, + }); + + render(); + + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + + it('displays a checkmark when statusName is success', () => { + (useBuyContext as Mock).mockReturnValue({ + setIsDropdownOpen: mockSetIsDropdownOpen, + from: { loading: false }, + fromETH: { loading: false }, + fromUSDC: { loading: false }, + to: { loading: false, amount: 10, token: 'ETH' }, + lifecycleStatus: { statusName: 'success' }, + }); + + render(); + + expect(screen.getByTestId('checkmarkSvg')).toBeInTheDocument(); + }); + + it('disables the button when required fields are missing', () => { + (useBuyContext as Mock).mockReturnValue({ + setIsDropdownOpen: mockSetIsDropdownOpen, + from: { loading: false }, + fromETH: { loading: false }, + fromUSDC: { loading: false }, + to: { loading: false, amount: null, token: null }, + lifecycleStatus: { statusName: 'idle' }, + }); + + render(); + + const button = screen.getByTestId('ockBuyButton_Button'); + expect(button).toBeDisabled(); + }); + + it('calls setIsDropdownOpen when clicked', () => { + render(); + + const button = screen.getByTestId('ockBuyButton_Button'); + fireEvent.click(button); + + expect(mockSetIsDropdownOpen).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/buy/components/BuyButton.tsx b/src/buy/components/BuyButton.tsx index 24316c0c20..0bba064c28 100644 --- a/src/buy/components/BuyButton.tsx +++ b/src/buy/components/BuyButton.tsx @@ -54,6 +54,7 @@ export function BuyButton() { )} onClick={handleSubmit} data-testid="ockBuyButton_Button" + disabled={isDisabled} > {isLoading ? ( diff --git a/src/buy/components/BuyMessage.test.tsx b/src/buy/components/BuyMessage.test.tsx new file mode 100644 index 0000000000..b746959840 --- /dev/null +++ b/src/buy/components/BuyMessage.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BuyMessage } from './BuyMessage'; +import { useBuyContext } from './BuyProvider'; + +vi.mock('./BuyProvider', () => ({ + useBuyContext: vi.fn(), +})); + +describe('BuyMessage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders null when statusName is not "error"', () => { + (useBuyContext as Mock).mockReturnValue({ + lifecycleStatus: { statusName: 'success' }, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders error message when statusName is "error"', () => { + (useBuyContext as Mock).mockReturnValue({ + lifecycleStatus: { statusName: 'error' }, + }); + + render(); + + expect( + screen.getByText('Something went wrong. Please try again.'), + ).toBeInTheDocument(); + expect( + screen.getByText('Something went wrong. Please try again.'), + ).toHaveClass('text-sm'); + }); +}); diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index f6c98b91c8..689e44835f 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -146,13 +146,10 @@ export function BuyProvider({ onError?.(lifecycleStatus.statusData); } // Success - if ( - lifecycleStatus.statusName === 'success' && - lifecycleStatus?.statusData.transactionReceipt - ) { - onSuccess?.(lifecycleStatus?.statusData.transactionReceipt); + if (lifecycleStatus.statusName === 'success') { + onSuccess?.(lifecycleStatus?.statusData?.transactionReceipt); setTransactionHash( - lifecycleStatus.statusData.transactionReceipt?.transactionHash, + lifecycleStatus.statusData.transactionReceipt?.transactionHash || '', ); setHasHandledSuccess(true); } diff --git a/src/buy/types.ts b/src/buy/types.ts index 18ca8a5a4b..71b8693097 100644 --- a/src/buy/types.ts +++ b/src/buy/types.ts @@ -17,7 +17,7 @@ export type BuyReact = { isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt fromToken?: Token; toToken: Token; projectId: string; // Your CDP project ID found at https://portal.cdp.coinbase.com/ @@ -55,7 +55,7 @@ export type BuyProviderReact = { isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt fromToken?: Token; toToken: Token; projectId: string; From 819439292c745d149ba11517261dd5b4912f0d84 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 12:05:28 -0800 Subject: [PATCH 23/65] add test coverage --- src/buy/components/BuyAmountInput.test.tsx | 113 +++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/buy/components/BuyAmountInput.test.tsx diff --git a/src/buy/components/BuyAmountInput.test.tsx b/src/buy/components/BuyAmountInput.test.tsx new file mode 100644 index 0000000000..12931f05f2 --- /dev/null +++ b/src/buy/components/BuyAmountInput.test.tsx @@ -0,0 +1,113 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BuyAmountInput } from './BuyAmountInput'; +import { useBuyContext } from './BuyProvider'; +import { TextInput } from '../../internal/components/TextInput'; +import { Token, TokenChip } from '../../token'; + +vi.mock('./BuyProvider', () => ({ + useBuyContext: vi.fn(), +})); + +vi.mock('../../internal/components/TextInput', () => ({ + TextInput: ({ + value, + setValue, + onChange, + disabled, + }: { + disabled: boolean; + value: string; + setValue: (value: string) => void; + onChange: (value: string) => void; + }) => ( + { + onChange(e.target.value); + setValue(e.target.value); + }} + disabled={disabled} + /> + ), +})); + +vi.mock('../../token', () => ({ + TokenChip: ({ token }: { token: string }) => ( +
{token}
+ ), +})); + +describe('BuyAmountInput', () => { + const mockHandleAmountChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useBuyContext as Mock).mockReturnValue({ + to: { + token: 'ETH', + amount: 10, + setAmount: vi.fn(), + loading: false, + }, + handleAmountChange: mockHandleAmountChange, + }); + }); + + it('renders null when there is no token', () => { + (useBuyContext as Mock).mockReturnValue({ + to: { token: null }, + handleAmountChange: mockHandleAmountChange, + }); + + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the input and token chip when a token is present', () => { + render(); + + expect(screen.getByTestId('text-input')).toBeInTheDocument(); + expect(screen.getByTestId('token-chip')).toBeInTheDocument(); + expect(screen.getByTestId('token-chip')).toHaveTextContent('ETH'); + }); + + it('calls handleAmountChange and setAmount on input change', () => { + const mockSetAmount = vi.fn(); + (useBuyContext as Mock).mockReturnValue({ + to: { + token: 'ETH', + amount: 10, + setAmount: mockSetAmount, + loading: false, + }, + handleAmountChange: mockHandleAmountChange, + }); + + render(); + + const input = screen.getByTestId('text-input'); + fireEvent.change(input, { target: { value: '20' } }); + + expect(mockHandleAmountChange).toHaveBeenCalledWith('20'); + expect(mockSetAmount).toHaveBeenCalledWith('20'); + }); + + it('disables the input when loading is true', () => { + (useBuyContext as Mock).mockReturnValue({ + to: { + token: 'ETH', + amount: 10, + setAmount: vi.fn(), + loading: true, + }, + handleAmountChange: mockHandleAmountChange, + }); + + render(); + + const input = screen.getByTestId('text-input'); + expect(input).toBeDisabled(); + }); +}); From e792579f29eba272d7685c1f3a6d36fa16a80980 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 12:15:56 -0800 Subject: [PATCH 24/65] fix imports and lint --- src/buy/components/BuyAmountInput.test.tsx | 2 -- src/buy/components/BuyAmountInput.tsx | 2 +- src/buy/components/BuyButton.test.tsx | 4 +-- src/buy/components/BuyDropdown.tsx | 2 +- src/buy/components/BuyOnrampItem.test.tsx | 4 +-- src/buy/components/BuyProvider.tsx | 14 ++++----- src/buy/components/BuyTokenItem.test.tsx | 8 +++--- src/buy/components/BuyTokenItem.tsx | 2 +- src/buy/hooks/usePopupMonitor.ts | 33 ++++++++++++---------- src/buy/types.ts | 6 ++-- 10 files changed, 38 insertions(+), 39 deletions(-) diff --git a/src/buy/components/BuyAmountInput.test.tsx b/src/buy/components/BuyAmountInput.test.tsx index 12931f05f2..1d2ee8cbc5 100644 --- a/src/buy/components/BuyAmountInput.test.tsx +++ b/src/buy/components/BuyAmountInput.test.tsx @@ -2,8 +2,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { BuyAmountInput } from './BuyAmountInput'; import { useBuyContext } from './BuyProvider'; -import { TextInput } from '../../internal/components/TextInput'; -import { Token, TokenChip } from '../../token'; vi.mock('./BuyProvider', () => ({ useBuyContext: vi.fn(), diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx index 7a62d2862b..006dc39293 100644 --- a/src/buy/components/BuyAmountInput.tsx +++ b/src/buy/components/BuyAmountInput.tsx @@ -2,8 +2,8 @@ import { useCallback } from 'react'; import { isValidAmount } from '../../core/utils/isValidAmount'; import { TextInput } from '../../internal/components/TextInput'; import { cn, pressable } from '../../styles/theme'; -import { TokenChip } from '../../token'; import { formatAmount } from '../../swap/utils/formatAmount'; +import { TokenChip } from '../../token'; import { useBuyContext } from './BuyProvider'; export function BuyAmountInput() { diff --git a/src/buy/components/BuyButton.test.tsx b/src/buy/components/BuyButton.test.tsx index 23a2bc0fcd..af4dfa0613 100644 --- a/src/buy/components/BuyButton.test.tsx +++ b/src/buy/components/BuyButton.test.tsx @@ -1,9 +1,7 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { BuyButton } from './BuyButton'; import { useBuyContext } from './BuyProvider'; -import { Spinner } from '../../internal/components/Spinner'; -import { checkmarkSvg } from '../../internal/svg/checkmarkSvg'; vi.mock('./BuyProvider', () => ({ useBuyContext: vi.fn(), diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index c6a14793dd..bbdabcbdcd 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -40,7 +40,7 @@ export function BuyDropdown() { } }; }, - [address, to, projectId], + [address, to, projectId, startPopupMonitor], ); const formattedAmountUSD = useMemo(() => { diff --git a/src/buy/components/BuyOnrampItem.test.tsx b/src/buy/components/BuyOnrampItem.test.tsx index aca971234e..a85ebd3d02 100644 --- a/src/buy/components/BuyOnrampItem.test.tsx +++ b/src/buy/components/BuyOnrampItem.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { BuyOnrampItem } from './BuyOnrampItem'; import { useBuyContext } from './BuyProvider'; diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index 689e44835f..ec00cc86b3 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -13,21 +13,21 @@ import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabili import { useValue } from '../../core-react/internal/hooks/useValue'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; -import { getBuyQuote } from '../utils/getBuyQuote'; import { setupOnrampEventListeners } from '../../fund'; -import type { EventMetadata, OnrampError } from '../../fund/types'; -import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; -import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; +import type { EventMetadata } from '../../fund/types'; import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; import { useAwaitCalls } from '../../swap/hooks/useAwaitCalls'; import { useLifecycleStatus } from '../../swap/hooks/useLifecycleStatus'; import type { SwapUnit } from '../../swap/types'; import { isSwapError } from '../../swap/utils/isSwapError'; import { processSwapTransaction } from '../../swap/utils/processSwapTransaction'; +import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; +import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; import { useBuyTokens } from '../hooks/useBuyTokens'; import { usePopupMonitor } from '../hooks/usePopupMonitor'; import { useResetBuyInputs } from '../hooks/useResetBuyInputs'; -import { BuyContextType, BuyProviderReact } from '../types'; +import type { BuyContextType, BuyProviderReact } from '../types'; +import { getBuyQuote } from '../utils/getBuyQuote'; const emptyContext = {} as BuyContextType; @@ -114,7 +114,7 @@ export function BuyProvider({ statusName: 'success', statusData: {}, }); - }, []); + }, [updateLifecycleStatus]); const onPopupClose = useCallback(() => { updateLifecycleStatus({ @@ -124,7 +124,7 @@ export function BuyProvider({ maxSlippage: config.maxSlippage, }, }); - }, [updateLifecycleStatus]); + }, [updateLifecycleStatus, config.maxSlippage]); useEffect(() => { const unsubscribe = setupOnrampEventListeners({ diff --git a/src/buy/components/BuyTokenItem.test.tsx b/src/buy/components/BuyTokenItem.test.tsx index 7496138e48..7d7c9c75ee 100644 --- a/src/buy/components/BuyTokenItem.test.tsx +++ b/src/buy/components/BuyTokenItem.test.tsx @@ -1,9 +1,9 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { BuyTokenItem } from './BuyTokenItem'; -import { useBuyContext } from './BuyProvider'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { ethToken } from '../../token/constants'; +import { useBuyContext } from './BuyProvider'; +import { BuyTokenItem } from './BuyTokenItem'; vi.mock('./BuyProvider', () => ({ useBuyContext: vi.fn(), diff --git a/src/buy/components/BuyTokenItem.tsx b/src/buy/components/BuyTokenItem.tsx index b130c73fa7..949d0bfe3c 100644 --- a/src/buy/components/BuyTokenItem.tsx +++ b/src/buy/components/BuyTokenItem.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo } from 'react'; import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { cn, color } from '../../styles/theme'; -import { TokenImage } from '../../token'; import type { SwapUnit } from '../../swap/types'; +import { TokenImage } from '../../token'; import { useBuyContext } from './BuyProvider'; export function BuyTokenItem({ swapUnit }: { swapUnit?: SwapUnit }) { diff --git a/src/buy/hooks/usePopupMonitor.ts b/src/buy/hooks/usePopupMonitor.ts index c9414541be..2d079a8694 100644 --- a/src/buy/hooks/usePopupMonitor.ts +++ b/src/buy/hooks/usePopupMonitor.ts @@ -1,24 +1,27 @@ -import { useRef, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; export const usePopupMonitor = (onClose?: () => void) => { const intervalRef = useRef(null); // Start monitoring the popup - const startPopupMonitor = useCallback((popupWindow: Window) => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - intervalRef.current = window.setInterval(() => { - if (popupWindow.closed) { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - console.log('Popup closed'); - onClose?.(); + const startPopupMonitor = useCallback( + (popupWindow: Window) => { + if (intervalRef.current) { + clearInterval(intervalRef.current); } - }, 500); - }, []); + intervalRef.current = window.setInterval(() => { + if (popupWindow.closed) { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + console.log('Popup closed'); + onClose?.(); + } + }, 500); + }, + [onClose], + ); // Stop monitoring the popup const stopPopupMonitor = useCallback(() => { diff --git a/src/buy/types.ts b/src/buy/types.ts index 71b8693097..4aa8a124b9 100644 --- a/src/buy/types.ts +++ b/src/buy/types.ts @@ -1,12 +1,12 @@ -import { +import type { LifecycleStatus, LifecycleStatusUpdate, SwapConfig, SwapError, SwapUnit, } from '@/swap/types'; -import { Token } from '@/token'; -import { Address, TransactionReceipt } from 'viem'; +import type { Token } from '@/token'; +import type { Address, TransactionReceipt } from 'viem'; export type BuyReact = { className?: string; // Optional className override for top div element. From 0cee0d38bc3df076b12e035b120951ed082b9826 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 12:18:03 -0800 Subject: [PATCH 25/65] fix import --- src/buy/components/BuyAmountInput.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/components/BuyAmountInput.test.tsx b/src/buy/components/BuyAmountInput.test.tsx index 1d2ee8cbc5..67175633e5 100644 --- a/src/buy/components/BuyAmountInput.test.tsx +++ b/src/buy/components/BuyAmountInput.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { BuyAmountInput } from './BuyAmountInput'; import { useBuyContext } from './BuyProvider'; From 40e807475a7b197572ec9083817f88ff9bfbd21d Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 14:01:18 -0800 Subject: [PATCH 26/65] update import --- src/buy/components/BuyDropdown.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index bbdabcbdcd..8f28152740 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -3,12 +3,13 @@ import { useAccount } from 'wagmi'; import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { ONRAMP_BUY_URL } from '../../fund/constants'; import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; -import { openPopup } from '../../internal/utils/openPopup'; + import { background, cn, color, text } from '../../styles/theme'; import { ONRAMP_PAYMENT_METHODS } from '../constants'; import { BuyOnrampItem } from './BuyOnrampItem'; import { useBuyContext } from './BuyProvider'; import { BuyTokenItem } from './BuyTokenItem'; +import { openPopup } from '@/ui-react/internal/utils/openPopup'; export function BuyDropdown() { const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } = From 24c5d01292fe347e7c42e545c6fac10049a0a577 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 14:22:16 -0800 Subject: [PATCH 27/65] throw error if no project id --- src/buy/components/BuyProvider.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index ec00cc86b3..7866a80ba1 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -87,6 +87,13 @@ export function BuyProvider({ const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable) + // Validate `projectId` prop + if (!projectId) { + throw new Error( + 'Buy: projectId must be provided as a prop to the Buy component.', + ); + } + // Refreshes balances and inputs post-swap const resetInputs = useResetBuyInputs({ fromETH, fromUSDC, from, to }); // For batched transactions, listens to and awaits calls from the Wallet server From 12c5ae7cd695f5bb62d337c9a4f4512c8266f345 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 14:32:58 -0800 Subject: [PATCH 28/65] fix import --- src/buy/components/BuyDropdown.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index 8f28152740..f9f0629c22 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -3,13 +3,12 @@ import { useAccount } from 'wagmi'; import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { ONRAMP_BUY_URL } from '../../fund/constants'; import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; - +import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { background, cn, color, text } from '../../styles/theme'; import { ONRAMP_PAYMENT_METHODS } from '../constants'; import { BuyOnrampItem } from './BuyOnrampItem'; import { useBuyContext } from './BuyProvider'; import { BuyTokenItem } from './BuyTokenItem'; -import { openPopup } from '@/ui-react/internal/utils/openPopup'; export function BuyDropdown() { const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } = From 676fed9f9308adf9119bbd1c20639506e25ab10e Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 14:36:34 -0800 Subject: [PATCH 29/65] fix imports --- src/buy/components/BuyDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index f9f0629c22..7d4fd02288 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -1,9 +1,9 @@ +import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { useCallback, useMemo } from 'react'; import { useAccount } from 'wagmi'; import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { ONRAMP_BUY_URL } from '../../fund/constants'; import { getFundingPopupSize } from '../../fund/utils/getFundingPopupSize'; -import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { background, cn, color, text } from '../../styles/theme'; import { ONRAMP_PAYMENT_METHODS } from '../constants'; import { BuyOnrampItem } from './BuyOnrampItem'; From a30f64e5c912bfa560e1a4c1e0691bef67037b09 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 14:37:35 -0800 Subject: [PATCH 30/65] remove console log --- src/buy/components/BuyProvider.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index 7866a80ba1..0b7ed96ef5 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -105,7 +105,6 @@ export function BuyProvider({ const handleOnrampEvent = useCallback( (data: EventMetadata) => { - console.log('EVENT HANDLER', { data }); if (data.eventName === 'transition_view') { updateLifecycleStatus({ statusName: 'transactionPending', @@ -116,7 +115,6 @@ export function BuyProvider({ ); const handleOnrampSuccess = useCallback(() => { - console.log('SUCCESS HANDLER'); updateLifecycleStatus({ statusName: 'success', statusData: {}, From e2837e723f23709693df590c92fafb8803a3de2c Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 14:42:14 -0800 Subject: [PATCH 31/65] add comments --- src/buy/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/buy/types.ts b/src/buy/types.ts index 4aa8a124b9..669a17dbf4 100644 --- a/src/buy/types.ts +++ b/src/buy/types.ts @@ -18,9 +18,9 @@ export type BuyReact = { onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt - fromToken?: Token; - toToken: Token; - projectId: string; // Your CDP project ID found at https://portal.cdp.coinbase.com/ + fromToken?: Token; // An optional token to swap from + toToken: Token; // The token to swap to + projectId: string; // A CDP project ID (found at https://portal.cdp.coinbase.com/) }; export type BuyContextType = { From d60b789d43d871431a1b3aafdffb7b0bfa5f1269 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 21:08:00 -0800 Subject: [PATCH 32/65] add test coverage --- src/buy/components/Buy.test.tsx | 127 +++++++++++++++++++++++++++++++ src/buy/components/Buy.tsx | 28 +++---- src/buy/components/BuyButton.tsx | 4 +- 3 files changed, 138 insertions(+), 21 deletions(-) create mode 100644 src/buy/components/Buy.test.tsx diff --git a/src/buy/components/Buy.test.tsx b/src/buy/components/Buy.test.tsx new file mode 100644 index 0000000000..4c9e7e990a --- /dev/null +++ b/src/buy/components/Buy.test.tsx @@ -0,0 +1,127 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOutsideClick } from '../../ui/react/internal/hooks/useOutsideClick'; +import { Buy } from './Buy'; +import { useBuyContext } from './BuyProvider'; +import { degenToken } from '../../token/constants'; + +vi.mock('./BuyProvider', () => ({ + useBuyContext: vi.fn(), + BuyProvider: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('./BuyDropdown', () => ({ + BuyDropdown: () =>
BuyDropdown
, +})); + +vi.mock('../../core-react/internal/hooks/useTheme', () => ({ + useTheme: vi.fn(), +})); + +vi.mock('../../ui/react/internal/hooks/useOutsideClick', () => ({ + useOutsideClick: vi.fn(), +})); + +type useOutsideClickType = ReturnType< + typeof vi.fn< + ( + ref: React.RefObject, + callback: (event: MouseEvent) => void, + ) => void + > +>; + +describe('Buy', () => { + let mockSetIsOpen: ReturnType; + let mockOutsideClickCallback: (e: MouseEvent) => void; + + beforeEach(() => { + mockSetIsOpen = vi.fn(); + (useBuyContext as Mock).mockReturnValue({ + isDropdownOpen: false, + setIsDropdownOpen: mockSetIsOpen, + lifecycleStatus: { + statusName: 'idle', + statusData: { + maxSlippage: 10, + }, + }, + to: { + token: degenToken, + amount: 10, + setAmount: vi.fn(), + }, + }); + + (useOutsideClick as unknown as useOutsideClickType).mockImplementation( + (_, callback) => { + mockOutsideClickCallback = callback; + }, + ); + + vi.clearAllMocks(); + }); + + it('renders the Buy component', () => { + render(); + + expect(screen.getByText('Buy')).toBeInTheDocument(); + expect(screen.getByText('DEGEN')).toBeInTheDocument(); + }); + + it('closes the dropdown when clicking outside the container', () => { + (useBuyContext as Mock).mockReturnValue({ + isDropdownOpen: true, + setIsDropdownOpen: mockSetIsOpen, + lifecycleStatus: { + statusName: 'idle', + statusData: { + maxSlippage: 10, + }, + }, + to: { + token: degenToken, + amount: 10, + setAmount: vi.fn(), + }, + }); + + render(); + + expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined(); + mockOutsideClickCallback({} as MouseEvent); + + expect(mockSetIsOpen).toHaveBeenCalledWith(false); + }); + + it('does not close the dropdown when clicking inside the container', () => { + (useBuyContext as Mock).mockReturnValue({ + isDropdownOpen: true, + setIsDropdownOpen: mockSetIsOpen, + lifecycleStatus: { + statusName: 'idle', + statusData: { + maxSlippage: 10, + }, + }, + to: { + token: degenToken, + amount: 10, + setAmount: vi.fn(), + }, + }); + + render(); + + expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined(); + fireEvent.click(screen.getByTestId('mock-BuyDropdown')); + expect(mockSetIsOpen).not.toHaveBeenCalled(); + }); + + it('should not trigger click handler when dropdown is closed', () => { + render(); + expect(screen.queryByTestId('mock-BuyDropdown')).not.toBeInTheDocument(); + }); +}); diff --git a/src/buy/components/Buy.tsx b/src/buy/components/Buy.tsx index 6c51c48aeb..949d7e9eba 100644 --- a/src/buy/components/Buy.tsx +++ b/src/buy/components/Buy.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef } from 'react'; +import { useOutsideClick } from '@/ui-react/internal/hooks/useOutsideClick'; +import { useRef } from 'react'; import { cn } from '../../styles/theme'; import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; import type { BuyReact } from '../types'; @@ -10,28 +11,17 @@ import { BuyProvider, useBuyContext } from './BuyProvider'; export function BuyContent({ className }: { className?: string }) { const { isDropdownOpen, setIsDropdownOpen } = useBuyContext(); - const fundSwapContainerRef = useRef(null); + const buyContainerRef = useRef(null); - // Handle clicking outside the wallet component to close the dropdown. - useEffect(() => { - const handleClickOutsideComponent = (event: MouseEvent) => { - if ( - fundSwapContainerRef.current && - !fundSwapContainerRef.current.contains(event.target as Node) && - isDropdownOpen - ) { - setIsDropdownOpen(false); - } - }; - - document.addEventListener('click', handleClickOutsideComponent); - return () => - document.removeEventListener('click', handleClickOutsideComponent); - }, [isDropdownOpen, setIsDropdownOpen]); + useOutsideClick(buyContainerRef, () => { + if (isDropdownOpen) { + setIsDropdownOpen(false); + } + }); return (
diff --git a/src/buy/components/BuyButton.tsx b/src/buy/components/BuyButton.tsx index 0bba064c28..b741e7bac7 100644 --- a/src/buy/components/BuyButton.tsx +++ b/src/buy/components/BuyButton.tsx @@ -23,8 +23,8 @@ export function BuyButton() { const isLoading = to?.loading || from?.loading || - fromETH.loading || - fromUSDC.loading || + fromETH?.loading || + fromUSDC?.loading || statusName === 'transactionPending' || statusName === 'transactionApproved'; From 404a730b07f3df2a8990f141cc866bc18fb9fff3 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 21:28:44 -0800 Subject: [PATCH 33/65] add test coverage --- src/buy/components/BuyDropdown.test.tsx | 87 +++++++++++++++++++++++++ src/buy/components/BuyOnrampItem.tsx | 1 + 2 files changed, 88 insertions(+) create mode 100644 src/buy/components/BuyDropdown.test.tsx diff --git a/src/buy/components/BuyDropdown.test.tsx b/src/buy/components/BuyDropdown.test.tsx new file mode 100644 index 0000000000..aff2661e84 --- /dev/null +++ b/src/buy/components/BuyDropdown.test.tsx @@ -0,0 +1,87 @@ +import { openPopup } from '@/ui-react/internal/utils/openPopup'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BuyDropdown } from './BuyDropdown'; +import { useBuyContext } from './BuyProvider'; +import { degenToken, ethToken, usdcToken } from '../../token/constants'; + +vi.mock('./BuyProvider', () => ({ + useBuyContext: vi.fn(), +})); + +vi.mock('@/ui-react/internal/utils/openPopup', () => ({ + openPopup: vi.fn(), +})); + +vi.mock('../../core/utils/getRoundedAmount', () => ({ + getRoundedAmount: vi.fn(() => '10'), +})); + +vi.mock('../../fund/utils/getFundingPopupSize', () => ({ + getFundingPopupSize: vi.fn(() => ({ height: 600, width: 400 })), +})); + +vi.mock('wagmi', async () => { + const actual = await vi.importActual('wagmi'); + return { + ...actual, + useAccount: () => ({ address: '0xMockAddress' }), + }; +}); + +const mockStartPopupMonitor = vi.fn(); + +const mockContextValue = { + to: { + token: degenToken, + amount: '1.23', + amountUSD: '123.45', + }, + fromETH: { token: ethToken }, + fromUSDC: { token: usdcToken }, + from: { token: { symbol: 'DAI' } }, + projectId: 'mock-project-id', + startPopupMonitor: mockStartPopupMonitor, + setIsDropdownOpen: vi.fn(), +}; + +describe('BuyDropdown', () => { + beforeEach(() => { + vi.clearAllMocks(); + (useBuyContext as Mock).mockReturnValue(mockContextValue); + }); + + it('renders the dropdown with correct content', () => { + render(); + + expect(screen.getByText('Buy with')).toBeInTheDocument(); + expect(screen.getByText('10 ETH')).toBeInTheDocument(); + expect(screen.getByText('10 USDC')).toBeInTheDocument(); + expect(screen.getByText('1.23 DEGEN ≈ $10.00')).toBeInTheDocument(); + }); + + it('triggers handleOnrampClick on payment method click', () => { + (openPopup as Mock).mockReturnValue('popup'); + render(); + + const onrampButton = screen.getByTestId('ock-applePayOrampItem'); + + act(() => { + fireEvent.click(onrampButton); + }); + + expect(mockStartPopupMonitor).toHaveBeenCalled(); + }); + + it('does not render formatted amount if amountUSD is missing', () => { + const contextWithNoUSD = { + ...mockContextValue, + to: { ...mockContextValue.to, amountUSD: '0' }, + }; + (useBuyContext as Mock).mockReturnValue(contextWithNoUSD); + + render(); + + expect(screen.queryByText(/≈/)).not.toBeInTheDocument(); + }); +}); diff --git a/src/buy/components/BuyOnrampItem.tsx b/src/buy/components/BuyOnrampItem.tsx index 84a2e3dd72..6544182ec5 100644 --- a/src/buy/components/BuyOnrampItem.tsx +++ b/src/buy/components/BuyOnrampItem.tsx @@ -40,6 +40,7 @@ export function BuyOnrampItem({ )} onClick={handleClick} type="button" + data-testid={`ock-${icon}OrampItem`} >
{ONRAMP_ICON_MAP[icon]} From 42d3a11078555c784f9047cacfc0622423b1d8d5 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 22:44:00 -0800 Subject: [PATCH 34/65] add test coverage --- src/buy/components/BuyProvider.test.tsx | 846 ++++++++++++++++++++++++ src/buy/types.ts | 7 + src/buy/utils/getBuyQuote.ts | 7 +- 3 files changed, 854 insertions(+), 6 deletions(-) create mode 100644 src/buy/components/BuyProvider.test.tsx diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx new file mode 100644 index 0000000000..a618d3b562 --- /dev/null +++ b/src/buy/components/BuyProvider.test.tsx @@ -0,0 +1,846 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + fireEvent, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; +import React, { act, useCallback, useEffect } from 'react'; +import type { TransactionReceipt } from 'viem'; +import { + type Mock, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { + http, + WagmiProvider, + createConfig, + useAccount, + useChainId, + useSwitchChain, +} from 'wagmi'; +import { waitForTransactionReceipt } from 'wagmi/actions'; +import { base } from 'wagmi/chains'; +import { mock } from 'wagmi/connectors'; +import { useSendCalls } from 'wagmi/experimental'; +import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; +import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import { getBuyQuote } from '../utils/getBuyQuote'; +import { + daiToken, + degenToken, + ethToken, + usdcToken, +} from '../../token/constants'; +import type { LifecycleStatus, SwapError, SwapUnit } from '../../swap/types'; +import { getSwapErrorCode } from '../../swap/utils/getSwapErrorCode'; +import { BuyProvider, useBuyContext } from './BuyProvider'; +import { APIError, GetSwapQuoteResponse } from '@/core/api'; +// import { isSwapError } from '../../swap/utils/isSwapError'; + +const mockResetFunction = vi.fn(); +vi.mock('../hooks/useResetBuyInputs', () => ({ + useResetBuyInputs: () => useCallback(mockResetFunction, []), +})); + +vi.mock('../utils/getBuyQuote', () => ({ + getBuyQuote: vi.fn(), +})); + +vi.mock('../../core/api/buildSwapTransaction', () => ({ + buildSwapTransaction: vi + .fn() + .mockRejectedValue(new Error('buildSwapTransaction')), +})); + +vi.mock('../../swap/utils/processSwapTransaction', () => ({ + processSwapTransaction: vi.fn(), +})); + +// vi.mock('../../swap/utils/isSwapError', () => ({ +// isSwapError: vi.fn(), +// })); + +const mockSwitchChain = vi.fn(); +vi.mock('wagmi', async (importOriginal) => { + return { + ...(await importOriginal()), + useAccount: vi.fn(), + useChainId: vi.fn(), + useSwitchChain: vi.fn(), + }; +}); + +const mockAwaitCalls = vi.fn(); +vi.mock('../../swap/hooks/useAwaitCalls', () => ({ + useAwaitCalls: () => useCallback(mockAwaitCalls, []), +})); + +vi.mock('../../core-react/internal/hooks/useCapabilitiesSafe', () => ({ + useCapabilitiesSafe: vi.fn(), +})); + +vi.mock('wagmi/actions', () => ({ + waitForTransactionReceipt: vi.fn(), +})); + +vi.mock('wagmi/experimental', () => ({ + useSendCalls: vi.fn(), +})); + +vi.mock('../path/to/maxSlippageModule', () => ({ + getMaxSlippage: vi.fn().mockReturnValue(10), +})); + +const queryClient = new QueryClient(); + +const mockFromDai: SwapUnit = { + balance: '100', + amount: '50', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: daiToken, + loading: false, + setLoading: vi.fn(), + error: undefined, +} as unknown as SwapUnit; + +const mockFromEth: SwapUnit = { + balance: '100', + amount: '50', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: ethToken, + loading: false, + setLoading: vi.fn(), + error: undefined, +} as unknown as SwapUnit; + +const accountConfig = createConfig({ + chains: [base], + connectors: [ + mock({ + accounts: [ + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', + ], + }), + ], + transports: { + [base.id]: http(), + }, +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + +); + +const renderWithProviders = ({ + Component, + onError = vi.fn(), + onStatus = vi.fn(), + onSuccess = vi.fn(), +}: { + Component: () => React.ReactNode; + onError?: (error: SwapError) => void; + onStatus?: (status: LifecycleStatus) => void; + onSuccess?: (transactionReceipt?: TransactionReceipt) => void; +}) => { + const config = { maxSlippage: 10 }; + const mockExperimental = { useAggregator: true }; + return render( + + + + + + + , + ); +}; + +const TestSwapComponent = () => { + const context = useBuyContext(); + useEffect(() => { + context?.from?.setToken?.(daiToken); + context?.from?.setAmount?.('100'); + context?.to?.setToken?.(degenToken); + }, [context]); + const handleStatusError = async () => { + context.updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'code', + error: 'error_long_messages', + message: '', + }, + }); + }; + const handleStatusAmountChange = async () => { + context.updateLifecycleStatus({ + statusName: 'amountChange', + statusData: { + amountFrom: '', + amountTo: '', + }, + }); + }; + const handleStatusTransactionPending = async () => { + context.updateLifecycleStatus({ + statusName: 'transactionPending', + }); + }; + const handleStatusTransactionApproved = async () => { + context.updateLifecycleStatus({ + statusName: 'transactionApproved', + statusData: { + transactionHash: '0x123', + transactionType: 'ERC20', + }, + }); + }; + const handleStatusSuccess = async () => { + context.updateLifecycleStatus({ + statusName: 'success', + statusData: { + transactionReceipt: { transactionHash: '0x123' }, + }, + } as unknown as LifecycleStatus); + }; + return ( +
+ + {context.lifecycleStatus.statusName} + + {context.lifecycleStatus.statusName === 'error' && ( + + {context.lifecycleStatus.statusData.code} + + )} + + + + + + +
+ ); +}; + +describe('useBuyContext', () => { + beforeEach(async () => { + vi.resetAllMocks(); + (useAccount as ReturnType).mockReturnValue({ + address: '0x123', + }); + (useChainId as ReturnType).mockReturnValue(8453); + (useSendCalls as ReturnType).mockReturnValue({ + status: 'idle', + sendCallsAsync: vi.fn(), + }); + (useSwitchChain as ReturnType).mockReturnValue({ + switchChainAsync: mockSwitchChain, + }); + await act(async () => { + renderWithProviders({ Component: () => null }); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should throw an error when used outside of BuyProvider', () => { + const TestComponent = () => { + useBuyContext(); + return null; + }; + // Suppress console.error for this test to avoid noisy output + const originalError = console.error; + console.error = vi.fn(); + expect(() => { + render(); + }).toThrow('useBuyContext must be used within a Buy component'); + // Restore console.error + console.error = originalError; + }); + + it('should provide context when used within BuyProvider', async () => { + const TestComponent = () => { + const context = useBuyContext(); + expect(context).toBeDefined(); + expect(context.from).toBeDefined(); + expect(context.to).toBeDefined(); + expect(context.handleAmountChange).toBeDefined(); + return null; + }; + await act(async () => { + renderWithProviders({ Component: TestComponent }); + }); + }); +}); + +describe('BuyProvider', () => { + beforeEach(async () => { + vi.resetAllMocks(); + (useAccount as ReturnType).mockReturnValue({ + address: '0x123', + }); + (useChainId as ReturnType).mockReturnValue(8453); + (useSendCalls as ReturnType).mockReturnValue({ + status: 'idle', + sendCallsAsync: vi.fn(), + }); + (useSwitchChain as ReturnType).mockReturnValue({ + switchChainAsync: mockSwitchChain, + }); + (useCapabilitiesSafe as ReturnType).mockReturnValue({}); + }); + + it('should reset inputs when setLifecycleStatus is called with success', async () => { + const { result } = renderHook(() => useBuyContext(), { wrapper }); + await act(async () => { + result.current.updateLifecycleStatus({ + statusName: 'success', + statusData: { + transactionReceipt: { transactionHash: '0x123' }, + }, + } as unknown as LifecycleStatus); + }); + + await waitFor( + () => { + expect(mockResetFunction).toHaveBeenCalled(); + }, + { timeout: 5000 }, + ); + + expect(mockResetFunction).toHaveBeenCalledTimes(1); + }); + + it('should handle batched transactions', async () => { + const { result } = renderHook(() => useBuyContext(), { wrapper }); + (useCapabilitiesSafe as ReturnType).mockReturnValue({ + atomicBatch: { supported: true }, + paymasterService: { supported: true }, + auxiliaryFunds: { supported: true }, + }); + (waitForTransactionReceipt as ReturnType).mockResolvedValue({ + transactionHash: 'receiptHash', + }); + await act(async () => { + result.current.updateLifecycleStatus({ + statusName: 'transactionApproved', + statusData: { + transactionType: 'Batched', + }, + }); + }); + await waitFor(() => { + expect(mockAwaitCalls).toHaveBeenCalled(); + }); + expect(mockAwaitCalls).toHaveBeenCalledTimes(1); + }); + + it('should emit onError when setLifecycleStatus is called with error', async () => { + const onErrorMock = vi.fn(); + renderWithProviders({ Component: TestSwapComponent, onError: onErrorMock }); + const button = screen.getByText('setLifecycleStatus.error'); + fireEvent.click(button); + expect(onErrorMock).toHaveBeenCalled(); + }); + + it('should emit onStatus when setLifecycleStatus is called with amountChange', async () => { + const onStatusMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onStatus: onStatusMock, + }); + const button = screen.getByText('setLifecycleStatus.amountChange'); + fireEvent.click(button); + expect(onStatusMock).toHaveBeenCalled(); + }); + + it('should persist statusData when upodating lifecycle status', async () => { + const onStatusMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onStatus: onStatusMock, + }); + fireEvent.click(screen.getByText('setLifecycleStatus.transactionPending')); + expect(onStatusMock).toHaveBeenLastCalledWith({ + statusName: 'transactionPending', + statusData: { + isMissingRequiredField: true, + maxSlippage: 10, + }, + }); + fireEvent.click(screen.getByText('setLifecycleStatus.transactionApproved')); + expect(onStatusMock).toHaveBeenLastCalledWith({ + statusName: 'transactionApproved', + statusData: { + transactionHash: '0x123', + transactionType: 'ERC20', + isMissingRequiredField: true, + maxSlippage: 10, + }, + }); + }); + + it('should not persist error when updating lifecycle status', async () => { + const onStatusMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onStatus: onStatusMock, + }); + fireEvent.click(screen.getByText('setLifecycleStatus.error')); + expect(onStatusMock).toHaveBeenLastCalledWith({ + statusName: 'error', + statusData: { + code: 'code', + error: 'error_long_messages', + message: '', + isMissingRequiredField: true, + maxSlippage: 10, + }, + }); + fireEvent.click(screen.getByText('setLifecycleStatus.transactionPending')); + expect(onStatusMock).toHaveBeenLastCalledWith({ + statusName: 'transactionPending', + statusData: { + isMissingRequiredField: true, + maxSlippage: 10, + }, + }); + }); + + // it.only('should update lifecycle status correctly after fetching quote for to token', async () => { + // vi.mocked(getBuyQuote).mockResolvedValueOnce({ + // formattedFromAmount: '1000', + // response: { + // toAmount: '1000', + // toAmountUSD: '$100', + // to: { + // decimals: 10, + // }, + // } as unknown as GetSwapQuoteResponse, + // } as unknown as GetBuyQuoteResponse); + + // (isSwapError as Mock).mockReturnValueOnce(false); + + // const { result } = renderHook(() => useBuyContext(), { wrapper }); + + // await act(async () => { + // result.current.handleAmountChange('10'); + // }); + // expect(result.current.lifecycleStatus).toStrictEqual({ + // statusName: 'amountChange', + // statusData: { + // amountETH: '', + // amountUSDC: '', + // amountFrom: '10', + // amountTo: '1e-9', + // isMissingRequiredField: false, + // maxSlippage: 5, + // tokenFromUSDC: usdcToken, + // tokenFromETH: ethToken, + // tokenTo: degenToken, + // tokenFrom: undefined + // }, + // }); + // }); + + // it('should update lifecycle status correctly after fetching quote for from token', async () => { + // vi.mocked(getBuyQuote).mockResolvedValueOnce({ + // toAmount: '10', + // to: { + // decimals: 10, + // }, + // } as unknown as GetBuyQuoteResponse); + // const { result } = renderHook(() => useBuyContext(), { wrapper }); + // await act(async () => { + // result.current.handleAmountChange('15'); + // }); + // expect(result.current.lifecycleStatus).toStrictEqual({ + // statusName: 'amountChange', + // statusData: { + // amountFrom: '1e-9', + // amountTo: '10', + // isMissingRequiredField: false, + // maxSlippage: 5, + // tokenTo: { + // address: '', + // name: 'ETH', + // symbol: 'ETH', + // chainId: 8453, + // decimals: 18, + // image: + // 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', + // }, + // tokenFrom: { + // address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', + // name: 'DEGEN', + // symbol: 'DEGEN', + // chainId: 8453, + // decimals: 18, + // image: + // 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', + // }, + // }, + // }); + // }); + + it('should emit onStatus when setLifecycleStatus is called with transactionPending', async () => { + const onStatusMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onStatus: onStatusMock, + }); + const button = screen.getByText('setLifecycleStatus.transactionPending'); + fireEvent.click(button); + expect(onStatusMock).toHaveBeenCalled(); + }); + + it('should emit onStatus when setLifecycleStatus is called with transactionApproved', async () => { + const onStatusMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onStatus: onStatusMock, + }); + const button = screen.getByText('setLifecycleStatus.transactionApproved'); + fireEvent.click(button); + expect(onStatusMock).toHaveBeenCalled(); + }); + + it('should emit onSuccess when setLifecycleStatus is called with success', async () => { + const onSuccessMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onSuccess: onSuccessMock, + }); + const button = screen.getByText('setLifecycleStatus.success'); + fireEvent.click(button); + expect(onSuccessMock).toHaveBeenCalled(); + }); + + it('should reset status to init when setLifecycleStatus is called with success', async () => { + const onStatusMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onStatus: onStatusMock, + }); + const button = screen.getByText('setLifecycleStatus.success'); + fireEvent.click(button); + await waitFor(() => { + expect(onStatusMock).toHaveBeenCalledWith( + expect.objectContaining({ + statusName: 'init', + statusData: { + isMissingRequiredField: true, + maxSlippage: 10, + }, + }), + ); + }); + }); + + it('should emit onStatus when setLifecycleStatus is called with error', async () => { + const onStatusMock = vi.fn(); + renderWithProviders({ + Component: TestSwapComponent, + onStatus: onStatusMock, + }); + const button = screen.getByText('setLifecycleStatus.error'); + fireEvent.click(button); + expect(onStatusMock).toHaveBeenCalled(); + }); + + it('should pass the correct slippage to getBuyQuote', async () => { + const TestComponent = () => { + const { handleAmountChange } = useBuyContext(); + // biome-ignore lint: hello + React.useEffect(() => { + const initializeSwap = () => { + handleAmountChange('5'); + }; + initializeSwap(); + }, []); + return null; + }; + await act(async () => { + renderWithProviders({ Component: TestComponent }); + }); + expect(getBuyQuote).toHaveBeenCalledWith( + expect.objectContaining({ + maxSlippage: '10', + amount: '5', + amountReference: 'to', + from: ethToken, + to: degenToken, + useAggregator: true, + }), + ); + }); + + it('should pass the correct amountReference to get', async () => { + const TestComponent = () => { + const { handleAmountChange } = useBuyContext(); + // biome-ignore lint: hello + React.useEffect(() => { + const initializeSwap = () => { + handleAmountChange('100'); + }; + initializeSwap(); + }, []); + return null; + }; + await act(async () => { + renderWithProviders({ Component: TestComponent }); + }); + expect(getBuyQuote).toHaveBeenCalledWith( + expect.objectContaining({ + maxSlippage: '10', + amount: '100', + amountReference: 'to', + from: ethToken, + to: degenToken, + useAggregator: true, + }), + ); + }); + + it('should handle undefined in input', async () => { + const TestComponent = () => { + const { handleAmountChange } = useBuyContext(); + // biome-ignore lint: hello + React.useEffect(() => { + const initializeSwap = () => { + handleAmountChange('100'); + }; + initializeSwap(); + }, []); + return null; + }; + await act(async () => { + renderWithProviders({ Component: TestComponent }); + }); + }); + + it('should initialize with empty values', () => { + const { result } = renderHook(() => useBuyContext(), { wrapper }); + expect(result.current.from?.amount).toBe(''); + expect(result.current.to?.amount).toBe(''); + expect(result.current.to?.amount).toBe(''); + }); + + it('should update amount and trigger quote', async () => { + const { result } = renderHook(() => useBuyContext(), { wrapper }); + await act(async () => { + result.current.handleAmountChange('10'); + }); + expect(getBuyQuote).toHaveBeenCalled(); + expect(result.current.to?.loading).toBe(false); + }); + + it('should handle empty amount input', async () => { + const { result } = renderHook(() => useBuyContext(), { wrapper }); + await act(async () => { + await result.current.handleAmountChange(''); + }); + expect(result.current.to?.amount).toBe(''); + }); + + it('should handle zero amount input', async () => { + const { result } = renderHook(() => useBuyContext(), { wrapper }); + await act(async () => { + await result.current.handleAmountChange('0'); + }); + expect(result.current.to?.amount).toBe(''); + }); + + it('should not setLifecycleStatus to error when getBuyQuote throws an error', async () => { + const mockError = new Error('Test error'); + vi.mocked(getBuyQuote).mockRejectedValueOnce(mockError); + const { result } = renderHook(() => useBuyContext(), { wrapper }); + await act(async () => { + result.current.handleAmountChange('10'); + }); + expect(result.current.lifecycleStatus).toEqual({ + statusName: 'error', + statusData: expect.objectContaining({ + code: 'TmSPc01', + error: JSON.stringify(mockError), + message: '', + }), + }); + }); + + // it('should setLifecycleStatus to error when getBuyQuote returns an error', async () => { + // vi.mocked(getBuyQuote).mockResolvedValueOnce({ + // error: 'Something went wrong' as unknown as APIError, + // }); + + // const { result } = renderHook(() => useBuyContext(), { wrapper }); + // await act(async () => { + // result.current.handleAmountChange('10'); + // }); + // expect(result.current.lifecycleStatus).toEqual({ + // statusName: 'error', + // statusData: expect.objectContaining({ + // code: 'TmSPc01', + // error: 'Something went wrong', + // message: '', + // }), + // }); + // }); + + it('should handle submit correctly', async () => { + await act(async () => { + renderWithProviders({ Component: TestSwapComponent }); + }); + await act(async () => { + fireEvent.click(screen.getByText('Swap')); + }); + expect(buildSwapTransaction).toBeCalledTimes(1); + }); + + it('should not call buildSwapTransaction when missing required fields', async () => { + const TestComponent = () => { + const context = useBuyContext(); + return ( + + ); + }; + renderWithProviders({ Component: TestComponent }); + fireEvent.click(screen.getByText('Swap')); + expect(buildSwapTransaction).not.toBeCalled(); + }); + + it('should setLifecycleStatus to error when buildSwapTransaction throws an "User rejected the request." error', async () => { + const mockError = { + shortMessage: 'User rejected the request.', + }; + vi.mocked(buildSwapTransaction).mockRejectedValueOnce(mockError); + renderWithProviders({ Component: TestSwapComponent }); + fireEvent.click(screen.getByText('Swap')); + await waitFor(() => { + expect( + screen.getByTestId('context-value-lifecycleStatus-statusName') + .textContent, + ).toBe('error'); + expect( + screen.getByTestId('context-value-lifecycleStatus-statusData-code') + .textContent, + ).toBe('TmSPc02'); + }); + }); + + it('should setLifecycleStatus to error when buildSwapTransaction throws an error', async () => { + const mockError = new Error('Test error'); + vi.mocked(buildSwapTransaction).mockRejectedValueOnce(mockError); + renderWithProviders({ Component: TestSwapComponent }); + fireEvent.click(screen.getByText('Swap')); + await waitFor(() => { + expect( + screen.getByTestId('context-value-lifecycleStatus-statusName') + .textContent, + ).toBe('error'); + expect( + screen.getByTestId('context-value-lifecycleStatus-statusData-code') + .textContent, + ).toBe('TmSPc02'); + }); + }); + + it('should setLifecycleStatus to error when buildSwapTransaction returns an error', async () => { + vi.mocked(buildSwapTransaction).mockResolvedValueOnce({ + code: getSwapErrorCode('uncaught-swap'), + error: 'Something went wrong', + message: '', + }); + renderWithProviders({ Component: TestSwapComponent }); + fireEvent.click(screen.getByText('Swap')); + await waitFor(() => { + expect( + screen.getByTestId('context-value-lifecycleStatus-statusName') + .textContent, + ).toBe('error'); + expect( + screen.getByTestId('context-value-lifecycleStatus-statusData-code') + .textContent, + ).toBe('UNCAUGHT_SWAP_ERROR'); + }); + }); + + it('should use default maxSlippage when not provided in experimental', () => { + const useTestHook = () => { + const { lifecycleStatus } = useBuyContext(); + return lifecycleStatus; + }; + const config = { maxSlippage: 3 }; + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); + const { result } = renderHook(() => useTestHook(), { wrapper }); + expect(result.current.statusName).toBe('init'); + if (result.current.statusName === 'init') { + expect(result.current.statusData.maxSlippage).toBe(3); + } + }); +}); diff --git a/src/buy/types.ts b/src/buy/types.ts index 669a17dbf4..0177223f39 100644 --- a/src/buy/types.ts +++ b/src/buy/types.ts @@ -1,3 +1,4 @@ +import type { APIError, GetSwapQuoteResponse } from '@/core/api/types'; import type { LifecycleStatus, LifecycleStatusUpdate, @@ -67,3 +68,9 @@ export type BuyTokens = { to: SwapUnit; from?: SwapUnit; }; + +export type GetBuyQuoteResponse = { + response?: GetSwapQuoteResponse; + error?: APIError; + formattedFromAmount?: string; +}; diff --git a/src/buy/utils/getBuyQuote.ts b/src/buy/utils/getBuyQuote.ts index 0234c3dec7..d73cbe9850 100644 --- a/src/buy/utils/getBuyQuote.ts +++ b/src/buy/utils/getBuyQuote.ts @@ -8,12 +8,7 @@ import { formatTokenAmount } from '@/core/utils/formatTokenAmount'; import type { SwapError, SwapUnit } from '../../swap/types'; import { isSwapError } from '../../swap/utils/isSwapError'; import type { Token } from '../../token'; - -type GetBuyQuoteResponse = { - response?: GetSwapQuoteResponse; - error?: APIError; - formattedFromAmount?: string; -}; +import type { GetBuyQuoteResponse } from '../types'; type GetBuyQuoteParams = Omit & { fromSwapUnit?: SwapUnit; From fe9c92b4c60c7795d76d18c4cb59ba05eb7f654d Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Mon, 16 Dec 2024 23:19:44 -0800 Subject: [PATCH 35/65] remove uneccesary error --- src/buy/components/BuyDropdown.test.tsx | 21 ++++++++++++++++++++- src/buy/components/BuyOnrampItem.tsx | 2 +- src/buy/components/BuyProvider.tsx | 7 ------- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/buy/components/BuyDropdown.test.tsx b/src/buy/components/BuyDropdown.test.tsx index aff2661e84..d0e4b58e29 100644 --- a/src/buy/components/BuyDropdown.test.tsx +++ b/src/buy/components/BuyDropdown.test.tsx @@ -64,7 +64,7 @@ describe('BuyDropdown', () => { (openPopup as Mock).mockReturnValue('popup'); render(); - const onrampButton = screen.getByTestId('ock-applePayOrampItem'); + const onrampButton = screen.getByTestId('ock-applePayOnrampItem'); act(() => { fireEvent.click(onrampButton); @@ -84,4 +84,23 @@ describe('BuyDropdown', () => { expect(screen.queryByText(/≈/)).not.toBeInTheDocument(); }); + + it('adds a leading zero to fundAmount if it starts with a period', () => { + (openPopup as Mock).mockImplementation(() => ({ closed: false })); // Mock popup function + (useBuyContext as Mock).mockReturnValue({ + ...mockContextValue, + to: { ...mockContextValue.to, amount: '.5' }, + }); + render(); + + // Find and click the first BuyOnrampItem button + const buyButton = screen.getByTestId('ock-applePayOnrampItem'); + fireEvent.click(buyButton); + + expect(openPopup).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://pay.coinbase.com/buy/one-click?appId=mock-project-id&addresses={"0xMockAddress":["base"]}&assets=["DEGEN"]&presetCryptoAmount=0.5&defaultPaymentMethod=APPLE_PAY', + }), + ); + }); }); diff --git a/src/buy/components/BuyOnrampItem.tsx b/src/buy/components/BuyOnrampItem.tsx index 6544182ec5..c4c263adb6 100644 --- a/src/buy/components/BuyOnrampItem.tsx +++ b/src/buy/components/BuyOnrampItem.tsx @@ -40,7 +40,7 @@ export function BuyOnrampItem({ )} onClick={handleClick} type="button" - data-testid={`ock-${icon}OrampItem`} + data-testid={`ock-${icon}OnrampItem`} >
{ONRAMP_ICON_MAP[icon]} diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index 0b7ed96ef5..f1104f3c46 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -87,13 +87,6 @@ export function BuyProvider({ const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable) const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable) - // Validate `projectId` prop - if (!projectId) { - throw new Error( - 'Buy: projectId must be provided as a prop to the Buy component.', - ); - } - // Refreshes balances and inputs post-swap const resetInputs = useResetBuyInputs({ fromETH, fromUSDC, from, to }); // For batched transactions, listens to and awaits calls from the Wallet server From 215761b121f7375e6ef39d941dc37e8ec2641f17 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 08:55:58 -0800 Subject: [PATCH 36/65] remove project id --- src/buy/components/Buy.tsx | 2 -- src/buy/components/BuyDropdown.test.tsx | 9 ++++++++- src/buy/components/BuyDropdown.tsx | 5 +++-- src/buy/components/BuyProvider.test.tsx | 3 --- src/buy/components/BuyProvider.tsx | 10 ++++++++-- src/buy/types.ts | 3 --- 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/buy/components/Buy.tsx b/src/buy/components/Buy.tsx index 949d7e9eba..28f800ee22 100644 --- a/src/buy/components/Buy.tsx +++ b/src/buy/components/Buy.tsx @@ -45,7 +45,6 @@ export function Buy({ onSuccess, toToken, fromToken, - projectId, }: BuyReact) { return ( diff --git a/src/buy/components/BuyDropdown.test.tsx b/src/buy/components/BuyDropdown.test.tsx index d0e4b58e29..66c00eff80 100644 --- a/src/buy/components/BuyDropdown.test.tsx +++ b/src/buy/components/BuyDropdown.test.tsx @@ -1,6 +1,7 @@ import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOnchainKit } from '../../core-react/useOnchainKit'; import { BuyDropdown } from './BuyDropdown'; import { useBuyContext } from './BuyProvider'; import { degenToken, ethToken, usdcToken } from '../../token/constants'; @@ -29,6 +30,10 @@ vi.mock('wagmi', async () => { }; }); +vi.mock('../../core-react/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + const mockStartPopupMonitor = vi.fn(); const mockContextValue = { @@ -40,7 +45,6 @@ const mockContextValue = { fromETH: { token: ethToken }, fromUSDC: { token: usdcToken }, from: { token: { symbol: 'DAI' } }, - projectId: 'mock-project-id', startPopupMonitor: mockStartPopupMonitor, setIsDropdownOpen: vi.fn(), }; @@ -49,6 +53,9 @@ describe('BuyDropdown', () => { beforeEach(() => { vi.clearAllMocks(); (useBuyContext as Mock).mockReturnValue(mockContextValue); + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'mock-project-id', + }); }); it('renders the dropdown with correct content', () => { diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index 7d4fd02288..b195148d74 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -1,3 +1,4 @@ +import { useOnchainKit } from '@/core-react/useOnchainKit'; import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { useCallback, useMemo } from 'react'; import { useAccount } from 'wagmi'; @@ -11,8 +12,8 @@ import { useBuyContext } from './BuyProvider'; import { BuyTokenItem } from './BuyTokenItem'; export function BuyDropdown() { - const { to, fromETH, fromUSDC, from, projectId, startPopupMonitor } = - useBuyContext(); + const { projectId } = useOnchainKit(); + const { to, fromETH, fromUSDC, from, startPopupMonitor } = useBuyContext(); const { address } = useAccount(); const handleOnrampClick = useCallback( diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index a618d3b562..3480e5b34f 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -145,7 +145,6 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( config={{ maxSlippage: 5 }} experimental={{ useAggregator: true }} toToken={degenToken} - projectId="mock-project-id" fromToken={daiToken} > {children} @@ -178,7 +177,6 @@ const renderWithProviders = ({ onSuccess={onSuccess} toToken={degenToken} fromToken={daiToken} - projectId="mock-project-id" > @@ -829,7 +827,6 @@ describe('BuyProvider', () => { {children} diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index f1104f3c46..24eb675d06 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -53,7 +53,6 @@ export function BuyProvider({ onSuccess, toToken, fromToken, - projectId, }: BuyProviderReact) { const { config: { paymaster } = { paymaster: undefined }, @@ -89,6 +88,14 @@ export function BuyProvider({ // Refreshes balances and inputs post-swap const resetInputs = useResetBuyInputs({ fromETH, fromUSDC, from, to }); + + const { projectId } = useOnchainKit(); + if (!projectId) { + throw new Error( + 'Buy: Project ID is required, please set the projectId in the OnchainKitProvider', + ); + } + // For batched transactions, listens to and awaits calls from the Wallet server const awaitCallsStatus = useAwaitCalls({ accountConfig, @@ -462,7 +469,6 @@ export function BuyProvider({ setIsDropdownOpen, toToken, fromToken, - projectId, startPopupMonitor, }); diff --git a/src/buy/types.ts b/src/buy/types.ts index 0177223f39..c74c4cd0bc 100644 --- a/src/buy/types.ts +++ b/src/buy/types.ts @@ -21,7 +21,6 @@ export type BuyReact = { onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt fromToken?: Token; // An optional token to swap from toToken: Token; // The token to swap to - projectId: string; // A CDP project ID (found at https://portal.cdp.coinbase.com/) }; export type BuyContextType = { @@ -41,7 +40,6 @@ export type BuyContextType = { transactionHash: string; isDropdownOpen: boolean; setIsDropdownOpen: (open: boolean) => void; - projectId: string; startPopupMonitor: (popupWindow: Window) => void; }; @@ -59,7 +57,6 @@ export type BuyProviderReact = { onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt fromToken?: Token; toToken: Token; - projectId: string; }; export type BuyTokens = { From c7b0e8cdad60c22801e2a88f7476e17bcc3e6751 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 09:01:43 -0800 Subject: [PATCH 37/65] address pr comments --- src/buy/components/BuyAmountInput.tsx | 10 +---- src/buy/components/BuyProvider.tsx | 6 +-- src/buy/hooks/usePopupMonitor.ts | 1 - src/internal/svg/appleSvg.tsx | 2 +- src/internal/svg/cardSvg.tsx | 2 +- src/internal/svg/coinbaseLogoSvg.tsx | 2 +- src/swap/types.ts | 56 --------------------------- 7 files changed, 7 insertions(+), 72 deletions(-) diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx index 006dc39293..bee2229ba4 100644 --- a/src/buy/components/BuyAmountInput.tsx +++ b/src/buy/components/BuyAmountInput.tsx @@ -1,4 +1,3 @@ -import { useCallback } from 'react'; import { isValidAmount } from '../../core/utils/isValidAmount'; import { TextInput } from '../../internal/components/TextInput'; import { cn, pressable } from '../../styles/theme'; @@ -9,13 +8,6 @@ import { useBuyContext } from './BuyProvider'; export function BuyAmountInput() { const { to, handleAmountChange } = useBuyContext(); - const handleChange = useCallback( - (amount: string) => { - handleAmountChange(amount); - }, - [handleAmountChange], - ); - if (!to?.token) { return null; } @@ -32,7 +24,7 @@ export function BuyAmountInput() { value={formatAmount(to.amount)} setValue={to.setAmount} disabled={to.loading} - onChange={handleChange} + onChange={handleAmountChange} inputValidator={isValidAmount} /> void) => { clearInterval(intervalRef.current); intervalRef.current = null; } - console.log('Popup closed'); onClose?.(); } }, 500); diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index 274767452e..34da7a1b14 100644 --- a/src/internal/svg/appleSvg.tsx +++ b/src/internal/svg/appleSvg.tsx @@ -6,7 +6,7 @@ export const appleSvg = ( id="Artwork" data-testid="appleSvg" > - AppleSvg + Apple Pay - CardSvg + Debit Card diff --git a/src/internal/svg/coinbaseLogoSvg.tsx b/src/internal/svg/coinbaseLogoSvg.tsx index 01f8eb3e63..3d6a7e48a8 100644 --- a/src/internal/svg/coinbaseLogoSvg.tsx +++ b/src/internal/svg/coinbaseLogoSvg.tsx @@ -10,7 +10,7 @@ export const coinbaseLogoSvg = ( className={cn(icon.foreground)} data-testid="coinbaseLogoSvg" > - CoinbaseLogoSvg + Coinbase Pay void; // An optional callback function that handles errors within the provider. - onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt - fromToken?: Token; - toToken: Token; - projectId: string; // Your CDP project ID found at https://portal.cdp.coinbase.com/ -}; - -export type SwapLiteContextType = { - address?: Address; // Used to check if user is connected in SwapButton - config: SwapConfig; - fromETH: SwapUnit; - fromUSDC: SwapUnit; - lifecycleStatus: LifecycleStatus; - handleAmountChange: (amount: string) => void; - handleSubmit: (fromToken: SwapUnit) => void; - updateLifecycleStatus: (state: LifecycleStatusUpdate) => void; // A function to set the lifecycle status of the component - setTransactionHash: (hash: string) => void; - fromToken?: Token; - to?: SwapUnit; - from?: SwapUnit; - toToken: Token; - transactionHash: string; - isDropdownOpen: boolean; - setIsDropdownOpen: (open: boolean) => void; - projectId: string; - startPopupMonitor: (popupWindow: Window) => void; -}; - -export type SwapLiteProviderReact = { - children: React.ReactNode; - config?: { - maxSlippage: number; // Maximum acceptable slippage for a swap. (default: 10) This is as a percent, not basis points - }; - experimental: { - useAggregator: boolean; // Whether to use a DEX aggregator. (default: true) - }; - isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) - onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. - onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt - fromToken?: Token; - toToken: Token; - projectId: string; -}; - /** * Note: exported as public Type */ From a43a105e274e68bd8d574581f4e87a43a83b3272 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 09:06:10 -0800 Subject: [PATCH 38/65] fix lint --- src/buy/components/Buy.test.tsx | 2 +- src/buy/components/BuyDropdown.test.tsx | 4 ++-- src/buy/components/BuyProvider.test.tsx | 1 - src/buy/utils/getBuyQuote.ts | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/buy/components/Buy.test.tsx b/src/buy/components/Buy.test.tsx index 4c9e7e990a..ebe8c93e11 100644 --- a/src/buy/components/Buy.test.tsx +++ b/src/buy/components/Buy.test.tsx @@ -1,9 +1,9 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { degenToken } from '../../token/constants'; import { useOutsideClick } from '../../ui/react/internal/hooks/useOutsideClick'; import { Buy } from './Buy'; import { useBuyContext } from './BuyProvider'; -import { degenToken } from '../../token/constants'; vi.mock('./BuyProvider', () => ({ useBuyContext: vi.fn(), diff --git a/src/buy/components/BuyDropdown.test.tsx b/src/buy/components/BuyDropdown.test.tsx index 66c00eff80..99dce5faa9 100644 --- a/src/buy/components/BuyDropdown.test.tsx +++ b/src/buy/components/BuyDropdown.test.tsx @@ -2,9 +2,9 @@ import { openPopup } from '@/ui-react/internal/utils/openPopup'; import { act, fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { useOnchainKit } from '../../core-react/useOnchainKit'; +import { degenToken, ethToken, usdcToken } from '../../token/constants'; import { BuyDropdown } from './BuyDropdown'; import { useBuyContext } from './BuyProvider'; -import { degenToken, ethToken, usdcToken } from '../../token/constants'; vi.mock('./BuyProvider', () => ({ useBuyContext: vi.fn(), @@ -23,7 +23,7 @@ vi.mock('../../fund/utils/getFundingPopupSize', () => ({ })); vi.mock('wagmi', async () => { - const actual = await vi.importActual('wagmi'); + const actual = await vi.importActual('wagmi'); return { ...actual, useAccount: () => ({ address: '0xMockAddress' }), diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index 3480e5b34f..37b3c41994 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -9,7 +9,6 @@ import { import React, { act, useCallback, useEffect } from 'react'; import type { TransactionReceipt } from 'viem'; import { - type Mock, afterEach, beforeEach, describe, diff --git a/src/buy/utils/getBuyQuote.ts b/src/buy/utils/getBuyQuote.ts index d73cbe9850..c07513ddc0 100644 --- a/src/buy/utils/getBuyQuote.ts +++ b/src/buy/utils/getBuyQuote.ts @@ -1,6 +1,5 @@ import { getSwapQuote } from '@/core/api/getSwapQuote'; import type { - APIError, GetSwapQuoteParams, GetSwapQuoteResponse, } from '@/core/api/types'; From c9daec438089167cf27ae729dfd94293733e0067 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 09:17:30 -0800 Subject: [PATCH 39/65] fix tests --- src/buy/components/Buy.test.tsx | 17 ++++++++++---- src/buy/components/BuyProvider.test.tsx | 31 ++++++++++++++++++++++--- src/buy/components/BuyProvider.tsx | 2 +- src/internal/svg/appleSvg.tsx | 2 +- src/internal/svg/cardSvg.tsx | 2 +- src/internal/svg/coinbaseLogoSvg.tsx | 2 +- 6 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/buy/components/Buy.test.tsx b/src/buy/components/Buy.test.tsx index ebe8c93e11..ba3606f6da 100644 --- a/src/buy/components/Buy.test.tsx +++ b/src/buy/components/Buy.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useOnchainKit } from '../../core-react/useOnchainKit'; import { degenToken } from '../../token/constants'; import { useOutsideClick } from '../../ui/react/internal/hooks/useOutsideClick'; import { Buy } from './Buy'; @@ -24,6 +25,10 @@ vi.mock('../../ui/react/internal/hooks/useOutsideClick', () => ({ useOutsideClick: vi.fn(), })); +vi.mock('../../core-react/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + type useOutsideClickType = ReturnType< typeof vi.fn< ( @@ -61,11 +66,15 @@ describe('Buy', () => { }, ); + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'mock-project-id', + }); + vi.clearAllMocks(); }); it('renders the Buy component', () => { - render(); + render(); expect(screen.getByText('Buy')).toBeInTheDocument(); expect(screen.getByText('DEGEN')).toBeInTheDocument(); @@ -88,7 +97,7 @@ describe('Buy', () => { }, }); - render(); + render(); expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined(); mockOutsideClickCallback({} as MouseEvent); @@ -113,7 +122,7 @@ describe('Buy', () => { }, }); - render(); + render(); expect(screen.getByTestId('mock-BuyDropdown')).toBeDefined(); fireEvent.click(screen.getByTestId('mock-BuyDropdown')); @@ -121,7 +130,7 @@ describe('Buy', () => { }); it('should not trigger click handler when dropdown is closed', () => { - render(); + render(); expect(screen.queryByTestId('mock-BuyDropdown')).not.toBeInTheDocument(); }); }); diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index 37b3c41994..a6a4e68804 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -9,6 +9,7 @@ import { import React, { act, useCallback, useEffect } from 'react'; import type { TransactionReceipt } from 'viem'; import { + type Mock, afterEach, beforeEach, describe, @@ -30,6 +31,7 @@ import { mock } from 'wagmi/connectors'; import { useSendCalls } from 'wagmi/experimental'; import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import { useOnchainKit } from '../../core-react/useOnchainKit'; import { getBuyQuote } from '../utils/getBuyQuote'; import { daiToken, @@ -62,6 +64,10 @@ vi.mock('../../swap/utils/processSwapTransaction', () => ({ processSwapTransaction: vi.fn(), })); +vi.mock('../../core-react/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + // vi.mock('../../swap/utils/isSwapError', () => ({ // isSwapError: vi.fn(), // })); @@ -165,6 +171,12 @@ const renderWithProviders = ({ }) => { const config = { maxSlippage: 10 }; const mockExperimental = { useAggregator: true }; + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'mock-project-id', + config: { + paymaster: undefined, + }, + }); return render( @@ -266,6 +278,12 @@ const TestSwapComponent = () => { describe('useBuyContext', () => { beforeEach(async () => { + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'mock-project-id', + config: { + paymaster: undefined, + }, + }); vi.resetAllMocks(); (useAccount as ReturnType).mockReturnValue({ address: '0x123', @@ -278,6 +296,7 @@ describe('useBuyContext', () => { (useSwitchChain as ReturnType).mockReturnValue({ switchChainAsync: mockSwitchChain, }); + await act(async () => { renderWithProviders({ Component: () => null }); }); @@ -332,6 +351,12 @@ describe('BuyProvider', () => { switchChainAsync: mockSwitchChain, }); (useCapabilitiesSafe as ReturnType).mockReturnValue({}); + (useOnchainKit as Mock).mockReturnValue({ + projectId: 'mock-project-id', + config: { + paymaster: undefined, + }, + }); }); it('should reset inputs when setLifecycleStatus is called with success', async () => { @@ -705,7 +730,7 @@ describe('BuyProvider', () => { expect(result.current.lifecycleStatus).toEqual({ statusName: 'error', statusData: expect.objectContaining({ - code: 'TmSPc01', + code: 'TmBPc02', error: JSON.stringify(mockError), message: '', }), @@ -773,7 +798,7 @@ describe('BuyProvider', () => { expect( screen.getByTestId('context-value-lifecycleStatus-statusData-code') .textContent, - ).toBe('TmSPc02'); + ).toBe('TmBPc03'); }); }); @@ -790,7 +815,7 @@ describe('BuyProvider', () => { expect( screen.getByTestId('context-value-lifecycleStatus-statusData-code') .textContent, - ).toBe('TmSPc02'); + ).toBe('TmBPc03'); }); }); diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index f6ef2418c4..c479facc21 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -56,6 +56,7 @@ export function BuyProvider({ }: BuyProviderReact) { const { config: { paymaster } = { paymaster: undefined }, + projectId, } = useOnchainKit(); const { address, chainId } = useAccount(); const { switchChainAsync } = useSwitchChain(); @@ -89,7 +90,6 @@ export function BuyProvider({ // Refreshes balances and inputs post-swap const resetInputs = useResetBuyInputs({ fromETH, fromUSDC, from, to }); - const { projectId } = useOnchainKit(); if (!projectId) { throw new Error( 'Buy: Project ID is required, please set the projectId in the OnchainKitProvider', diff --git a/src/internal/svg/appleSvg.tsx b/src/internal/svg/appleSvg.tsx index 34da7a1b14..3126142a29 100644 --- a/src/internal/svg/appleSvg.tsx +++ b/src/internal/svg/appleSvg.tsx @@ -6,7 +6,7 @@ export const appleSvg = ( id="Artwork" data-testid="appleSvg" > - Apple Pay + Apple Pay Onramp - Debit Card + Debit Card Onramp diff --git a/src/internal/svg/coinbaseLogoSvg.tsx b/src/internal/svg/coinbaseLogoSvg.tsx index 3d6a7e48a8..0ae2a6b268 100644 --- a/src/internal/svg/coinbaseLogoSvg.tsx +++ b/src/internal/svg/coinbaseLogoSvg.tsx @@ -10,7 +10,7 @@ export const coinbaseLogoSvg = ( className={cn(icon.foreground)} data-testid="coinbaseLogoSvg" > - Coinbase Pay + Coinbase Pay Onramp Date: Tue, 17 Dec 2024 09:41:50 -0800 Subject: [PATCH 40/65] refactor useOnrampeventlistener --- src/buy/components/BuyProvider.tsx | 44 +++------------- src/buy/hooks/useOnrampEventListeners.ts | 67 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 38 deletions(-) create mode 100644 src/buy/hooks/useOnrampEventListeners.ts diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index c479facc21..a6eccaccac 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -14,7 +14,6 @@ import { useValue } from '../../core-react/internal/hooks/useValue'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; import { setupOnrampEventListeners } from '../../fund'; -import type { EventMetadata } from '../../fund/types'; import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; import { useAwaitCalls } from '../../swap/hooks/useAwaitCalls'; import { useLifecycleStatus } from '../../swap/hooks/useLifecycleStatus'; @@ -24,6 +23,7 @@ import { processSwapTransaction } from '../../swap/utils/processSwapTransaction' import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants'; import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; import { useBuyTokens } from '../hooks/useBuyTokens'; +import { useOnrampEventListeners } from '../hooks/useOnrampEventListeners'; import { usePopupMonitor } from '../hooks/usePopupMonitor'; import { useResetBuyInputs } from '../hooks/useResetBuyInputs'; import type { BuyContextType, BuyProviderReact } from '../types'; @@ -103,43 +103,11 @@ export function BuyProvider({ updateLifecycleStatus, }); - const handleOnrampEvent = useCallback( - (data: EventMetadata) => { - if (data.eventName === 'transition_view') { - updateLifecycleStatus({ - statusName: 'transactionPending', - }); - } - }, - [updateLifecycleStatus], - ); - - const handleOnrampSuccess = useCallback(() => { - updateLifecycleStatus({ - statusName: 'success', - statusData: {}, - }); - }, [updateLifecycleStatus]); - - const onPopupClose = useCallback(() => { - updateLifecycleStatus({ - statusName: 'init', - statusData: { - isMissingRequiredField: false, - maxSlippage: config.maxSlippage, - }, - }); - }, [updateLifecycleStatus, config.maxSlippage]); - - useEffect(() => { - const unsubscribe = setupOnrampEventListeners({ - onEvent: handleOnrampEvent, - onSuccess: handleOnrampSuccess, - }); - return () => { - unsubscribe(); - }; - }, [handleOnrampEvent, handleOnrampSuccess]); + const { onPopupClose } = useOnrampEventListeners({ + updateLifecycleStatus, + setupOnrampEventListeners, + maxSlippage: config.maxSlippage, + }); // used to detect when the popup is closed in order to stop loading state const { startPopupMonitor } = usePopupMonitor(onPopupClose); diff --git a/src/buy/hooks/useOnrampEventListeners.ts b/src/buy/hooks/useOnrampEventListeners.ts new file mode 100644 index 0000000000..2aca6f7c6f --- /dev/null +++ b/src/buy/hooks/useOnrampEventListeners.ts @@ -0,0 +1,67 @@ +import type { EventMetadata } from '@/fund/types'; +import type { LifecycleStatus } from '@/swap/types'; +import { useEffect, useCallback } from 'react'; + +type UseOnrampLifecycleParams = { + updateLifecycleStatus: (status: LifecycleStatus) => void; + setupOnrampEventListeners: (listeners: { + onEvent: (data: EventMetadata) => void; + onSuccess: () => void; + }) => () => void; + maxSlippage: number; +}; + +export const useOnrampEventListeners = ({ + updateLifecycleStatus, + setupOnrampEventListeners, + maxSlippage, +}: UseOnrampLifecycleParams) => { + const handleOnrampEvent = useCallback( + (data: EventMetadata) => { + if (data.eventName === 'transition_view') { + updateLifecycleStatus({ + statusName: 'transactionPending', + statusData: { + isMissingRequiredField: false, + maxSlippage, + }, + }); + } + }, + [updateLifecycleStatus], + ); + + const handleOnrampSuccess = useCallback(() => { + updateLifecycleStatus({ + statusName: 'success', + statusData: { + isMissingRequiredField: false, + transactionReceipt: undefined, + maxSlippage, + }, + }); + }, [updateLifecycleStatus]); + + const onPopupClose = useCallback(() => { + updateLifecycleStatus({ + statusName: 'init', + statusData: { + isMissingRequiredField: false, + maxSlippage, + }, + }); + }, [updateLifecycleStatus, maxSlippage]); + + useEffect(() => { + const unsubscribe = setupOnrampEventListeners({ + onEvent: handleOnrampEvent, + onSuccess: handleOnrampSuccess, + }); + + return () => { + unsubscribe(); + }; + }, [setupOnrampEventListeners, handleOnrampEvent, handleOnrampSuccess]); + + return { onPopupClose }; +}; From ad8be9d77f3ca0141c349fac245f620e7546d392 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 09:54:04 -0800 Subject: [PATCH 41/65] add test coverage --- src/buy/components/BuyProvider.tsx | 2 - src/buy/hooks/useOnrampEventListeners.test.ts | 111 ++++++++++++++++++ src/buy/hooks/useOnrampEventListeners.ts | 6 +- 3 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/buy/hooks/useOnrampEventListeners.test.ts diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index a6eccaccac..dbe76f4c60 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -13,7 +13,6 @@ import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabili import { useValue } from '../../core-react/internal/hooks/useValue'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; -import { setupOnrampEventListeners } from '../../fund'; import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; import { useAwaitCalls } from '../../swap/hooks/useAwaitCalls'; import { useLifecycleStatus } from '../../swap/hooks/useLifecycleStatus'; @@ -105,7 +104,6 @@ export function BuyProvider({ const { onPopupClose } = useOnrampEventListeners({ updateLifecycleStatus, - setupOnrampEventListeners, maxSlippage: config.maxSlippage, }); diff --git a/src/buy/hooks/useOnrampEventListeners.test.ts b/src/buy/hooks/useOnrampEventListeners.test.ts new file mode 100644 index 0000000000..54664aec61 --- /dev/null +++ b/src/buy/hooks/useOnrampEventListeners.test.ts @@ -0,0 +1,111 @@ +import { renderHook, act } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupOnrampEventListeners } from '../../fund/utils/setupOnrampEventListeners'; +import { useOnrampEventListeners } from './useOnrampEventListeners'; + +vi.mock('../../fund/utils/setupOnrampEventListeners'); + +describe('useOnrampEventListeners', () => { + const mockUpdateLifecycleStatus = vi.fn(); + const mockUnsubscribe = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + const mockedSetupOnrampEventListeners = + setupOnrampEventListeners as unknown as Mock; + mockedSetupOnrampEventListeners.mockImplementation(() => mockUnsubscribe); + }); + + it('should call setupOnrampEventListeners and cleanup on unmount', () => { + const { unmount } = renderHook(() => + useOnrampEventListeners({ + updateLifecycleStatus: mockUpdateLifecycleStatus, + maxSlippage: 0.5, + }), + ); + + expect(setupOnrampEventListeners).toHaveBeenCalledWith({ + onEvent: expect.any(Function), + onSuccess: expect.any(Function), + }); + + unmount(); + expect(mockUnsubscribe).toHaveBeenCalled(); + }); + + it('should handle transition_view event', () => { + renderHook(() => + useOnrampEventListeners({ + updateLifecycleStatus: mockUpdateLifecycleStatus, + maxSlippage: 0.5, + }), + ); + + const mockedSetupOnrampEventListeners = + setupOnrampEventListeners as unknown as Mock; + const onEventCallback = + mockedSetupOnrampEventListeners.mock.calls[0][0].onEvent; + + act(() => { + onEventCallback({ + eventName: 'transition_view', + }); + }); + + expect(mockUpdateLifecycleStatus).toHaveBeenCalledWith({ + statusName: 'transactionPending', + statusData: { + isMissingRequiredField: false, + maxSlippage: 0.5, + }, + }); + }); + + it('should handle onramp success', () => { + renderHook(() => + useOnrampEventListeners({ + updateLifecycleStatus: mockUpdateLifecycleStatus, + maxSlippage: 0.5, + }), + ); + + const mockedSetupOnrampEventListeners = + setupOnrampEventListeners as unknown as Mock; + const onSuccessCallback = + mockedSetupOnrampEventListeners.mock.calls[0][0].onSuccess; + + act(() => { + onSuccessCallback(); + }); + + expect(mockUpdateLifecycleStatus).toHaveBeenCalledWith({ + statusName: 'success', + statusData: { + isMissingRequiredField: false, + transactionReceipt: undefined, + maxSlippage: 0.5, + }, + }); + }); + + it('should handle popup close', () => { + const { result } = renderHook(() => + useOnrampEventListeners({ + updateLifecycleStatus: mockUpdateLifecycleStatus, + maxSlippage: 0.5, + }), + ); + + act(() => { + result.current.onPopupClose(); + }); + + expect(mockUpdateLifecycleStatus).toHaveBeenCalledWith({ + statusName: 'init', + statusData: { + isMissingRequiredField: false, + maxSlippage: 0.5, + }, + }); + }); +}); diff --git a/src/buy/hooks/useOnrampEventListeners.ts b/src/buy/hooks/useOnrampEventListeners.ts index 2aca6f7c6f..25b6ab6965 100644 --- a/src/buy/hooks/useOnrampEventListeners.ts +++ b/src/buy/hooks/useOnrampEventListeners.ts @@ -1,19 +1,15 @@ import type { EventMetadata } from '@/fund/types'; import type { LifecycleStatus } from '@/swap/types'; import { useEffect, useCallback } from 'react'; +import { setupOnrampEventListeners } from '../../fund/utils/setupOnrampEventListeners'; type UseOnrampLifecycleParams = { updateLifecycleStatus: (status: LifecycleStatus) => void; - setupOnrampEventListeners: (listeners: { - onEvent: (data: EventMetadata) => void; - onSuccess: () => void; - }) => () => void; maxSlippage: number; }; export const useOnrampEventListeners = ({ updateLifecycleStatus, - setupOnrampEventListeners, maxSlippage, }: UseOnrampLifecycleParams) => { const handleOnrampEvent = useCallback( From ebf9200754c026bee8d78a9d74c6dd2c132f7c86 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 10:03:12 -0800 Subject: [PATCH 42/65] add test coverage --- src/buy/hooks/usePopupMonitor.test.ts | 107 ++++++++++++++++++++++++++ src/buy/hooks/usePopupMonitor.ts | 2 +- 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 src/buy/hooks/usePopupMonitor.test.ts diff --git a/src/buy/hooks/usePopupMonitor.test.ts b/src/buy/hooks/usePopupMonitor.test.ts new file mode 100644 index 0000000000..371533dcad --- /dev/null +++ b/src/buy/hooks/usePopupMonitor.test.ts @@ -0,0 +1,107 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { usePopupMonitor } from './usePopupMonitor'; + +describe('usePopupMonitor', () => { + let popupWindow: { closed: boolean }; + let onCloseMock: () => void; + + beforeEach(() => { + popupWindow = { + closed: false, + }; + onCloseMock = vi.fn(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.resetAllMocks(); + vi.restoreAllMocks(); + }); + + it('calls onClose when the popup is closed', () => { + const { result } = renderHook(() => usePopupMonitor(onCloseMock)); + + act(() => { + result.current.startPopupMonitor(popupWindow as unknown as Window); + }); + + act(() => { + popupWindow.closed = true; + vi.advanceTimersByTime(500); + }); + + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('does not call onClose when the popup remains open', () => { + const { result } = renderHook(() => usePopupMonitor(onCloseMock)); + + act(() => { + result.current.startPopupMonitor(popupWindow as unknown as Window); + }); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(onCloseMock).not.toHaveBeenCalled(); + }); + + it('cleans up interval when the component is unmounted', () => { + const { result, unmount } = renderHook(() => usePopupMonitor(onCloseMock)); + + act(() => { + result.current.startPopupMonitor(popupWindow as unknown as Window); + }); + + act(() => { + unmount(); + }); + + expect(() => vi.runOnlyPendingTimers()).not.toThrow(); + expect(onCloseMock).not.toHaveBeenCalled(); + }); + + it('stops monitoring the popup when stopPopupMonitor is called', () => { + const { result } = renderHook(() => usePopupMonitor(onCloseMock)); + + act(() => { + result.current.startPopupMonitor(popupWindow as unknown as Window); + }); + + act(() => { + result.current.stopPopupMonitor(); + }); + + act(() => { + popupWindow.closed = true; + vi.advanceTimersByTime(500); + }); + + expect(onCloseMock).not.toHaveBeenCalled(); + }); + + it('clears the previous interval before starting a new one', () => { + const { result } = renderHook(() => usePopupMonitor(onCloseMock)); + + // Start monitoring the first popup + act(() => { + result.current.startPopupMonitor(popupWindow as unknown as Window); + }); + + // Mock and spy on clearInterval + const clearIntervalSpy = vi.spyOn(global, 'clearInterval'); + + // Start monitoring a second popup + act(() => { + result.current.startPopupMonitor(popupWindow as unknown as Window); + }); + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1); // clearInterval should be called once + + clearIntervalSpy.mockRestore(); + }); +}); diff --git a/src/buy/hooks/usePopupMonitor.ts b/src/buy/hooks/usePopupMonitor.ts index 7668e05013..4efb5c5da8 100644 --- a/src/buy/hooks/usePopupMonitor.ts +++ b/src/buy/hooks/usePopupMonitor.ts @@ -35,5 +35,5 @@ export const usePopupMonitor = (onClose?: () => void) => { return () => stopPopupMonitor(); }, [stopPopupMonitor]); - return { startPopupMonitor }; + return { startPopupMonitor, stopPopupMonitor }; }; From 81e7ae4679636bc4237b175ab3dd014e930b3ee2 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 10:10:57 -0800 Subject: [PATCH 43/65] add test coverage --- src/buy/components/BuyProvider.test.tsx | 20 ++++++++++++++++++++ src/buy/components/BuyProvider.tsx | 21 ++++++++++++++------- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index a6a4e68804..7d49622ffb 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -737,6 +737,26 @@ describe('BuyProvider', () => { }); }); + it('should setLifecycleStatus to error when projectId is not provided', async () => { + (useOnchainKit as Mock).mockReturnValue({ + projectId: undefined, + config: { + paymaster: undefined, + }, + }); + + const { result } = renderHook(() => useBuyContext(), { wrapper }); + expect(result.current.lifecycleStatus).toEqual({ + statusName: 'error', + statusData: expect.objectContaining({ + code: 'TmBPc04', + error: + 'Project ID is required, please set the projectId in the OnchainKitProvider', + message: '', + }), + }); + }); + // it('should setLifecycleStatus to error when getBuyQuote returns an error', async () => { // vi.mocked(getBuyQuote).mockResolvedValueOnce({ // error: 'Something went wrong' as unknown as APIError, diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index dbe76f4c60..33ad43faaf 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -88,13 +88,6 @@ export function BuyProvider({ // Refreshes balances and inputs post-swap const resetInputs = useResetBuyInputs({ fromETH, fromUSDC, from, to }); - - if (!projectId) { - throw new Error( - 'Buy: Project ID is required, please set the projectId in the OnchainKitProvider', - ); - } - // For batched transactions, listens to and awaits calls from the Wallet server const awaitCallsStatus = useAwaitCalls({ accountConfig, @@ -135,6 +128,20 @@ export function BuyProvider({ lifecycleStatus.statusName, // Keep statusName, so that the effect runs when it changes ]); + useEffect(() => { + if (!projectId) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmBPc04', + error: + 'Project ID is required, please set the projectId in the OnchainKitProvider', + message: '', + }, + }); + } + }, [projectId]); + useEffect(() => { // Reset inputs after status reset. `resetInputs` is dependent // on 'from' and 'to' so moved to separate useEffect to From ddd20db37a4d04938076414974938148c5150b64 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 11:29:51 -0800 Subject: [PATCH 44/65] add test coverage --- src/buy/components/BuyProvider.test.tsx | 166 ++++++++++-------------- 1 file changed, 66 insertions(+), 100 deletions(-) diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index 7d49622ffb..e6c848941c 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -42,6 +42,7 @@ import { import type { LifecycleStatus, SwapError, SwapUnit } from '../../swap/types'; import { getSwapErrorCode } from '../../swap/utils/getSwapErrorCode'; import { BuyProvider, useBuyContext } from './BuyProvider'; +import { useBuyTokens } from '../hooks/useBuyTokens'; import { APIError, GetSwapQuoteResponse } from '@/core/api'; // import { isSwapError } from '../../swap/utils/isSwapError'; @@ -54,6 +55,10 @@ vi.mock('../utils/getBuyQuote', () => ({ getBuyQuote: vi.fn(), })); +vi.mock('../hooks/useBuyTokens', () => ({ + useBuyTokens: vi.fn(), +})); + vi.mock('../../core/api/buildSwapTransaction', () => ({ buildSwapTransaction: vi .fn() @@ -68,10 +73,6 @@ vi.mock('../../core-react/useOnchainKit', () => ({ useOnchainKit: vi.fn(), })); -// vi.mock('../../swap/utils/isSwapError', () => ({ -// isSwapError: vi.fn(), -// })); - const mockSwitchChain = vi.fn(); vi.mock('wagmi', async (importOriginal) => { return { @@ -107,7 +108,7 @@ const queryClient = new QueryClient(); const mockFromDai: SwapUnit = { balance: '100', - amount: '50', + amount: '', setAmount: vi.fn(), setAmountUSD: vi.fn(), token: daiToken, @@ -116,6 +117,28 @@ const mockFromDai: SwapUnit = { error: undefined, } as unknown as SwapUnit; +const mockToDegen: SwapUnit = { + balance: '100', + amount: '', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: degenToken, + loading: false, + setLoading: vi.fn(), + error: undefined, +} as unknown as SwapUnit; + +const mockFromUsdc: SwapUnit = { + balance: '100', + amount: '', + setAmount: vi.fn(), + setAmountUSD: vi.fn(), + token: usdcToken, + loading: false, + setLoading: vi.fn(), + error: undefined, +} as unknown as SwapUnit; + const mockFromEth: SwapUnit = { balance: '100', amount: '50', @@ -297,6 +320,13 @@ describe('useBuyContext', () => { switchChainAsync: mockSwitchChain, }); + (useBuyTokens as Mock).mockReturnValue({ + from: mockFromDai, + to: mockToDegen, + fromETH: mockFromEth, + fromUSDC: mockFromUsdc, + }); + await act(async () => { renderWithProviders({ Component: () => null }); }); @@ -357,6 +387,12 @@ describe('BuyProvider', () => { paymaster: undefined, }, }); + (useBuyTokens as Mock).mockReturnValue({ + from: mockFromDai, + to: mockToDegen, + fromETH: mockFromEth, + fromUSDC: mockFromUsdc, + }); }); it('should reset inputs when setLifecycleStatus is called with success', async () => { @@ -476,82 +512,6 @@ describe('BuyProvider', () => { }); }); - // it.only('should update lifecycle status correctly after fetching quote for to token', async () => { - // vi.mocked(getBuyQuote).mockResolvedValueOnce({ - // formattedFromAmount: '1000', - // response: { - // toAmount: '1000', - // toAmountUSD: '$100', - // to: { - // decimals: 10, - // }, - // } as unknown as GetSwapQuoteResponse, - // } as unknown as GetBuyQuoteResponse); - - // (isSwapError as Mock).mockReturnValueOnce(false); - - // const { result } = renderHook(() => useBuyContext(), { wrapper }); - - // await act(async () => { - // result.current.handleAmountChange('10'); - // }); - // expect(result.current.lifecycleStatus).toStrictEqual({ - // statusName: 'amountChange', - // statusData: { - // amountETH: '', - // amountUSDC: '', - // amountFrom: '10', - // amountTo: '1e-9', - // isMissingRequiredField: false, - // maxSlippage: 5, - // tokenFromUSDC: usdcToken, - // tokenFromETH: ethToken, - // tokenTo: degenToken, - // tokenFrom: undefined - // }, - // }); - // }); - - // it('should update lifecycle status correctly after fetching quote for from token', async () => { - // vi.mocked(getBuyQuote).mockResolvedValueOnce({ - // toAmount: '10', - // to: { - // decimals: 10, - // }, - // } as unknown as GetBuyQuoteResponse); - // const { result } = renderHook(() => useBuyContext(), { wrapper }); - // await act(async () => { - // result.current.handleAmountChange('15'); - // }); - // expect(result.current.lifecycleStatus).toStrictEqual({ - // statusName: 'amountChange', - // statusData: { - // amountFrom: '1e-9', - // amountTo: '10', - // isMissingRequiredField: false, - // maxSlippage: 5, - // tokenTo: { - // address: '', - // name: 'ETH', - // symbol: 'ETH', - // chainId: 8453, - // decimals: 18, - // image: - // 'https://wallet-api-production.s3.amazonaws.com/uploads/tokens/eth_288.png', - // }, - // tokenFrom: { - // address: '0x4ed4e862860bed51a9570b96d89af5e1b0efefed', - // name: 'DEGEN', - // symbol: 'DEGEN', - // chainId: 8453, - // decimals: 18, - // image: - // 'https://d3r81g40ycuhqg.cloudfront.net/wallet/wais/3b/bf/3bbf118b5e6dc2f9e7fc607a6e7526647b4ba8f0bea87125f971446d57b296d2-MDNmNjY0MmEtNGFiZi00N2I0LWIwMTItMDUyMzg2ZDZhMWNm', - // }, - // }, - // }); - // }); - it('should emit onStatus when setLifecycleStatus is called with transactionPending', async () => { const onStatusMock = vi.fn(); renderWithProviders({ @@ -644,6 +604,25 @@ describe('BuyProvider', () => { ); }); + it('should set lifecycle status to amountChange with missing required fields when to token is undefined', async () => { + (useBuyTokens as Mock).mockReturnValue({ + from: mockFromDai, + to: { ...mockToDegen, token: undefined }, + fromETH: mockFromEth, + fromUSDC: mockFromUsdc, + }); + const { result } = renderHook(() => useBuyContext(), { wrapper }); + await act(async () => { + result.current.handleAmountChange('10'); + }); + expect(result.current.lifecycleStatus).toEqual({ + statusName: 'amountChange', + statusData: expect.objectContaining({ + isMissingRequiredField: true, + }), + }); + }); + it('should pass the correct amountReference to get', async () => { const TestComponent = () => { const { handleAmountChange } = useBuyContext(); @@ -757,26 +736,13 @@ describe('BuyProvider', () => { }); }); - // it('should setLifecycleStatus to error when getBuyQuote returns an error', async () => { - // vi.mocked(getBuyQuote).mockResolvedValueOnce({ - // error: 'Something went wrong' as unknown as APIError, - // }); - - // const { result } = renderHook(() => useBuyContext(), { wrapper }); - // await act(async () => { - // result.current.handleAmountChange('10'); - // }); - // expect(result.current.lifecycleStatus).toEqual({ - // statusName: 'error', - // statusData: expect.objectContaining({ - // code: 'TmSPc01', - // error: 'Something went wrong', - // message: '', - // }), - // }); - // }); - it('should handle submit correctly', async () => { + (useBuyTokens as Mock).mockReturnValue({ + from: mockFromDai, + to: { ...mockToDegen, amount: '50' }, + fromETH: { ...mockFromEth, amount: '100' }, + fromUSDC: mockFromUsdc, + }); await act(async () => { renderWithProviders({ Component: TestSwapComponent }); }); From e8672f6b274444b4a64f8539254677070b6bd33b Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 11:38:47 -0800 Subject: [PATCH 45/65] re require transactionReceipt --- src/buy/components/BuyProvider.tsx | 4 ++-- src/buy/hooks/useOnrampEventListeners.ts | 3 ++- src/swap/components/SwapProvider.tsx | 4 ++-- src/swap/types.ts | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/buy/components/BuyProvider.tsx b/src/buy/components/BuyProvider.tsx index 33ad43faaf..aa6d2ec605 100644 --- a/src/buy/components/BuyProvider.tsx +++ b/src/buy/components/BuyProvider.tsx @@ -111,9 +111,9 @@ export function BuyProvider({ } // Success if (lifecycleStatus.statusName === 'success') { - onSuccess?.(lifecycleStatus?.statusData?.transactionReceipt); + onSuccess?.(lifecycleStatus?.statusData.transactionReceipt); setTransactionHash( - lifecycleStatus.statusData.transactionReceipt?.transactionHash || '', + lifecycleStatus.statusData.transactionReceipt?.transactionHash, ); setHasHandledSuccess(true); } diff --git a/src/buy/hooks/useOnrampEventListeners.ts b/src/buy/hooks/useOnrampEventListeners.ts index 25b6ab6965..0642cd3c67 100644 --- a/src/buy/hooks/useOnrampEventListeners.ts +++ b/src/buy/hooks/useOnrampEventListeners.ts @@ -2,6 +2,7 @@ import type { EventMetadata } from '@/fund/types'; import type { LifecycleStatus } from '@/swap/types'; import { useEffect, useCallback } from 'react'; import { setupOnrampEventListeners } from '../../fund/utils/setupOnrampEventListeners'; +import { TransactionReceipt } from 'viem'; type UseOnrampLifecycleParams = { updateLifecycleStatus: (status: LifecycleStatus) => void; @@ -32,7 +33,7 @@ export const useOnrampEventListeners = ({ statusName: 'success', statusData: { isMissingRequiredField: false, - transactionReceipt: undefined, + transactionReceipt: {} as TransactionReceipt, maxSlippage, }, }); diff --git a/src/swap/components/SwapProvider.tsx b/src/swap/components/SwapProvider.tsx index 73fe73dcec..206f0b85f1 100644 --- a/src/swap/components/SwapProvider.tsx +++ b/src/swap/components/SwapProvider.tsx @@ -95,9 +95,9 @@ export function SwapProvider({ } // Success if (lifecycleStatus.statusName === 'success') { - onSuccess?.(lifecycleStatus.statusData?.transactionReceipt); + onSuccess?.(lifecycleStatus.statusData.transactionReceipt); setTransactionHash( - lifecycleStatus.statusData?.transactionReceipt?.transactionHash ?? '', + lifecycleStatus.statusData?.transactionReceipt.transactionHash, ); setHasHandledSuccess(true); setIsToastVisible(true); diff --git a/src/swap/types.ts b/src/swap/types.ts index 1c2251dcb0..4c9079b06f 100644 --- a/src/swap/types.ts +++ b/src/swap/types.ts @@ -122,7 +122,7 @@ export type LifecycleStatus = | { statusName: 'success'; statusData: { - transactionReceipt?: TransactionReceipt; + transactionReceipt: TransactionReceipt; } & LifecycleStatusDataShared; }; @@ -285,7 +285,7 @@ export type SwapProviderReact = { isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt }; /** @@ -301,7 +301,7 @@ export type SwapReact = { isSponsored?: boolean; // An optional setting to sponsor swaps with a Paymaster. (default: false) onError?: (error: SwapError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifecycleStatus: LifecycleStatus) => void; // An optional callback function that exposes the component lifecycle state - onSuccess?: (transactionReceipt?: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt + onSuccess?: (transactionReceipt: TransactionReceipt) => void; // An optional callback function that exposes the transaction receipt title?: string; // Title for the Swap component. (default: "Swap") }; From 0b88800b2043f9978f4ecf3e531ac312ede02698 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 11:40:32 -0800 Subject: [PATCH 46/65] fix test --- src/buy/hooks/useOnrampEventListeners.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/hooks/useOnrampEventListeners.test.ts b/src/buy/hooks/useOnrampEventListeners.test.ts index 54664aec61..a830e8c833 100644 --- a/src/buy/hooks/useOnrampEventListeners.test.ts +++ b/src/buy/hooks/useOnrampEventListeners.test.ts @@ -82,7 +82,7 @@ describe('useOnrampEventListeners', () => { statusName: 'success', statusData: { isMissingRequiredField: false, - transactionReceipt: undefined, + transactionReceipt: {}, maxSlippage: 0.5, }, }); From ec56e5a3f6cca14a72e89a8205b1c7371ec517ee Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 12:32:45 -0800 Subject: [PATCH 47/65] update buy button --- src/buy/components/Buy.test.tsx | 22 +++++++++++++++ src/buy/components/BuyButton.test.tsx | 39 +++++++++++++++++++++++++++ src/buy/components/BuyButton.tsx | 6 +++++ 3 files changed, 67 insertions(+) diff --git a/src/buy/components/Buy.test.tsx b/src/buy/components/Buy.test.tsx index ba3606f6da..de5a64b406 100644 --- a/src/buy/components/Buy.test.tsx +++ b/src/buy/components/Buy.test.tsx @@ -1,5 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type Config, + type UseConnectReturnType, + useAccount, + useConnect, +} from 'wagmi'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { degenToken } from '../../token/constants'; import { useOutsideClick } from '../../ui/react/internal/hooks/useOutsideClick'; @@ -29,6 +35,11 @@ vi.mock('../../core-react/useOnchainKit', () => ({ useOnchainKit: vi.fn(), })); +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useConnect: vi.fn(), +})); + type useOutsideClickType = ReturnType< typeof vi.fn< ( @@ -58,8 +69,19 @@ describe('Buy', () => { amount: 10, setAmount: vi.fn(), }, + address: '0x123', }); + (useAccount as Mock).mockReturnValue({ + address: '0x123', + }); + + vi.mocked(useConnect).mockReturnValue({ + connectors: [{ id: 'mockConnector' }], + connect: vi.fn(), + status: 'connected', + } as unknown as UseConnectReturnType); + (useOutsideClick as unknown as useOutsideClickType).mockImplementation( (_, callback) => { mockOutsideClickCallback = callback; diff --git a/src/buy/components/BuyButton.test.tsx b/src/buy/components/BuyButton.test.tsx index af4dfa0613..4a3ec26d5a 100644 --- a/src/buy/components/BuyButton.test.tsx +++ b/src/buy/components/BuyButton.test.tsx @@ -1,5 +1,12 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type Config, + type UseAccountReturnType, + type UseConnectReturnType, + useAccount, + useConnect, +} from 'wagmi'; import { BuyButton } from './BuyButton'; import { useBuyContext } from './BuyProvider'; @@ -15,6 +22,11 @@ vi.mock('../../internal/svg/checkmarkSvg', () => ({ checkmarkSvg: , })); +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useConnect: vi.fn(), +})); + describe('BuyButton', () => { const mockSetIsDropdownOpen = vi.fn(); @@ -27,6 +39,7 @@ describe('BuyButton', () => { fromUSDC: { loading: false }, to: { loading: false, amount: 10, token: 'ETH' }, lifecycleStatus: { statusName: 'idle' }, + address: '0x123', }); }); @@ -47,6 +60,7 @@ describe('BuyButton', () => { fromUSDC: { loading: false }, to: { loading: false, amount: 10, token: 'ETH' }, lifecycleStatus: { statusName: 'idle' }, + address: '0x123', }); render(); @@ -62,6 +76,7 @@ describe('BuyButton', () => { fromUSDC: { loading: false }, to: { loading: false, amount: 10, token: 'ETH' }, lifecycleStatus: { statusName: 'success' }, + address: '0x123', }); render(); @@ -77,6 +92,7 @@ describe('BuyButton', () => { fromUSDC: { loading: false }, to: { loading: false, amount: null, token: null }, lifecycleStatus: { statusName: 'idle' }, + address: '0x123', }); render(); @@ -93,4 +109,27 @@ describe('BuyButton', () => { expect(mockSetIsDropdownOpen).toHaveBeenCalledWith(true); }); + + it('should render ConnectWallet if disconnected and no missing fields', () => { + (useBuyContext as Mock).mockReturnValue({ + setIsDropdownOpen: mockSetIsDropdownOpen, + from: { loading: false }, + fromETH: { loading: false }, + fromUSDC: { loading: false }, + to: { loading: false, amount: 10, token: 'ETH' }, + lifecycleStatus: { statusName: 'idle' }, + }); + vi.mocked(useAccount).mockReturnValue({ + address: '', + status: 'disconnected', + } as unknown as UseAccountReturnType); + vi.mocked(useConnect).mockReturnValue({ + connectors: [{ id: 'mockConnector' }], + connect: vi.fn(), + status: 'idle', + } as unknown as UseConnectReturnType); + render(); + const button = screen.getByTestId('ockConnectWallet_Container'); + expect(button).toBeDefined(); + }); }); diff --git a/src/buy/components/BuyButton.tsx b/src/buy/components/BuyButton.tsx index b741e7bac7..0592832077 100644 --- a/src/buy/components/BuyButton.tsx +++ b/src/buy/components/BuyButton.tsx @@ -9,10 +9,12 @@ import { pressable, text, } from '../../styles/theme'; +import { ConnectWallet } from '../../wallet'; import { useBuyContext } from './BuyProvider'; export function BuyButton() { const { + address, setIsDropdownOpen, from, fromETH, @@ -41,6 +43,10 @@ export function BuyButton() { return 'Buy'; }, [statusName]); + if (!isDisabled && !address) { + return ; + } + return (
diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index d2ca0d617d..684996b55b 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -30,20 +30,20 @@ import { base } from 'wagmi/chains'; import { mock } from 'wagmi/connectors'; import { useSendCalls } from 'wagmi/experimental'; import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; -import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; import { useOnchainKit } from '../../core-react/useOnchainKit'; -import { getBuyQuote } from '../utils/getBuyQuote'; +import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import type { LifecycleStatus, SwapError, SwapUnit } from '../../swap/types'; +import { getSwapErrorCode } from '../../swap/utils/getSwapErrorCode'; import { daiToken, degenToken, ethToken, usdcToken, } from '../../token/constants'; -import type { LifecycleStatus, SwapError, SwapUnit } from '../../swap/types'; -import { getSwapErrorCode } from '../../swap/utils/getSwapErrorCode'; -import { BuyProvider, useBuyContext } from './BuyProvider'; import { useBuyTokens } from '../hooks/useBuyTokens'; +import { getBuyQuote } from '../utils/getBuyQuote'; import { validateQuote } from '../utils/validateQuote'; +import { BuyProvider, useBuyContext } from './BuyProvider'; const mockResetFunction = vi.fn(); vi.mock('../hooks/useResetBuyInputs', () => ({ diff --git a/src/buy/hooks/useOnrampEventListeners.test.ts b/src/buy/hooks/useOnrampEventListeners.test.ts index a830e8c833..3a890b83dc 100644 --- a/src/buy/hooks/useOnrampEventListeners.test.ts +++ b/src/buy/hooks/useOnrampEventListeners.test.ts @@ -1,4 +1,4 @@ -import { renderHook, act } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; import { setupOnrampEventListeners } from '../../fund/utils/setupOnrampEventListeners'; import { useOnrampEventListeners } from './useOnrampEventListeners'; diff --git a/src/buy/hooks/useOnrampEventListeners.ts b/src/buy/hooks/useOnrampEventListeners.ts index 0642cd3c67..5f01541779 100644 --- a/src/buy/hooks/useOnrampEventListeners.ts +++ b/src/buy/hooks/useOnrampEventListeners.ts @@ -1,8 +1,8 @@ import type { EventMetadata } from '@/fund/types'; import type { LifecycleStatus } from '@/swap/types'; -import { useEffect, useCallback } from 'react'; +import { useCallback, useEffect } from 'react'; +import type { TransactionReceipt } from 'viem'; import { setupOnrampEventListeners } from '../../fund/utils/setupOnrampEventListeners'; -import { TransactionReceipt } from 'viem'; type UseOnrampLifecycleParams = { updateLifecycleStatus: (status: LifecycleStatus) => void; @@ -25,7 +25,7 @@ export const useOnrampEventListeners = ({ }); } }, - [updateLifecycleStatus], + [maxSlippage, updateLifecycleStatus], ); const handleOnrampSuccess = useCallback(() => { @@ -37,7 +37,7 @@ export const useOnrampEventListeners = ({ maxSlippage, }, }); - }, [updateLifecycleStatus]); + }, [maxSlippage, updateLifecycleStatus]); const onPopupClose = useCallback(() => { updateLifecycleStatus({ @@ -58,7 +58,7 @@ export const useOnrampEventListeners = ({ return () => { unsubscribe(); }; - }, [setupOnrampEventListeners, handleOnrampEvent, handleOnrampSuccess]); + }, [handleOnrampEvent, handleOnrampSuccess]); return { onPopupClose }; }; diff --git a/src/buy/hooks/usePopupMonitor.test.ts b/src/buy/hooks/usePopupMonitor.test.ts index 371533dcad..71e4feb637 100644 --- a/src/buy/hooks/usePopupMonitor.test.ts +++ b/src/buy/hooks/usePopupMonitor.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { act } from 'react'; -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { usePopupMonitor } from './usePopupMonitor'; describe('usePopupMonitor', () => { diff --git a/src/buy/utils/validateQuote.test.ts b/src/buy/utils/validateQuote.test.ts index 96f1209d1f..206d885c0b 100644 --- a/src/buy/utils/validateQuote.test.ts +++ b/src/buy/utils/validateQuote.test.ts @@ -1,8 +1,8 @@ -import { type Mock, beforeEach, vi, expect, it, describe } from 'vitest'; -import { validateQuote } from './validateQuote'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GetSwapQuoteResponse } from '../../core/api/types'; +import type { SwapUnit } from '../../swap/types'; import { isSwapError } from '../../swap/utils/isSwapError'; -import { SwapUnit } from '../../swap/types'; -import { GetSwapQuoteResponse } from '../../core/api/types'; +import { validateQuote } from './validateQuote'; vi.mock('../../swap/utils/isSwapError', () => ({ isSwapError: vi.fn(), diff --git a/src/buy/utils/validateQuote.ts b/src/buy/utils/validateQuote.ts index d44808da16..28d6e30c36 100644 --- a/src/buy/utils/validateQuote.ts +++ b/src/buy/utils/validateQuote.ts @@ -1,5 +1,5 @@ import type { GetSwapQuoteResponse } from '@/core/api/types'; -import type { SwapUnit, LifecycleStatusUpdate } from '@/swap/types'; +import type { LifecycleStatusUpdate, SwapUnit } from '@/swap/types'; import { isSwapError } from '../../swap/utils/isSwapError'; type ValidateQuoteParams = { From 27320d902d7bf8ca9494c7eed7f88e5174ee6d9e Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 14:50:08 -0800 Subject: [PATCH 50/65] adjust styling --- src/buy/components/Buy.tsx | 4 +++- src/buy/components/BuyAmountInput.tsx | 13 ++++++++++--- src/buy/components/BuyButton.tsx | 15 ++++++++++++--- src/buy/components/BuyDropdown.tsx | 4 ++-- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/buy/components/Buy.tsx b/src/buy/components/Buy.tsx index 693ed6bfdb..302b16caef 100644 --- a/src/buy/components/Buy.tsx +++ b/src/buy/components/Buy.tsx @@ -1,5 +1,6 @@ import { useOutsideClick } from '@/ui-react/internal/hooks/useOutsideClick'; import { useRef } from 'react'; +import { useTheme } from '../../core-react/internal/hooks/useTheme'; import { cn } from '../../styles/theme'; import { FALLBACK_DEFAULT_MAX_SLIPPAGE } from '../../swap/constants'; import type { BuyReact } from '../types'; @@ -10,6 +11,7 @@ import { BuyMessage } from './BuyMessage'; import { BuyProvider, useBuyContext } from './BuyProvider'; function BuyContent({ className }: { className?: string }) { + const componentTheme = useTheme(); const { isDropdownOpen, setIsDropdownOpen } = useBuyContext(); const buyContainerRef = useRef(null); @@ -22,7 +24,7 @@ function BuyContent({ className }: { className?: string }) { return (
diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx index e40e820ff0..7350145419 100644 --- a/src/buy/components/BuyAmountInput.tsx +++ b/src/buy/components/BuyAmountInput.tsx @@ -1,6 +1,6 @@ import { isValidAmount } from '../../core/utils/isValidAmount'; import { TextInput } from '../../internal/components/TextInput'; -import { cn, color } from '../../styles/theme'; +import { background, cn, color } from '../../styles/theme'; import { formatAmount } from '../../swap/utils/formatAmount'; import { TokenChip } from '../../token'; import { useBuyContext } from './BuyProvider'; @@ -13,11 +13,18 @@ export function BuyAmountInput() { } return ( -
+
{ - setIsDropdownOpen(true); - }, [setIsDropdownOpen]); + if (isDropdownOpen) { + setIsDropdownOpen(false); + } else { + setIsDropdownOpen(true); + } + }, [setIsDropdownOpen, isDropdownOpen]); const buttonContent = useMemo(() => { if (statusName === 'success') { return checkmarkSvg; } + if (isDropdownOpen) { + return closeSvg; + } return 'Buy'; - }, [statusName]); + }, [statusName, isDropdownOpen]); if (!isDisabled && !address) { return ; diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index b195148d74..21c276060a 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -64,9 +64,9 @@ export function BuyDropdown() {
Buy with
From a082d8e810ad5839308477e6e48e2f1417750d5f Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 14:56:26 -0800 Subject: [PATCH 51/65] fix lint --- src/buy/components/BuyDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index 21c276060a..4659258eb0 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -66,7 +66,7 @@ export function BuyDropdown() { color.foreground, background.default, 'absolute right-0 bottom-0 flex translate-y-[105%] flex-col gap-2', - 'border min-w-80 rounded p-2 rounded-lg', + 'min-w-80 rounded rounded-lg border p-2', )} >
Buy with
From 81177578fc3a997c2bcf23fd65ebee1442719c51 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 15:14:55 -0800 Subject: [PATCH 52/65] add test coverage --- src/buy/components/BuyButton.test.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/buy/components/BuyButton.test.tsx b/src/buy/components/BuyButton.test.tsx index 4a3ec26d5a..b440cb75a8 100644 --- a/src/buy/components/BuyButton.test.tsx +++ b/src/buy/components/BuyButton.test.tsx @@ -110,6 +110,26 @@ describe('BuyButton', () => { expect(mockSetIsDropdownOpen).toHaveBeenCalledWith(true); }); + it('calls setIsDropdownOpen when clicked and dropdown is open', () => { + (useBuyContext as Mock).mockReturnValue({ + setIsDropdownOpen: mockSetIsDropdownOpen, + isDropdownOpen: true, + from: { loading: false }, + fromETH: { loading: false }, + fromUSDC: { loading: false }, + to: { loading: false, amount: 10, token: 'ETH' }, + lifecycleStatus: { statusName: 'idle' }, + address: '0x123', + }); + + render(); + + const button = screen.getByTestId('ockBuyButton_Button'); + fireEvent.click(button); + + expect(mockSetIsDropdownOpen).toHaveBeenCalledWith(false); + }); + it('should render ConnectWallet if disconnected and no missing fields', () => { (useBuyContext as Mock).mockReturnValue({ setIsDropdownOpen: mockSetIsDropdownOpen, From 8c13dd2a769c494ee2b857926aff4b2090854c84 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 15:41:34 -0800 Subject: [PATCH 53/65] add test coverage --- src/buy/components/BuyProvider.test.tsx | 27 ++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index 684996b55b..46f230dbe0 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -32,6 +32,7 @@ import { useSendCalls } from 'wagmi/experimental'; import { useCapabilitiesSafe } from '../../core-react/internal/hooks/useCapabilitiesSafe'; import { useOnchainKit } from '../../core-react/useOnchainKit'; import { buildSwapTransaction } from '../../core/api/buildSwapTransaction'; +import type { GetSwapQuoteResponse } from '../../core/api/types'; import type { LifecycleStatus, SwapError, SwapUnit } from '../../swap/types'; import { getSwapErrorCode } from '../../swap/utils/getSwapErrorCode'; import { @@ -224,11 +225,6 @@ const renderWithProviders = ({ const TestSwapComponent = () => { const context = useBuyContext(); - useEffect(() => { - context?.from?.setToken?.(daiToken); - context?.from?.setAmount?.('100'); - context?.to?.setToken?.(degenToken); - }, [context]); const handleStatusError = async () => { context.updateLifecycleStatus({ statusName: 'error', @@ -401,6 +397,27 @@ describe('BuyProvider', () => { }); }); + it('should return response', async () => { + const mockResponse = { + response: { amountUsd: '10' }, + } as unknown as GetSwapQuoteResponse; + vi.mocked(getBuyQuote).mockResolvedValue({ response: mockResponse }); + const { result } = renderHook(() => useBuyContext(), { wrapper }); + // console.log('result', result); + await act(async () => { + result.current.handleAmountChange('10'); + }); + + // expect(result.current.to?.amount).toBe('10'); + expect(validateQuote).toHaveBeenCalledWith({ + to: mockToDegen, + responseETH: mockResponse, + responseUSDC: mockResponse, + responseFrom: mockResponse, + updateLifecycleStatus: expect.any(Function), + }); + }); + it('should reset inputs when setLifecycleStatus is called with success', async () => { const { result } = renderHook(() => useBuyContext(), { wrapper }); await act(async () => { From ede2e92ecaa96d505334642572186142ec32221e Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 15:55:25 -0800 Subject: [PATCH 54/65] add test coverage --- src/buy/components/BuyProvider.test.tsx | 82 +++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 4 deletions(-) diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index 46f230dbe0..8dbc1a5f19 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -397,18 +397,15 @@ describe('BuyProvider', () => { }); }); - it('should return response', async () => { + it('should call validateQuote with responses', async () => { const mockResponse = { response: { amountUsd: '10' }, } as unknown as GetSwapQuoteResponse; vi.mocked(getBuyQuote).mockResolvedValue({ response: mockResponse }); const { result } = renderHook(() => useBuyContext(), { wrapper }); - // console.log('result', result); await act(async () => { result.current.handleAmountChange('10'); }); - - // expect(result.current.to?.amount).toBe('10'); expect(validateQuote).toHaveBeenCalledWith({ to: mockToDegen, responseETH: mockResponse, @@ -418,6 +415,83 @@ describe('BuyProvider', () => { }); }); + it('should not set lifecycle status to amountChange with invalid quote', async () => { + const mockResponse = { + response: { amountUsd: '10' }, + } as unknown as GetSwapQuoteResponse; + vi.mocked(getBuyQuote).mockResolvedValue({ response: mockResponse }); + (validateQuote as Mock).mockReturnValue({ + isValid: false, + }); + + const { result } = renderHook(() => useBuyContext(), { wrapper }); + await act(async () => { + result.current.handleAmountChange('10'); + }); + expect(result.current.lifecycleStatus).not.toEqual({ + statusName: 'amountChange', + statusData: expect.objectContaining({ + isMissingRequiredField: false, + }), + }); + }); + + it('should set lifecycle status to amountChange with valid quote', async () => { + const mockResponse = { + response: { amountUsd: '10' }, + } as unknown as GetSwapQuoteResponse; + vi.mocked(getBuyQuote).mockResolvedValue({ + response: mockResponse, + formattedFromAmount: '20', + }); + (validateQuote as Mock).mockReturnValue({ + isValid: true, + }); + + const { result } = renderHook(() => useBuyContext(), { wrapper }); + // console.log('result', result); + await act(async () => { + result.current.handleAmountChange('10'); + }); + expect(result.current.lifecycleStatus).toEqual({ + statusName: 'amountChange', + statusData: expect.objectContaining({ + amountETH: '20', + amountUSDC: '20', + amountFrom: '20', + amountTo: '10', + }), + }); + }); + + it('should set lifecycle status to amountChange with valid quote and empty formattedFromAmount', async () => { + const mockResponse = { + response: { amountUsd: '10' }, + } as unknown as GetSwapQuoteResponse; + vi.mocked(getBuyQuote).mockResolvedValue({ + response: mockResponse, + formattedFromAmount: '', + }); + (validateQuote as Mock).mockReturnValue({ + isValid: true, + }); + + const { result } = renderHook(() => useBuyContext(), { wrapper }); + // console.log('result', result); + await act(async () => { + result.current.handleAmountChange('10'); + }); + expect(result.current.lifecycleStatus).toEqual({ + statusName: 'amountChange', + statusData: expect.objectContaining({ + amountETH: '', + amountUSDC: '', + amountFrom: '', + amountTo: '10', + }), + }); + }); + it('should reset inputs when setLifecycleStatus is called with success', async () => { const { result } = renderHook(() => useBuyContext(), { wrapper }); await act(async () => { From 96de464c9a100e9b53ffb49ebd320f6bcc4b70bb Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 15:59:22 -0800 Subject: [PATCH 55/65] fix lint --- src/buy/components/BuyProvider.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/components/BuyProvider.test.tsx b/src/buy/components/BuyProvider.test.tsx index 8dbc1a5f19..4f1e2663d4 100644 --- a/src/buy/components/BuyProvider.test.tsx +++ b/src/buy/components/BuyProvider.test.tsx @@ -6,7 +6,7 @@ import { screen, waitFor, } from '@testing-library/react'; -import React, { act, useCallback, useEffect } from 'react'; +import React, { act, useCallback } from 'react'; import type { TransactionReceipt } from 'viem'; import { type Mock, From 727bb99c8dfa614f5f7b913f243bfc818379a467 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 16:01:56 -0800 Subject: [PATCH 56/65] add changelog --- .changeset/happy-avocados-carry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/happy-avocados-carry.md diff --git a/.changeset/happy-avocados-carry.md b/.changeset/happy-avocados-carry.md new file mode 100644 index 0000000000..a30335330b --- /dev/null +++ b/.changeset/happy-avocados-carry.md @@ -0,0 +1,5 @@ +--- +'@coinbase/onchainkit': patch +--- + +- **feat**: Added Buy component. By @abcrane123. #1729 From 1090562321b27dec76d2168a8bbb7e9a0152edcc Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 20:39:33 -0800 Subject: [PATCH 57/65] address qa comments --- src/buy/components/Buy.tsx | 2 +- src/buy/components/BuyAmountInput.tsx | 2 ++ src/buy/components/BuyDropdown.test.tsx | 11 +++++++++++ src/buy/components/BuyDropdown.tsx | 20 +++++++++++++++++--- src/buy/components/BuyTokenItem.tsx | 2 +- src/token/components/TokenChip.tsx | 14 ++++++++++---- src/token/types.ts | 1 + 7 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/buy/components/Buy.tsx b/src/buy/components/Buy.tsx index 302b16caef..61fc8ab124 100644 --- a/src/buy/components/Buy.tsx +++ b/src/buy/components/Buy.tsx @@ -26,7 +26,7 @@ function BuyContent({ className }: { className?: string }) { ref={buyContainerRef} className={cn('relative flex flex-col gap-2', componentTheme, className)} > -
+
{isDropdownOpen && } diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx index 7350145419..a2634f41bf 100644 --- a/src/buy/components/BuyAmountInput.tsx +++ b/src/buy/components/BuyAmountInput.tsx @@ -23,6 +23,7 @@ export function BuyAmountInput() { className={cn( 'mr-2 w-full border-[none] font-display', 'leading-none outline-none', + 'disabled:cursor-not-allowed', background.default, color.foreground, )} @@ -37,6 +38,7 @@ export function BuyAmountInput() {
); diff --git a/src/buy/components/BuyDropdown.test.tsx b/src/buy/components/BuyDropdown.test.tsx index 99dce5faa9..11303ca1c4 100644 --- a/src/buy/components/BuyDropdown.test.tsx +++ b/src/buy/components/BuyDropdown.test.tsx @@ -110,4 +110,15 @@ describe('BuyDropdown', () => { }), ); }); + + it('closes the dropdown when Escape key is pressed', () => { + render(); + + const { setIsDropdownOpen } = mockContextValue; + + act(() => { + fireEvent.keyDown(document, { key: 'Escape' }); + }); + expect(setIsDropdownOpen).toHaveBeenCalledWith(false); + }); }); diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index 4659258eb0..b2f71926ff 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -1,6 +1,6 @@ import { useOnchainKit } from '@/core-react/useOnchainKit'; import { openPopup } from '@/ui-react/internal/utils/openPopup'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useAccount } from 'wagmi'; import { getRoundedAmount } from '../../core/utils/getRoundedAmount'; import { ONRAMP_BUY_URL } from '../../fund/constants'; @@ -13,7 +13,8 @@ import { BuyTokenItem } from './BuyTokenItem'; export function BuyDropdown() { const { projectId } = useOnchainKit(); - const { to, fromETH, fromUSDC, from, startPopupMonitor } = useBuyContext(); + const { to, fromETH, fromUSDC, from, startPopupMonitor, setIsDropdownOpen } = + useBuyContext(); const { address } = useAccount(); const handleOnrampClick = useCallback( @@ -60,12 +61,25 @@ export function BuyDropdown() { from?.token?.symbol !== 'ETH' && from?.token?.symbol !== 'USDC'; + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsDropdownOpen(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [setIsDropdownOpen]); + return (
diff --git a/src/buy/components/BuyTokenItem.tsx b/src/buy/components/BuyTokenItem.tsx index 949d0bfe3c..0aebe51301 100644 --- a/src/buy/components/BuyTokenItem.tsx +++ b/src/buy/components/BuyTokenItem.tsx @@ -26,7 +26,7 @@ export function BuyTokenItem({ swapUnit }: { swapUnit?: SwapUnit }) { }, [swapUnit.amount]); const roundedBalance = useMemo(() => { - return getRoundedAmount(swapUnit.balance || '0', 10); + return getRoundedAmount(swapUnit.balance || '0', 3); }, [swapUnit.balance]); return ( diff --git a/src/token/components/TokenChip.tsx b/src/token/components/TokenChip.tsx index bf1a7d9e3a..52328c5590 100644 --- a/src/token/components/TokenChip.tsx +++ b/src/token/components/TokenChip.tsx @@ -1,5 +1,5 @@ import { useTheme } from '../../core-react/internal/hooks/useTheme'; -import { cn, pressable, text } from '../../styles/theme'; +import { background, cn, pressable, text } from '../../styles/theme'; import type { TokenChipReact } from '../types'; import { TokenImage } from './TokenImage'; @@ -9,7 +9,12 @@ import { TokenImage } from './TokenImage'; * WARNING: This component is under development and * may change in the next few weeks. */ -export function TokenChip({ token, onClick, className }: TokenChipReact) { +export function TokenChip({ + token, + onClick, + className, + isPressable = true, +}: TokenChipReact) { const componentTheme = useTheme(); return ( @@ -18,8 +23,9 @@ export function TokenChip({ token, onClick, className }: TokenChipReact) { data-testid="ockTokenChip_Button" className={cn( componentTheme, - pressable.secondary, - pressable.shadow, + isPressable + ? [pressable.secondary, pressable.shadow] + : [background.secondary, 'cursor-default'], 'flex w-fit shrink-0 items-center gap-2 rounded-lg py-1 pr-3 pl-1 ', className, )} diff --git a/src/token/types.ts b/src/token/types.ts index d96454fc33..f82403af89 100644 --- a/src/token/types.ts +++ b/src/token/types.ts @@ -34,6 +34,7 @@ export type TokenChipReact = { token: Token; // Rendered token onClick?: (token: Token) => void; className?: string; + isPressable?: boolean; }; /** From bbda91a076813d846800f1eb9c780516c7d0728c Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 20:44:29 -0800 Subject: [PATCH 58/65] fix test --- src/buy/components/BuyTokenItem.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/components/BuyTokenItem.test.tsx b/src/buy/components/BuyTokenItem.test.tsx index 7d7c9c75ee..67278e57d7 100644 --- a/src/buy/components/BuyTokenItem.test.tsx +++ b/src/buy/components/BuyTokenItem.test.tsx @@ -103,7 +103,7 @@ describe('BuyTokenItem', () => { render(); expect(getRoundedAmount).toHaveBeenCalledWith('10.5678', 10); - expect(getRoundedAmount).toHaveBeenCalledWith('20.1234', 10); + expect(getRoundedAmount).toHaveBeenCalledWith('20.1234', 3); expect(screen.getByText('10.5 ETH')).toBeInTheDocument(); expect(screen.getByText('Balance: 20.1')).toBeInTheDocument(); }); From dc415b8de77f1411e6c4c7d881dd99b6935a4019 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 22:17:44 -0800 Subject: [PATCH 59/65] add z index --- src/buy/components/BuyDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index b2f71926ff..da1edc1fd2 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -80,7 +80,7 @@ export function BuyDropdown() { color.foreground, background.default, 'absolute right-0 bottom-0 flex translate-y-[102%] flex-col gap-2', - 'min-w-80 rounded rounded-lg border p-2', + 'min-w-80 rounded rounded-lg border p-2 z-10', )} >
Buy with
From 48434329859e7427280dd0dfc0431e1b0b64a75f Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Tue, 17 Dec 2024 22:21:35 -0800 Subject: [PATCH 60/65] fix lint --- src/buy/components/BuyDropdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/buy/components/BuyDropdown.tsx b/src/buy/components/BuyDropdown.tsx index da1edc1fd2..7a35270185 100644 --- a/src/buy/components/BuyDropdown.tsx +++ b/src/buy/components/BuyDropdown.tsx @@ -80,7 +80,7 @@ export function BuyDropdown() { color.foreground, background.default, 'absolute right-0 bottom-0 flex translate-y-[102%] flex-col gap-2', - 'min-w-80 rounded rounded-lg border p-2 z-10', + 'z-10 min-w-80 rounded rounded-lg border p-2', )} >
Buy with
From 5b3afb312bbc7c0472f1d2de2ea62414b633e215 Mon Sep 17 00:00:00 2001 From: Alissa Crane Date: Wed, 18 Dec 2024 10:46:43 -0800 Subject: [PATCH 61/65] add missing required fields message and address pr comments --- site/docs/pages/token/types.mdx | 1 + src/buy/components/BuyAmountInput.tsx | 7 +++---- src/buy/components/BuyButton.tsx | 27 ++++++++++++++++++++++++--- src/buy/components/BuyMessage.tsx | 25 +++++++++++++++++-------- src/buy/components/BuyTokenItem.tsx | 2 +- 5 files changed, 46 insertions(+), 16 deletions(-) diff --git a/site/docs/pages/token/types.mdx b/site/docs/pages/token/types.mdx index 12abb96c0e..c2593ed5b5 100644 --- a/site/docs/pages/token/types.mdx +++ b/site/docs/pages/token/types.mdx @@ -41,6 +41,7 @@ type TokenChipReact = { token: Token; // Rendered token onClick?: (token: Token) => void; className?: string; + isPressable?: boolean; // Default: true }; ``` diff --git a/src/buy/components/BuyAmountInput.tsx b/src/buy/components/BuyAmountInput.tsx index a2634f41bf..7078884684 100644 --- a/src/buy/components/BuyAmountInput.tsx +++ b/src/buy/components/BuyAmountInput.tsx @@ -15,15 +15,14 @@ export function BuyAmountInput() { return (
{ + if (isMissingRequiredField) { + updateLifecycleStatus({ + statusName: 'error', + statusData: { + code: 'TmBPc05', + error: 'Missing required fields', + message: 'Complete the field to continue', + }, + }); + return; + } if (isDropdownOpen) { setIsDropdownOpen(false); - } else { + return; + } + if (!isDropdownOpen) { setIsDropdownOpen(true); } - }, [setIsDropdownOpen, isDropdownOpen]); + }, [ + isMissingRequiredField, + setIsDropdownOpen, + isDropdownOpen, + updateLifecycleStatus, + ]); const buttonContent = useMemo(() => { if (statusName === 'success') { diff --git a/src/buy/components/BuyMessage.tsx b/src/buy/components/BuyMessage.tsx index 8aede245f6..ca33fe8ee8 100644 --- a/src/buy/components/BuyMessage.tsx +++ b/src/buy/components/BuyMessage.tsx @@ -3,16 +3,25 @@ import { useBuyContext } from './BuyProvider'; export function BuyMessage() { const { - lifecycleStatus: { statusName }, + lifecycleStatus: { statusName, statusData }, } = useBuyContext(); - if (statusName !== 'error') { - return null; + // Missing required fields + if (statusName === 'error' && statusData.code === 'TmBPc05') { + return ( +
+ Complete the field to continue +
+ ); } - return ( -
- Something went wrong. Please try again. -
- ); + if (statusName === 'error') { + return ( +
+ Something went wrong. Please try again. +
+ ); + } + + return null; } diff --git a/src/buy/components/BuyTokenItem.tsx b/src/buy/components/BuyTokenItem.tsx index 0aebe51301..bb6a0c99da 100644 --- a/src/buy/components/BuyTokenItem.tsx +++ b/src/buy/components/BuyTokenItem.tsx @@ -33,7 +33,7 @@ export function BuyTokenItem({ swapUnit }: { swapUnit?: SwapUnit }) {