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
diff --git a/apps/coordinator/e2e/pages/SendTab.ts b/apps/coordinator/e2e/pages/SendTab.ts
index 5cb37c9c36..3b2584f7ed 100644
--- a/apps/coordinator/e2e/pages/SendTab.ts
+++ b/apps/coordinator/e2e/pages/SendTab.ts
@@ -78,12 +78,12 @@ export class SendTab {
}
async addChangeOutput() {
- await this.page.getByTestId("AddIcon").click();
+ await this.page.getByRole("button", { name: "Add output" }).click();
await this.page
.getByLabel("Set to wallet change address")
.getByRole("button")
.click();
- await this.page.getByTestId("AddCircleIcon").click();
+ await this.page.getByTestId("rebalance-button").click();
}
async previewTransaction() {
diff --git a/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx b/apps/coordinator/src/components/ScriptExplorer/OutputEntry.jsx
index 56fb1ed37c..127d0ba1b4 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";
@@ -133,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();
@@ -152,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 = () => {
@@ -210,10 +216,18 @@ 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 &&
+ (changeOutputIndex === 0 || isChangeOutput);
return (
-
-
+
+
- {this.displayBalanceAction() && (
-
-
-
-
- {this.balanceAction() === "Increase" ? (
+
+ {showRebalance && (
+
+
) : (
- )}
-
-
-
-
- )}
+ )
+ }
+ sx={{
+ textTransform: "none",
+ fontSize: "0.75rem",
+ whiteSpace: "nowrap",
+ minWidth: "auto",
+ }}
+ >
+ Rebalance
+
+
+ {this.balanceAction()} to {this.autoBalancedAmount().toFixed(8)}{" "}
+ BTC
+
+
+ )}
+
- {!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 3bcabfb828..911c146ff7 100644
--- a/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
+++ b/apps/coordinator/src/components/ScriptExplorer/OutputsForm.jsx
@@ -14,6 +14,9 @@ import {
InputAdornment,
Typography,
FormHelperText,
+ Paper,
+ Divider,
+ Chip,
} from "@mui/material";
import { Speed } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
@@ -154,19 +157,31 @@ class OutputsForm extends React.Component {
handleFeeRateChange = (event) => {
const { setFeeRate } = this.props;
let rate = event.target.value;
+ // 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
+ }
- if (
- rate === "" ||
- Number.isNaN(parseFloat(rate, 10)) ||
- parseFloat(rate, 10) < 1
- )
- rate = "0";
- setFeeRate(rate);
+ setFeeRate(rate === "" ? "0" : rate); // Reducer handles fee calculation and validation
};
handleFeeChange = (event) => {
const { setFee } = this.props;
setFee(event.target.value);
+ // That's it. Reducer handles rate back-calculation.
+ };
+
+ // Bump fee by 1 satoshi
+ handleFeeBump = () => {
+ const { fee, setFee } = this.props;
+ try {
+ const currentSats = new BigNumber(bitcoinsToSatoshis(fee || 0));
+ const bumped = currentSats.plus(1);
+ setFee(satoshisToBitcoins(bumped.toFixed(8)));
+ } catch (e) {
+ // If current fee is unparseable, ignore
+ }
};
handleFinalize = () => {
@@ -235,190 +250,466 @@ class OutputsForm extends React.Component {
const {
feeRate,
fee,
+ feeError,
finalizedOutputs,
feeRateError,
- feeError,
balanceError,
inputs,
isWallet,
autoSpend,
} = this.props;
const { feeRateFetchError } = this.state;
- const feeDisplay = inputs && inputs.length > 0 ? fee : "0.0000";
- const feeMt = 3;
- const totalMt = 7;
- const actionMt = 7;
+
+ const hasInputs = inputs.length > 0;
+ const canEditFee = !autoSpend && hasInputs;
const gridSpacing = isWallet ? 10 : 1;
+ const isManual = !autoSpend;
+
return (
<>
-
-
-
- To
-
-
-
-
-
-
-
- Amount
-
-
-
-
- {this.renderOutputs()}
-
-
-
-
-
-
-
-
-
+ {/* ── Outputs ── */}
+
+
+
- Fee Rate
+ RECIPIENT
-
-
-
-
-
-
-
-
- Sats/byte
-
- ),
- }}
- />
-
-
- Refer to mempool monitoring websites to ensure your selected fee
- rate is appropriate.
-
+
+
+
+ AMOUNT
+
+
-
-
-
- {!isWallet || (isWallet && !autoSpend) ? (
-
-
+ {this.renderOutputs()}
+
+
+
+
+ {autoSpend ? (
+ /* Auto Spend Mode */
+
+
+
+
- Estimated Fees
+ Fee rate
+
+
+
+
+
+
+
+
+
+
+ ),
+ }}
/>
-
+
+
+
+ sats/vB
+
+
+
+
+ ·{" "}
+
+ mempool.space
+
+
+
- ) : (
- ""
- )}
-
-
-
-
-
-
-
-
- {!isWallet || (isWallet && !autoSpend)
- ? "Totals"
- : "Output Total"}
+ {(feeRateFetchError || feeRateError) && (
+
+ {feeRateFetchError || feeRateError}
-
-
-
-
+ ) : (
+ /* Manual Spend Mode */
+ <>
+
+
-
-
-
-
-
-
+
+ Transaction Fee
+
+
+
+ {/* 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 && (
+
+
+ Transaction Summary
+
+
+
+ {(!isWallet || (isWallet && isManual)) && (
+
+
+ Inputs Total
+
+
+
+ {this.inputsTotal().toString()}
+
+
+ BTC
+
+
+
+ )}
+
+
+
+ Outputs + Fee
+
+
+
+ {this.outputsAndFeeTotal()}
+
+
+ BTC
+
+
+ {balanceError && (
+
+ {balanceError}
+
+ )}
+
+
+ {hasInputs && !this.hasBalanceError() && (
+
+
+
+ )}
+
+
+ )}
+
+ {/* Auto mode — simple output total inline */}
+ {autoSpend && (
+
+
+
+
+ Sending
+
+
+ {this.outputsAndFeeTotal()}
+
+
+ BTC
+
-
-
-
+ {balanceError && (
+
+ {balanceError}
+
+ )}
+
+ )}
+ {/* ── Action Buttons (Script Explorer) ── */}
{!isWallet && (
-
-
+
+
-