From 2781e9566606171fb182a974b098f2121640e790 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Wed, 7 Aug 2024 04:22:01 +0530 Subject: [PATCH 01/12] Implement RBF and CPFP with new fees package and add pending transactions view in @caravan/coordinator This commit introduces significant enhancements to the transaction management capabilities of caravan/coordinator, focusing on Replace-By-Fee (RBF) and Child-Pays-For-Parent (CPFP) functionalities, along with a new pending transactions view. Key changes Expected: 1. Pending Transactions View: - Add new component 'PendingTransactionsView' in src/components/Wallet/ - Implement basic layout showing transaction ID, amount, recipient, and timestamp - Integrate view into main Wallet dashboard 2. Enhanced Transaction Information: - Add 'TransactionDetails' component to display comprehensive tx info - Implement time-pending calculation and display - Add current fee rate vs market rate comparison - Include estimated blocks until confirmation based on current mempool state 3. Fee Bumping Possibility Indicators: - Add RBF status indicator to transaction details - Implement change output detection and display - Calculate and show estimated cost for potential fee bump 4. UI Support for Manual RBF: - Modify existing transaction creation flow to support RBF - Add RBF toggle in transaction creation form (OutputsForm.jsx) - Implement logic to replace existing transaction when RBF is selected 5. Fee Bumping Interfaces: - Add 'CancelTransaction' button and associated logic - Implement 'AccelerateTransaction' functionality - Create 'DownloadPSBT' option for RBF/CPFP transactions - Modify transaction authoring page to support pre-filled RBF/CPFP data 6. Integration with new fees package: - Import and utilize functions from @caravan/fees for fee calculations - Implement FeeManager class to handle fee-related operations 7. State Management Updates: - Add new reducers for managing pending transactions and fee bump operations - Implement new actions for RBF and CPFP operations - Update selectors to support new state structure --- .../src/actions/transactionActions.js | 5 ++ .../components/ScriptExplorer/OutputsForm.jsx | 53 ++++++++++++++++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/coordinator/src/actions/transactionActions.js b/apps/coordinator/src/actions/transactionActions.js index 9468282b7e..f812718c42 100644 --- a/apps/coordinator/src/actions/transactionActions.js +++ b/apps/coordinator/src/actions/transactionActions.js @@ -114,6 +114,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/components/ScriptExplorer/OutputsForm.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx index 3bcabfb828..17f56a3cf9 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"; @@ -277,11 +287,11 @@ class OutputsForm extends React.Component { disabled={finalizedOutputs} onClick={this.handleAddOutput} > - Add output + Add outputsss - + - -   - {!isWallet || (isWallet && !autoSpend) ? ( @@ -357,10 +364,38 @@ class OutputsForm extends React.Component { ) : ( - "" + )} - + {/* */} + + + + + + + + + + } + label="Enable RBF" + labelPlacement="start" + /> + + @@ -488,6 +523,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 +541,7 @@ function mapStateToProps(state) { ...state.client, signatureImporters: state.spend.signatureImporters, change: state.wallet.change, + rbfEnabled: state.spend.transaction.rbfEnabled, }; } @@ -515,6 +553,7 @@ const mapDispatchToProps = { finalizeOutputs: finalizeOutputsAction, resetOutputs: resetOutputsAction, getBlockchainClient: updateBlockchainClient, + setRBF: setRBFAction, }; export default connect(mapStateToProps, mapDispatchToProps)(OutputsForm); From efd947f362e5e922e156a15f56bf1ba2f2823883 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 27 Aug 2024 01:44:19 +0530 Subject: [PATCH 02/12] Revert "Implement RBF and CPFP with new fees package and add pending transactions view in @caravan/coordinator" This reverts commit 2781e9566606171fb182a974b098f2121640e790. --- .../src/actions/transactionActions.js | 5 -- .../components/ScriptExplorer/OutputsForm.jsx | 53 +++---------------- 2 files changed, 7 insertions(+), 51 deletions(-) diff --git a/apps/coordinator/src/actions/transactionActions.js b/apps/coordinator/src/actions/transactionActions.js index f812718c42..9468282b7e 100644 --- a/apps/coordinator/src/actions/transactionActions.js +++ b/apps/coordinator/src/actions/transactionActions.js @@ -114,11 +114,6 @@ 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/components/ScriptExplorer/OutputsForm.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx index 17f56a3cf9..3bcabfb828 100644 --- a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx @@ -14,10 +14,7 @@ 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 { @@ -27,7 +24,6 @@ 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"; @@ -235,11 +231,6 @@ class OutputsForm extends React.Component { setOutputAmount(1, outputAmount); } - handleRBFToggle = () => { - const { setRBF, rbfEnabled } = this.props; - setRBF(!rbfEnabled); - }; - render() { const { feeRate, @@ -251,7 +242,6 @@ class OutputsForm extends React.Component { inputs, isWallet, autoSpend, - rbfEnabled, } = this.props; const { feeRateFetchError } = this.state; const feeDisplay = inputs && inputs.length > 0 ? fee : "0.0000"; @@ -287,11 +277,11 @@ class OutputsForm extends React.Component { disabled={finalizedOutputs} onClick={this.handleAddOutput} > - Add outputsss + Add output - + + +   + {!isWallet || (isWallet && !autoSpend) ? ( @@ -364,38 +357,10 @@ class OutputsForm extends React.Component { ) : ( - + "" )} - {/* */} - - - - - - - - - - } - label="Enable RBF" - labelPlacement="start" - /> - - + @@ -523,8 +488,6 @@ OutputsForm.propTypes = { signatureImporters: PropTypes.shape({}).isRequired, updatesComplete: PropTypes.bool, getBlockchainClient: PropTypes.func.isRequired, - rbfEnabled: PropTypes.bool.isRequired, - setRBF: PropTypes.func.isRequired, }; OutputsForm.defaultProps = { @@ -541,7 +504,6 @@ function mapStateToProps(state) { ...state.client, signatureImporters: state.spend.signatureImporters, change: state.wallet.change, - rbfEnabled: state.spend.transaction.rbfEnabled, }; } @@ -553,7 +515,6 @@ const mapDispatchToProps = { finalizeOutputs: finalizeOutputsAction, resetOutputs: resetOutputsAction, getBlockchainClient: updateBlockchainClient, - setRBF: setRBFAction, }; export default connect(mapStateToProps, mapDispatchToProps)(OutputsForm); From 210edc42fb78f1fb71f2ba81ebaf2f1862a7be48 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 27 Aug 2024 02:15:39 +0530 Subject: [PATCH 03/12] feat: add RBF toggle button and functionality to signal RBF with sequence 0xfffffffd in coordinator UI --- .../src/actions/transactionActions.js | 6 +++ .../components/ScriptExplorer/OutputsForm.jsx | 50 ++++++++++++++++--- .../src/reducers/transactionReducer.js | 11 +++- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/apps/coordinator/src/actions/transactionActions.js b/apps/coordinator/src/actions/transactionActions.js index 9468282b7e..e78c83a475 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/components/ScriptExplorer/OutputsForm.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx index 3bcabfb828..a329d37f15 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/reducers/transactionReducer.js b/apps/coordinator/src/reducers/transactionReducer.js index d072fad9da..89fa05083f 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; } From fae53978bce6fb7f35baf707b308e0a7c9b01699 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Wed, 28 Aug 2024 02:16:35 +0530 Subject: [PATCH 04/12] feat(coordinator):Introduce 'Pending Transactions' tab for fee bumping Add a new 'Pending Transactions' tab to the Wallet component, enabling users to easily view and manage pending transactions. This update helps users perform fee bumping actions (RBF, CPFP) when necessary. --- apps/coordinator/src/actions/walletActions.js | 1 + .../src/components/Wallet/WalletControl.jsx | 4 + .../Wallet/WalletPendingTransactions.tsx | 162 +++++++++++++ .../fee-bumping/PendingTransactionTable.tsx | 216 ++++++++++++++++++ .../fee-bumping/rbf/AccelerateFeeDialog.tsx | 36 +++ .../rbf/CancelTransactionDialog.tsx | 36 +++ .../fee-bumping/rbf/RBFOptionsDialog.tsx | 42 ++++ .../components/Wallet/fee-bumping/types.ts | 37 +++ .../components/Wallet/fee-bumping/utils.ts | 21 ++ 9 files changed, 555 insertions(+) create mode 100644 apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/RBFOptionsDialog.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/types.ts create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/utils.ts diff --git a/apps/coordinator/src/actions/walletActions.js b/apps/coordinator/src/actions/walletActions.js index f884bb3f42..abd18319cc 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/Wallet/WalletControl.jsx b/apps/coordinator/src/components/Wallet/WalletControl.jsx index 0eb62573ed..06f8fe769b 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 0000000000..1ed268c1a8 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { Box, Paper } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import { useGetClient } from "../../hooks"; +// import { TransactionAnalyzer } from "@caravan/bitcoin"; +import { AnalyzedTransaction, RootState, UTXO } from "./fee-bumping/types"; +import { calculateTimeElapsed } from "./fee-bumping/utils"; +import TransactionTable from "./fee-bumping/PendingTransactionTable"; +import RBFOptionsDialog from "./fee-bumping/rbf/RBFOptionsDialog"; +import AccelerateFeeDialog from "./fee-bumping/rbf/AccelerateFeeDialog"; +import CancelTransactionDialog from "./fee-bumping/rbf/CancelTransactionDialog"; + +const StyledPaper = styled(Paper)(({ theme }) => ({ + padding: theme.spacing(3), + marginBottom: theme.spacing(2), +})); + +const WalletPendingTransactions: React.FC = () => { + const [pendingTransactions, setPendingTransactions] = useState< + AnalyzedTransaction[] + >([]); + const [selectedTx, setSelectedTx] = useState( + null, + ); + const [showRBFOptions, setShowRBFOptions] = useState(false); + const [showIncreaseFees, setShowIncreaseFees] = useState(false); + const [showCancelTx, setShowCancelTx] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const network = useSelector((state: RootState) => state.settings.network); + const walletSlices = useSelector((state: RootState) => [ + ...Object.values(state.wallet.deposits.nodes), + ...Object.values(state.wallet.change.nodes), + ]); + const blockchainClient = useGetClient(); + + useEffect(() => { + fetchPendingTransactions(); + }, [network, walletSlices, blockchainClient]); + + const fetchPendingTransactions = async () => { + try { + const pendingTxs = walletSlices + .flatMap((slice) => slice.utxos) + .filter((utxo) => !utxo.confirmed); + + const currentNetworkFeeRate = await getCurrentNetworkFeeRate(); + + const analyzedTransactions = await Promise.all( + pendingTxs.map(async (utxo) => + analyzeTransaction(utxo, currentNetworkFeeRate), + ), + ); + + setPendingTransactions(analyzedTransactions); + } catch (error) { + console.error("Error fetching pending transactions:", error); + setError("Failed to fetch pending transactions. Please try again later."); + } finally { + setIsLoading(false); + } + }; + + 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 analyzeTransaction = async ( + utxo: UTXO, + currentNetworkFeeRate: number, + ): Promise => { + // const analyzer = new TransactionAnalyzer({ + // txHex: utxo.transactionHex, + // network, + // targetFeeRate: currentNetworkFeeRate, + // // Add other required options for TransactionAnalyzer + // }); + // const analysis = analyzer.analyze(); + + return { + ...utxo, + timeElapsed: calculateTimeElapsed(utxo.time), + currentFeeRate: currentNetworkFeeRate, + canRBF: true, + canCPFP: false, + }; + }; + + // const estimateBlocksToMine = (feeRate: number): number => { + // TO DO (MRIGESH) : Implement new methods in client package's BlockchainClient class + // to enable use of this method ... + // }; + + const handleRBF = (tx: AnalyzedTransaction) => { + setSelectedTx(tx); + setShowRBFOptions(true); + }; + + const handleCPFP = (tx: AnalyzedTransaction) => { + console.log("CPFP initiated for transaction:", tx.txid); + //To Implement CPFP logic here + }; + + const handleIncreaseFees = () => { + setShowRBFOptions(false); + setShowIncreaseFees(true); + }; + + const handleCancelTx = () => { + setShowRBFOptions(false); + setShowCancelTx(true); + }; + + const closeAllModals = () => { + setShowRBFOptions(false); + setShowIncreaseFees(false); + setShowCancelTx(false); + setSelectedTx(null); + }; + + 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 0000000000..efa6bd0d2c --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx @@ -0,0 +1,216 @@ +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 { OpenInNew, Search, Edit } from "@mui/icons-material"; +import { AnalyzedTransaction } from "./types"; +import { formatTxid } from "./utils"; +import Copyable from "./../../Copyable"; + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + "&.MuiTableCell-head": { + backgroundColor: theme.palette.grey[200], + fontWeight: "bold", + }, +})); + +const ActionButton = styled(Button)(({ theme }) => ({ + margin: theme.spacing(0.5), + textTransform: "none", +})); + +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)(({ theme }) => ({ + minWidth: "70px", + height: "30px", + fontSize: "0.875rem", +})); + +interface TransactionTableProps { + transactions: AnalyzedTransaction[]; + onRBF: (tx: AnalyzedTransaction) => void; + onCPFP: (tx: AnalyzedTransaction) => void; + isLoading: boolean; + error: string | null; +} + +const TransactionTable: React.FC = ({ + transactions, + onRBF, + onCPFP, + isLoading, + error, +}) => { + const network = useSelector((state) => 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`; + } + }; + + 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 + + + + Current Fee Rate + Actions + + + + {transactions.map((tx) => ( + + + + {formatTxid(tx.txid)} + + + + + + + + + {tx.timeElapsed} + + {formatAmount(tx.amountSats)} + + + 4 ? "success" : "warning"} + size="small" + /> + + + {tx.canRBF && ( + onRBF(tx)} + startIcon={} + > + RBF + + )} + {tx.canCPFP && ( + onCPFP(tx)} + startIcon={} + > + CPFP + + )} + {!tx.canRBF && !tx.canCPFP && ( + + No actions available + + )} + + + ))} + +
+ ); +}; + +export default TransactionTable; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx new file mode 100644 index 0000000000..370d3081cb --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from "@mui/material"; + +interface AccelerateFeeDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; +} + +const AccelerateFeeDialog: React.FC = ({ + open, + onClose, + onConfirm, +}) => ( + + Increase Fees + + Implement your increase fees UI here + + + + + + +); + +export default AccelerateFeeDialog; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx new file mode 100644 index 0000000000..18ff905fb2 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from "@mui/material"; + +interface CancelTransactionDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; +} + +const CancelTransactionDialog: React.FC = ({ + open, + onClose, + onConfirm, +}) => ( + + Cancel Transaction + + Implement your cancel transaction UI here + + + + + + +); + +export default CancelTransactionDialog; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/RBFOptionsDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/RBFOptionsDialog.tsx new file mode 100644 index 0000000000..7215ceb6c9 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/RBFOptionsDialog.tsx @@ -0,0 +1,42 @@ +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/types.ts b/apps/coordinator/src/components/Wallet/fee-bumping/types.ts new file mode 100644 index 0000000000..c452d60550 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/fee-bumping/types.ts @@ -0,0 +1,37 @@ +import { Network } from "@caravan/bitcoin"; + +export interface UTXO { + txid: string; + vout: number; + amount: number; + amountSats: number; + address: string; + confirmed: boolean; + transactionHex: string; + time: number; +} + +export interface AnalyzedTransaction extends UTXO { + timeElapsed: string; + currentFeeRate: number; + canRBF: boolean; + canCPFP: boolean; +} + +export interface WalletSlice { + utxos: UTXO[]; +} + +export interface RootState { + settings: { + network: Network; + }; + wallet: { + deposits: { + nodes: Record; + }; + change: { + nodes: Record; + }; + }; +} 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 0000000000..ec87b4ad26 --- /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)}...`; +}; From 193ace18ce6f2b3ed3435a22caa2c96612cd220c Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Wed, 4 Sep 2024 01:29:55 +0530 Subject: [PATCH 05/12] add fees hook : Code cleaning by taking out the code from pending component to a hook --- .../Wallet/WalletPendingTransactions.tsx | 89 ++--------------- apps/coordinator/src/components/types/fees.ts | 32 ++++++ apps/coordinator/src/hooks/fees.ts | 98 +++++++++++++++++++ 3 files changed, 139 insertions(+), 80 deletions(-) create mode 100644 apps/coordinator/src/components/types/fees.ts create mode 100644 apps/coordinator/src/hooks/fees.ts diff --git a/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx index 1ed268c1a8..2bda92f327 100644 --- a/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx +++ b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx @@ -1,15 +1,12 @@ -import React, { useEffect, useState } from "react"; -import { useSelector } from "react-redux"; +import React, { useState } from "react"; import { Box, Paper } from "@mui/material"; import { styled } from "@mui/material/styles"; -import { useGetClient } from "../../hooks"; -// import { TransactionAnalyzer } from "@caravan/bitcoin"; -import { AnalyzedTransaction, RootState, UTXO } from "./fee-bumping/types"; -import { calculateTimeElapsed } from "./fee-bumping/utils"; +import { useGetPendingTransactions } from "../../hooks"; import TransactionTable from "./fee-bumping/PendingTransactionTable"; import RBFOptionsDialog from "./fee-bumping/rbf/RBFOptionsDialog"; import AccelerateFeeDialog from "./fee-bumping/rbf/AccelerateFeeDialog"; import CancelTransactionDialog from "./fee-bumping/rbf/CancelTransactionDialog"; +import { AnalyzerWithTimeElapsed } from "components/types/fees"; const StyledPaper = styled(Paper)(({ theme }) => ({ padding: theme.spacing(3), @@ -17,93 +14,24 @@ const StyledPaper = styled(Paper)(({ theme }) => ({ })); const WalletPendingTransactions: React.FC = () => { - const [pendingTransactions, setPendingTransactions] = useState< - AnalyzedTransaction[] - >([]); - const [selectedTx, setSelectedTx] = useState( - null, - ); + const { pendingTransactions, currentNetworkFeeRate, isLoading, error } = + useGetPendingTransactions(); + const [, setSelectedTx] = useState(null); const [showRBFOptions, setShowRBFOptions] = useState(false); const [showIncreaseFees, setShowIncreaseFees] = useState(false); const [showCancelTx, setShowCancelTx] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - const network = useSelector((state: RootState) => state.settings.network); - const walletSlices = useSelector((state: RootState) => [ - ...Object.values(state.wallet.deposits.nodes), - ...Object.values(state.wallet.change.nodes), - ]); - const blockchainClient = useGetClient(); - - useEffect(() => { - fetchPendingTransactions(); - }, [network, walletSlices, blockchainClient]); - - const fetchPendingTransactions = async () => { - try { - const pendingTxs = walletSlices - .flatMap((slice) => slice.utxos) - .filter((utxo) => !utxo.confirmed); - - const currentNetworkFeeRate = await getCurrentNetworkFeeRate(); - - const analyzedTransactions = await Promise.all( - pendingTxs.map(async (utxo) => - analyzeTransaction(utxo, currentNetworkFeeRate), - ), - ); - - setPendingTransactions(analyzedTransactions); - } catch (error) { - console.error("Error fetching pending transactions:", error); - setError("Failed to fetch pending transactions. Please try again later."); - } finally { - setIsLoading(false); - } - }; - - 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 analyzeTransaction = async ( - utxo: UTXO, - currentNetworkFeeRate: number, - ): Promise => { - // const analyzer = new TransactionAnalyzer({ - // txHex: utxo.transactionHex, - // network, - // targetFeeRate: currentNetworkFeeRate, - // // Add other required options for TransactionAnalyzer - // }); - // const analysis = analyzer.analyze(); - - return { - ...utxo, - timeElapsed: calculateTimeElapsed(utxo.time), - currentFeeRate: currentNetworkFeeRate, - canRBF: true, - canCPFP: false, - }; - }; // const estimateBlocksToMine = (feeRate: number): number => { // TO DO (MRIGESH) : Implement new methods in client package's BlockchainClient class // to enable use of this method ... // }; - const handleRBF = (tx: AnalyzedTransaction) => { + const handleRBF = (tx: AnalyzerWithTimeElapsed) => { setSelectedTx(tx); setShowRBFOptions(true); }; - const handleCPFP = (tx: AnalyzedTransaction) => { + const handleCPFP = (tx: AnalyzerWithTimeElapsed) => { console.log("CPFP initiated for transaction:", tx.txid); //To Implement CPFP logic here }; @@ -133,6 +61,7 @@ const WalletPendingTransactions: React.FC = () => { onRBF={handleRBF} onCPFP={handleCPFP} isLoading={isLoading} + currentFeeRate={currentNetworkFeeRate!} error={error} /> diff --git a/apps/coordinator/src/components/types/fees.ts b/apps/coordinator/src/components/types/fees.ts new file mode 100644 index 0000000000..350dbb8ab1 --- /dev/null +++ b/apps/coordinator/src/components/types/fees.ts @@ -0,0 +1,32 @@ +import { TransactionAnalyzer } from "@caravan/fees"; + +export interface UTXO { + txid: string; + vout: number; + amount: number; + amountSats: number; + address: string; + confirmed: boolean; + transactionHex: string; + time: number; +} + +export type AnalyzerWithTimeElapsed = TransactionAnalyzer & { + timeElapsed: string; +}; + +export interface WalletSlice { + utxos: UTXO[]; +} + +export interface RootState { + settings: any; + wallet: { + deposits: { + nodes: Record; + }; + change: { + nodes: Record; + }; + }; +} diff --git a/apps/coordinator/src/hooks/fees.ts b/apps/coordinator/src/hooks/fees.ts new file mode 100644 index 0000000000..0d291f88a5 --- /dev/null +++ b/apps/coordinator/src/hooks/fees.ts @@ -0,0 +1,98 @@ +import { useState, useEffect } from "react"; +import { useSelector } from "react-redux"; +import { calculateTimeElapsed } from "../components/Wallet/fee-bumping/utils"; +import { useGetClient } from "./client"; +import { TransactionAnalyzer } from "@caravan/fees"; +import { + RootState, + UTXO, + AnalyzerWithTimeElapsed, +} from "components/types/fees"; + +export const useGetPendingTransactions = () => { + const [pendingTransactions, setPendingTransactions] = useState< + AnalyzerWithTimeElapsed[] + >([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentNetworkFeeRate, setCurrentNetworkFeeRate] = useState< + number | null + >(1); + + const settings = useSelector((state: RootState) => 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 analyzeTransaction = async ( + utxo: UTXO, + currentNetworkFeeRate: number, + settings: RootState["settings"], + ): Promise => { + try { + const analyzer = new TransactionAnalyzer({ + txHex: utxo.transactionHex, + network: settings.network, + targetFeeRate: currentNetworkFeeRate, + absoluteFee: utxo.amountSats.toString(), + availableUtxos: [], // need to provide available UTXOs for CPFP + requiredSigners: settings.requiredSigners, + totalSigners: settings.totalSigners, + addressType: settings.addressType, + }); + console.log("checkl11", analyzer.analyze()); + // Add the timeElapsed property to the analyzer instance + (analyzer as AnalyzerWithTimeElapsed).timeElapsed = calculateTimeElapsed( + utxo.time, + ); + + return analyzer as AnalyzerWithTimeElapsed; + } 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) => + analyzeTransaction(utxo, currentNetworkFeeRate, settings), + ), + ); + console.log("done", pendingTxs); + 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 }; +}; From 41b3cc3edcc658da51d34cdf019e1f3009fb4536 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Wed, 4 Sep 2024 01:31:07 +0530 Subject: [PATCH 06/12] refactor : hooks index file to export fees hook --- apps/coordinator/src/hooks/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/coordinator/src/hooks/index.ts b/apps/coordinator/src/hooks/index.ts index 615f4d8113..3c8467249d 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 { useGetPendingTransactions } from "./fees"; From 94df4f1ce63f1fc437dd22e417e6e6b4a4a0601e Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Wed, 4 Sep 2024 01:32:25 +0530 Subject: [PATCH 07/12] changes to package.json and UI improvements in pendingTx table --- apps/coordinator/package.json | 1 + .../fee-bumping/PendingTransactionTable.tsx | 124 +++++++++++++----- .../components/Wallet/fee-bumping/types.ts | 37 ------ package-lock.json | 40 ++++++ 4 files changed, 132 insertions(+), 70 deletions(-) delete mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/types.ts diff --git a/apps/coordinator/package.json b/apps/coordinator/package.json index 73eccd2d5c..ae2aec0e0d 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/components/Wallet/fee-bumping/PendingTransactionTable.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx index efa6bd0d2c..287b7344f9 100644 --- a/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx +++ b/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx @@ -22,8 +22,8 @@ import { satoshisToBitcoins, } from "@caravan/bitcoin"; import { styled } from "@mui/material/styles"; -import { OpenInNew, Search, Edit } from "@mui/icons-material"; -import { AnalyzedTransaction } from "./types"; +import { Search, Edit, TrendingUp, TrendingDown } from "@mui/icons-material"; +import { RootState, AnalyzerWithTimeElapsed } from "components/types/fees"; import { formatTxid } from "./utils"; import Copyable from "./../../Copyable"; @@ -51,17 +51,35 @@ const AmountHeaderCell = styled(StyledTableCell)(({ theme }) => ({ gap: theme.spacing(1), })); -const StyledSelect = styled(Select)(({ theme }) => ({ +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), +})); + +const ActionCell = styled(TableCell)(() => ({ + whiteSpace: "nowrap", +})); + interface TransactionTableProps { - transactions: AnalyzedTransaction[]; - onRBF: (tx: AnalyzedTransaction) => void; - onCPFP: (tx: AnalyzedTransaction) => void; + transactions: AnalyzerWithTimeElapsed[]; + onRBF: (tx: AnalyzerWithTimeElapsed) => void; + onCPFP: (tx: AnalyzerWithTimeElapsed) => void; isLoading: boolean; + currentFeeRate: number; error: string | null; } @@ -70,9 +88,10 @@ const TransactionTable: React.FC = ({ onRBF, onCPFP, isLoading, + currentFeeRate, error, }) => { - const network = useSelector((state) => state.settings.network); + const network = useSelector((state: RootState) => state.settings.network); const [amountUnit, setAmountUnit] = useState<"BTC" | "satoshis">("BTC"); const formatAmount = (amountSats: number) => { @@ -83,6 +102,31 @@ const TransactionTable: React.FC = ({ } }; + const renderFeeRateComparison = (tx: AnalyzerWithTimeElapsed) => { + const txFeeRate = parseFloat(tx.feeRate); + const feeRateDiff = currentFeeRate - txFeeRate; + const icon = + feeRateDiff > 0 ? ( + + ) : ( + + ); + + return ( + + + Paid: + + + + Current: + + {icon} + + + ); + }; + if (isLoading) { return ( = ({ } return ( - +
Transaction ID @@ -143,7 +187,8 @@ const TransactionTable: React.FC = ({ - Current Fee Rate + Fee Rate + Recommended Strategy Actions @@ -168,44 +213,57 @@ const TransactionTable: React.FC = ({ {tx.timeElapsed} - {formatAmount(tx.amountSats)} + + {formatAmount(parseInt(tx.fee))} + + {renderFeeRateComparison(tx)} - 4 ? "success" : "warning"} - size="small" - /> + + {tx.recommendedStrategy === "NONE" + ? "No action needed" + : tx.recommendedStrategy} + - + {tx.canRBF && ( - onRBF(tx)} - startIcon={} + - RBF - + onRBF(tx)} + startIcon={} + > + RBF + + )} {tx.canCPFP && ( - onCPFP(tx)} - startIcon={} + - CPFP - + onCPFP(tx)} + startIcon={} + > + CPFP + + )} {!tx.canRBF && !tx.canCPFP && ( No actions available )} - + ))} diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/types.ts b/apps/coordinator/src/components/Wallet/fee-bumping/types.ts deleted file mode 100644 index c452d60550..0000000000 --- a/apps/coordinator/src/components/Wallet/fee-bumping/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Network } from "@caravan/bitcoin"; - -export interface UTXO { - txid: string; - vout: number; - amount: number; - amountSats: number; - address: string; - confirmed: boolean; - transactionHex: string; - time: number; -} - -export interface AnalyzedTransaction extends UTXO { - timeElapsed: string; - currentFeeRate: number; - canRBF: boolean; - canCPFP: boolean; -} - -export interface WalletSlice { - utxos: UTXO[]; -} - -export interface RootState { - settings: { - network: Network; - }; - wallet: { - deposits: { - nodes: Record; - }; - change: { - nodes: Record; - }; - }; -} diff --git a/package-lock.json b/package-lock.json index cbdc5ec566..0dad661e3d 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", From fd776ada2be376922955389e1b23293f266ec08b Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sat, 7 Sep 2024 02:39:47 +0530 Subject: [PATCH 08/12] refactor useGetPendingTransactions to give full tx details including witness utxo --- .../Wallet/WalletPendingTransactions.tsx | 60 +++++++++--- apps/coordinator/src/hooks/fees.ts | 95 ++++++++++++++++--- apps/coordinator/src/hooks/index.ts | 2 +- 3 files changed, 127 insertions(+), 30 deletions(-) diff --git a/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx index 2bda92f327..3bf431dbb2 100644 --- a/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx +++ b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx @@ -1,4 +1,8 @@ import React, { useState } from "react"; +import { + createAcceleratedRbfTransaction, + createCancelRbfTransaction, +} from "@caravan/fees"; import { Box, Paper } from "@mui/material"; import { styled } from "@mui/material/styles"; import { useGetPendingTransactions } from "../../hooks"; @@ -7,45 +11,39 @@ import RBFOptionsDialog from "./fee-bumping/rbf/RBFOptionsDialog"; import AccelerateFeeDialog from "./fee-bumping/rbf/AccelerateFeeDialog"; import CancelTransactionDialog from "./fee-bumping/rbf/CancelTransactionDialog"; import { AnalyzerWithTimeElapsed } 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(); - const [, setSelectedTx] = useState(null); + const [selectedTx, setSelectedTx] = useState( + null, + ); const [showRBFOptions, setShowRBFOptions] = useState(false); const [showIncreaseFees, setShowIncreaseFees] = useState(false); const [showCancelTx, setShowCancelTx] = 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 ... // }; - const handleRBF = (tx: AnalyzerWithTimeElapsed) => { setSelectedTx(tx); setShowRBFOptions(true); }; - const handleCPFP = (tx: AnalyzerWithTimeElapsed) => { console.log("CPFP initiated for transaction:", tx.txid); //To Implement CPFP logic here }; - const handleIncreaseFees = () => { setShowRBFOptions(false); setShowIncreaseFees(true); }; - const handleCancelTx = () => { setShowRBFOptions(false); setShowCancelTx(true); }; - const closeAllModals = () => { setShowRBFOptions(false); setShowIncreaseFees(false); @@ -53,6 +51,38 @@ const WalletPendingTransactions: React.FC = () => { setSelectedTx(null); }; + const handleAccelerateFee = async (newFeeRate: number) => { + if (selectedTx) { + try { + const result = await createAcceleratedRbfTransaction({ + originalTx: selectedTx.txHex, + targetFeeRate: newFeeRate, + // Add other necessary options here + }); + console.log("Accelerated RBF transaction created:", result); + closeAllModals(); + } catch (error) { + console.error("Error creating accelerated RBF transaction:", error); + } + } + }; + + const handleCancelTransaction = async (newFeeRate: number) => { + if (selectedTx) { + try { + const result = await createCancelRbfTransaction({ + originalTx: selectedTx.txHex, + targetFeeRate: newFeeRate, + // Add other necessary options here + }); + console.log("Cancel RBF transaction created:", result); + closeAllModals(); + } catch (error) { + console.error("Error creating cancel RBF transaction:", error); + } + } + }; + return ( @@ -65,27 +95,27 @@ const WalletPendingTransactions: React.FC = () => { error={error} /> - - - ); }; - export default WalletPendingTransactions; diff --git a/apps/coordinator/src/hooks/fees.ts b/apps/coordinator/src/hooks/fees.ts index 0d291f88a5..da1579ed03 100644 --- a/apps/coordinator/src/hooks/fees.ts +++ b/apps/coordinator/src/hooks/fees.ts @@ -1,15 +1,30 @@ import { useState, useEffect } from "react"; import { useSelector } from "react-redux"; import { calculateTimeElapsed } from "../components/Wallet/fee-bumping/utils"; +import { Buffer } from "buffer/"; import { useGetClient } from "./client"; -import { TransactionAnalyzer } from "@caravan/fees"; +import { TransactionAnalyzer, UTXO } from "@caravan/fees"; import { RootState, - UTXO, AnalyzerWithTimeElapsed, + WalletSliceUTXO, + PendingTransactionsResult, } from "components/types/fees"; -export const useGetPendingTransactions = () => { +/** + * 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< AnalyzerWithTimeElapsed[] >([]); @@ -25,6 +40,7 @@ export const useGetPendingTransactions = () => { ...Object.values(state.wallet.change.nodes), ]); const blockchainClient = useGetClient(); + const getCurrentNetworkFeeRate = async (): Promise => { try { return await blockchainClient.getFeeEstimate(); @@ -33,10 +49,55 @@ export const useGetPendingTransactions = () => { 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]; + console.log("fullTx", fullTx, output); + 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), + ]); + console.log("combinedInputs", combinedInputs); + return combinedInputs; + }; + const analyzeTransaction = async ( - utxo: UTXO, + utxo: WalletSliceUTXO, currentNetworkFeeRate: number, settings: RootState["settings"], + availableInputs: UTXO[], ): Promise => { try { const analyzer = new TransactionAnalyzer({ @@ -44,17 +105,15 @@ export const useGetPendingTransactions = () => { network: settings.network, targetFeeRate: currentNetworkFeeRate, absoluteFee: utxo.amountSats.toString(), - availableUtxos: [], // need to provide available UTXOs for CPFP + 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 }); - console.log("checkl11", analyzer.analyze()); - // Add the timeElapsed property to the analyzer instance - (analyzer as AnalyzerWithTimeElapsed).timeElapsed = calculateTimeElapsed( - utxo.time, - ); + const analysis = analyzer.analyze(); + console.log(" analysis ", utxo, analysis); return analyzer as AnalyzerWithTimeElapsed; } catch (error) { console.error("Error analyzing transaction:", error); @@ -74,12 +133,20 @@ export const useGetPendingTransactions = () => { const currentNetworkFeeRate = await getCurrentNetworkFeeRate(); setCurrentNetworkFeeRate(currentNetworkFeeRate); + const analyzedTransactions = await Promise.all( - pendingTxs.map(async (utxo) => - analyzeTransaction(utxo, currentNetworkFeeRate, settings), - ), + pendingTxs.map(async (utxo) => { + const availableInputs = await getAvailableInputs(utxo.txid); + console.log(" availableInputs ", availableInputs); + return analyzeTransaction( + utxo, + currentNetworkFeeRate, + settings, + availableInputs, + ); + }), ); - console.log("done", pendingTxs); + setPendingTransactions(analyzedTransactions); } catch (error) { console.error("Error fetching pending transactions:", error); diff --git a/apps/coordinator/src/hooks/index.ts b/apps/coordinator/src/hooks/index.ts index 3c8467249d..bd06a9dcf1 100644 --- a/apps/coordinator/src/hooks/index.ts +++ b/apps/coordinator/src/hooks/index.ts @@ -1,3 +1,3 @@ export { useGetClient } from "./client"; export { useGetDescriptors } from "./descriptors"; -export { useGetPendingTransactions } from "./fees"; +export * from "./fees"; From 9f2142e8635b22db35acaa2df72cb2e324bac722 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 8 Sep 2024 02:41:15 +0530 Subject: [PATCH 09/12] refactor: Pending Transaction Table ... added additional warning , UI improvement and error fixes --- .../fee-bumping/PendingTransactionTable.tsx | 84 +++------ .../Wallet/fee-bumping/TransactionActions.tsx | 162 ++++++++++++++++++ apps/coordinator/src/components/types/fees.ts | 33 ++-- apps/coordinator/src/hooks/fees.ts | 138 +++++++++++++-- 4 files changed, 331 insertions(+), 86 deletions(-) create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/TransactionActions.tsx diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx index 287b7344f9..653c4a3a88 100644 --- a/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx +++ b/apps/coordinator/src/components/Wallet/fee-bumping/PendingTransactionTable.tsx @@ -23,7 +23,8 @@ import { } from "@caravan/bitcoin"; import { styled } from "@mui/material/styles"; import { Search, Edit, TrendingUp, TrendingDown } from "@mui/icons-material"; -import { RootState, AnalyzerWithTimeElapsed } from "components/types/fees"; +import { RootState, ExtendedAnalyzer } from "components/types/fees"; +import TransactionActions from "./TransactionActions"; import { formatTxid } from "./utils"; import Copyable from "./../../Copyable"; @@ -34,11 +35,6 @@ const StyledTableCell = styled(TableCell)(({ theme }) => ({ }, })); -const ActionButton = styled(Button)(({ theme }) => ({ - margin: theme.spacing(0.5), - textTransform: "none", -})); - const TxidCell = styled(TableCell)(({ theme }) => ({ display: "flex", alignItems: "center", @@ -70,14 +66,10 @@ const FeeRateRow = styled(Box)(({ theme }) => ({ gap: theme.spacing(1), })); -const ActionCell = styled(TableCell)(() => ({ - whiteSpace: "nowrap", -})); - interface TransactionTableProps { - transactions: AnalyzerWithTimeElapsed[]; - onRBF: (tx: AnalyzerWithTimeElapsed) => void; - onCPFP: (tx: AnalyzerWithTimeElapsed) => void; + transactions: ExtendedAnalyzer[]; + onRBF: (tx: ExtendedAnalyzer) => void; + onCPFP: (tx: ExtendedAnalyzer) => void; isLoading: boolean; currentFeeRate: number; error: string | null; @@ -102,8 +94,8 @@ const TransactionTable: React.FC = ({ } }; - const renderFeeRateComparison = (tx: AnalyzerWithTimeElapsed) => { - const txFeeRate = parseFloat(tx.feeRate); + const renderFeeRateComparison = (tx: ExtendedAnalyzer) => { + const txFeeRate = parseFloat(tx.analyzer.feeRate); const feeRateDiff = currentFeeRate - txFeeRate; const icon = feeRateDiff > 0 ? ( @@ -194,16 +186,20 @@ const TransactionTable: React.FC = ({ {transactions.map((tx) => ( - + - - {formatTxid(tx.txid)} + + {formatTxid(tx.analyzer.txid)} - + @@ -214,56 +210,20 @@ const TransactionTable: React.FC = ({ {tx.timeElapsed} - {formatAmount(parseInt(tx.fee))} + {formatAmount(parseInt(tx.analyzer.fee))} {renderFeeRateComparison(tx)} - {tx.recommendedStrategy === "NONE" + {tx.analyzer.recommendedStrategy === "NONE" ? "No action needed" - : tx.recommendedStrategy} + : tx.canRBF + ? tx.analyzer.recommendedStrategy + : "CPFP"} - - {tx.canRBF && ( - - onRBF(tx)} - startIcon={} - > - RBF - - - )} - {tx.canCPFP && ( - - onCPFP(tx)} - startIcon={} - > - CPFP - - - )} - {!tx.canRBF && !tx.canCPFP && ( - - No actions available - - )} - + ))} 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 0000000000..86b6c3159d --- /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/types/fees.ts b/apps/coordinator/src/components/types/fees.ts index 350dbb8ab1..951e2d15c2 100644 --- a/apps/coordinator/src/components/types/fees.ts +++ b/apps/coordinator/src/components/types/fees.ts @@ -1,22 +1,28 @@ import { TransactionAnalyzer } from "@caravan/fees"; +import BigNumber from "bignumber.js"; -export interface UTXO { - txid: string; - vout: number; - amount: number; - amountSats: number; - address: string; +export interface WalletSliceUTXO { + amount: string; + amountSats: BigNumber; + checked: boolean; confirmed: boolean; - transactionHex: string; + index: number; time: number; + transactionHex: string; + txid: string; } -export type AnalyzerWithTimeElapsed = TransactionAnalyzer & { +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: UTXO[]; + utxos: WalletSliceUTXO[]; } export interface RootState { @@ -30,3 +36,10 @@ export interface RootState { }; }; } + +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 index da1579ed03..5742c5426d 100644 --- a/apps/coordinator/src/hooks/fees.ts +++ b/apps/coordinator/src/hooks/fees.ts @@ -1,16 +1,76 @@ import { useState, useEffect } from "react"; -import { useSelector } from "react-redux"; +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 } from "@caravan/fees"; +import { + TransactionAnalyzer, + UTXO, + BtcTransactionTemplate, +} from "@caravan/fees"; import { RootState, - AnalyzerWithTimeElapsed, + 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. * @@ -26,7 +86,7 @@ import { */ export const useGetPendingTransactions = (): PendingTransactionsResult => { const [pendingTransactions, setPendingTransactions] = useState< - AnalyzerWithTimeElapsed[] + ExtendedAnalyzer[] >([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -34,7 +94,9 @@ export const useGetPendingTransactions = (): PendingTransactionsResult => { number | null >(1); - const settings = useSelector((state: RootState) => state.settings); + 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), @@ -70,9 +132,7 @@ export const useGetPendingTransactions = (): PendingTransactionsResult => { const formatUtxo = async (utxo: WalletSliceUTXO): Promise => { const fullTx = await blockchainClient.getTransaction(utxo.txid); - const output = fullTx.vout[utxo.index]; - console.log("fullTx", fullTx, output); return { txid: utxo.txid, vout: utxo.index, @@ -89,7 +149,23 @@ export const useGetPendingTransactions = (): PendingTransactionsResult => { ...existingInputs.map(formatUtxo), ...sortedAdditionalUtxos.map(formatUtxo), ]); - console.log("combinedInputs", combinedInputs); + + // 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; }; @@ -98,12 +174,12 @@ export const useGetPendingTransactions = (): PendingTransactionsResult => { currentNetworkFeeRate: number, settings: RootState["settings"], availableInputs: UTXO[], - ): Promise => { + ): Promise => { try { const analyzer = new TransactionAnalyzer({ txHex: utxo.transactionHex, network: settings.network, - targetFeeRate: currentNetworkFeeRate, + targetFeeRate: 100, // currentNetworkFeeRate absoluteFee: utxo.amountSats.toString(), availableUtxos: availableInputs, requiredSigners: settings.requiredSigners, @@ -112,9 +188,43 @@ export const useGetPendingTransactions = (): PendingTransactionsResult => { 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 analysis = analyzer.analyze(); - console.log(" analysis ", utxo, analysis); - return analyzer as AnalyzerWithTimeElapsed; + 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; @@ -137,7 +247,7 @@ export const useGetPendingTransactions = (): PendingTransactionsResult => { const analyzedTransactions = await Promise.all( pendingTxs.map(async (utxo) => { const availableInputs = await getAvailableInputs(utxo.txid); - console.log(" availableInputs ", availableInputs); + return analyzeTransaction( utxo, currentNetworkFeeRate, From 7237530375847626d13b04b1f1a9c99d0a430887 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 8 Sep 2024 02:44:00 +0530 Subject: [PATCH 10/12] Add : CPFP UI and functionality --- .../Wallet/fee-bumping/cpfp/CPFPDialog.tsx | 266 ++++++++++++++++++ .../cpfp/components/ChangeAddressSelector.tsx | 53 ++++ .../cpfp/components/FeeComparison.tsx | 72 +++++ .../cpfp/components/FeeRateAdjuster.tsx | 43 +++ .../cpfp/components/TransactionDetails.tsx | 94 +++++++ .../Wallet/fee-bumping/cpfp/index.ts | 1 + .../Wallet/fee-bumping/cpfp/styles.ts | 24 ++ 7 files changed, 553 insertions(+) create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/cpfp/CPFPDialog.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/ChangeAddressSelector.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeComparison.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/FeeRateAdjuster.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/cpfp/components/TransactionDetails.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/cpfp/index.ts create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/cpfp/styles.ts 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 0000000000..b4889616e8 --- /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 0000000000..b3da32eb3b --- /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 0000000000..f8a3f5b04e --- /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 0000000000..4865656d9b --- /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 0000000000..a594b4ea28 --- /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 0000000000..ea79228262 --- /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 0000000000..7238c9a19b --- /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), +})); From 5f3c8f667ced9187e6220f41d98258b270bce689 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 8 Sep 2024 02:44:41 +0530 Subject: [PATCH 11/12] refactor: WalletPendingTransactions --- .../Wallet/WalletPendingTransactions.tsx | 198 ++++++++++++++++-- 1 file changed, 178 insertions(+), 20 deletions(-) diff --git a/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx index 3bf431dbb2..e54e3f6967 100644 --- a/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx +++ b/apps/coordinator/src/components/Wallet/WalletPendingTransactions.tsx @@ -1,16 +1,19 @@ -import React, { useState } from "react"; +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/RBFOptionsDialog"; -import AccelerateFeeDialog from "./fee-bumping/rbf/AccelerateFeeDialog"; -import CancelTransactionDialog from "./fee-bumping/rbf/CancelTransactionDialog"; -import { AnalyzerWithTimeElapsed } from "components/types/fees"; +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), @@ -18,23 +21,38 @@ const StyledPaper = styled(Paper)(({ theme }) => ({ const WalletPendingTransactions: React.FC = () => { const { pendingTransactions, currentNetworkFeeRate, isLoading, error } = useGetPendingTransactions(); - const [selectedTx, setSelectedTx] = useState( - null, - ); + 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 ... // }; - const handleRBF = (tx: AnalyzerWithTimeElapsed) => { + // + + // 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: AnalyzerWithTimeElapsed) => { - console.log("CPFP initiated for transaction:", tx.txid); - //To Implement CPFP logic here + const handleCPFP = (tx: ExtendedAnalyzer) => { + setSelectedTx(tx); + setShowCPFP(true); }; const handleIncreaseFees = () => { setShowRBFOptions(false); @@ -48,37 +66,162 @@ const WalletPendingTransactions: React.FC = () => { setShowRBFOptions(false); setShowIncreaseFees(false); setShowCancelTx(false); + setShowCPFP(false); setSelectedTx(null); + setFeePsbt(null); }; const handleAccelerateFee = async (newFeeRate: number) => { if (selectedTx) { try { - const result = await createAcceleratedRbfTransaction({ + const result = createAcceleratedRbfTransaction({ originalTx: selectedTx.txHex, targetFeeRate: newFeeRate, - // Add other necessary options here + 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"], }); - console.log("Accelerated RBF transaction created:", result); - closeAllModals(); + 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 = await createCancelRbfTransaction({ + const result = createCancelRbfTransaction({ originalTx: selectedTx.txHex, targetFeeRate: newFeeRate, - // Add other necessary options here + 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, }); - console.log("Cancel RBF transaction created:", result); - closeAllModals(); + 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) } } }; @@ -105,15 +248,30 @@ const WalletPendingTransactions: React.FC = () => { open={showIncreaseFees} onClose={closeAllModals} onConfirm={handleAccelerateFee} + createPsbt={createAccelerateFeePsbt} transaction={selectedTx} currentNetworkFeeRate={currentNetworkFeeRate!} + isGeneratingPSBT={isGeneratingPSBT} /> + + ); From bd4842da3c6e3c89184895e7822f06ba120f5845 Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Sun, 8 Sep 2024 02:46:06 +0530 Subject: [PATCH 12/12] Add : RBF UI and functionality --- .../fee-bumping/rbf/AccelerateFeeDialog.tsx | 36 ---- .../rbf/CancelTransactionDialog.tsx | 36 ---- .../rbf/components/AdjustFeeRateSlider.tsx | 41 ++++ .../rbf/components/FeeComparisonBox.tsx | 43 ++++ .../rbf/components/TransactionTable.tsx | 69 ++++++ .../rbf/dialogs/AccelerateFeeDialog.tsx | 195 +++++++++++++++++ .../rbf/dialogs/CancelTransactionDialog.tsx | 200 ++++++++++++++++++ .../rbf/{ => dialogs}/RBFOptionsDialog.tsx | 10 +- .../Wallet/fee-bumping/rbf/index.ts | 15 ++ .../Wallet/fee-bumping/rbf/types.ts | 34 +++ .../fee-bumping/rbf/utils/psbtHelpers.ts | 25 +++ 11 files changed, 630 insertions(+), 74 deletions(-) delete mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx delete mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/AdjustFeeRateSlider.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/FeeComparisonBox.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/components/TransactionTable.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/AccelerateFeeDialog.tsx create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/CancelTransactionDialog.tsx rename apps/coordinator/src/components/Wallet/fee-bumping/rbf/{ => dialogs}/RBFOptionsDialog.tsx (78%) create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/index.ts create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/types.ts create mode 100644 apps/coordinator/src/components/Wallet/fee-bumping/rbf/utils/psbtHelpers.ts diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx deleted file mode 100644 index 370d3081cb..0000000000 --- a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/AccelerateFeeDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Typography, -} from "@mui/material"; - -interface AccelerateFeeDialogProps { - open: boolean; - onClose: () => void; - onConfirm: () => void; -} - -const AccelerateFeeDialog: React.FC = ({ - open, - onClose, - onConfirm, -}) => ( - - Increase Fees - - Implement your increase fees UI here - - - - - - -); - -export default AccelerateFeeDialog; diff --git a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx deleted file mode 100644 index 18ff905fb2..0000000000 --- a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/CancelTransactionDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - Typography, -} from "@mui/material"; - -interface CancelTransactionDialogProps { - open: boolean; - onClose: () => void; - onConfirm: () => void; -} - -const CancelTransactionDialog: React.FC = ({ - open, - onClose, - onConfirm, -}) => ( - - Cancel Transaction - - Implement your cancel transaction UI here - - - - - - -); - -export default CancelTransactionDialog; 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 0000000000..b47f5aa896 --- /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 0000000000..73e58232fe --- /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 0000000000..58e16b06db --- /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 0000000000..f9f3cc50a4 --- /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 0000000000..98e7a809e6 --- /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/RBFOptionsDialog.tsx b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/RBFOptionsDialog.tsx similarity index 78% rename from apps/coordinator/src/components/Wallet/fee-bumping/rbf/RBFOptionsDialog.tsx rename to apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/RBFOptionsDialog.tsx index 7215ceb6c9..8852a41842 100644 --- a/apps/coordinator/src/components/Wallet/fee-bumping/rbf/RBFOptionsDialog.tsx +++ b/apps/coordinator/src/components/Wallet/fee-bumping/rbf/dialogs/RBFOptionsDialog.tsx @@ -29,10 +29,16 @@ const RBFOptionsDialog: React.FC = ({ - - 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 0000000000..ca8a99ec5d --- /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 0000000000..5080385a99 --- /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 0000000000..5cdd80bd84 --- /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 }; +};