@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
JavaScript
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