diff --git a/packages/checkout/src/hooks/index.ts b/packages/checkout/src/hooks/index.ts index 4ca6765fd..d75718c55 100644 --- a/packages/checkout/src/hooks/index.ts +++ b/packages/checkout/src/hooks/index.ts @@ -10,3 +10,5 @@ export * from './useCheckoutOptionsSalesContract.js' export * from './useERC1155SaleContractCheckout.js' export * from './useSkipOnCloseCallback.js' export * from './useFortePaymentIntent.js' +export * from './useAddFundsModal.js' +export * from './useTransactionCounter.js' diff --git a/packages/checkout/src/hooks/useCheckoutUI/useCryptoPayment.tsx b/packages/checkout/src/hooks/useCheckoutUI/useCryptoPayment.tsx index c77ba6e6c..70c26b9b9 100644 --- a/packages/checkout/src/hooks/useCheckoutUI/useCryptoPayment.tsx +++ b/packages/checkout/src/hooks/useCheckoutUI/useCryptoPayment.tsx @@ -238,7 +238,8 @@ export const useCryptoPayment = ({ } ] - const txHash = await sendTransactions({ + let txHash: string | undefined + const txs = await sendTransactions({ chainId, senderAddress: userAddress, publicClient, @@ -250,7 +251,25 @@ export const useCryptoPayment = ({ waitConfirmationForLastTransaction: false }) - onSuccess?.(txHash) + if (txs.length === 0) { + throw new Error('No transactions to send') + } + + for (const [index, tx] of txs.entries()) { + const currentTxHash = await tx() + + const isLastTransaction = index === txs.length - 1 + + if (isLastTransaction) { + onSuccess?.(currentTxHash) + txHash = currentTxHash + } + } + + if (!txHash) { + throw new Error('Transaction hash is not available') + } + return txHash } else { const swapOption = swapRoutes @@ -322,7 +341,8 @@ export const useCryptoPayment = ({ } ] - const txHash = await sendTransactions({ + let txHash: string | undefined + const txs = await sendTransactions({ chainId, senderAddress: userAddress, publicClient, @@ -334,7 +354,25 @@ export const useCryptoPayment = ({ waitConfirmationForLastTransaction: false }) - onSuccess?.(txHash) + if (txs.length === 0) { + throw new Error('No transactions to send') + } + + for (const [index, tx] of txs.entries()) { + const currentTxHash = await tx() + + const isLastTransaction = index === txs.length - 1 + + if (isLastTransaction) { + onSuccess?.(currentTxHash) + txHash = currentTxHash + } + } + + if (!txHash) { + throw new Error('Transaction hash is not available') + } + return txHash } } catch (error) { diff --git a/packages/checkout/src/hooks/useTransactionCounter.ts b/packages/checkout/src/hooks/useTransactionCounter.ts new file mode 100644 index 000000000..bfa84b232 --- /dev/null +++ b/packages/checkout/src/hooks/useTransactionCounter.ts @@ -0,0 +1,31 @@ +import { useState } from 'react' + +export const useTransactionCounter = () => { + const [currentTransactionNumber, setCurrentTransactionNumber] = useState(1) + const [maxTransactions, setMaxTransactions] = useState(0) + + const initializeTransactionCounter = (maxTransactions: number) => { + setCurrentTransactionNumber(1) + setMaxTransactions(maxTransactions) + } + + const resetTransactionCounter = () => { + setCurrentTransactionNumber(1) + setMaxTransactions(0) + } + + const incrementTransactionCount = () => { + setCurrentTransactionNumber(prev => prev + 1) + } + + const isTransactionCounterInitialized = maxTransactions > 0 + + return { + currentTransactionNumber, + maxTransactions, + incrementTransactionCount, + initializeTransactionCounter, + resetTransactionCounter, + isTransactionCounterInitialized + } +} diff --git a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx index 060717f1e..c7e6e46a1 100644 --- a/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx +++ b/packages/checkout/src/views/Checkout/PaymentMethodSelect/PayWithCrypto/index.tsx @@ -26,7 +26,7 @@ import { ERC_20_CONTRACT_ABI } from '../../../../constants/abi.js' import { EVENT_SOURCE } from '../../../../constants/index.js' import { type PaymentMethodSelectionParams } from '../../../../contexts/NavigationCheckout.js' import type { SelectPaymentSettings } from '../../../../contexts/SelectPaymentModal.js' -import { useAddFundsModal } from '../../../../hooks/index.js' +import { useAddFundsModal, useTransactionCounter } from '../../../../hooks/index.js' import { useSelectPaymentModal, useTransactionStatusModal } from '../../../../hooks/index.js' import { useNavigationCheckout } from '../../../../hooks/useNavigationCheckout.js' @@ -48,6 +48,14 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P const { analytics } = useAnalyticsContext() const [isError, setIsError] = useState(false) const { navigation, setNavigation } = useNavigationCheckout() + const { + initializeTransactionCounter, + incrementTransactionCount, + currentTransactionNumber, + maxTransactions, + isTransactionCounterInitialized, + resetTransactionCounter + } = useTransactionCounter() const { chain, @@ -275,7 +283,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } ] - const txHash = await sendTransactions({ + const txs = await sendTransactions({ chainId, senderAddress: userAddress, publicClient, @@ -287,6 +295,29 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P waitConfirmationForLastTransaction: false }) + if (txs.length === 0) { + throw new Error('No transactions to send') + } + + initializeTransactionCounter(txs.length) + + let txHash: string | undefined + for (const [index, tx] of txs.entries()) { + const currentTxHash = await tx() + incrementTransactionCount() + + const isLastTransaction = index === txs.length - 1 + + if (isLastTransaction) { + onSuccess?.(currentTxHash) + txHash = currentTxHash + } + } + + if (!txHash) { + throw new Error('Transaction hash is not available') + } + analytics?.track({ event: 'SEND_TRANSACTION_REQUEST', props: { @@ -340,6 +371,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P setIsError(true) } + resetTransactionCounter() setIsPurchasing(false) } @@ -423,7 +455,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } ] - const txHash = await sendTransactions({ + const txs = await sendTransactions({ chainId, senderAddress: userAddress, publicClient, @@ -435,6 +467,29 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P waitConfirmationForLastTransaction: false }) + if (txs.length === 0) { + throw new Error('No transactions to send') + } + + initializeTransactionCounter(txs.length) + + let txHash: string | undefined + for (const [index, tx] of txs.entries()) { + const currentTxHash = await tx() + incrementTransactionCount() + + const isLastTransaction = index === txs.length - 1 + + if (isLastTransaction) { + onSuccess?.(currentTxHash) + txHash = currentTxHash + } + } + + if (!txHash) { + throw new Error('Transaction hash is not available') + } + analytics?.track({ event: 'SEND_TRANSACTION_REQUEST', props: { @@ -489,6 +544,7 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } setIsPurchasing(false) + resetTransactionCounter() } const onClickPurchase = () => { @@ -612,6 +668,23 @@ export const PayWithCryptoTab = ({ skipOnCloseCallback, isSwitchingChainRef }: P } const PriceSection = () => { + if (isTransactionCounterInitialized) { + const descriptionText = + maxTransactions > 1 + ? `Confirming transaction ${currentTransactionNumber} of ${maxTransactions}` + : `Confirming transaction` + return ( +
+
+ + {descriptionText} + +
+ +
+ ) + } + if (isFree) { return (
diff --git a/packages/checkout/src/views/Swap/index.tsx b/packages/checkout/src/views/Swap/index.tsx index 27d81fb9f..be860a080 100644 --- a/packages/checkout/src/views/Swap/index.tsx +++ b/packages/checkout/src/views/Swap/index.tsx @@ -237,7 +237,7 @@ export const Swap = () => { await walletClient.switchChain({ id: chainId }) } - const txHash = await sendTransactions({ + const txs = await sendTransactions({ connector, walletClient, publicClient, @@ -248,6 +248,26 @@ export const Swap = () => { transactions: [...getSwapTransactions(), ...(postSwapTransactions ?? [])] }) + if (txs.length === 0) { + throw new Error('No transactions to send') + } + + let txHash: string | undefined + + for (const [index, tx] of txs.entries()) { + const currentTxHash = await tx() + + const isLastTransaction = index === txs.length - 1 + if (isLastTransaction) { + onSuccess?.(currentTxHash) + txHash = currentTxHash + } + } + + if (!txHash) { + throw new Error('Transaction hash is not available') + } + closeSwapModal() openTransactionStatusModal({ chainId, diff --git a/packages/connect/src/utils/transactions.ts b/packages/connect/src/utils/transactions.ts index 7111e9df1..7ae4caed8 100644 --- a/packages/connect/src/utils/transactions.ts +++ b/packages/connect/src/utils/transactions.ts @@ -46,7 +46,9 @@ export const sendTransactions = async ({ indexerClient, transactionConfirmations = TRANSACTION_CONFIRMATIONS_DEFAULT, waitConfirmationForLastTransaction = true -}: SendTransactionsInput): Promise => { +}: SendTransactionsInput): Promise<(() => Promise)[]> => { + const returnedTransactions: (() => Promise)[] = [] + const walletClientChainId = await walletClient.getChainId() if (walletClientChainId !== chainId) { @@ -63,105 +65,115 @@ export const sendTransactions = async ({ // Sequence WaaS if (isEmbeddedWallet) { - // waas connector logic - const resp = await sequenceWaaS.feeOptions({ - transactions: transactions, - network: chainId - }) + const triggerEmbeddedWalletPromise = async (): Promise => { + // waas connector logic + const resp = await sequenceWaaS.feeOptions({ + transactions: transactions, + network: chainId + }) - const isSponsored = resp.data.feeOptions.length == 0 + const isSponsored = resp.data.feeOptions.length == 0 - let transactionsFeeOption - const transactionsFeeQuote = resp.data.feeQuote + let transactionsFeeOption + const transactionsFeeQuote = resp.data.feeQuote - const balances = await indexerClient.getTokenBalancesDetails({ - filter: { - accountAddresses: [senderAddress], - omitNativeBalances: false - } - }) + const balances = await indexerClient.getTokenBalancesDetails({ + filter: { + accountAddresses: [senderAddress], + omitNativeBalances: false + } + }) - for (const feeOption of resp.data.feeOptions) { - const isNativeToken = feeOption.token.contractAddress == null + for (const feeOption of resp.data.feeOptions) { + const isNativeToken = feeOption.token.contractAddress == null - if (isNativeToken) { - const nativeTokenBalance = balances.nativeBalances?.[0].balance || '0' - if (BigInt(nativeTokenBalance) >= BigInt(feeOption.value)) { - transactionsFeeOption = feeOption - break - } - } else { - const erc20TokenBalance = balances.balances.find(b => - compareAddress(b.contractAddress, feeOption.token.contractAddress || '') - ) - const erc20TokenBalanceValue = erc20TokenBalance?.balance || '0' - if (BigInt(erc20TokenBalanceValue) >= BigInt(feeOption.value)) { - transactionsFeeOption = feeOption - break + if (isNativeToken) { + const nativeTokenBalance = balances.nativeBalances?.[0].balance || '0' + if (BigInt(nativeTokenBalance) >= BigInt(feeOption.value)) { + transactionsFeeOption = feeOption + break + } + } else { + const erc20TokenBalance = balances.balances.find(b => + compareAddress(b.contractAddress, feeOption.token.contractAddress || '') + ) + const erc20TokenBalanceValue = erc20TokenBalance?.balance || '0' + if (BigInt(erc20TokenBalanceValue) >= BigInt(feeOption.value)) { + transactionsFeeOption = feeOption + break + } } } - } - if (!transactionsFeeOption && !isSponsored) { - throw new FeeOptionInsufficientFundsError( - `Transaction fee option with valid user balance not found: ${resp.data.feeOptions.map(f => f.token.symbol).join(', ')}`, - resp.data.feeOptions - ) - } + if (!transactionsFeeOption && !isSponsored) { + throw new FeeOptionInsufficientFundsError( + `Transaction fee option with valid user balance not found: ${resp.data.feeOptions.map(f => f.token.symbol).join(', ')}`, + resp.data.feeOptions + ) + } - const response = await sequenceWaaS.sendTransaction({ - transactions, - transactionsFeeOption, - transactionsFeeQuote, - network: chainId - }) + const response = await sequenceWaaS.sendTransaction({ + transactions, + transactionsFeeOption, + transactionsFeeQuote, + network: chainId + }) - if (response.code === 'transactionFailed') { - throw new Error(response.data.error) - } + if (response.code === 'transactionFailed') { + throw new Error(response.data.error) + } - const txnHash = response.data.txHash + const txnHash = response.data.txHash - if (waitConfirmationForLastTransaction) { - const { txnStatus } = await waitForTransactionReceipt({ - indexerClient, - txnHash: txnHash as Hex, - publicClient, - confirmations: transactionConfirmations - }) + if (waitConfirmationForLastTransaction) { + const { txnStatus } = await waitForTransactionReceipt({ + indexerClient, + txnHash: txnHash as Hex, + publicClient, + confirmations: transactionConfirmations + }) - if (txnStatus === TransactionStatus.FAILED) { - throw new Error('Transaction failed') + if (txnStatus === TransactionStatus.FAILED) { + throw new Error('Transaction failed') + } } + + return txnHash } - return txnHash + returnedTransactions.push(triggerEmbeddedWalletPromise) // Sequence-Based Connector } else if (isSequenceUniversalWallet) { - const wallet = sequence.getWallet() - const signer = wallet.getSigner() - const response = await signer.sendTransaction(transactions) - - if (waitConfirmationForLastTransaction) { - const { txnStatus } = await waitForTransactionReceipt({ - indexerClient, - txnHash: response.hash as Hex, - publicClient, - confirmations: transactionConfirmations - }) + const triggerSequenceUniversalWalletPromise = async () => { + const wallet = sequence.getWallet() + const signer = wallet.getSigner() + const response = await signer.sendTransaction(transactions) + + if (waitConfirmationForLastTransaction) { + const { txnStatus } = await waitForTransactionReceipt({ + indexerClient, + txnHash: response.hash as Hex, + publicClient, + confirmations: transactionConfirmations + }) - if (txnStatus === TransactionStatus.FAILED) { - throw new Error('Transaction failed') + if (txnStatus === TransactionStatus.FAILED) { + throw new Error('Transaction failed') + } } + + return response.hash } - return response.hash + returnedTransactions.push(triggerSequenceUniversalWalletPromise) // Other connectors (metamask, eip-6963, etc...) } else { - let txHash: string = '' - // We fire the transactions one at a time since the cannot be batched - for (const [index, transaction] of transactions.entries()) { + interface TriggerOtherConnectorsPromiseInput { + transaction: Transaction + isLastTransaction: boolean + } + const triggerOtherConnectorTransaction = async ({ transaction, isLastTransaction }: TriggerOtherConnectorsPromiseInput) => { const txnHash = await walletClient.sendTransaction({ account: senderAddress, to: transaction.to, @@ -170,8 +182,6 @@ export const sendTransactions = async ({ chain: undefined }) - const isLastTransaction = index === transactions.length - 1 - if (!isLastTransaction || (isLastTransaction && waitConfirmationForLastTransaction)) { const { txnStatus } = await waitForTransactionReceipt({ indexerClient, @@ -185,12 +195,18 @@ export const sendTransactions = async ({ } } - // The transaction hash of the last transaction is the one that should be returned - txHash = txnHash + return txnHash } - return txHash + for (const [index, transaction] of transactions.entries()) { + const isLastTransaction = index === transactions.length - 1 + const triggerOtherConnectorTransactionPromise = () => triggerOtherConnectorTransaction({ transaction, isLastTransaction }) + + returnedTransactions.push(triggerOtherConnectorTransactionPromise) + } } + + return returnedTransactions } interface WaitForTransactionReceiptInput { diff --git a/packages/wallet-widget/src/views/SwapCoin/SwapList.tsx b/packages/wallet-widget/src/views/SwapCoin/SwapList.tsx index 5d5eed65d..b99f57129 100644 --- a/packages/wallet-widget/src/views/SwapCoin/SwapList.tsx +++ b/packages/wallet-widget/src/views/SwapCoin/SwapList.tsx @@ -168,7 +168,7 @@ export const SwapList = ({ chainId, contractAddress, amount, slippageBps }: Swap await walletClient.switchChain({ id: chainId }) } - const txHash = await sendTransactions({ + const txs = await sendTransactions({ connector, walletClient, publicClient, @@ -178,6 +178,26 @@ export const SwapList = ({ chainId, contractAddress, amount, slippageBps }: Swap transactions: [...getSwapTransactions()] }) + if (txs.length === 0) { + throw new Error('No transactions to send') + } + + let txHash: string | undefined + + for (const [index, tx] of txs.entries()) { + const currentTxHash = await tx() + + const isLastTransaction = index === txs.length - 1 + + if (isLastTransaction) { + txHash = currentTxHash + } + } + + if (!txHash) { + throw new Error('Transaction hash is not available') + } + analytics?.track({ event: 'SEND_TRANSACTION_REQUEST', props: {