From f10e41699d0485de2473e557bcb477111f5fc5fa Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Tue, 13 Jan 2026 20:50:50 +0530 Subject: [PATCH 01/11] feat: add rebalance button for better UX --- .../components/ScriptExplorer/OutputEntry.jsx | 42 +++++-- .../components/ScriptExplorer/OutputsForm.jsx | 109 ++++++++++++++++-- 2 files changed, 128 insertions(+), 23 deletions(-) diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx index 56fb1ed37c..b5c39c3ec2 100644 --- a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx @@ -15,6 +15,8 @@ import { InputAdornment, FormHelperText, Typography, + Box, + Button, } from "@mui/material"; import AccountBalanceWalletOutlinedIcon from "@mui/icons-material/AccountBalanceWallet"; import { Delete, AddCircle, RemoveCircle } from "@mui/icons-material"; @@ -267,21 +269,37 @@ class OutputEntry extends React.Component { /> {this.displayBalanceAction() && ( - - - - - {this.balanceAction() === "Increase" ? ( + + + + + {this.balanceAction()} to {this.autoBalancedAmount()} BTC + + )} diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx index 3bcabfb828..52b1d8f87e 100644 --- a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx @@ -3,7 +3,12 @@ import PropTypes from "prop-types"; import { connect } from "react-redux"; import { map } from "lodash"; import BigNumber from "bignumber.js"; -import { bitcoinsToSatoshis, satoshisToBitcoins } from "@caravan/bitcoin"; +import { + bitcoinsToSatoshis, + satoshisToBitcoins, + estimateMultisigTransactionFeeRate, + estimateMultisigTransactionFee, +} from "@caravan/bitcoin"; import { Grid, Button, @@ -56,6 +61,7 @@ class OutputsForm extends React.Component { super(props); this.state = { feeRateFetchError: "", + lastEditedFeeField: "rate", // Track which field was edited last: 'rate' or 'amount' }; } @@ -152,21 +158,87 @@ class OutputsForm extends React.Component { }; handleFeeRateChange = (event) => { - const { setFeeRate } = this.props; - let rate = event.target.value; + const { setFeeRate, setFee, inputs, outputs } = this.props; + const { addressType, requiredSigners: m, totalSigners: n } = this.props; + let rate = event.target.value; if ( rate === "" || Number.isNaN(parseFloat(rate, 10)) || parseFloat(rate, 10) < 1 ) rate = "0"; + setFeeRate(rate); + this.setState({ lastEditedFeeField: "rate" }); + + // Optional: Update fee amount if inputs are already selected + if (inputs.length > 0 && parseFloat(rate) > 0) { + const actualOutputs = outputs.filter( + (o) => o.amount && o.amount !== "", + ).length; + const estimatedFees = estimateMultisigTransactionFee({ + addressType, + numInputs: inputs.length, + numOutputs: actualOutputs, + m, + n, + feesPerByteInSatoshis: rate, + }); + console.log("estimatedFees", estimatedFees); + const feeInBTC = satoshisToBitcoins(estimatedFees); + setFee(feeInBTC); + } }; handleFeeChange = (event) => { - const { setFee } = this.props; - setFee(event.target.value); + const { setFee, setFeeRate, inputs, outputs } = this.props; + const { addressType, requiredSigners: m, totalSigners: n } = this.props; + + const feeAmount = event.target.value; + setFee(feeAmount); + this.setState({ lastEditedFeeField: "amount" }); + + // Calculate effective fee rate from the entered fee amount + if ( + inputs.length > 0 && + feeAmount && + !Number.isNaN(parseFloat(feeAmount)) + ) { + // Count actual outputs (excluding empty ones and change if auto-calculated) + const actualOutputs = outputs.filter( + (o) => o.amount && o.amount !== "", + ).length; + const feeSats = bitcoinsToSatoshis(new BigNumber(feeAmount)); + const estimatedFeeRate = estimateMultisigTransactionFeeRate({ + addressType, + numInputs: inputs.length, + numOutputs: actualOutputs, + m, + n, + feesInSatoshis: feeSats, + }); + + if (estimatedFeeRate > 0) { + // Update fee rate to show what rate this fee amount represents + setFeeRate(estimatedFeeRate); + } + } + console.log("fee", { setFee, setFeeRate, inputs, outputs }); + }; + + // Helper to show effective fee rate when user edits fee amount + getFeeHelperText = () => { + const { feeError, inputs } = this.props; + const { lastEditedFeeField } = this.state; + + if (feeError) return feeError; + + if (lastEditedFeeField === "amount" && inputs.length > 0) { + return "Note: Fee rate shown is effective rate. Actual rate may vary after coin selection."; + } + + return ""; }; handleFinalize = () => { @@ -237,13 +309,12 @@ class OutputsForm extends React.Component { fee, finalizedOutputs, feeRateError, - feeError, balanceError, inputs, isWallet, autoSpend, } = this.props; - const { feeRateFetchError } = this.state; + const { feeRateFetchError, lastEditedFeeField } = this.state; const feeDisplay = inputs && inputs.length > 0 ? fee : "0.0000"; const feeMt = 3; const totalMt = 7; @@ -345,16 +416,26 @@ class OutputsForm extends React.Component { disabled={finalizedOutputs} value={feeDisplay} variant="standard" + type="number" onChange={this.handleFeeChange} error={this.hasFeeError()} - helperText={feeError} + helperText={this.getFeeHelperText()} InputProps={OutputsForm.unitLabel("BTC", { - readOnly: true, - disableUnderline: true, - style: { color: "gray" }, + readOnly: false, + disableUnderline: false, + style: { color: "inherit" }, })} /> + {lastEditedFeeField === "amount" && inputs.length > 0 && ( + + Effective rate: {feeRate} sats/vB + + )} ) : ( "" @@ -488,6 +569,9 @@ OutputsForm.propTypes = { signatureImporters: PropTypes.shape({}).isRequired, updatesComplete: PropTypes.bool, getBlockchainClient: PropTypes.func.isRequired, + addressType: PropTypes.string.isRequired, + requiredSigners: PropTypes.number.isRequired, + totalSigners: PropTypes.number.isRequired, }; OutputsForm.defaultProps = { @@ -498,6 +582,9 @@ function mapStateToProps(state) { return { ...{ network: state.settings.network, + addressType: state.settings.addressType, + requiredSigners: state.settings.requiredSigners, + totalSigners: state.settings.totalSigners, client: state.client, }, ...state.spend.transaction, From 9deae58bba3db01c14a60f5f47e7051340b9b70f Mon Sep 17 00:00:00 2001 From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:07:07 +0530 Subject: [PATCH 02/11] fix: sync issues and styling for fees and feeRate --- .../components/ScriptExplorer/OutputEntry.jsx | 23 +- .../components/ScriptExplorer/OutputsForm.jsx | 581 +++++++++++------- .../src/reducers/transactionReducer.js | 50 +- .../src/reducers/transactionReducer.test.js | 42 ++ 4 files changed, 478 insertions(+), 218 deletions(-) diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx index b5c39c3ec2..171d926466 100644 --- a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx @@ -135,12 +135,14 @@ class OutputEntry extends React.Component { } const newAmount = this.autoBalancedAmount(); if ( + !BigNumber.isBigNumber(newAmount) || + newAmount.isNaN() || validateOutputAmount(bitcoinsToSatoshis(newAmount), inputsTotalSats) !== - "" + "" ) { return true; } - return amountError === "" && newAmount === new BigNumber(amount); + return amountError === "" && newAmount.isEqualTo(new BigNumber(amount)); }; isBalanceable = () => !this.isNotBalanceable(); @@ -154,15 +156,17 @@ class OutputEntry extends React.Component { const { number, fee, inputsTotalSats, outputs } = this.props; const outputTotalSats = outputs .filter((output, i) => i !== number - 1) - .map((output) => output.amountSats) + .map((output) => new BigNumber(output.amountSats || 0)) .reduce( (accumulator, currentValue) => accumulator.plus(currentValue), new BigNumber(0), ); const feeSats = bitcoinsToSatoshis(new BigNumber(fee)); - return satoshisToBitcoins( + const result = satoshisToBitcoins( inputsTotalSats.minus(outputTotalSats.plus(feeSats)), ); + // Guarantee we always return a BigNumber so .toFixed() never crashes + return BigNumber.isBigNumber(result) ? result : new BigNumber(result || 0); }; balanceAction = () => { @@ -212,9 +216,11 @@ class OutputEntry extends React.Component { } = this.props; const gridSpacing = isWallet ? 10 : 1; + const showRebalance = + this.displayBalanceAction() && this.balanceAction() !== null; return ( - + - {this.displayBalanceAction() && ( + {showRebalance && ( + - - - - - - - - + Transaction Fee + + {autoSpend && ( + + )} + + + + {/* Fee Rate */} + Fee Rate - - + + - + - + - Sats/byte + + sats/vB + ), }} /> - - - Refer to mempool monitoring websites to ensure your selected fee - rate is appropriate. - - + + Check{" "} + + mempool.space + {" "} + for current rates + + - -   - - {!isWallet || (isWallet && !autoSpend) ? ( - - + {/* Estimated Fee */} + + + {autoSpend ? "Estimated Fee" : "Fee Amount"} + + + {/* +1 sat bump — only in manual mode with inputs */} + {canEditFee && !finalizedOutputs && ( + + + + + + + + )} + + BTC + + + ), + sx: { + backgroundColor: canEditFee + ? "background.paper" + : "transparent", + "& .MuiOutlinedInput-notchedOutline": { + borderStyle: canEditFee ? "solid" : "dashed", + }, + }, + }} + /> + {/* Contextual hints */} + {!canEditFee && !autoSpend && !hasInputs && ( - Estimated Fees + Select inputs to edit fee directly - - - {lastEditedFeeField === "amount" && inputs.length > 0 && ( + )} + {autoSpend && !hasInputs && ( + + Estimate based on ~1 input. Final fee set during coin + selection. + + )} + {autoSpend && hasInputs && ( + + Finalized during coin selection + + )} + {canEditFee && ( - Effective rate: {feeRate} sats/vB + Edit to set exact fee — rate updates automatically )} - ) : ( - "" - )} - - - - - - - - - {!isWallet || (isWallet && !autoSpend) - ? "Totals" - : "Output Total"} - - - - - - - + + - - - - + + + {/* ====== Totals Section ====== */} + + + {!isWallet || (isWallet && !autoSpend) + ? "Transaction Summary" + : "Output Summary"} + + + + {/* Inputs Total — hidden in auto-spend */} + {(!isWallet || (isWallet && !autoSpend)) && ( + + + + Inputs Total + + + + {this.inputsTotal().toString()} + + + BTC + + + + + )} + + {/* Outputs + Fee Total */} + + + + {!isWallet || (isWallet && !autoSpend) + ? "Outputs + Fee" + : "Outputs Total"} + + + + {this.outputsAndFeeTotal()} + + + BTC + + + {balanceError && ( + + {balanceError} + + )} + + + + {/* Balance difference indicator */} + {(!isWallet || (isWallet && !autoSpend)) && + hasInputs && + !this.hasBalanceError() && ( + + + + )} - - + + {/* ====== Action Buttons (Script Explorer only) ====== */} {!isWallet && ( - - + +