diff --git a/.changeset/fix-pending-tx-tracking.md b/.changeset/fix-pending-tx-tracking.md new file mode 100644 index 0000000000..93c93e4c1b --- /dev/null +++ b/.changeset/fix-pending-tx-tracking.md @@ -0,0 +1,5 @@ +--- +"caravan-coordinator": patch +--- + +Fix pending transaction tracking for transactions without change outputs (e.g., "MAX" sends). diff --git a/apps/coordinator/src/clients/transactions.ts b/apps/coordinator/src/clients/transactions.ts index 8eda855482..bc60f4c02f 100644 --- a/apps/coordinator/src/clients/transactions.ts +++ b/apps/coordinator/src/clients/transactions.ts @@ -1,14 +1,6 @@ -import { useQueries, useQuery } from "@tanstack/react-query"; -import { useSelector } from "react-redux"; -import { useMemo } from "react"; +import { useQuery, useQueries } from "@tanstack/react-query"; import { BlockchainClient, TransactionDetails } from "@caravan/clients"; -import { - getPendingTransactionIds, - getWalletAddresses, - Slice, - selectProcessedTransactions, -} from "selectors/wallet"; -import { calculateTransactionValue } from "utils/transactionCalculations"; +import { Slice } from "selectors/wallet"; import { useGetClient } from "hooks/client"; import { bitcoinsToSatoshis } from "@caravan/bitcoin"; @@ -16,11 +8,11 @@ import { bitcoinsToSatoshis } from "@caravan/bitcoin"; export const transactionKeys = { all: ["transactions"] as const, tx: (txid: string) => [...transactionKeys.all, txid] as const, - pending: () => [...transactionKeys.all, "pending"] as const, txWithHex: (txid: string) => [...transactionKeys.all, txid, "withHex"] as const, coins: (txid: string) => [...transactionKeys.all, txid, "coins"] as const, confirmedHistory: () => [...transactionKeys.all, "confirmed"] as const, + pendingHistory: () => [...transactionKeys.all, "unconfirmed"] as const, }; // Service function for fetching transaction details @@ -44,48 +36,6 @@ export const useFetchTransactionDetails = (txid: string) => { }); }; -// Hook for fetching pending transaction IDs and their details -// Service function for fetching pending transaction fees -const fetchPendingTransactionFee = async ( - txid: string, - client: BlockchainClient, -) => { - if (!client) { - throw new Error("No blockchain client available"); - } - return await client.getFeesForPendingTransaction(txid); -}; - -// Hook for fetching all pending transactions -const useFetchPendingTransactions = () => { - const pendingTransactionIds = useSelector(getPendingTransactionIds); - const blockchainClient = useGetClient(); - - return useQueries({ - queries: pendingTransactionIds.map((txid) => ({ - queryKey: transactionKeys.tx(txid), - queryFn: async () => { - const transaction = await fetchTransactionDetails( - txid, - blockchainClient, - ); - - // If transaction doesn't have a fee, fetch it - if (!transaction.fee) { - const fee = await fetchPendingTransactionFee(txid, blockchainClient); - return { - ...transaction, - fee: Number(fee), - }; - } - - return transaction; - }, - enabled: !!blockchainClient && !!txid, - })), - }); -}; - // Hook for fetching transactions with their hex data export const useTransactionsWithHex = (txids: string[]) => { const blockchainClient = useGetClient(); @@ -105,62 +55,6 @@ export const useTransactionsWithHex = (txids: string[]) => { }); }; -// Basic hook for raw pending transactions (no processing) -export const useRawPendingTransactions = () => { - const walletAddresses = useSelector(getWalletAddresses); - const transactionQueries = useFetchPendingTransactions(); - - const isLoading = transactionQueries.some((query) => query.isLoading); - const error = transactionQueries.find((query) => query.error)?.error; - - // Process transactions with calculated values and filter out confirmed ones - const pendingTransactions = transactionQueries - .filter((query) => query.data && !query.data.status?.confirmed) - .map((query) => query.data!); - - const transactions = pendingTransactions.map((tx) => { - return { - ...tx, - valueToWallet: calculateTransactionValue(tx, walletAddresses), - isReceived: - tx.isReceived !== undefined - ? tx.isReceived - : calculateTransactionValue(tx, walletAddresses) > 0, - }; - }); - - return { - transactions, - isLoading, - error, - refetch: () => { - transactionQueries.forEach((query) => query.refetch()); - }, - }; -}; - -// Hook for processed pending transactions - uses selector -export const usePendingTransactions = () => { - const walletAddresses = useSelector(getWalletAddresses); - const rawPendingQuery = useRawPendingTransactions(); - - const transactions = useMemo(() => { - if (!rawPendingQuery.transactions) return []; - return selectProcessedTransactions( - rawPendingQuery.transactions, - walletAddresses, - "unconfirmed", - ); - }, [rawPendingQuery.transactions, walletAddresses]); - - return { - transactions, - isLoading: rawPendingQuery.isLoading, - error: rawPendingQuery.error, - refetch: rawPendingQuery.refetch, - }; -}; - export interface Coin { prevTxId: string; vout: number; diff --git a/apps/coordinator/src/clients/txHistory.ts b/apps/coordinator/src/clients/txHistory.ts index c51a247e4e..4f48f04697 100644 --- a/apps/coordinator/src/clients/txHistory.ts +++ b/apps/coordinator/src/clients/txHistory.ts @@ -17,8 +17,11 @@ import { calculateTransactionValue } from "utils/transactionCalculations"; // cascading complexity where the query keys change, cache invalidation gets messy, and the logic becomes hard to follow const MAX_TRANSACTIONS_TO_FETCH = 500; const TRANSACTION_STALE_TIME = 30 * 1000; // 30 seconds +const PENDING_STALE_TIME = 10 * 1000; // 10 seconds - shorter for pending -export const usePublicClientTransactions = () => { +export const usePublicClientTransactions = ( + filter: "confirmed" | "unconfirmed" = "confirmed", +) => { const blockchainClient = useGetClient(); const clientType = blockchainClient?.type; const queryClient = useQueryClient(); @@ -36,19 +39,22 @@ export const usePublicClientTransactions = () => { return walletAddresses.length > 0 ? walletAddresses : spentAddresses; }, [walletAddresses, spentSlices]); + const queryKey = + filter === "confirmed" + ? transactionKeys.confirmedHistory() + : transactionKeys.pendingHistory(); + // When addresses change, we invalidate the cache // rather than creating a new cache with a different key. This basically tells React Query // that the data at this location is now stale, please refetch it useEffect(() => { if (currentAddresses.length > 0 && clientType === "public") { - queryClient.invalidateQueries({ - queryKey: transactionKeys.confirmedHistory(), - }); + queryClient.invalidateQueries({ queryKey }); } }, [currentAddresses.length, clientType, queryClient]); return useQuery({ - queryKey: transactionKeys.confirmedHistory(), + queryKey, queryFn: async () => { // So we fetch all transactions in one call and let the blockchain client handle this efficiently const rawTransactions = @@ -61,7 +67,7 @@ export const usePublicClientTransactions = () => { const processedTransactions = selectProcessedTransactions( rawTransactions, walletAddresses, - "confirmed", + filter, ); // Deduplication step — when querying multiple addresses, a transaction that @@ -86,32 +92,38 @@ export const usePublicClientTransactions = () => { !!blockchainClient && clientType === "public" && currentAddresses.length > 0, - staleTime: TRANSACTION_STALE_TIME, + staleTime: + filter === "confirmed" ? TRANSACTION_STALE_TIME : PENDING_STALE_TIME, refetchOnWindowFocus: true, - refetchInterval: 60000, // Refetch every minute + refetchInterval: filter === "confirmed" ? 60000 : 30000, refetchIntervalInBackground: false, }); }; -export const usePrivateClientTransactions = () => { +export const usePrivateClientTransactions = ( + filter: "confirmed" | "unconfirmed" = "confirmed", +) => { const blockchainClient = useGetClient(); const walletAddresses = useSelector(getWalletAddresses); const clientType = blockchainClient?.type; const queryClient = useQueryClient(); + const queryKey = + filter === "confirmed" + ? transactionKeys.confirmedHistory() + : transactionKeys.pendingHistory(); + // When addresses change, we invalidate the cache // rather than creating a new cache with a different key. This basically tells React Query // that the data at this location is now stale, please refetch it useEffect(() => { if (clientType === "private" && blockchainClient) { - queryClient.invalidateQueries({ - queryKey: transactionKeys.confirmedHistory(), - }); + queryClient.invalidateQueries({ queryKey }); } }, [walletAddresses.length, clientType, queryClient, blockchainClient]); return useQuery({ - queryKey: transactionKeys.confirmedHistory(), + queryKey, queryFn: async () => { // Fetch all transactions from the wallet in one call const rawTransactions = @@ -120,11 +132,11 @@ export const usePrivateClientTransactions = () => { 0, ); - // Process transactions to add wallet-specific data - const confirmedTx = selectProcessedTransactions( + // Process transactions according to the requested filter + const filteredTx = selectProcessedTransactions( rawTransactions, walletAddresses, - "confirmed", + filter, ); // Deduplication step — this fixes the issue where private nodes serving multiple @@ -132,7 +144,7 @@ export const usePrivateClientTransactions = () => { // in the query, we ensure it's done once at fetch time rather than repeatedly // during every UI render. const seenTxids = new Set(); - const deduplicated = confirmedTx.filter((tx) => { + const deduplicated = filteredTx.filter((tx) => { if (seenTxids.has(tx.txid)) { return false; } @@ -147,9 +159,10 @@ export const usePrivateClientTransactions = () => { })); }, enabled: !!blockchainClient && clientType === "private", - staleTime: TRANSACTION_STALE_TIME, + staleTime: + filter === "confirmed" ? TRANSACTION_STALE_TIME : PENDING_STALE_TIME, refetchOnWindowFocus: true, - refetchInterval: 60000, + refetchInterval: filter === "confirmed" ? 60000 : 30000, refetchIntervalInBackground: false, }); }; @@ -166,3 +179,22 @@ export const useConfirmedTransactions = () => { return clientType === "private" ? privateQuery : publicQuery; }; + +/** + * Hook for fetching unconfirmed (pending) transactions. + * Reuses the existing public/private client hooks with "unconfirmed" filter. + */ +export const usePendingTransactions = () => { + const clientType = useSelector((state: WalletState) => state.client.type); + const privateQuery = usePrivateClientTransactions("unconfirmed"); + const publicQuery = usePublicClientTransactions("unconfirmed"); + + const query = clientType === "private" ? privateQuery : publicQuery; + + return { + transactions: query.data || [], + isLoading: query.isLoading, + error: query.error, + refetch: query.refetch, + }; +}; diff --git a/apps/coordinator/src/components/Wallet/TransactionsTab/TableComponents/PendingTransactionsView.tsx b/apps/coordinator/src/components/Wallet/TransactionsTab/TableComponents/PendingTransactionsView.tsx index 3208b4eb97..125ae79073 100644 --- a/apps/coordinator/src/components/Wallet/TransactionsTab/TableComponents/PendingTransactionsView.tsx +++ b/apps/coordinator/src/components/Wallet/TransactionsTab/TableComponents/PendingTransactionsView.tsx @@ -3,7 +3,7 @@ import { Box, Typography, CircularProgress } from "@mui/material"; import { TransactionTable } from "./TransactionsTable"; import { PaginationControls } from "./PaginationControls"; import { TransactionFilter } from "./TransactionFilter"; -import { usePendingTransactions } from "clients/transactions"; +import { usePendingTransactions } from "clients/txHistory"; import { useSortedTransactions, useTransactionPagination, diff --git a/apps/coordinator/src/components/Wallet/TransactionsTab/index.tsx b/apps/coordinator/src/components/Wallet/TransactionsTab/index.tsx index 5b02025657..cac6bfa4c8 100644 --- a/apps/coordinator/src/components/Wallet/TransactionsTab/index.tsx +++ b/apps/coordinator/src/components/Wallet/TransactionsTab/index.tsx @@ -3,8 +3,10 @@ import { useSelector } from "react-redux"; import { useGetClient } from "hooks/client"; import { Box, Tabs, Tab } from "@mui/material"; import { AccelerationModal } from "./FeeBumping/components/AccelerationModal"; -import { usePendingTransactions } from "clients/transactions"; -import { useConfirmedTransactions } from "clients/txHistory"; +import { + usePendingTransactions, + useConfirmedTransactions, +} from "clients/txHistory"; import { ConfirmedTransactionsView } from "./TableComponents/ConfirmedTransactionsView"; import { PendingTransactionsView } from "./TableComponents/PendingTransactionsView"; import { useHandleTransactionExplorerLinkClick } from "./hooks"; diff --git a/apps/coordinator/src/hooks/utxos.ts b/apps/coordinator/src/hooks/utxos.ts index f48b1d5060..12e019b6b6 100644 --- a/apps/coordinator/src/hooks/utxos.ts +++ b/apps/coordinator/src/hooks/utxos.ts @@ -14,9 +14,9 @@ import { UTXO } from "@caravan/transactions"; import { Coin, fetchTransactionCoins, - usePendingTransactions, useTransactionsWithHex, } from "clients/transactions"; +import { usePendingTransactions } from "clients/txHistory"; import { MultisigAddressType, P2SH, P2SH_P2WSH, P2WSH } from "@caravan/bitcoin"; import { useGetClient } from "hooks/client"; import { TransactionDetails } from "@caravan/clients";