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 && ( + + + + {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 && ( - - + +