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" ? (
+
+
+
) : (
- )}
-
-
-
+ )
+ }
+ sx={{
+ textTransform: "none",
+ fontSize: "0.75rem",
+ whiteSpace: "nowrap",
+ minWidth: "auto",
+ }}
+ >
+ Rebalance
+
+
+ {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 && (
diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
index 52b1d8f87e..b874f63d6d 100644
--- a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
+++ b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
@@ -6,7 +6,6 @@ import BigNumber from "bignumber.js";
import {
bitcoinsToSatoshis,
satoshisToBitcoins,
- estimateMultisigTransactionFeeRate,
estimateMultisigTransactionFee,
} from "@caravan/bitcoin";
import {
@@ -19,6 +18,9 @@ import {
InputAdornment,
Typography,
FormHelperText,
+ Paper,
+ Divider,
+ Chip,
} from "@mui/material";
import { Speed } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
@@ -61,7 +63,6 @@ class OutputsForm extends React.Component {
super(props);
this.state = {
feeRateFetchError: "",
- lastEditedFeeField: "rate", // Track which field was edited last: 'rate' or 'amount'
};
}
@@ -158,87 +159,56 @@ class OutputsForm extends React.Component {
};
handleFeeRateChange = (event) => {
- const { setFeeRate, setFee, inputs, outputs } = this.props;
- const { addressType, requiredSigners: m, totalSigners: n } = this.props;
-
+ const { setFeeRate } = 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);
+ console.log("rate", rate);
+ // Limit to 2 decimal places in the input
+ if (rate.includes(".")) {
+ const parts = rate.split(".");
+ if (parts[1] && parts[1].length > 2) return; // Don't accept more than 2 decimals
}
+
+ setFeeRate(rate === "" ? "0" : rate); // Reducer handles fee calculation and validation
};
handleFeeChange = (event) => {
- 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,
- });
+ const { setFee } = this.props;
+ setFee(event.target.value);
+ // That's it. Reducer handles rate back-calculation.
+ };
- if (estimatedFeeRate > 0) {
- // Update fee rate to show what rate this fee amount represents
- setFeeRate(estimatedFeeRate);
- }
+ // Bump fee by 1 satoshi
+ handleFeeBump = () => {
+ const { fee, setFee } = this.props;
+ try {
+ const currentSats = bitcoinsToSatoshis(new BigNumber(fee || 0));
+ const bumped = currentSats.plus(1);
+ setFee(satoshisToBitcoins(bumped).toString());
+ } catch (e) {
+ // If current fee is unparseable, ignore
}
- 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;
+ // Compute estimated fee from rate for display in auto mode
+ getEstimatedFeeFromRate = () => {
+ const { feeRate, outputs, addressType, requiredSigners, totalSigners } =
+ this.props;
- if (feeError) return feeError;
+ if (!feeRate || parseFloat(feeRate) <= 0) return "—";
- if (lastEditedFeeField === "amount" && inputs.length > 0) {
- return "Note: Fee rate shown is effective rate. Actual rate may vary after coin selection.";
+ try {
+ const estimated = estimateMultisigTransactionFee({
+ addressType,
+ numInputs: 1, // estimate with 1 input as minimum
+ numOutputs: Math.max(outputs.length, 1),
+ m: requiredSigners,
+ n: totalSigners,
+ feesPerByteInSatoshis: feeRate,
+ });
+ return satoshisToBitcoins(estimated).toString();
+ } catch (e) {
+ return "—";
}
-
- return "";
};
handleFinalize = () => {
@@ -307,6 +277,7 @@ class OutputsForm extends React.Component {
const {
feeRate,
fee,
+ feeError,
finalizedOutputs,
feeRateError,
balanceError,
@@ -314,60 +285,119 @@ class OutputsForm extends React.Component {
isWallet,
autoSpend,
} = this.props;
- const { feeRateFetchError, lastEditedFeeField } = this.state;
- const feeDisplay = inputs && inputs.length > 0 ? fee : "0.0000";
- const feeMt = 3;
- const totalMt = 7;
- const actionMt = 7;
+ const { feeRateFetchError } = this.state;
+
+ // Auto-spend: no inputs selected yet → show estimate from rate, not reducer fee
+ // Manual: show reducer fee directly
+ const hasInputs = inputs.length > 0;
+ const canEditFee = !autoSpend && hasInputs;
+
+ let feeDisplay;
+ if (autoSpend) {
+ feeDisplay = hasInputs ? fee : this.getEstimatedFeeFromRate();
+ } else {
+ feeDisplay = fee || "";
+ }
+
const gridSpacing = isWallet ? 10 : 1;
+
return (
<>
-
-
-
- To
-
-
-
-
-
-
-
- Amount
-
+ {/* ====== Outputs Section ====== */}
+
+
+
+
+ RECIPIENT
+
+
+
+
+ AMOUNT
+
+
-
- {this.renderOutputs()}
+ {this.renderOutputs()}
+
+
+
-
-
-
+
+ {/* ====== Fee Section ====== */}
+
+
+
- Add output
-
-
-
-
-
-
+ 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 && (
-
-
+
+
-
-
-
- {/* ====== Totals Section ====== */}
-
-
+ {/* Fee Rate */}
+
+
+ Fee Rate
+
+
+
+
+
+
+
+
+
+
+ sats/vB
+
+
+ ),
+ }}
+ />
+
+ Check{" "}
+
+ mempool.space
+ {" "}
+ for current rates
+
+
+
+ {/* Fee Amount */}
+
+
+ Fee Amount
+
+
+ {canEditFee && !finalizedOutputs && (
+
+
+
+
+
+
+
+ )}
+
+ BTC
+
+
+ ),
+ sx: {
+ backgroundColor: canEditFee
+ ? "background.paper"
+ : "transparent",
+ "& .MuiOutlinedInput-notchedOutline": {
+ borderStyle: canEditFee ? "solid" : "dashed",
+ },
+ },
+ }}
+ />
+ {!hasInputs && (
+
+ Select inputs to edit fee directly
+
+ )}
+ {canEditFee && (
+
+ Edit to set exact fee — rate updates automatically
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+ {/* ── Totals ── */}
+ {isManual && (
+
- {!isWallet || (isWallet && !autoSpend)
- ? "Transaction Summary"
- : "Output Summary"}
-
-
-
- {/* Inputs Total — hidden in auto-spend */}
- {(!isWallet || (isWallet && !autoSpend)) && (
-
-
+
+ Transaction Summary
+
+
+
+ {(!isWallet || (isWallet && isManual)) && (
+
-
-
- )}
+
+ )}
- {/* Outputs + Fee Total */}
-
-
+
- {!isWallet || (isWallet && !autoSpend)
- ? "Outputs + Fee"
- : "Outputs Total"}
+ Outputs + Fee
)}
-
-
+
- {/* Balance difference indicator */}
- {(!isWallet || (isWallet && !autoSpend)) &&
- hasInputs &&
- !this.hasBalanceError() && (
+ {hasInputs && !this.hasBalanceError() && (
)}
-
-
+
+
+ )}
+
+ {/* Auto mode — simple output total inline */}
+ {autoSpend && (
+
+
+
+
+ Sending
+
+
+ {this.outputsAndFeeTotal()}
+
+
+ BTC
+
+
+ {balanceError && (
+
+ {balanceError}
+
+ )}
+
+ )}
- {/* ====== Action Buttons (Script Explorer only) ====== */}
+ {/* ── Action Buttons (Script Explorer) ── */}
{!isWallet && (
From 9f3b381acd6c347b6706ff7baf1978d2264a9e9d Mon Sep 17 00:00:00 2001
From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com>
Date: Tue, 17 Mar 2026 23:06:34 +0530
Subject: [PATCH 04/11] chore: add changeset
---
.changeset/famous-forks-build.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/famous-forks-build.md
diff --git a/.changeset/famous-forks-build.md b/.changeset/famous-forks-build.md
new file mode 100644
index 0000000000..769d31bf7e
--- /dev/null
+++ b/.changeset/famous-forks-build.md
@@ -0,0 +1,5 @@
+---
+"caravan-coordinator": patch
+---
+
+Feat: add func to edit fees in manual mode
From 900977dd9657bf16a10b0f1a156992b1f857445a Mon Sep 17 00:00:00 2001
From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com>
Date: Mon, 27 Apr 2026 20:47:14 +0530
Subject: [PATCH 05/11] fix: restrict rebalance to change output and apply
EPSILON rounding
Address Buck's review feedback: only show the Rebalance button on the
change output (or on recipients when no change output is set), and use
Math.round((n + Number.EPSILON) * 100) / 100 for fee-rate rounding so
floating-point artifacts don't leak into the input.
---
.../src/components/ScriptExplorer/OutputEntry.jsx | 7 ++++++-
apps/coordinator/src/reducers/transactionReducer.js | 10 ++++------
2 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
index 171d926466..d7533c395c 100644
--- a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
+++ b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
@@ -216,8 +216,13 @@ class OutputEntry extends React.Component {
} = this.props;
const gridSpacing = isWallet ? 10 : 1;
+ // Only show the rebalance button on the change output when one is set,
+ // not on recipient outputs.
+ const isChangeOutput = changeOutputIndex > 0 && number === changeOutputIndex;
const showRebalance =
- this.displayBalanceAction() && this.balanceAction() !== null;
+ this.displayBalanceAction() &&
+ this.balanceAction() !== null &&
+ (changeOutputIndex === 0 || isChangeOutput);
return (
diff --git a/apps/coordinator/src/reducers/transactionReducer.js b/apps/coordinator/src/reducers/transactionReducer.js
index 979b8dcbbf..3a1b103aca 100644
--- a/apps/coordinator/src/reducers/transactionReducer.js
+++ b/apps/coordinator/src/reducers/transactionReducer.js
@@ -172,12 +172,10 @@ function deleteOutput(state, action) {
function updateFeeRate(state, action) {
let feeRateString = action.value;
- // Limit to 2 decimal places
- if (feeRateString && feeRateString.includes(".")) {
- const parts = feeRateString.split(".");
- if (parts[1] && parts[1].length > 2) {
- feeRateString = parseFloat(feeRateString).toFixed(2);
- }
+ // Round to 2 decimal places
+ if (feeRateString && feeRateString !== "" && !Number.isNaN(parseFloat(feeRateString))) {
+ const parsed = parseFloat(feeRateString);
+ feeRateString = String(Math.round((parsed + Number.EPSILON) * 100) / 100);
}
// Gets the error type. Useful for conditionally displaying errors.
From 1f3be1a3c9baf92c38fff17153692ddac85ca6e1 Mon Sep 17 00:00:00 2001
From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com>
Date: Mon, 27 Apr 2026 20:48:35 +0530
Subject: [PATCH 06/11] fix: bump fee-rate step to 1 sat/vB and stabilize
output row layout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Buck noted ↑/↓ on the fee rate input bumping by 0.01 sat/vB has no
practical value. Switch the step to 1 (manual decimal entry still works,
reducer still rounds to 2 places).
Also fix the row jumping when a change output is added: the rebalance
column (xs=2) plus delete column (xs=1) was overflowing past 12, causing
the row to wrap. Address column shrunk to xs=6 and both action slots are
now always rendered (empty when inactive) so columns stay stable. Helper
text under the Rebalance button is clipped with an ellipsis instead of
wrapping to a second line.
---
.../components/ScriptExplorer/OutputEntry.jsx | 31 ++++++++++++-------
.../components/ScriptExplorer/OutputsForm.jsx | 6 ++--
2 files changed, 23 insertions(+), 14 deletions(-)
diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
index d7533c395c..0129196276 100644
--- a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
+++ b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
@@ -226,7 +226,7 @@ class OutputEntry extends React.Component {
return (
-
+
- {showRebalance && (
-
+
+ {showRebalance && (
-
- )}
+ )}
+
- {!finalizedOutputs &&
- outputs.length > (changeOutputIndex > 0 && autoSpend ? 2 : 1) && (
-
+
+ {!finalizedOutputs &&
+ outputs.length >
+ (changeOutputIndex > 0 && autoSpend ? 2 : 1) && (
-
- )}
+ )}
+
);
}
diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
index be226cb314..911c146ff7 100644
--- a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
+++ b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
@@ -271,7 +271,7 @@ class OutputsForm extends React.Component {
{/* ── Outputs ── */}
-
+
Date: Mon, 27 Apr 2026 20:51:31 +0530
Subject: [PATCH 07/11] fix: always re-derive fee rate from fee amount when
inputs are known
Buck reported the fee rate field staying stale while editing the fee
amount. updateFee was guarded by !feeError, so any non-fatal validation
hiccup (intermediate typing, fee-too-high) suppressed the back-calc and
left the rate showing the previous value.
Drop the !feeError guard. As long as inputs are known and feeSats > 0,
recompute the rate from the typed fee. Use the same EPSILON-based
rounding as updateFeeRate so display values stay consistent.
---
apps/coordinator/src/reducers/transactionReducer.js | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/apps/coordinator/src/reducers/transactionReducer.js b/apps/coordinator/src/reducers/transactionReducer.js
index 3a1b103aca..6b0f795868 100644
--- a/apps/coordinator/src/reducers/transactionReducer.js
+++ b/apps/coordinator/src/reducers/transactionReducer.js
@@ -217,12 +217,14 @@ function updateFee(state, action) {
const feeError = validateFee(feeSats, state.inputsTotalSats);
- // Back-calculate effective fee rate when inputs exist
+ // Back-calculate effective fee rate whenever we have a positive fee and
+ // a known input set. Recompute even when validateFee surfaced a
+ // non-fatal error (e.g. fee too high) so the rate field always reflects
+ // the typed amount instead of going stale.
let feeRate = state.feeRate;
let feeRateError = "";
if (
state.inputs.length > 0 &&
- !feeError &&
BigNumber.isBigNumber(feeSats) &&
!feeSats.isNaN() &&
feeSats.isGreaterThan(0)
@@ -235,8 +237,11 @@ function updateFee(state, action) {
n: state.totalSigners,
feesInSatoshis: feeSats,
});
- if (estimatedRate && parseFloat(estimatedRate) > 0) {
- feeRate = parseFloat(estimatedRate).toFixed(2);
+ const parsedRate = parseFloat(estimatedRate);
+ if (estimatedRate && parsedRate > 0) {
+ feeRate = String(
+ Math.round((parsedRate + Number.EPSILON) * 100) / 100,
+ );
}
}
From 86144949024664d1d6d4385dfb20a5044406be06 Mon Sep 17 00:00:00 2001
From: Legend101Zz <96632943+Legend101Zz@users.noreply.github.com>
Date: Mon, 27 Apr 2026 20:52:45 +0530
Subject: [PATCH 08/11] test(e2e): target rebalance button by stable test id
The IconButton wrapping AddCircle was replaced by a labelled Button, so
locating it via the auto-generated AddCircleIcon test id is brittle and
only matches the "Increase" variant. Tag the button with
data-testid="rebalance-button" and have the manual-coin-selection flow
look it up directly.
---
apps/coordinator/e2e/tests/03-transaction_flow.spec.ts | 2 +-
apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/coordinator/e2e/tests/03-transaction_flow.spec.ts b/apps/coordinator/e2e/tests/03-transaction_flow.spec.ts
index 27a10501f5..85a35a617c 100644
--- a/apps/coordinator/e2e/tests/03-transaction_flow.spec.ts
+++ b/apps/coordinator/e2e/tests/03-transaction_flow.spec.ts
@@ -237,7 +237,7 @@ test.describe("Transaction Creation and Signing", () => {
.getByRole("button")
.click();
- await page.getByTestId("AddCircleIcon").click();
+ await page.getByTestId("rebalance-button").click();
//Preview Tx
await page.locator('button:has-text("Preview Transaction")').click();
diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
index 0129196276..c07061746d 100644
--- a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
+++ b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
@@ -283,6 +283,7 @@ class OutputEntry extends React.Component {
{showRebalance && (