Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-pending-tx-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"caravan-coordinator": patch
---

Fix pending transaction tracking for transactions without change outputs (e.g., "MAX" sends).
112 changes: 3 additions & 109 deletions apps/coordinator/src/clients/transactions.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
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";

// Centralized query key factory for all transaction-related queries
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
Expand All @@ -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 (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes these can be removed as you did

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();
Expand All @@ -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;
Expand Down
70 changes: 51 additions & 19 deletions apps/coordinator/src/clients/txHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 =
Expand All @@ -61,7 +67,7 @@ export const usePublicClientTransactions = () => {
const processedTransactions = selectProcessedTransactions(
rawTransactions,
walletAddresses,
"confirmed",
filter,
);

// Deduplication step — when querying multiple addresses, a transaction that
Expand All @@ -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 =
Expand All @@ -120,19 +132,19 @@ 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
// wallets might return the same transaction more than once. By handling it here
// in the query, we ensure it's done once at fetch time rather than repeatedly
// during every UI render.
const seenTxids = new Set<string>();
const deduplicated = confirmedTx.filter((tx) => {
const deduplicated = filteredTx.filter((tx) => {
if (seenTxids.has(tx.txid)) {
return false;
}
Expand All @@ -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,
});
};
Expand All @@ -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,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion apps/coordinator/src/hooks/utxos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading