diff --git a/apps/coordinator/package.json b/apps/coordinator/package.json index 73eccd2d..ae2aec0e 100644 --- a/apps/coordinator/package.json +++ b/apps/coordinator/package.json @@ -99,6 +99,7 @@ "@caravan/descriptors": "^0.1.1", "@caravan/eslint-config": "*", "@caravan/psbt": "*", + "@caravan/fees": "*", "@caravan/typescript-config": "*", "@caravan/wallets": "*", "@emotion/react": "^11.10.6", diff --git a/apps/coordinator/src/actions/transactionActions.js b/apps/coordinator/src/actions/transactionActions.js index 9468282b..e78c83a4 100644 --- a/apps/coordinator/src/actions/transactionActions.js +++ b/apps/coordinator/src/actions/transactionActions.js @@ -15,6 +15,7 @@ export const SET_REQUIRED_SIGNERS = "SET_REQUIRED_SIGNERS"; export const SET_TOTAL_SIGNERS = "SET_TOTAL_SIGNERS"; export const SET_INPUTS = "SET_INPUTS"; +export const SET_RBF = "SET_RBF"; export const ADD_OUTPUT = "ADD_OUTPUT"; export const SET_OUTPUT_ADDRESS = "SET_OUTPUT_ADDRESS"; @@ -114,6 +115,11 @@ export function setChangeAddressAction(value) { }; } +export const setRBF = (enabled) => ({ + type: SET_RBF, + value: enabled, +}); + export function setChangeOutput({ value, address }) { return (dispatch, getState) => { const { diff --git a/apps/coordinator/src/actions/walletActions.js b/apps/coordinator/src/actions/walletActions.js index f884bb3f..abd18319 100644 --- a/apps/coordinator/src/actions/walletActions.js +++ b/apps/coordinator/src/actions/walletActions.js @@ -33,6 +33,7 @@ export const WALLET_MODES = { VIEW: 0, DEPOSIT: 1, SPEND: 2, + PENDING: 3, }; export function updateDepositSliceAction(value) { diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx index 3bcabfb8..a329d37f 100644 --- a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx @@ -14,7 +14,10 @@ import { InputAdornment, Typography, FormHelperText, + Switch, + FormControlLabel, } from "@mui/material"; +import InfoIcon from "@mui/icons-material/Info"; import { Speed } from "@mui/icons-material"; import AddIcon from "@mui/icons-material/Add"; import { @@ -24,6 +27,7 @@ import { setFee as setFeeAction, finalizeOutputs as finalizeOutputsAction, resetOutputs as resetOutputsAction, + setRBF as setRBFAction, } from "../../actions/transactionActions"; import { updateBlockchainClient } from "../../actions/clientActions"; import { MIN_SATS_PER_BYTE_FEE } from "../Wallet/constants"; @@ -231,6 +235,11 @@ class OutputsForm extends React.Component { setOutputAmount(1, outputAmount); } + handleRBFToggle = () => { + const { setRBF, rbfEnabled } = this.props; + setRBF(!rbfEnabled); + }; + render() { const { feeRate, @@ -242,6 +251,7 @@ class OutputsForm extends React.Component { inputs, isWallet, autoSpend, + rbfEnabled, } = this.props; const { feeRateFetchError } = this.state; const feeDisplay = inputs && inputs.length > 0 ? fee : "0.0000"; @@ -281,7 +291,7 @@ class OutputsForm extends React.Component { - + - -   - {!isWallet || (isWallet && !autoSpend) ? ( @@ -357,10 +364,37 @@ class OutputsForm extends React.Component { ) : ( - "" + )} - + + + + + + + + + + } + label="Enable RBF" + labelPlacement="start" + /> + + @@ -488,6 +522,8 @@ OutputsForm.propTypes = { signatureImporters: PropTypes.shape({}).isRequired, updatesComplete: PropTypes.bool, getBlockchainClient: PropTypes.func.isRequired, + rbfEnabled: PropTypes.bool.isRequired, + setRBF: PropTypes.func.isRequired, }; OutputsForm.defaultProps = { @@ -504,6 +540,7 @@ function mapStateToProps(state) { ...state.client, signatureImporters: state.spend.signatureImporters, change: state.wallet.change, + rbfEnabled: state.spend.transaction.rbfEnabled, }; } @@ -515,6 +552,7 @@ const mapDispatchToProps = { finalizeOutputs: finalizeOutputsAction, resetOutputs: resetOutputsAction, getBlockchainClient: updateBlockchainClient, + setRBF: setRBFAction, }; export default connect(mapStateToProps, mapDispatchToProps)(OutputsForm); diff --git a/apps/coordinator/src/components/Wallet/WalletControl.jsx b/apps/coordinator/src/components/Wallet/WalletControl.jsx index 0eb62573..06f8fe76 100644 --- a/apps/coordinator/src/components/Wallet/WalletControl.jsx +++ b/apps/coordinator/src/components/Wallet/WalletControl.jsx @@ -16,6 +16,7 @@ import { setRequiredSigners as setRequiredSignersAction } from "../../actions/tr import { MAX_FETCH_UTXOS_ERRORS, MAX_TRAILING_EMPTY_NODES } from "./constants"; import WalletDeposit from "./WalletDeposit"; import WalletSpend from "./WalletSpend"; +import WalletPendingTransactions from "./WalletPendingTransactions"; import { SlicesTableContainer } from "../Slices"; class WalletControl extends React.Component { @@ -41,6 +42,7 @@ class WalletControl extends React.Component { , , , + , ]} {this.renderModeComponent()} @@ -55,6 +57,8 @@ class WalletControl extends React.Component { if (walletMode === WALLET_MODES.SPEND) return ; if (walletMode === WALLET_MODES.VIEW) return ; + if (walletMode === WALLET_MODES.PENDING) + return ; } const progress = this.progress(); return [ diff --git a/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx new file mode 100644 index 00000000..e54e3f69 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx @@ -0,0 +1,279 @@ +import React, { useState, useCallback } from "react"; +import { + createAcceleratedRbfTransaction, + createCancelRbfTransaction, + createCPFPTransaction, +} from "@caravan/fees"; +import { useSelector } from "react-redux"; +import { Box, Paper } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { useGetPendingTransactions } from "../../hooks"; +import TransactionTable from "./fee-bumping/PendingTransactionTable"; +import RBFOptionsDialog from "./fee-bumping/rbf/dialogs/RBFOptionsDialog"; +import AccelerateFeeDialog from "./fee-bumping/rbf/dialogs/AccelerateFeeDialog"; +import CancelTransactionDialog from "./fee-bumping/rbf/dialogs/CancelTransactionDialog"; +import CPFPDialog from "./fee-bumping/cpfp/CPFPDialog"; +import { ExtendedAnalyzer, RootState } from "components/types/fees"; +const StyledPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(3), + marginBottom: theme.spacing(2), +})); +const WalletPendingTransactions: React.FC = () => { + const { pendingTransactions, currentNetworkFeeRate, isLoading, error } = + useGetPendingTransactions(); + console.log("check", pendingTransactions); + const [selectedTx, setSelectedTx] = useState(null); + const [showRBFOptions, setShowRBFOptions] = useState(false); + const [showIncreaseFees, setShowIncreaseFees] = useState(false); + const [showCancelTx, setShowCancelTx] = useState(false); + const [showCPFP, setShowCPFP] = useState(false); + const [feePsbt, setFeePsbt] = useState(null); + const [isGeneratingPSBT, setIsGeneratingPSBT] = useState(false); + // const estimateBlocksToMine = (feeRate: number): number => { + // TO DO (MRIGESH) : Implement new methods in client package's BlockchainClient class + // to enable use of this method ... + // }; + // + + // get change addr + + const changeAddresses = useSelector((state: RootState) => [ + ...Object.values(state.wallet.change.nodes), + ]); + + console.log("changeadd", changeAddresses[0].multisig.address); + + const settings = useSelector((state: RootState) => state.settings); + const handleRBF = (tx: ExtendedAnalyzer) => { + setSelectedTx(tx); + console.log("handleRBF", tx); + + setShowRBFOptions(true); + }; + const handleCPFP = (tx: ExtendedAnalyzer) => { + setSelectedTx(tx); + setShowCPFP(true); + }; + const handleIncreaseFees = () => { + setShowRBFOptions(false); + setShowIncreaseFees(true); + }; + const handleCancelTx = () => { + setShowRBFOptions(false); + setShowCancelTx(true); + }; + const closeAllModals = () => { + setShowRBFOptions(false); + setShowIncreaseFees(false); + setShowCancelTx(false); + setShowCPFP(false); + setSelectedTx(null); + setFeePsbt(null); + }; + + const handleAccelerateFee = async (newFeeRate: number) => { + if (selectedTx) { + try { + const result = createAcceleratedRbfTransaction({ + originalTx: selectedTx.txHex, + targetFeeRate: newFeeRate, + network: settings.network, + availableInputs: selectedTx.analyzer.availableUTXOs, + dustThreshold: "546", + scriptType: settings.addressType, + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + absoluteFee: selectedTx.analyzer.fee, + changeIndex: selectedTx.analyzer["_changeOutputIndex"], + }); + setFeePsbt(result); + setShowIncreaseFees(false); + } catch (error) { + console.error("Error creating accelerated RBF transaction:", error); + //TO DO : Handle error (e.g., show an error message to the user) + } + } + }; + + const createAccelerateFeePsbt = useCallback( + (newFeeRate: number): string => { + if (selectedTx) { + setIsGeneratingPSBT(true); + try { + const result = createAcceleratedRbfTransaction({ + originalTx: selectedTx.txHex, + targetFeeRate: newFeeRate, + network: settings.network, + availableInputs: selectedTx.analyzer.availableUTXOs, + changeAddress: settings.changeAddress, + dustThreshold: "546", + scriptType: settings.addressType, + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + absoluteFee: selectedTx.analyzer.fee, + changeIndex: 0, + }); + return result; + } catch (error) { + console.error("Error creating accelerated RBF transaction:", error); + throw error; // Rethrow the error to be handled in the dialog + } finally { + setIsGeneratingPSBT(false); + } + } + throw new Error("No transaction selected"); + }, + [selectedTx, settings], + ); + + const handleCancelTransaction = async (newFeeRate: number) => { + if (selectedTx) { + try { + const result = createCancelRbfTransaction({ + originalTx: selectedTx.txHex, + targetFeeRate: newFeeRate, + network: settings.network, + availableInputs: selectedTx.analyzer.availableUTXOs, + cancelAddress: changeAddresses[0].multisig.address, + dustThreshold: "546", + scriptType: settings.addressType, + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + absoluteFee: selectedTx.analyzer.fee, + }); + setFeePsbt(result); + setShowCancelTx(false); + } catch (error) { + console.error("Error creating cancel RBF transaction:", error); + //TO DO : Handle error (e.g., show an error message to the user) + } + } + }; + + const createCancelFeePsbt = useCallback( + (newFeeRate: number): string => { + if (selectedTx) { + setIsGeneratingPSBT(true); + try { + const result = createCancelRbfTransaction({ + originalTx: selectedTx.txHex, + targetFeeRate: newFeeRate, + network: settings.network, + availableInputs: selectedTx.analyzer.availableUTXOs, + cancelAddress: changeAddresses[0].multisig.address, + dustThreshold: "546", + scriptType: settings.addressType, + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + absoluteFee: selectedTx.analyzer.fee, + }); + return result; + } catch (error) { + console.error("Error creating accelerated RBF transaction:", error); + throw error; // Rethrow the error to be handled in the dialog + } finally { + setIsGeneratingPSBT(false); + } + } + throw new Error("No transaction selected"); + }, + [selectedTx, settings], + ); + + const createCPFPPsbt = useCallback( + (newFeeRate: number): string => { + if (selectedTx) { + setIsGeneratingPSBT(true); + try { + const result = createCPFPTransaction({ + originalTx: selectedTx.txHex, + targetFeeRate: newFeeRate, + network: settings.network, + availableInputs: selectedTx.analyzer.availableUTXOs, + changeAddress: changeAddresses[0].multisig.address, + dustThreshold: "546", + scriptType: settings.addressType, + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + absoluteFee: selectedTx.analyzer.fee, + spendableOutputIndex: selectedTx.analyzer.outputs.findIndex( + (output) => output.isMalleable, + ), + }); + return result; + } catch (error) { + console.error("Error creating CPFP transaction:", error); + throw error; + } finally { + setIsGeneratingPSBT(false); + } + } + throw new Error("No transaction selected"); + }, + [selectedTx, settings, changeAddresses], + ); + + const handleConfirmCPFP = async (newFeeRate: number) => { + if (selectedTx) { + try { + const result = createCPFPPsbt(newFeeRate); + setFeePsbt(result); + setShowCPFP(false); + } catch (error) { + console.error("Error creating CPFP transaction:", error); + // TODO: Handle error (e.g., show an error message to the user) + } + } + }; + + return ( + + + + + + + + + + + ); +}; +export default WalletPendingTransactions; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx new file mode 100644 index 00000000..653c4a3a --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx @@ -0,0 +1,234 @@ +import React, { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Chip, + Typography, + Button, + Tooltip, + IconButton, + Box, + CircularProgress, + Select, + MenuItem, + FormControl, +} from "@mui/material"; +import { useSelector } from "react-redux"; +import { + blockExplorerTransactionURL, + satoshisToBitcoins, +} from "@caravan/bitcoin"; +import { styled } from "@mui/material/styles"; +import { Search, Edit, TrendingUp, TrendingDown } from "@mui/icons-material"; +import { RootState, ExtendedAnalyzer } from "components/types/fees"; +import TransactionActions from "./TransactionActions"; +import { formatTxid } from "./utils"; +import Copyable from "./../../Copyable"; + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + "&.MuiTableCell-head": { + backgroundColor: theme.palette.grey[200], + fontWeight: "bold", + }, +})); + +const TxidCell = styled(TableCell)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), +})); + +const AmountHeaderCell = styled(StyledTableCell)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), +})); + +const StyledSelect = styled(Select)(() => ({ + minWidth: "70px", + height: "30px", + fontSize: "0.875rem", +})); + +const FeeRateComparison = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: theme.spacing(0.5), +})); + +const FeeRateRow = styled(Box)(({ theme }) => ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(1), +})); + +interface TransactionTableProps { + transactions: ExtendedAnalyzer[]; + onRBF: (tx: ExtendedAnalyzer) => void; + onCPFP: (tx: ExtendedAnalyzer) => void; + isLoading: boolean; + currentFeeRate: number; + error: string | null; +} + +const TransactionTable: React.FC = ({ + transactions, + onRBF, + onCPFP, + isLoading, + currentFeeRate, + error, +}) => { + const network = useSelector((state: RootState) => state.settings.network); + const [amountUnit, setAmountUnit] = useState<"BTC" | "satoshis">("BTC"); + + const formatAmount = (amountSats: number) => { + if (amountUnit === "BTC") { + return `${satoshisToBitcoins(amountSats)} BTC`; + } else { + return `${amountSats} sats`; + } + }; + + const renderFeeRateComparison = (tx: ExtendedAnalyzer) => { + const txFeeRate = parseFloat(tx.analyzer.feeRate); + const feeRateDiff = currentFeeRate - txFeeRate; + const icon = + feeRateDiff > 0 ? ( + + ) : ( + + ); + + return ( + + + Paid: + + + + Current: + + {icon} + + + ); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (transactions.length === 0) { + return ( + + No pending transactions found. + + ); + } + + return ( + + + + Transaction ID + Time Elapsed + + Amount + + + setAmountUnit(e.target.value as "BTC" | "satoshis") + } + displayEmpty + > + BTC + sats + + + + Fee Rate + Recommended Strategy + Actions + + + + {transactions.map((tx) => ( + + + + {formatTxid(tx.analyzer.txid)} + + + + + + + + + {tx.timeElapsed} + + + {formatAmount(parseInt(tx.analyzer.fee))} + + + {renderFeeRateComparison(tx)} + + + {tx.analyzer.recommendedStrategy === "NONE" + ? "No action needed" + : tx.canRBF + ? tx.analyzer.recommendedStrategy + : "CPFP"} + + + + + ))} + +
+ ); +}; + +export default TransactionTable; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/TransactionActions.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/TransactionActions.tsx new file mode 100644 index 00000000..86b6c315 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/TransactionActions.tsx @@ -0,0 +1,162 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; +import { + TableCell, + Tooltip, + Button, + Typography, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { Edit, Warning } from "@mui/icons-material"; +import { styled } from "@mui/material/styles"; + +const ActionCell = styled(TableCell)(() => ({ + whiteSpace: "nowrap", +})); + +const ActionButton = styled(Button)(({ theme }) => ({ + margin: theme.spacing(0.5), + textTransform: "none", +})); + +const WarningDialog = ({ + open, + onClose, + onProceed, + recommendedStrategy, + chosenStrategy, +}) => ( + + + Warning: Not Recommended Strategy + + + + The recommended strategy for this transaction is {recommendedStrategy}, + but you've chosen {chosenStrategy}. Proceeding with {chosenStrategy} may + not be optimal for this transaction. + + + + + + + +); + +WarningDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onProceed: PropTypes.func.isRequired, + recommendedStrategy: PropTypes.string.isRequired, + chosenStrategy: PropTypes.string.isRequired, +}; + +const TransactionActions = ({ tx, onRBF, onCPFP }) => { + const [warningOpen, setWarningOpen] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + + const handleAction = (action) => { + if (getRecommendedStrategy() !== action) { + setWarningOpen(true); + setPendingAction(action); + } else { + executeAction(action); + } + }; + + const executeAction = (action) => { + if (action === "RBF") { + onRBF(tx); + } else if (action === "CPFP") { + onCPFP(tx); + } + }; + + const handleProceed = () => { + setWarningOpen(false); + executeAction(pendingAction); + }; + + const getRecommendedStrategy = () => { + if (tx.analyzer.recommendedStrategy === "RBF" && tx.canRBF) { + return "RBF"; + } else if (tx.canCPFP) { + return "CPFP"; + } + return "NONE"; + }; + + const recommendedStrategy = getRecommendedStrategy(); + + const renderActionButton = (strategy, isRecommended) => { + const feeRate = + strategy === "RBF" ? tx.analyzer.rbfFeeRate : tx.analyzer.cpfpFeeRate; + return ( + + handleAction(strategy)} + startIcon={} + > + {strategy} + + + ); + }; + + return ( + + {recommendedStrategy !== "NONE" ? ( + <> + {renderActionButton(recommendedStrategy, true)} + {recommendedStrategy === "RBF" && + tx.canCPFP && + renderActionButton("CPFP", false)} + {recommendedStrategy === "CPFP" && + tx.canRBF && + renderActionButton("RBF", false)} + + ) : ( + + No actions available + + )} + setWarningOpen(false)} + onProceed={handleProceed} + recommendedStrategy={recommendedStrategy} + chosenStrategy={pendingAction} + /> + + ); +}; + +TransactionActions.propTypes = { + tx: PropTypes.shape({ + canRBF: PropTypes.bool.isRequired, + canCPFP: PropTypes.bool.isRequired, + analyzer: PropTypes.shape({ + rbfFeeRate: PropTypes.string.isRequired, + cpfpFeeRate: PropTypes.string.isRequired, + recommendedStrategy: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, + onRBF: PropTypes.func.isRequired, + onCPFP: PropTypes.func.isRequired, +}; + +export default TransactionActions; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/CPFPDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/CPFPDialog.tsx new file mode 100644 index 00000000..b4889616 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/CPFPDialog.tsx @@ -0,0 +1,266 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + CircularProgress, + Alert, +} from "@mui/material"; +import { usePsbtDetails } from "../../../../hooks"; +import { ExtendedAnalyzer } from "components/types/fees"; +import { useGetClient } from "../../../../hooks"; +import { satoshisToBitcoins } from "@caravan/bitcoin"; +import FeeComparison from "./components/FeeComparison"; +import FeeRateAdjuster from "./components/FeeRateAdjuster"; +import ChangeAddressSelector from "./components/ChangeAddressSelector"; +import TransactionDetails from "./components/TransactionDetails"; + +interface CPFPDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (newFeeRate: number, customChangeAddress?: string) => void; + createPsbt: (newFeeRate: number, customChangeAddress?: string) => string; + transaction: ExtendedAnalyzer | null; + currentNetworkFeeRate: number; + isGeneratingPSBT: boolean; + defaultChangeAddress: string; +} + +const CPFPDialog: React.FC = ({ + open, + onClose, + onConfirm, + createPsbt, + transaction, + currentNetworkFeeRate, + isGeneratingPSBT, + defaultChangeAddress, +}) => { + const [newFeeRate, setNewFeeRate] = useState(currentNetworkFeeRate); + const [psbtHex, setPsbtHex] = useState(null); + const [previewPsbtHex, setPreviewPsbtHex] = useState(null); + const [error, setError] = useState(null); + const [parentTx, setParentTx] = useState(null); + const [useCustomAddress, setUseCustomAddress] = useState(false); + const [customAddress, setCustomAddress] = useState(""); + const [showPreview, setShowPreview] = useState(false); + const [expandedParent, setExpandedParent] = useState(false); + const [expandedChild, setExpandedChild] = useState(false); + const blockchainClient = useGetClient(); + + const { + txTemplate: childTxTemplate, + error: psbtError, + calculateFee: calculateChildFee, + } = usePsbtDetails(psbtHex!); + + const { + txTemplate: previewChildTxTemplate, + calculateFee: calculatePreviewChildFee, + } = usePsbtDetails(previewPsbtHex!); + + useEffect(() => { + if (open) { + setNewFeeRate(currentNetworkFeeRate); + setPsbtHex(null); + setPreviewPsbtHex(null); + setError(null); + setParentTx(null); + setUseCustomAddress(false); + setCustomAddress(""); + setShowPreview(false); + } + }, [open, currentNetworkFeeRate]); + + const generatePsbt = useCallback( + async (feeRate: number, preview: boolean = false) => { + if (transaction) { + try { + const result = createPsbt( + feeRate, + useCustomAddress ? customAddress : undefined, + ); + if (preview) { + setPreviewPsbtHex(result); + } else { + setPsbtHex(result); + } + + if (!parentTx) { + const parentTxDetails = await blockchainClient.getTransaction( + transaction.txId, + ); + setParentTx(parentTxDetails); + } + } catch (err) { + setError("Failed to generate PSBT. Please try again."); + console.error(err); + } + } + }, + [ + transaction, + createPsbt, + useCustomAddress, + customAddress, + blockchainClient, + parentTx, + ], + ); + + useEffect(() => { + if (open && !isGeneratingPSBT) { + generatePsbt(currentNetworkFeeRate); + } + }, [open, isGeneratingPSBT, generatePsbt, currentNetworkFeeRate]); + + const handlePreview = async () => { + await generatePsbt(newFeeRate, true); + setShowPreview(true); + }; + + if (isGeneratingPSBT || !psbtHex || !parentTx) { + return ( + + Preparing CPFP Transaction + + + + + Please wait while we prepare the CPFP transaction... + + + + + ); + } + + if (!childTxTemplate) { + return ( + + Error + + + {error || psbtError || "Failed to load transaction details"} + + + + + + + ); + } + + const parentFee = satoshisToBitcoins(parentTx.fee); + const childFee = satoshisToBitcoins(calculateChildFee()); + const previewChildFee = showPreview + ? satoshisToBitcoins(calculatePreviewChildFee()) + : childFee; + const totalFee = ( + parseFloat(parentFee) + parseFloat(previewChildFee) + ).toFixed(8); + const parentSize = parentTx.size; + const childSize = showPreview + ? previewChildTxTemplate!.estimatedVsize + : childTxTemplate.estimatedVsize; + const combinedSize = parentSize + childSize; + const combinedFeeRate = ( + (parseFloat(totalFee) / combinedSize) * + 100000000 + ).toFixed(2); + + return ( + + + Create CPFP Transaction + + Boost your transaction priority by creating a child transaction with a + higher fee + + + + + + + + + + + + {showPreview && ( + + )} + + + + The child transaction will help accelerate the confirmation of the + parent transaction by paying a higher fee rate for both transactions + combined. + + + + + + + + + + ); +}; + +export default CPFPDialog; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/ChangeAddressSelector.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/ChangeAddressSelector.tsx new file mode 100644 index 00000000..b3da32eb --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/ChangeAddressSelector.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Typography, FormControlLabel, Switch, TextField } from "@mui/material"; +import { StyledPaper } from "../styles"; + +interface ChangeAddressSelectorProps { + useCustomAddress: boolean; + setUseCustomAddress: (use: boolean) => void; + customAddress: string; + setCustomAddress: (address: string) => void; + defaultChangeAddress: string; +} + +const ChangeAddressSelector: React.FC = ({ + useCustomAddress, + setUseCustomAddress, + customAddress, + setCustomAddress, + defaultChangeAddress, +}) => { + return ( + + + Change Address + + setUseCustomAddress(e.target.checked)} + /> + } + label="Use custom change address" + /> + {useCustomAddress && ( + setCustomAddress(e.target.value)} + margin="normal" + variant="outlined" + /> + )} + {!useCustomAddress && ( + + Using default change address: {defaultChangeAddress} + + )} + + ); +}; + +export default ChangeAddressSelector; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeComparison.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeComparison.tsx new file mode 100644 index 00000000..f8a3f5b0 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeComparison.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { Typography, Grid, Box } from "@mui/material"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import { FeeBox, StyledPaper } from "../styles"; + +interface FeeComparisonProps { + parentFee: string; + childFee: string; + parentSize: number; + childSize: number; + combinedFeeRate: string; +} + +const FeeComparison: React.FC = ({ + parentFee, + childFee, + parentSize, + childSize, + combinedFeeRate, +}) => { + const totalFee = (parseFloat(parentFee) + parseFloat(childFee)).toFixed(8); + + return ( + + + Fee Comparison + + + + + Parent Transaction + {parentFee} BTC + + ({((parseFloat(parentFee) / parentSize) * 100000000).toFixed(2)}{" "} + sat/vB) + + + + + + + + + + + Child Transaction + + {childFee} BTC + + + ({((parseFloat(childFee) / childSize) * 100000000).toFixed(2)}{" "} + sat/vB) + + + + + + Combined + + {totalFee} BTC + + + ({combinedFeeRate} sat/vB) + + + + + + ); +}; + +export default FeeComparison; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeRateAdjuster.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeRateAdjuster.tsx new file mode 100644 index 00000000..4865656d --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeRateAdjuster.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Typography, Slider } from "@mui/material"; +import { StyledPaper } from "../styles"; + +interface FeeRateAdjusterProps { + newFeeRate: number; + setNewFeeRate: (rate: number) => void; + minFeeRate: number; + currentNetworkFeeRate: number; +} + +const FeeRateAdjuster: React.FC = ({ + newFeeRate, + setNewFeeRate, + minFeeRate, + currentNetworkFeeRate, +}) => { + return ( + + + Adjust Fee Rate + + setNewFeeRate(value as number)} + min={minFeeRate} + max={Math.max(100, currentNetworkFeeRate * 2)} + step={0.1} + marks={[ + { value: minFeeRate, label: "Min" }, + { value: currentNetworkFeeRate, label: "Current" }, + { value: Math.max(100, currentNetworkFeeRate * 2), label: "Max" }, + ]} + valueLabelDisplay="auto" + /> + + Move the slider to adjust the fee rate for the child transaction + + + ); +}; + +export default FeeRateAdjuster; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/TransactionDetails.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/TransactionDetails.tsx new file mode 100644 index 00000000..a594b4ea --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/TransactionDetails.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { + Typography, + Button, + Collapse, + List, + ListItem, + ListItemText, + Divider, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import { satoshisToBitcoins } from "@caravan/bitcoin"; +import { TransactionDetailsBox } from "../styles"; + +interface TransactionDetailsProps { + title: string; + tx: any; + expanded: boolean; + setExpanded: (expanded: boolean) => void; +} + +const TransactionDetails: React.FC = ({ + title, + tx, + expanded, + setExpanded, +}) => { + return ( + + + {title} + + + + + + + + + + + + + + + + + + {(tx.inputs || tx.analyzer?.inputs || []).map( + (input: any, index: number) => ( + + + + ), + )} + + + + + {(tx.outputs || tx.analyzer?.outputs || []).map( + (output: any, index: number) => ( + + + + ), + )} + + + + ); +}; + +export default TransactionDetails; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/index.ts b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/index.ts new file mode 100644 index 00000000..ea792282 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/index.ts @@ -0,0 +1 @@ +export { default as CPFPDialog } from "./CPFPDialog"; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/styles.ts b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/styles.ts new file mode 100644 index 00000000..7238c9a1 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/cpfp/styles.ts @@ -0,0 +1,24 @@ +import { styled } from "@mui/material/styles"; +import { Paper, Box } from "@mui/material"; + +export const StyledPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(3), + margin: theme.spacing(2, 0), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[3], +})); + +export const FeeBox = styled(Box)(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.default, +})); + +export const TransactionDetailsBox = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), +})); diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/AdjustFeeRateSlider.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/AdjustFeeRateSlider.tsx new file mode 100644 index 00000000..b47f5aa8 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/AdjustFeeRateSlider.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Grid, Slider, Button } from "@mui/material"; +import InfoIcon from "@mui/icons-material/Info"; +import { AdjustFeeRateSliderProps } from "../types"; + +const AdjustFeeRateSlider: React.FC = ({ + newFeeRate, + setNewFeeRate, + currentFeeRate, + currentNetworkFeeRate, + handlePreviewTransaction, +}) => ( + + + setNewFeeRate(value as number)} + min={currentFeeRate} + max={Math.max(100, currentNetworkFeeRate * 2)} + step={0.1} + marks={[ + { value: currentFeeRate, label: "Current" }, + + { value: Math.max(100, currentNetworkFeeRate * 2), label: "Max" }, + ]} + valueLabelDisplay="auto" + /> + + + + + +); + +export default AdjustFeeRateSlider; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/FeeComparisonBox.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/FeeComparisonBox.tsx new file mode 100644 index 00000000..73e58232 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/FeeComparisonBox.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import CompareArrowsIcon from "@mui/icons-material/CompareArrows"; +import { FeeComparisonBoxProps } from "../types"; + +const StyledBox = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.background.paper, + boxShadow: theme.shadows[1], +})); + +const FeeComparisonBox: React.FC = ({ + currentFees, + newFees, + currentFeeRate, + newFeeRate, + additionalFees, +}) => ( + + + Current Fee + {currentFees} BTC + ({currentFeeRate} sat/vB) + + + + New Fee + + {newFees} BTC + + + ({newFeeRate.toFixed(2)} sat/vB) + + + +); + +export default FeeComparisonBox; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/TransactionTable.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/TransactionTable.tsx new file mode 100644 index 00000000..58e16b06 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/TransactionTable.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, + Paper, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { satoshisToBitcoins } from "@caravan/bitcoin"; +import { TransactionTableProps } from "../types"; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(2), + margin: theme.spacing(2, 0), + backgroundColor: theme.palette.background.default, +})); + +const TransactionTable: React.FC = ({ + title, + items, + isInputs, + template, +}) => ( + + + {title} + + + + + Address + {isInputs && UTXO count} + Amount (BTC) + + + + {items.map((item, index) => ( + + {isInputs ? item.txid : item.address} + {isInputs && 1} + + {satoshisToBitcoins(item.amountSats)} BTC + + + ))} + + + TOTAL: + + + + {satoshisToBitcoins( + isInputs + ? template.getTotalInputAmount() + : template.getTotalOutputAmount(), + )}{" "} + BTC + + + + +
+
+); + +export default TransactionTable; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/AccelerateFeeDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/AccelerateFeeDialog.tsx new file mode 100644 index 00000000..f9f3cc50 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/AccelerateFeeDialog.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Alert, + CircularProgress, + Paper, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { RBFDialogProps } from "../types"; +import FeeComparisonBox from "../components/FeeComparisonBox"; +import TransactionTable from "../components/TransactionTable"; +import AdjustFeeRateSlider from "../components/AdjustFeeRateSlider"; +import { usePsbtHook, calculateFees } from "../utils/psbtHelpers"; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(2), + margin: theme.spacing(2, 0), + backgroundColor: theme.palette.background.default, +})); + +const AccelerateFeeDialog: React.FC = ({ + open, + onClose, + onConfirm, + createPsbt, + transaction, + currentNetworkFeeRate, + isGeneratingPSBT, +}) => { + const [newFeeRate, setNewFeeRate] = useState(currentNetworkFeeRate); + const [psbtHex, setPsbtHex] = useState(null); + const [previewPsbtHex, setPreviewPsbtHex] = useState(null); + const [error, setError] = useState(null); + const [rbfError, setRbfError] = useState(null); + + useEffect(() => { + if (open) { + setNewFeeRate(currentNetworkFeeRate); + setPsbtHex(null); + setPreviewPsbtHex(null); + setError(null); + setRbfError(null); + } + }, [open, currentNetworkFeeRate]); + + useEffect(() => { + const generatePsbt = async () => { + if (open && !isGeneratingPSBT) { + try { + const result = createPsbt(currentNetworkFeeRate); + setPsbtHex(result); + } catch (err) { + setError("Failed to generate initial PSBT. Please try again."); + console.error(err); + } + } + }; + + generatePsbt(); + }, [open, isGeneratingPSBT, createPsbt, currentNetworkFeeRate]); + + const { txTemplate, error: psbtError } = usePsbtHook(psbtHex); + const { txTemplate: previewTxTemplate, calculateFee: calculatePreviewFee } = + usePsbtHook(previewPsbtHex); + + const handlePreviewTransaction = () => { + try { + const newPsbtHex = createPsbt(newFeeRate); + setPreviewPsbtHex(newPsbtHex); + setRbfError(null); + } catch (err) { + setRbfError(err instanceof Error ? err.message : String(err)); + setPreviewPsbtHex(null); + } + }; + + if (isGeneratingPSBT || !psbtHex) { + return ( + + Generating PSBT + + + + + Please wait while we generate the PSBT... + + + + + ); + } + + if (!txTemplate) { + return ( + + Error + + + {error || "Failed to load transaction details"} + + + + + + + ); + } + + const { currentFees, newFees, additionalFees } = calculateFees( + txTemplate, + previewTxTemplate, + calculatePreviewFee, + ); + + return ( + + + Accelerate Transaction + + Boost your transaction priority by increasing the fee + + + + + + Fee Comparison + + + + + + + Adjust Fee Rate + + + + + {rbfError && ( + + {rbfError} + + )} + + + + + + + + + + ); +}; + +export default AccelerateFeeDialog; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/CancelTransactionDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/CancelTransactionDialog.tsx new file mode 100644 index 00000000..98e7a809 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/CancelTransactionDialog.tsx @@ -0,0 +1,200 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Box, + Alert, + CircularProgress, + Paper, +} from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { RBFDialogProps } from "../types"; +import FeeComparisonBox from "../components/FeeComparisonBox"; +import TransactionTable from "../components/TransactionTable"; +import AdjustFeeRateSlider from "../components/AdjustFeeRateSlider"; +import { usePsbtHook, calculateFees } from "../utils/psbtHelpers"; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(2), + margin: theme.spacing(2, 0), + backgroundColor: theme.palette.background.default, +})); + +const CancelTransactionDialog: React.FC = ({ + open, + onClose, + onConfirm, + createPsbt, + transaction, + currentNetworkFeeRate, + isGeneratingPSBT, +}) => { + const [newFeeRate, setNewFeeRate] = useState(currentNetworkFeeRate); + const [psbtHex, setPsbtHex] = useState(null); + const [previewPsbtHex, setPreviewPsbtHex] = useState(null); + const [error, setError] = useState(null); + const [rbfError, setRbfError] = useState(null); + + useEffect(() => { + if (open) { + setNewFeeRate(currentNetworkFeeRate); + setPsbtHex(null); + setPreviewPsbtHex(null); + setError(null); + setRbfError(null); + } + }, [open, currentNetworkFeeRate]); + + useEffect(() => { + const generatePsbt = async () => { + if (open && !isGeneratingPSBT) { + try { + const result = createPsbt(currentNetworkFeeRate); + setPsbtHex(result); + } catch (err) { + setError("Failed to generate initial PSBT. Please try again."); + console.error(err); + } + } + }; + + generatePsbt(); + }, [open, isGeneratingPSBT, createPsbt, currentNetworkFeeRate]); + + const { txTemplate, error: psbtError } = usePsbtHook(psbtHex); + const { txTemplate: previewTxTemplate, calculateFee: calculatePreviewFee } = + usePsbtHook(previewPsbtHex); + + const handlePreviewTransaction = () => { + try { + const newPsbtHex = createPsbt(newFeeRate); + setPreviewPsbtHex(newPsbtHex); + setRbfError(null); + } catch (err) { + setRbfError(err instanceof Error ? err.message : String(err)); + setPreviewPsbtHex(null); + } + }; + + if (!transaction || isGeneratingPSBT || !psbtHex) { + return ( + + Generating PSBT + + + + + Please wait while we generate the PSBT... + + + + + ); + } + + if (!txTemplate) { + return ( + + Error + + + {error || "Failed to load transaction details"} + + + + + + + ); + } + + const { currentFees, newFees, additionalFees } = calculateFees( + txTemplate, + previewTxTemplate, + calculatePreviewFee, + ); + + return ( + + + Cancel Unconfirmed Transaction + + Replace the transaction with a new one that returns funds to your + wallet + + + + + + Fee Comparison + + + + + + + Adjust Cancellation Fee Rate + + + + + {rbfError && ( + + {rbfError} + + )} + + + + + + + + + + ); +}; + +export default CancelTransactionDialog; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/RBFOptionsDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/RBFOptionsDialog.tsx new file mode 100644 index 00000000..8852a418 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/RBFOptionsDialog.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from "@mui/material"; + +interface RBFOptionsDialogProps { + open: boolean; + onClose: () => void; + onIncreaseFees: () => void; + onCancelTx: () => void; +} + +const RBFOptionsDialog: React.FC = ({ + open, + onClose, + onIncreaseFees, + onCancelTx, +}) => ( + + RBF Options + + + Do you want to increase fees or cancel the transaction? + + + + + + + +); + +export default RBFOptionsDialog; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/index.ts b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/index.ts new file mode 100644 index 00000000..ca8a99ec --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/index.ts @@ -0,0 +1,15 @@ +// Components +export { default as FeeComparisonBox } from "./components/FeeComparisonBox"; +export { default as TransactionTable } from "./components/TransactionTable"; +export { default as AdjustFeeRateSlider } from "./components/AdjustFeeRateSlider"; + +// Dialogs +export { default as AccelerateFeeDialog } from "./dialogs/AccelerateFeeDialog"; +export { default as CancelTransactionDialog } from "./dialogs/CancelTransactionDialog"; +export { default as RBFOptionsDialog } from "./dialogs/RBFOptionsDialog"; + +// Utils +export * from "./utils/psbtHelpers"; + +// Types +export * from "./types"; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/types.ts b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/types.ts new file mode 100644 index 00000000..5080385a --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/types.ts @@ -0,0 +1,34 @@ +import { ExtendedAnalyzer } from "components/types/fees"; + +export interface RBFDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (newFeeRate: number) => void; + createPsbt: (newFeeRate: number) => string; + transaction: ExtendedAnalyzer | null; + currentNetworkFeeRate: number; + isGeneratingPSBT: boolean; +} + +export interface TransactionTableProps { + title: string; + items: any[]; + isInputs: boolean; + template: any; +} + +export interface AdjustFeeRateSliderProps { + newFeeRate: number; + setNewFeeRate: (value: number) => void; + currentFeeRate: number; + currentNetworkFeeRate: number; + handlePreviewTransaction: () => void; +} + +export interface FeeComparisonBoxProps { + currentFees: string; + newFees: string; + currentFeeRate: string; + newFeeRate: number; + additionalFees: string; +} diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/utils/psbtHelpers.ts b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/utils/psbtHelpers.ts new file mode 100644 index 00000000..5cdd80bd --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/utils/psbtHelpers.ts @@ -0,0 +1,25 @@ +import { usePsbtDetails } from "../../../../../hooks"; +import { satoshisToBitcoins } from "@caravan/bitcoin"; + +export const usePsbtHook = (psbtHex: string | null) => { + const { txTemplate, error, calculateFee } = usePsbtDetails(psbtHex!); + return { txTemplate, error, calculateFee }; +}; + +export const calculateFees = ( + txTemplate: any, + previewTxTemplate: any | null, + calculatePreviewFee: () => string, +) => { + const currentFees = satoshisToBitcoins(txTemplate.currentFee); + const newFees = previewTxTemplate + ? satoshisToBitcoins(calculatePreviewFee()) + : currentFees; + const additionalFees = satoshisToBitcoins( + ( + parseFloat(calculatePreviewFee()) - parseFloat(txTemplate.currentFee) + ).toString(), + ); + + return { currentFees, newFees, additionalFees }; +}; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/utils.ts b/apps/coordinator/src/components/Wallet/fee-bumping/utils.ts new file mode 100644 index 00000000..ec87b4ad --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/utils.ts @@ -0,0 +1,21 @@ +import { satoshisToBitcoins } from "@caravan/bitcoin"; + +export const calculateTimeElapsed = (timestamp: number): string => { + const now = Date.now(); + const elapsed = now - timestamp * 1000; + const minutes = Math.floor(elapsed / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days} day${days > 1 ? "s" : ""}`; + if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""}`; + return `${minutes} minute${minutes > 1 ? "s" : ""}`; +}; + +export const formatAmount = (amountSats: number): string => { + return satoshisToBitcoins(amountSats); +}; + +export const formatTxid = (txid: string): string => { + return `${txid.substring(0, 8)}...`; +}; diff --git a/apps/coordinator/src/components/types/fees.ts b/apps/coordinator/src/components/types/fees.ts new file mode 100644 index 00000000..951e2d15 --- /dev/null +++ b/apps/coordinator/src/components/types/fees.ts @@ -0,0 +1,45 @@ +import { TransactionAnalyzer } from "@caravan/fees"; +import BigNumber from "bignumber.js"; + +export interface WalletSliceUTXO { + amount: string; + amountSats: BigNumber; + checked: boolean; + confirmed: boolean; + index: number; + time: number; + transactionHex: string; + txid: string; +} + +export interface ExtendedAnalyzer { + analyzer: TransactionAnalyzer; + timeElapsed: string; + txHex: string; + txId: string; + canRBF: boolean; // Indicates if Replace-By-Fee is possible for this transaction + canCPFP: boolean; // Indicates if Child-Pays-For-Parent is possible for this transaction +} + +export interface WalletSlice { + utxos: WalletSliceUTXO[]; +} + +export interface RootState { + settings: any; + wallet: { + deposits: { + nodes: Record; + }; + change: { + nodes: Record; + }; + }; +} + +export type PendingTransactionsResult = { + pendingTransactions: ExtendedAnalyzer[]; + currentNetworkFeeRate: number | null; + isLoading: boolean; + error: string | null; +}; diff --git a/apps/coordinator/src/hooks/fees.ts b/apps/coordinator/src/hooks/fees.ts new file mode 100644 index 00000000..5742c542 --- /dev/null +++ b/apps/coordinator/src/hooks/fees.ts @@ -0,0 +1,275 @@ +import { useState, useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { calculateTimeElapsed } from "../components/Wallet/fee-bumping/utils"; +import { Buffer } from "buffer/"; +import { importPSBT } from "../actions/transactionActions"; +import { useGetClient } from "./client"; +import { + TransactionAnalyzer, + UTXO, + BtcTransactionTemplate, +} from "@caravan/fees"; +import { + RootState, + ExtendedAnalyzer, + WalletSliceUTXO, + PendingTransactionsResult, +} from "components/types/fees"; + +export const usePsbtDetails = (psbtHex: string) => { + const [txTemplate, setTxTemplate] = useState( + null, + ); + const [error, setError] = useState(null); + const network = useSelector((state: RootState) => state.settings.network); + const settings = useSelector((state: RootState) => state.settings); + + useEffect(() => { + if (!psbtHex) return; + + try { + const template = BtcTransactionTemplate.rawPsbt(psbtHex, { + network, + targetFeeRate: settings.targetFeeRate, + dustThreshold: settings.dustThreshold, + scriptType: settings.addressType, + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + }); + setTxTemplate(template); + setError(null); + } catch (err) { + setError(err.message); + setTxTemplate(null); + } + }, [psbtHex, network, settings]); + + const calculateFee = () => { + if (!txTemplate) return "0"; + return txTemplate.currentFee; + }; + + return { txTemplate, error, calculateFee }; +}; + +export const useTransactionDetails = (txid: string) => { + const walletSlices = useSelector((state: RootState) => [ + ...Object.values(state.wallet.deposits.nodes), + ...Object.values(state.wallet.change.nodes), + ]); + + const pendingTxs = walletSlices + .flatMap((slice) => slice.utxos) + .filter((utxo) => !utxo.confirmed); + + const targetTx = pendingTxs.find((tx) => tx.txid === txid); + + const inputs: WalletSliceUTXO[] = pendingTxs.filter( + (utxo) => utxo.txid === txid, + ); + const outputs: WalletSliceUTXO[] = targetTx ? [targetTx] : []; + + return { inputs, outputs }; +}; +/** + * Custom hook to fetch and analyze pending transactions in the wallet. + * + * This hook retrieves all unconfirmed transactions from the wallet state, + * analyzes them using the TransactionAnalyzer, and prepares them for potential + * fee bumping operations (RBF or CPFP). It also fetches the current network fee rate. + * + * @returns {Object} An object containing: + * - pendingTransactions: Array of analyzed transactions with time elapsed information. + * - currentNetworkFeeRate: The current estimated network fee rate. + * - isLoading: Boolean indicating if the data is still being fetched. + * - error: String containing any error message, or null if no error. + */ +export const useGetPendingTransactions = (): PendingTransactionsResult => { + const [pendingTransactions, setPendingTransactions] = useState< + ExtendedAnalyzer[] + >([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentNetworkFeeRate, setCurrentNetworkFeeRate] = useState< + number | null + >(1); + + const settings = useSelector((state: RootState) => { + return state.settings; + }); + const walletSlices = useSelector((state: RootState) => [ + ...Object.values(state.wallet.deposits.nodes), + ...Object.values(state.wallet.change.nodes), + ]); + const blockchainClient = useGetClient(); + + const getCurrentNetworkFeeRate = async (): Promise => { + try { + return await blockchainClient.getFeeEstimate(); + } catch (error) { + console.error("Error fetching network fee rate:", error); + return 1; // Default to 1 sat/vB if unable to fetch + } + }; + + const getAvailableInputs = async ( + txid: string, + maxAdditionalInputs: number = 5, + ): Promise => { + const allUtxos = walletSlices.flatMap((slice) => slice.utxos); + const existingInputs = allUtxos.filter((utxo) => utxo.txid === txid); + const additionalUtxos = allUtxos.filter( + (utxo) => + utxo.txid !== txid && + !existingInputs.some( + (input) => input.txid === utxo.txid && input.index === utxo.index, + ), + ); + + const sortedAdditionalUtxos = additionalUtxos + .sort((a, b) => b.amountSats.minus(a.amountSats).toNumber()) + .slice(0, maxAdditionalInputs); + + const formatUtxo = async (utxo: WalletSliceUTXO): Promise => { + const fullTx = await blockchainClient.getTransaction(utxo.txid); + const output = fullTx.vout[utxo.index]; + return { + txid: utxo.txid, + vout: utxo.index, + value: utxo.amountSats.toString(), + prevTxHex: utxo.transactionHex, + witnessUtxo: { + script: Buffer.from(output.scriptpubkey, "hex"), + value: Number(output.value), + }, + }; + }; + + const combinedInputs = await Promise.all([ + ...existingInputs.map(formatUtxo), + ...sortedAdditionalUtxos.map(formatUtxo), + ]); + + // Manually defined extra UTXO object based on the data provided + const extraUtxo: UTXO = { + txid: "19128c8c8c51c1677193db46034d485b159393a3c452ea89ecd13d2d94cd776d", + vout: 1, + value: "67023682", + prevTxHex: + "0200000000010113f6166509ff0bc3859f4dbc655c9110fc61d66863d5fbb01f85e34888c95d6b0100000000fdffffff02c62e00000000000016001452bda9bc68632002ad956b35d8fa02e25332843a42b3fe0300000000160014ebfd0815c01fed09827c8ec7963976a5641ee05e02473044022051481d5cfa0d7ca581b30b59f44689bf592e20a2f71b8fcca7a957a186cce52b02200c8df48490667716be48babc6186a733c0a91c0d900abc9f080ddf3a76ea4b8b01210232e706b2fb0738439ee02d25d0576c174faa389d1bd5fe1f210ced54c79f5a568b4e2c00", // Provide the previous transaction hex + witnessUtxo: { + script: Buffer.from("ebfd0815c01fed09827c8ec7963976a5641ee05e", "hex"), // Convert script to buffer + value: 67023682, // Replace with the correct amount in sats + }, + sequence: 0xfffffffd, // Defined sequence + }; + + // Append the extra UTXO to the combined inputs + combinedInputs.push(extraUtxo); + return combinedInputs; + }; + + const analyzeTransaction = async ( + utxo: WalletSliceUTXO, + currentNetworkFeeRate: number, + settings: RootState["settings"], + availableInputs: UTXO[], + ): Promise => { + try { + const analyzer = new TransactionAnalyzer({ + txHex: utxo.transactionHex, + network: settings.network, + targetFeeRate: 100, // currentNetworkFeeRate + absoluteFee: utxo.amountSats.toString(), + availableUtxos: availableInputs, + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + addressType: settings.addressType, + changeOutputIndex: 0, // as in pending tx we are checking for all UTXO's which are not confirmed and within our wallet so having a default here + }); + + const timeElapsed = calculateTimeElapsed(utxo.time); + + const inputTemplates = analyzer.getInputTemplates(); + + // Find the index of the matching input in availableInputs (note inputTemplates are generated from originalTx hex itself) + const changeOutputIndex = availableInputs.findIndex((input) => + inputTemplates.some( + (template) => + template.txid === input.txid && template.vout === input.vout, + ), + ); + + // If a match was found, update the analyzer with the correct changeOutputIndex + if (changeOutputIndex !== -1) { + analyzer.changeOutputIndex = changeOutputIndex; + } + + // Check if we have any inputs for this transaction in our wallet + // This is crucial for RBF because we can only replace transactions where we control the inputs + const hasInputsInWallet = changeOutputIndex !== -1; + + // Check if we have any spendable outputs for this transaction + // This is necessary for CPFP because we need to be able to spend an output to create a child transaction + const hasSpendableOutputs = analyzer.canCPFP; + + return { + analyzer, + timeElapsed, + txId: utxo.txid, + txHex: utxo.transactionHex, + // We can only perform RBF if: + // 1. We have inputs from this transaction in our wallet (we control the inputs) + // 2. The transaction signals RBF (checked by analyzer.canRBF) + canRBF: hasInputsInWallet && analyzer.canRBF, + // We can perform CPFP if we have any spendable outputs from this transaction + canCPFP: hasSpendableOutputs, + }; + } catch (error) { + console.error("Error analyzing transaction:", error); + throw error; + } + }; + + useEffect(() => { + const fetchPendingTransactions = async () => { + try { + setIsLoading(true); + setError(null); + + const pendingTxs = walletSlices + .flatMap((slice) => slice.utxos) + .filter((utxo) => utxo.confirmed); + + const currentNetworkFeeRate = await getCurrentNetworkFeeRate(); + setCurrentNetworkFeeRate(currentNetworkFeeRate); + + const analyzedTransactions = await Promise.all( + pendingTxs.map(async (utxo) => { + const availableInputs = await getAvailableInputs(utxo.txid); + + return analyzeTransaction( + utxo, + currentNetworkFeeRate, + settings, + availableInputs, + ); + }), + ); + + setPendingTransactions(analyzedTransactions); + } catch (error) { + console.error("Error fetching pending transactions:", error); + setError( + "Failed to fetch pending transactions. Please try again later.", + ); + } finally { + setIsLoading(false); + } + }; + + fetchPendingTransactions(); + }, []); + + return { pendingTransactions, currentNetworkFeeRate, isLoading, error }; +}; diff --git a/apps/coordinator/src/hooks/index.ts b/apps/coordinator/src/hooks/index.ts index 615f4d81..bd06a9dc 100644 --- a/apps/coordinator/src/hooks/index.ts +++ b/apps/coordinator/src/hooks/index.ts @@ -1,2 +1,3 @@ export { useGetClient } from "./client"; export { useGetDescriptors } from "./descriptors"; +export * from "./fees"; diff --git a/apps/coordinator/src/reducers/transactionReducer.js b/apps/coordinator/src/reducers/transactionReducer.js index d072fad9..89fa0508 100644 --- a/apps/coordinator/src/reducers/transactionReducer.js +++ b/apps/coordinator/src/reducers/transactionReducer.js @@ -46,6 +46,7 @@ import { SET_BALANCE_ERROR, SET_SPEND_STEP, SPEND_STEP_CREATE, + SET_RBF, } from "../actions/transactionActions"; import { RESET_NODES_SPEND } from "../actions/walletActions"; import { Transaction } from "bitcoinjs-lib"; @@ -98,6 +99,7 @@ export const initialState = () => ({ unsignedTransaction: {}, isWallet: false, autoSpend: true, + rbfEnabled: true, // Set RBF enabled by default changeAddress: "", updatesComplete: true, signingKeys: [0, 0], // default 2 required signers @@ -262,7 +264,6 @@ function updateOutputAmount(state, action) { let amount = action.value; const amountSats = bitcoinsToSatoshis(BigNumber(amount)); let error = validateOutputAmount(amountSats, state.inputsTotalSats); - if (state.isWallet && error === "Total input amount must be positive.") error = ""; if (state.isWallet && error === "Output amount is too large.") error = ""; @@ -281,12 +282,16 @@ function updateOutputAmount(state, action) { function finalizeOutputs(state, action) { let unsignedTransaction; + const rbfSequence = state.rbfEnabled ? 0xfffffffd : 0xffffffff; // First try to build the transaction via PSBT, if that fails (e.g. an input doesn't know about its braid), // then try to build it using the old TransactionBuilder plumbing. try { const args = { network: state.network, - inputs: state.inputs.map(convertLegacyInput), + inputs: state.inputs.map((input) => ({ + ...convertLegacyInput(input), + sequence: rbfSequence, + })), outputs: state.outputs.map(convertLegacyOutput), }; const psbt = getUnsignedMultisigPsbtV0(args); @@ -452,6 +457,8 @@ export default (state = initialState(), action) => { return updateState(state, { balanceError: action.value }); case SET_SPEND_STEP: return updateState(state, { spendingStep: action.value }); + case SET_RBF: + return updateState(state, { rbfEnabled: action.value }); default: return state; } diff --git a/package-lock.json b/package-lock.json index cbdc5ec5..0dad661e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@caravan/clients": "*", "@caravan/descriptors": "^0.1.1", "@caravan/eslint-config": "*", + "@caravan/fees": "*", "@caravan/psbt": "*", "@caravan/typescript-config": "*", "@caravan/wallets": "*", @@ -2721,6 +2722,10 @@ "resolved": "packages/eslint-config", "link": true }, + "node_modules/@caravan/fees": { + "resolved": "packages/caravan-fees", + "link": true + }, "node_modules/@caravan/multisig": { "resolved": "packages/multisig", "link": true @@ -26007,6 +26012,41 @@ "webidl-conversions": "^4.0.2" } }, + "packages/caravan-fees": { + "name": "@caravan/fees", + "version": "1.0.0-beta", + "license": "MIT", + "dependencies": { + "@caravan/bitcoin": "*", + "@caravan/psbt": "*", + "bignumber.js": "^9.1.2", + "bitcoinjs-lib-v6": "npm:bitcoinjs-lib@^6.1.5" + }, + "devDependencies": { + "@inrupt/jest-jsdom-polyfills": "^3.2.1", + "esbuild-plugin-polyfill-node": "^0.3.0", + "prettier": "^3.2.5", + "ts-jest": "^29.0.5", + "tsup": "^7.2.0", + "typescript": "^4.9.5" + }, + "engines": { + "node": ">=20" + } + }, + "packages/caravan-fees/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "packages/caravan-psbt": { "name": "@caravan/psbt", "version": "1.4.1",