UNPKG

@daimo/pay

Version:

Seamless crypto payments. Onboard users from any chain, any coin into your app with one click.

340 lines (337 loc) 15.3 kB
import { ethereum, ExternalPaymentOptions, isCCTPV1Chain, getOrderDestChainId, readDaimoPayOrderID, assert, debugJson, assertNotNull, writeDaimoPayOrderID } from '@daimo/pay-common'; import { useWallet, useConnection } from '@solana/wallet-adapter-react'; import { VersionedTransaction } from '@solana/web3.js'; import { useState, useEffect, useCallback } from 'react'; import { zeroAddress, erc20Abi, getAddress, hexToBytes } from 'viem'; import { useAccount, useEnsName, useSendTransaction, useWriteContract } from 'wagmi'; import { ROUTES } from '../constants/routes.js'; import { detectPlatform } from '../utils/platform.js'; import { useDaimoPay } from './useDaimoPay.js'; import { useDepositAddressOptions } from './useDepositAddressOptions.js'; import { useExternalPaymentOptions } from './useExternalPaymentOptions.js'; import useIsMobile from './useIsMobile.js'; import { useOrderUsdLimits } from './useOrderUsdLimits.js'; import { useSolanaPaymentOptions } from './useSolanaPaymentOptions.js'; import { useWalletPaymentOptions } from './useWalletPaymentOptions.js'; function usePaymentState({ trpc, lockPayParams, setRoute, log, redirectReturnUrl, }) { const pay = useDaimoPay(); // Browser state. const [platform, setPlatform] = useState(); useEffect(() => { setPlatform(detectPlatform(window.navigator.userAgent)); }, []); // Wallet state. const { address: ethWalletAddress } = useAccount(); const { data: senderEnsName } = useEnsName({ chainId: ethereum.chainId, address: ethWalletAddress, }); const { sendTransactionAsync } = useSendTransaction(); const { writeContractAsync } = useWriteContract(); // Solana wallet state. const solanaWallet = useWallet(); const { connection } = useConnection(); const solanaPubKey = solanaWallet.publicKey?.toBase58(); // TODO: backend should determine whether to show solana payment method const paymentOptions = pay.order?.metadata.payer?.paymentOptions; // Include by default if paymentOptions not provided. Solana bridging is only // supported on CCTP v1 chains. const showSolanaPaymentMethod = (paymentOptions == null || paymentOptions.includes(ExternalPaymentOptions.Solana)) && pay.order != null && isCCTPV1Chain(getOrderDestChainId(pay.order)); // From DaimoPayButton props const [buttonProps, setButtonProps] = useState(); const [currPayParams, setCurrPayParams] = useState(); const [paymentWaitingMessage, setPaymentWaitingMessage] = useState(); const [isDepositFlow, setIsDepositFlow] = useState(false); // UI state. Selection for external payment (Binance, etc) vs wallet payment. const externalPaymentOptions = useExternalPaymentOptions({ trpc, // allow <DaimoPayButton payId={...} paymentOptions={override} /> filterIds: buttonProps?.paymentOptions ?? pay.order?.metadata.payer?.paymentOptions, platform, usdRequired: pay.order?.destFinalCallTokenAmount.usd, mode: pay.order?.mode, }); const walletPaymentOptions = useWalletPaymentOptions({ trpc, address: ethWalletAddress, usdRequired: pay.order?.destFinalCallTokenAmount.usd, destChainId: pay.order?.destFinalCallTokenAmount.token.chainId, preferredChains: pay.order?.metadata.payer?.preferredChains, preferredTokens: pay.order?.metadata.payer?.preferredTokens, evmChains: pay.order?.metadata.payer?.evmChains, isDepositFlow, log, }); const solanaPaymentOptions = useSolanaPaymentOptions({ trpc, address: solanaPubKey, usdRequired: pay.order?.destFinalCallTokenAmount.usd, isDepositFlow, }); const depositAddressOptions = useDepositAddressOptions({ trpc, usdRequired: pay.order?.destFinalCallTokenAmount.usd, mode: pay.order?.mode, }); const chainOrderUsdLimits = useOrderUsdLimits({ trpc }); const [selectedExternalOption, setSelectedExternalOption] = useState(); const [selectedTokenOption, setSelectedTokenOption] = useState(); const [selectedSolanaTokenOption, setSelectedSolanaTokenOption] = useState(); const [selectedDepositAddressOption, setSelectedDepositAddressOption] = useState(); const [selectedWallet, setSelectedWallet] = useState(); const [selectedWalletDeepLink, setSelectedWalletDeepLink] = useState(); const getOrderUsdLimit = () => { const DEFAULT_USD_LIMIT = 20000; if (pay.order == null || chainOrderUsdLimits.loading) { return DEFAULT_USD_LIMIT; } const destChainId = pay.order.destFinalCallTokenAmount.token.chainId; return destChainId in chainOrderUsdLimits.limits ? chainOrderUsdLimits.limits[destChainId] : DEFAULT_USD_LIMIT; }; /** Commit to a token + amount = initiate payment. */ const payWithToken = async (walletOption) => { assert(ethWalletAddress != null, `[PAY TOKEN] null ethWalletAddress when paying on ethereum`); assert(pay.paymentState === "preview" || pay.paymentState === "unhydrated" || pay.paymentState === "payment_unpaid", `[PAY TOKEN] paymentState is ${pay.paymentState}, must be preview or unhydrated or payment_unpaid`); let hydratedOrder; const { required, fees } = walletOption; const paymentAmount = BigInt(required.amount) + BigInt(fees.amount); if (pay.paymentState !== "payment_unpaid") { assert(required.token.token === fees.token.token, `[PAY TOKEN] required token ${debugJson(required)} does not match fees token ${debugJson(fees)}`); // Will refund to ethWalletAddress if refundAddress was not set in payParams const res = await pay.hydrateOrder(ethWalletAddress); hydratedOrder = res.order; log(`[PAY TOKEN] hydrated order: ${debugJson(hydratedOrder)}, paying ${paymentAmount} of token ${required.token.token}`); } else { hydratedOrder = pay.order; } const paymentTxHash = await (async () => { try { if (required.token.token === zeroAddress) { return await sendTransactionAsync({ to: hydratedOrder.intentAddr, value: paymentAmount, }); } else { return await writeContractAsync({ abi: erc20Abi, address: getAddress(required.token.token), functionName: "transfer", args: [hydratedOrder.intentAddr, paymentAmount], }); } } catch (e) { console.error(`[PAY TOKEN] error sending token: ${e}`); throw e; } })(); try { await pay.payEthSource({ paymentTxHash, sourceChainId: required.token.chainId, payerAddress: ethWalletAddress, sourceToken: getAddress(required.token.token), sourceAmount: paymentAmount, }); return { txHash: paymentTxHash, success: true }; } catch { console.error(`[PAY TOKEN] could not verify payment tx on chain: ${paymentTxHash}`); return { txHash: paymentTxHash, success: false }; } }; const payWithSolanaToken = async (inputToken) => { const payerPublicKey = solanaWallet.publicKey; assert(payerPublicKey != null, "[PAY SOLANA] null payerPublicKey when paying on solana"); assert(pay.order?.id != null, "[PAY SOLANA] null orderId when paying on solana"); assert(pay.paymentState === "preview" || pay.paymentState === "unhydrated" || pay.paymentState === "payment_unpaid", `[PAY SOLANA] paymentState is ${pay.paymentState}, must be preview or unhydrated or payment_unpaid`); let hydratedOrder; if (pay.paymentState !== "payment_unpaid") { const res = await pay.hydrateOrder(); hydratedOrder = res.order; log(`[PAY SOLANA] Hydrated order: ${JSON.stringify(hydratedOrder)}, checking out with Solana ${inputToken}`); } else { hydratedOrder = pay.order; } const paymentTxHash = await (async () => { try { const serializedTx = await trpc.getSolanaSwapAndBurnTx.query({ orderId: pay.order.id.toString(), userPublicKey: assertNotNull(payerPublicKey, "[PAY SOLANA] wallet.publicKey cannot be null").toString(), inputTokenMint: inputToken, }); const tx = VersionedTransaction.deserialize(hexToBytes(serializedTx)); const txHash = await solanaWallet.sendTransaction(tx, connection); return txHash; } catch (e) { console.error(e); throw e; } })(); try { await pay.paySolanaSource({ paymentTxHash: paymentTxHash, sourceToken: inputToken, }); return { txHash: paymentTxHash, success: true }; } catch { console.error(`[PAY SOLANA] could not verify payment tx on chain: ${paymentTxHash}`); return { txHash: paymentTxHash, success: false }; } }; const payWithExternal = async (option) => { assert(pay.order != null, "[PAY EXTERNAL] order cannot be null"); assert(platform != null, "[PAY EXTERNAL] platform cannot be null"); const { order } = await pay.hydrateOrder(); const externalPaymentOptionData = await trpc.getExternalPaymentOptionData.query({ id: order.id.toString(), externalPaymentOption: option, platform, redirectReturnUrl, }); assert(externalPaymentOptionData != null, "[PAY EXTERNAL] missing externalPaymentOptionData"); log(`[PAY EXTERNAL] hydrated order: ${debugJson(order)}, checking out with external payment: ${option}`); setPaymentWaitingMessage(externalPaymentOptionData.waitingMessage); return externalPaymentOptionData.url; }; const payWithDepositAddress = async (option) => { const { order } = await pay.hydrateOrder(); log(`[PAY DEPOSIT ADDRESS] hydrated order ${order.id} for ${order.usdValue} USD, checking out with deposit address: ${option}`); const result = await trpc.getDepositAddressForOrder.query({ orderId: order.id.toString(), option, }); return "error" in result ? null : result; }; const { isIOS } = useIsMobile(); const openInWalletBrowser = (wallet, amountUsd) => { const paymentState = pay.paymentState; assert(paymentState === "payment_unpaid", `[OPEN IN WALLET BROWSER] paymentState is ${paymentState}, must be payment_unpaid`); assert(wallet.getDaimoPayDeeplink != null, `openInWalletBrowser: missing deeplink for ${wallet.name}`); const payId = writeDaimoPayOrderID(pay.order.id); const deeplink = wallet.getDaimoPayDeeplink(payId); // If we are in IOS, we don't open the deeplink in a new window, because it // will not work, the link will be opened in the page WAITING_WALLET if (!isIOS) { window.open(deeplink, "_blank"); } setSelectedWallet(wallet); setSelectedWalletDeepLink(deeplink); setRoute(ROUTES.WAITING_WALLET, { amountUsd, payId, wallet_name: wallet.name, }); }; /** User picked a different deposit amount. */ const setChosenUsd = (usd) => { assert(pay.paymentState === "preview", "[SET CHOSEN USD] paymentState is not preview"); // Too expensive to make an API call to regenerate preview order each time // the user changes the amount. Instead, we modify the order in memory. pay.setChosenUsd(usd); }; const setPayId = useCallback(async (payId) => { if (lockPayParams || payId == null) return; const id = readDaimoPayOrderID(payId).toString(); if (pay.order?.id && BigInt(id) == pay.order.id) { // Already loaded, ignore. return; } pay.reset(); pay.setPayId(payId); }, [lockPayParams, pay]); /** Called whenever params change. */ const setPayParams = async (payParams) => { if (lockPayParams) return; assert(payParams != null, "[SET PAY PARAMS] payParams cannot be null"); log("[SET PAY PARAMS] setting payParams", payParams); pay.reset(); await pay.createPreviewOrder(payParams); setCurrPayParams(payParams); setIsDepositFlow(payParams.toUnits == null); }; const generatePreviewOrder = async () => { pay.reset(); if (currPayParams == null) return; await pay.createPreviewOrder(currPayParams); }; const resetOrder = useCallback(async (payParams) => { const mergedPayParams = payParams != null && currPayParams != null ? { ...currPayParams, ...payParams } : currPayParams; // Clear the old order & state pay.reset(); setSelectedExternalOption(undefined); setSelectedTokenOption(undefined); setSelectedSolanaTokenOption(undefined); setSelectedDepositAddressOption(undefined); setSelectedWallet(undefined); setSelectedWalletDeepLink(undefined); setPaymentWaitingMessage(undefined); // Set the new payParams if (mergedPayParams) { await pay.createPreviewOrder(mergedPayParams); setCurrPayParams(mergedPayParams); setIsDepositFlow(mergedPayParams.toUnits == null); } setRoute(ROUTES.SELECT_METHOD); }, [setRoute, pay, currPayParams]); const [tokenMode, setTokenMode] = useState("evm"); return { buttonProps, setButtonProps, setPayId, setPayParams, tokenMode, setTokenMode, generatePreviewOrder, isDepositFlow, paymentWaitingMessage, selectedExternalOption, selectedTokenOption, selectedSolanaTokenOption, externalPaymentOptions, showSolanaPaymentMethod, selectedWallet, selectedWalletDeepLink, walletPaymentOptions, solanaPaymentOptions, depositAddressOptions, selectedDepositAddressOption, getOrderUsdLimit, resetOrder, setSelectedWallet, setSelectedWalletDeepLink, setPaymentWaitingMessage, setSelectedExternalOption, setSelectedTokenOption, setSelectedSolanaTokenOption, setSelectedDepositAddressOption, setChosenUsd, payWithToken, payWithExternal, payWithDepositAddress, payWithSolanaToken, openInWalletBrowser, senderEnsName: senderEnsName ?? undefined, }; } export { usePaymentState }; //# sourceMappingURL=usePaymentState.js.map