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
+ >
+
+
+
+
+
+ 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,
+}) => (
+
+);
+
+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,
+}) => (
+
+);
+
+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,
+}) => (
+
+);
+
+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,
+}) => (
+
+);
+
+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 (
+
+ );
+ }
+
+ if (!childTxTemplate) {
+ return (
+
+ );
+ }
+
+ 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 (
+
+ );
+};
+
+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,
-}) => (
-
-);
-
-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,
-}) => (
-
-);
-
-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"
+ />
+
+
+ }
+ >
+ Preview Transaction
+
+
+
+);
+
+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 (
+
+ );
+ }
+
+ if (!txTemplate) {
+ return (
+
+ );
+ }
+
+ const { currentFees, newFees, additionalFees } = calculateFees(
+ txTemplate,
+ previewTxTemplate,
+ calculatePreviewFee,
+ );
+
+ return (
+
+ );
+};
+
+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 (
+
+ );
+ }
+
+ if (!txTemplate) {
+ return (
+
+ );
+ }
+
+ const { currentFees, newFees, additionalFees } = calculateFees(
+ txTemplate,
+ previewTxTemplate,
+ calculatePreviewFee,
+ );
+
+ return (
+
+ );
+};
+
+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 };
+};