import { useLifecycleStatus } from '@/internal/hooks/useLifecycleStatus'; import { openPopup } from '@/internal/utils/openPopup'; import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import type { Address, ContractFunctionParameters } from 'viem'; import { base } from 'viem/chains'; import { useAccount, useConnect, useSwitchChain } from 'wagmi'; import { useWaitForTransactionReceipt, useSendCalls, useCallsStatus, } from 'wagmi'; import { coinbaseWallet } from 'wagmi/connectors'; import { useAnalytics } from '../../core/analytics/hooks/useAnalytics'; import { type AnalyticsEventData, CheckoutEvent, CheckoutEventType, } from '../../core/analytics/types'; import { useValue } from '../../internal/hooks/useValue'; import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError'; import { useOnchainKit } from '../../useOnchainKit'; import { useIsWalletACoinbaseSmartWallet } from '../../wallet/hooks/useIsWalletACoinbaseSmartWallet'; import { CHECKOUT_LIFECYCLE_STATUS, GENERIC_ERROR_MESSAGE, NO_CONNECTED_ADDRESS_ERROR, NO_CONTRACTS_ERROR, USER_REJECTED_ERROR, } from '../constants'; import { CheckoutErrorCode } from '../constants'; import { useCommerceContracts } from '../hooks/useCommerceContracts'; import type { CheckoutContextType, CheckoutProviderProps, LifecycleStatus, } from '../types'; import { ONRAMP_POPUP_HEIGHT, ONRAMP_POPUP_WIDTH } from '@/fund/constants'; import { normalizeStatus, normalizeTransactionId, } from '@/internal/utils/normalizeWagmi'; const emptyContext = {} as CheckoutContextType; export const CheckoutContext = createContext(emptyContext); /** * @deprecated The component and its related components and hooks are deprecated * and will be removed in a future version. We recommend looking at Base Pay for similar functionality. * @see {@link https://docs.base.org/base-account/guides/accept-payments} */ export function useCheckoutContext() { const context = useContext(CheckoutContext); if (context === emptyContext) { throw new Error( 'useCheckoutContext must be used within a Checkout component', ); } return context; } export function CheckoutProvider({ chargeHandler, children, isSponsored, onStatus, productId, }: CheckoutProviderProps) { // Core hooks const { config: { appearance, paymaster } = { appearance: { name: undefined, logo: undefined }, paymaster: undefined, }, } = useOnchainKit(); const { address, chainId, isConnected } = useAccount(); const { connectAsync } = useConnect(); const { switchChainAsync } = useSwitchChain(); const [chargeId, setChargeId] = useState(''); const [transactionId, setTransactionId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const isSmartWallet = useIsWalletACoinbaseSmartWallet(); const { sendAnalytics } = useAnalytics(); // Refs const fetchedDataUseEffect = useRef(false); const fetchedDataHandleSubmit = useRef(false); const userRejectedRef = useRef(false); const contractsRef = useRef(null); const insufficientBalanceRef = useRef(false); const priceInUSDCRef = useRef(''); // Component lifecycle const [lifecycleStatus, updateLifecycleStatus] = useLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.INIT, statusData: {}, }); // Transaction hooks const fetchContracts = useCommerceContracts({ chargeHandler, productId, }); // Helper function used in both `useEffect` and `handleSubmit` to fetch data from the Commerce API and set state and refs const fetchData = useCallback( async (address: Address) => { updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.FETCHING_DATA, statusData: {}, }); const { contracts, chargeId: hydratedChargeId, insufficientBalance, priceInUSDC, error, } = await fetchContracts(address); if (error) { setErrorMessage(GENERIC_ERROR_MESSAGE); updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.ERROR, statusData: { code: CheckoutErrorCode.UNEXPECTED_ERROR, error: (error as Error).name, message: (error as Error).message, }, }); return; } setChargeId(hydratedChargeId); contractsRef.current = contracts; insufficientBalanceRef.current = insufficientBalance; priceInUSDCRef.current = priceInUSDC; updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.READY, statusData: { chargeId, contracts: contractsRef.current || [], }, }); }, [chargeId, fetchContracts, updateLifecycleStatus], ); const { status, sendCallsAsync } = useSendCalls({ /* v8 ignore start */ mutation: { onSuccess: (data) => { setTransactionId(normalizeTransactionId(data)); }, }, /* v8 ignore stop */ }); const { data } = useCallsStatus({ id: transactionId, query: { /* v8 ignore next 5 */ refetchInterval: (query) => { return normalizeStatus(query.state.data?.status) === 'success' ? false : 1000; }, enabled: !!transactionId, }, }); const transactionHash = data?.receipts?.[0]?.transactionHash; const { data: receipt } = useWaitForTransactionReceipt({ hash: transactionHash, }); // Component lifecycle emitters useEffect(() => { onStatus?.(lifecycleStatus); }, [ 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 onStatus, ]); // Set transaction pending status when writeContracts is pending useEffect(() => { if (status === 'pending') { updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.PENDING, statusData: {}, }); } }, [status, updateLifecycleStatus]); // Trigger success status when receipt is generated by useWaitForTransactionReceipt useEffect(() => { if (!receipt) { return; } updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.SUCCESS, statusData: { transactionReceipts: [receipt], chargeId: chargeId, receiptUrl: `https://commerce.coinbase.com/pay/${chargeId}/receipt`, }, }); }, [chargeId, receipt, updateLifecycleStatus]); // We need to pre-load transaction data in `useEffect` when the wallet is already connected due to a Smart Wallet popup blocking issue in Safari iOS useEffect(() => { if ( lifecycleStatus.statusName === CHECKOUT_LIFECYCLE_STATUS.INIT && address && !fetchedDataHandleSubmit.current ) { fetchedDataUseEffect.current = true; fetchData(address); } }, [address, fetchData, lifecycleStatus]); const handleAnalytics = useCallback( (event: CheckoutEventType, data: AnalyticsEventData[CheckoutEventType]) => { sendAnalytics(event, data); }, [sendAnalytics], ); // eslint-disable-next-line complexity const handleSubmit = useCallback(async () => { try { handleAnalytics(CheckoutEvent.CheckoutInitiated, { address, amount: Number(priceInUSDCRef.current), productId: productId || '', }); // Open Coinbase Commerce receipt if (lifecycleStatus.statusName === CHECKOUT_LIFECYCLE_STATUS.SUCCESS) { window.open( `https://commerce.coinbase.com/pay/${chargeId}/receipt`, '_blank', 'noopener,noreferrer', ); return; } if (errorMessage === USER_REJECTED_ERROR) { // Reset status if previous request was a rejection setErrorMessage(''); } let connectedAddress = address; let connectedChainId = chainId; if (!isConnected || !isSmartWallet) { // Prompt for wallet connection // This is defaulted to Coinbase Smart Wallet fetchedDataHandleSubmit.current = true; // Set this here so useEffect does not run const { accounts, chainId: _connectedChainId } = await connectAsync({ /* v8 ignore next 5 */ connector: coinbaseWallet({ appName: appearance?.name ?? undefined, appLogoUrl: appearance?.logo ?? undefined, preference: 'smartWalletOnly', }), }); connectedAddress = accounts[0]; connectedChainId = _connectedChainId; } // This shouldn't ever happen, but to make Typescript happy /* v8 ignore start */ if (!connectedAddress) { setErrorMessage(GENERIC_ERROR_MESSAGE); updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.ERROR, statusData: { code: CheckoutErrorCode.UNEXPECTED_ERROR, error: NO_CONNECTED_ADDRESS_ERROR, message: NO_CONNECTED_ADDRESS_ERROR, }, }); return; } /* v8 ignore stop */ // Fetch contracts if not already done in useEffect // Don't re-fetch contracts if the user rejected the previous request, and just use the cached data /* v8 ignore next 3 */ if (!fetchedDataUseEffect.current && !userRejectedRef.current) { await fetchData(connectedAddress); } // Switch chain, if applicable if (connectedChainId !== base.id) { await switchChainAsync({ chainId: base.id }); } // Check for sufficient balance if (insufficientBalanceRef.current && priceInUSDCRef.current) { openPopup({ url: `https://keys.coinbase.com/fund?asset=USDC&chainId=8453&presetCryptoAmount=${priceInUSDCRef.current}`, target: '_blank', height: ONRAMP_POPUP_HEIGHT, width: ONRAMP_POPUP_WIDTH, }); // Reset state insufficientBalanceRef.current = false; priceInUSDCRef.current = undefined; fetchedDataUseEffect.current = false; return; } // Contracts weren't successfully fetched from `fetchContracts` if (!contractsRef.current || contractsRef.current.length === 0) { setErrorMessage(GENERIC_ERROR_MESSAGE); updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.ERROR, statusData: { code: CheckoutErrorCode.UNEXPECTED_ERROR, error: NO_CONTRACTS_ERROR, message: NO_CONTRACTS_ERROR, }, }); return; } // Open keys.coinbase.com for payment await sendCallsAsync({ calls: contractsRef.current.map((contract) => { return { to: contract.address, abi: contract.abi, functionName: contract.functionName, args: contract.args, }; }), capabilities: isSponsored && paymaster ? { paymasterService: { url: paymaster, }, } : undefined, }); } catch (error) { handleAnalytics(CheckoutEvent.CheckoutFailure, { error: error instanceof Error ? error.message : 'Checkout failed', metadata: { error: JSON.stringify(error) }, }); const isUserRejectedError = (error as Error).message?.includes('User denied connection request') || isUserRejectedRequestError(error); const errorCode = isUserRejectedError ? CheckoutErrorCode.USER_REJECTED_ERROR : CheckoutErrorCode.UNEXPECTED_ERROR; const errorMessage = isUserRejectedError ? USER_REJECTED_ERROR : GENERIC_ERROR_MESSAGE; if (isUserRejectedError) { // Set the ref so that we can use the cached commerce API call userRejectedRef.current = true; } setErrorMessage(errorMessage); updateLifecycleStatus({ statusName: CHECKOUT_LIFECYCLE_STATUS.ERROR, statusData: { code: errorCode, error: JSON.stringify(error), message: errorMessage, }, }); } }, [ address, appearance, chainId, chargeId, connectAsync, errorMessage, fetchData, isConnected, isSmartWallet, isSponsored, lifecycleStatus.statusName, paymaster, switchChainAsync, updateLifecycleStatus, sendCallsAsync, handleAnalytics, productId, ]); const value = useValue({ errorMessage, lifecycleStatus, onSubmit: handleSubmit, updateLifecycleStatus, }); return ( {children} ); }