diff --git a/apps/coordinator/src/actions/transactionActions.js b/apps/coordinator/src/actions/transactionActions.js index 283ba7bb06..d0c88cf84e 100644 --- a/apps/coordinator/src/actions/transactionActions.js +++ b/apps/coordinator/src/actions/transactionActions.js @@ -52,6 +52,7 @@ export const SET_CHANGE_ADDRESS = "SET_CHANGE_ADDRESS"; export const SET_SIGNING_KEY = "SET_SIGNING_KEY"; export const SET_SPEND_STEP = "SET_SPEND_STEP"; export const SET_BALANCE_ERROR = "SET_BALANCE_ERROR"; +export const SET_BROADCASTING = "SET_BROADCASTING"; export const SPEND_STEP_CREATE = 0; export const SPEND_STEP_PREVIEW = 1; export const SPEND_STEP_SIGN = 2; @@ -238,6 +239,13 @@ export function setTXID(txid) { }; } +export function setBroadcasting(value) { + return { + type: SET_BROADCASTING, + value, + }; +} + export function setIsWallet() { return { type: SET_IS_WALLET, diff --git a/apps/coordinator/src/components/ScriptExplorer/Transaction.jsx b/apps/coordinator/src/components/ScriptExplorer/Transaction.jsx index a539e70ae6..243188b7ef 100644 --- a/apps/coordinator/src/components/ScriptExplorer/Transaction.jsx +++ b/apps/coordinator/src/components/ScriptExplorer/Transaction.jsx @@ -20,7 +20,7 @@ import { OpenInNew } from "@mui/icons-material"; import { updateBlockchainClient } from "../../actions/clientActions"; import Copyable from "../Copyable"; import { externalLink } from "utils/ExternalLink"; -import { setTXID } from "../../actions/transactionActions"; +import { setTXID, setBroadcasting } from "../../actions/transactionActions"; import { convertLegacyInput, convertLegacyOutput, @@ -76,12 +76,13 @@ class Transaction extends React.Component { }; handleBroadcast = async () => { - const { getBlockchainClient, setTxid } = this.props; + const { getBlockchainClient, setTxid, setBroadcastingFlag } = this.props; const client = await getBlockchainClient(); const signedTransaction = this.buildSignedTransaction(); let error = ""; let txid = ""; this.setState({ broadcasting: true }); + setBroadcastingFlag(true); try { txid = await client.broadcastTransaction(signedTransaction); } catch (e) { @@ -91,6 +92,7 @@ class Transaction extends React.Component { } finally { this.setState({ txid, error, broadcasting: false }); setTxid(txid); + setBroadcastingFlag(false); } }; @@ -154,6 +156,7 @@ Transaction.propTypes = { inputs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, outputs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, setTxid: PropTypes.func.isRequired, + setBroadcastingFlag: PropTypes.func.isRequired, signatureImporters: PropTypes.shape({}).isRequired, getBlockchainClient: PropTypes.func.isRequired, enableRBF: PropTypes.bool.isRequired, @@ -174,6 +177,7 @@ function mapStateToProps(state) { const mapDispatchToProps = { setTxid: setTXID, getBlockchainClient: updateBlockchainClient, + setBroadcastingFlag: setBroadcasting, }; export default connect(mapStateToProps, mapDispatchToProps)(Transaction); diff --git a/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/FlowDrawers.tsx b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/FlowDrawers.tsx new file mode 100644 index 0000000000..792c4113d1 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/FlowDrawers.tsx @@ -0,0 +1,356 @@ +import React from "react"; +import { + Box, + Drawer, + Typography, + IconButton, + Divider, + Chip, + Tooltip, + useTheme, +} from "@mui/material"; +import { Close, OpenInNew, ContentCopy, CallMade } from "@mui/icons-material"; +import BigNumber from "bignumber.js"; +import { + satoshisToBitcoins, + blockExplorerTransactionURL, + Network, +} from "@caravan/bitcoin"; +import DustChip from "../../ScriptExplorer/DustChip"; +import { formatAddress, getScriptTypeColor, formatScriptType } from "./utils"; + +interface Input { + txid: string; + index: number; + amountSats: string; + valueUnknown?: boolean; + multisig?: { + name?: string; + }; +} + +interface Output { + address: string; + amount: string; + scriptType?: string; + isChange: boolean; + type: string; +} + +interface FlowDrawersProps { + // Inputs drawer props + inputsDrawerOpen: boolean; + setInputsDrawerOpen: (open: boolean) => void; + inputs: Input[]; + inputCount: number; + network: string; + + // Outputs drawer props + outputsDrawerOpen: boolean; + setOutputsDrawerOpen: (open: boolean) => void; + recipientOutputs: Output[]; + + // Shared props + copiedAddress: string | null; + handleCopyAddress: (address: string) => void; +} + +const FlowDrawers: React.FC = ({ + inputsDrawerOpen, + setInputsDrawerOpen, + inputs, + inputCount, + network, + outputsDrawerOpen, + setOutputsDrawerOpen, + recipientOutputs, + copiedAddress, + handleCopyAddress, +}) => { + const theme = useTheme(); + + return ( + <> + {/* Inputs Drawer */} + setInputsDrawerOpen(false)} + PaperProps={{ sx: { width: { xs: "100vw", sm: 420 } } }} + > + + + All Inputs ({inputCount}) + + setInputsDrawerOpen(false)} + sx={{ color: "inherit" }} + > + + + + + + {inputs.map((input, idx) => { + const inputAmount = BigNumber( + satoshisToBitcoins(input.amountSats.toString()), + ); + const scriptType = input.multisig?.name?.includes("p2wsh") + ? "P2WSH" + : input.multisig?.name?.includes("p2sh") + ? "P2SH" + : input.multisig?.name + ? input.multisig.name.toUpperCase() + : null; + const showValueUnknown = input.valueUnknown; + return ( + + + + + {formatAddress(input.txid)}:{input.index} + + + + + + {scriptType && ( + + )} + + + {showValueUnknown ? ( + + Value from prev tx + + ) : ( + <> + + {inputAmount.toFixed(8)} BTC + + + + + + )} + + + ); + })} + + + + {/* Outputs Drawer (recipient outputs only) */} + setOutputsDrawerOpen(false)} + PaperProps={{ sx: { width: { xs: "100vw", sm: 420 } } }} + > + + + All Payment Outputs ({recipientOutputs.length}) + + setOutputsDrawerOpen(false)} + sx={{ color: "inherit" }} + > + + + + + + {recipientOutputs.map((output, idx) => { + const amount = BigNumber(output.amount); + return ( + + + + + Payment + + + + + {formatAddress(output.address)} + + + handleCopyAddress(output.address)} + sx={{ + padding: 0.25, + color: theme.palette.text.secondary, + "&:hover": { + color: theme.palette.primary.main, + }, + "& svg": { fontSize: "0.75rem" }, + }} + > + + + + + + + {amount.toFixed(8)} BTC + + {output.scriptType && ( + + )} + + + ); + })} + + + + ); +}; + +export default FlowDrawers; diff --git a/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/FlowSummary.tsx b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/FlowSummary.tsx new file mode 100644 index 0000000000..6e8eac71f7 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/FlowSummary.tsx @@ -0,0 +1,350 @@ +import React from "react"; +import { Box, Paper, Typography, Chip, useTheme } from "@mui/material"; +import BigNumber from "bignumber.js"; + +interface FlowSummaryProps { + recipientOutputs: Array<{ amount: string; isChange: boolean }>; + changeOutputs: Array<{ amount: string; isChange: boolean }>; + feeBtc: BigNumber; + totalInputBtc: BigNumber; +} + +const FlowSummary: React.FC = ({ + recipientOutputs, + changeOutputs, + feeBtc, + totalInputBtc, +}) => { + const theme = useTheme(); + + return ( + + {/* Legend */} + + + + + Payment Output + + + + + + Change Output + + + + + + Network Fee + + + + + {/* Dust Status Explanation */} + + + Input Dust Status: + + + + + + = Cost-effective to spend + + + + + + = Consider batching + + + + + + = Costs more to spend than value + + + + + + {/* SUMMARY Section */} + + + Transaction Summary + + + {/* Summary Cards in Grid */} + + + + Total Sending + + + {recipientOutputs + .reduce((sum, o) => sum.plus(BigNumber(o.amount)), BigNumber(0)) + .toFixed(8)}{" "} + BTC + + + + {changeOutputs.length > 0 && ( + + + Change Returning + + + {changeOutputs + .reduce( + (sum, o) => sum.plus(BigNumber(o.amount)), + BigNumber(0), + ) + .toFixed(8)}{" "} + BTC + + + )} + + + + Network Fee + + + {feeBtc.toFixed(8)} BTC + + + {(() => { + const pct = + feeBtc + .dividedBy(totalInputBtc) + .multipliedBy(100) + .toNumber() || 0; + const pctStr = pct.toFixed(2); + const approx = pct > 0 && pctStr === "0.00" ? "~" : ""; + return `${approx}${pctStr}`; + })()} + % of total + + + + + + Total Input + + + {totalInputBtc.toFixed(8)} BTC + + + + + + ); +}; + +export default FlowSummary; diff --git a/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/hooks.ts b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/hooks.ts new file mode 100644 index 0000000000..4d1b3f3e97 --- /dev/null +++ b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/hooks.ts @@ -0,0 +1,79 @@ +import { useState, useLayoutEffect, useEffect, RefObject } from "react"; +import { buildCurvePath } from "./utils"; + +/** + * Calculate SVG paths for connecting lines + */ +export const useFlowPaths = ( + inputRefs: RefObject<(HTMLDivElement | null)[]>, + recipientOutputRefs: RefObject<(HTMLDivElement | null)[]>, + changeOutputRefs: RefObject<(HTMLDivElement | null)[]>, + feeRef: RefObject, + centerRef: RefObject, + svgRef: RefObject, + inputsLength: number, + outputsLength: number, +) => { + const [inputPaths, setInputPaths] = useState([]); + const [outputPaths, setOutputPaths] = useState([]); + const [svgSize, setSvgSize] = useState({ width: 0, height: 0 }); + + const computePaths = () => { + const svgEl = svgRef.current; + const centerEl = centerRef.current; + if (!svgEl || !centerEl) return; + + const containerRect = svgEl.getBoundingClientRect(); + const centerRect = centerEl.getBoundingClientRect(); + + setSvgSize({ width: containerRect.width, height: containerRect.height }); + + const centerLeftX = centerRect.left - containerRect.left; + const centerRightX = centerRect.right - containerRect.left; + const centerY = centerRect.top - containerRect.top + centerRect.height / 2; + + // Input paths + const newInputPaths: string[] = []; + inputRefs.current?.forEach((el) => { + if (!el) return; + const r = el.getBoundingClientRect(); + const x1 = r.right - containerRect.left; + const y1 = r.top - containerRect.top + r.height / 2; + newInputPaths.push(buildCurvePath(x1, y1, centerLeftX, centerY)); + }); + + // Output paths + const newOutputPaths: string[] = []; + const allOutputs = [ + ...(recipientOutputRefs.current || []), + ...(changeOutputRefs.current || []), + feeRef.current || null, + ]; + allOutputs.forEach((el) => { + if (!el) return; + const r = el.getBoundingClientRect(); + const x2 = r.left - containerRect.left; + const y2 = r.top - containerRect.top + r.height / 2; + newOutputPaths.push(buildCurvePath(centerRightX, centerY, x2, y2)); + }); + + setInputPaths(newInputPaths); + setOutputPaths(newOutputPaths); + }; + + useLayoutEffect(() => { + computePaths(); + }, [inputsLength, outputsLength]); + + useEffect(() => { + const onResize = () => computePaths(); + window.addEventListener("resize", onResize); + const id = window.setTimeout(() => computePaths(), 0); + return () => { + window.removeEventListener("resize", onResize); + window.clearTimeout(id); + }; + }, []); + + return { inputPaths, outputPaths, svgSize }; +}; diff --git a/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/index.tsx b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/index.tsx new file mode 100644 index 0000000000..58cbca61fd --- /dev/null +++ b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/index.tsx @@ -0,0 +1,976 @@ +import React, { useMemo, useState, useRef } from "react"; +import { + Box, + Paper, + Typography, + Chip, + Tooltip, + useTheme, + Button, + IconButton, +} from "@mui/material"; +import { + ArrowForward, + CallMade, + Savings, + LocalGasStation, + ExpandMore, + OpenInNew, + ContentCopy, +} from "@mui/icons-material"; +import BigNumber from "bignumber.js"; +import { + satoshisToBitcoins, + blockExplorerTransactionURL, + Network, +} from "@caravan/bitcoin"; +import DustChip from "../../ScriptExplorer/DustChip"; +import { useFlowPaths } from "./hooks"; +import { + formatAddress, + formatScriptType, + getScriptTypeColor, + getStatusDisplay, +} from "./utils"; +import FlowDrawers from "./FlowDrawers"; +import FlowSummary from "./FlowSummary"; + +interface TransactionFlowDiagramProps { + inputs: Array<{ + txid: string; + index: number; + amountSats: string; + valueUnknown?: boolean; + multisig?: { + name?: string; + }; + }>; + outputs: Array<{ + address: string; + amount: string; + scriptType?: string; + }>; + fee: string; + changeAddress?: string; + inputsTotalSats: any; + network?: string; + status?: + | "draft" + | "partial" + | "ready" + | "broadcast-pending" + | "unconfirmed" + | "confirmed" + | "finalized" + | "rbf" + | "dropped" + | "conflicted" + | "rejected" + | "unknown"; + confirmations?: number; +} + +const TransactionFlowDiagram: React.FC = ({ + inputs, + outputs, + fee, + changeAddress, + inputsTotalSats, + network = "mainnet", + status = "draft", + confirmations, +}) => { + const theme = useTheme(); + const [inputsDrawerOpen, setInputsDrawerOpen] = useState(false); + const [outputsDrawerOpen, setOutputsDrawerOpen] = useState(false); + const [copiedAddress, setCopiedAddress] = useState(null); + + // Refs for SVG path calculations + const svgRef = useRef(null); + const centerRef = useRef(null); + const inputRefs = useRef<(HTMLDivElement | null)[]>([]); + const recipientOutputRefs = useRef<(HTMLDivElement | null)[]>([]); + const changeOutputRefs = useRef<(HTMLDivElement | null)[]>([]); + const feeRef = useRef(null); + + // Use custom hook for SVG path calculations + const { inputPaths, outputPaths, svgSize } = useFlowPaths( + inputRefs, + recipientOutputRefs, + changeOutputRefs, + feeRef, + centerRef, + svgRef, + inputs.length, + outputs.length, + ); + + const handleCopyAddress = (address: string) => { + navigator.clipboard.writeText(address); + setCopiedAddress(address); + setTimeout(() => setCopiedAddress(null), 2000); + }; + + // Calculate totals and categorize outputs + const flowData = useMemo(() => { + const totalInputSats = BigNumber(inputsTotalSats.toString()); + const totalInputBtc = BigNumber( + satoshisToBitcoins(totalInputSats.toString()), + ); + const feeBtc = BigNumber(fee); + const totalOutputBtc = outputs.reduce( + (sum, output) => sum.plus(BigNumber(output.amount)), + BigNumber(0), + ); + + // Categorize outputs + const categorizedOutputs = outputs.map((output) => { + const isChange = output.address === changeAddress; + return { + ...output, + isChange, + type: isChange ? "change" : "recipient", + }; + }); + + const recipientOutputs = categorizedOutputs.filter((o) => !o.isChange); + const changeOutputs = categorizedOutputs.filter((o) => o.isChange); + + return { + totalInputBtc, + totalInputSats, + totalOutputBtc, + feeBtc, + recipientOutputs, + changeOutputs, + inputCount: inputs.length, + outputCount: outputs.length, + }; + }, [inputs, outputs, fee, changeAddress, inputsTotalSats]); + + return ( + + + + Transaction Flow Diagram + + 1 ? "s" : ""} → ${flowData.outputCount} Output${flowData.outputCount > 1 ? "s" : ""}`} + size="small" + variant="outlined" + sx={{ + fontWeight: 500, + borderColor: theme.palette.divider, + }} + /> + + + {/* Main Flow Visualization */} + + {/* SVG for connecting lines - desktop only */} + + + + + + + {inputPaths.map((d, i) => ( + + ))} + {outputPaths.map((d, i) => ( + + ))} + + + {/* INPUTS Column */} + + + + Inputs ({flowData.inputCount}) + + + + + {(() => { + inputRefs.current = []; + return null; + })()} + + {inputs.slice(0, 4).map((input, idx) => { + const inputAmount = BigNumber( + satoshisToBitcoins(input.amountSats.toString()), + ); + const scriptType = input.multisig?.name?.includes("p2wsh") + ? "P2WSH" + : input.multisig?.name?.includes("p2sh") + ? "P2SH" + : input.multisig?.name + ? input.multisig.name.toUpperCase() + : null; + const showValueUnknown = input.valueUnknown; + + return ( + { + inputRefs.current[idx] = el; + }} + > + + + + {formatAddress(input.txid)}:{input.index} + + + + + + {scriptType && ( + + )} + + + {showValueUnknown ? ( + + + Value from prev tx + + + ) : ( + <> + + {inputAmount.toFixed(8)} BTC + + + + + + )} + + + ); + })} + + {inputs.length > 4 && ( + + )} + + + + {/* Mobile Flow Indicator */} + + + + + {/* FLOW Diagram - Center */} + + {/* Transaction Node */} + + {(() => { + const sd = getStatusDisplay(status, confirmations, theme); + return ( + <> + + STATUS + + + {sd.label} + + + ); + })()} + + + + {/* OUTPUTS Column */} + + + + Outputs ({flowData.outputCount}) + + + + {/* Recipient Outputs (cap 4 inline) */} + {(() => { + recipientOutputRefs.current = []; + return null; + })()} + {flowData.recipientOutputs.slice(0, 4).map((output, idx) => { + const amount = BigNumber(output.amount); + return ( + + { + recipientOutputRefs.current[idx] = el; + }} + > + + + + Payment + + + + + {formatAddress(output.address)} + + + handleCopyAddress(output.address)} + sx={{ + padding: 0.25, + color: theme.palette.text.secondary, + "&:hover": { + color: theme.palette.primary.main, + }, + "& svg": { fontSize: "0.75rem" }, + }} + > + + + + + + + {amount.toFixed(8)} BTC + + {output.scriptType && ( + + )} + + + + ); + })} + + {flowData.recipientOutputs.length > 4 && ( + + )} + + {/* Change Outputs */} + {flowData.changeOutputs.map((output, idx) => { + const amount = BigNumber(output.amount); + return ( + + { + changeOutputRefs.current[idx] = el; + }} + > + + + + Change + + + + + {formatAddress(output.address)} + + + handleCopyAddress(output.address)} + sx={{ + padding: 0.25, + color: theme.palette.text.secondary, + "&:hover": { + color: theme.palette.primary.main, + }, + "& svg": { fontSize: "0.75rem" }, + }} + > + + + + + + + {amount.toFixed(8)} BTC + + {output.scriptType && ( + + )} + + + + ); + })} + + {/* Fee "Output" */} + + + + + Network Fee + + + + To miners + + + {flowData.feeBtc.toFixed(8)} BTC + + + + + + {/* Drawers */} + + + {/* Summary */} + + + ); +}; + +export default TransactionFlowDiagram; diff --git a/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/utils.ts b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/utils.ts new file mode 100644 index 0000000000..7a4030774b --- /dev/null +++ b/apps/coordinator/src/components/Wallet/TransactionFlowDiagram/utils.ts @@ -0,0 +1,104 @@ +/** + * Build smooth cubic-bezier path from (x1,y1) to (x2,y2) + */ +export const buildCurvePath = ( + x1: number, + y1: number, + x2: number, + y2: number, +) => { + const dx = Math.abs(x2 - x1); + const control = Math.max(dx * 0.25, 40); + const c1x = x1 + (x2 > x1 ? control : -control); + const c2x = x2 - (x2 > x1 ? control : -control); + return `M ${x1} ${y1} C ${c1x} ${y1}, ${c2x} ${y2}, ${x2} ${y2}`; +}; + +/** + * Format address for display (truncate middle) + */ +export const formatAddress = (address: string) => { + if (address.length <= 20) return address; + return `${address.slice(0, 10)}...${address.slice(-8)}`; +}; + +/** + * Format script type for display + */ +export const formatScriptType = (scriptType?: string) => { + if (!scriptType) return "Unknown"; + return scriptType.toUpperCase().replace("_", "-"); +}; + +/** + * Get script type color based on type + */ +export const getScriptTypeColor = ( + scriptType?: string, + theme?: any, +): string => { + if (!theme) return "#9e9e9e"; // fallback grey + + switch (scriptType?.toLowerCase()) { + case "p2wsh": + return theme.palette.success.main; + case "p2sh-p2wsh": + case "p2sh_p2wsh": + return theme.palette.info.main; + case "p2sh": + return theme.palette.warning.main; + case "p2wpkh": + return theme.palette.success.light; + case "p2pkh": + return theme.palette.warning.light; + default: + return theme.palette.grey[500]; + } +}; + +/** + * Get status display info (label and color) + */ +export const getStatusDisplay = ( + status?: string, + confirmations?: number, + theme?: any, +) => { + if (!theme) return { label: "Unknown", color: "#9e9e9e" }; // fallback + + switch (status) { + case "draft": + return { label: "Draft", color: theme.palette.grey[500] }; + case "partial": + return { label: "Partially Signed", color: theme.palette.info.main }; + case "ready": + return { + label: "Ready to Broadcast", + color: theme.palette.primary.main, + }; + case "broadcast-pending": + return { label: "Broadcast Pending", color: theme.palette.info.light }; + case "unconfirmed": + return { label: "Unconfirmed", color: theme.palette.warning.main }; + case "confirmed": + return { + label: `Confirmed${confirmations ? ` (${confirmations})` : ""}`, + color: theme.palette.success.main, + }; + case "finalized": + return { label: "Finalized", color: theme.palette.success.dark }; + case "rbf": + return { + label: "Replaced by Fee", + color: theme.palette.secondary.main, + }; + case "dropped": + return { label: "Dropped", color: theme.palette.grey[400] }; + case "conflicted": + return { label: "Conflicted", color: theme.palette.error.main }; + case "rejected": + return { label: "Rejected", color: theme.palette.error.dark }; + default: + return { label: "Unknown", color: theme.palette.grey[500] }; + } +}; diff --git a/apps/coordinator/src/components/Wallet/TransactionPreview.jsx b/apps/coordinator/src/components/Wallet/TransactionPreview.jsx index 8062211f9b..7337e12ad8 100644 --- a/apps/coordinator/src/components/Wallet/TransactionPreview.jsx +++ b/apps/coordinator/src/components/Wallet/TransactionPreview.jsx @@ -1,8 +1,6 @@ import React, { useMemo } from "react"; import PropTypes from "prop-types"; import { connect, useSelector } from "react-redux"; -import BigNumber from "bignumber.js"; -import { satoshisToBitcoins } from "@caravan/bitcoin"; import { Button, Box, @@ -12,29 +10,22 @@ import { Chip, Typography, Paper, - Tooltip, - Table, - TableBody, - TableCell, - TableHead, - TableRow, } from "@mui/material"; import { CheckCircle as CheckCircleIcon, Warning as WarningIcon, Edit as EditIcon, - WarningAmber, } from "@mui/icons-material"; -import UTXOSet from "../ScriptExplorer/UTXOSet"; import { downloadFile } from "../../utils"; import UnsignedTransaction from "../UnsignedTransaction"; import { finalizeOutputs as finalizeOutputsAction, setChangeOutputMultisig as setChangeOutputMultisigAction, + SPEND_STEP_PREVIEW, } from "../../actions/transactionActions"; import FingerprintingAnalysis from "../FingerprintingAnalysis"; import { TransactionAnalysis } from "./TransactionAnalysis"; -import { walletFingerprintAnalysis } from "../../utils/privacyUtils"; +import TransactionFlowDiagram from "./TransactionFlowDiagram"; /** * Custom hook to get current signing state @@ -193,9 +184,28 @@ class TransactionPreview extends React.Component { downloadFile(psbtData, "transaction.psbt"); } + getTransactionStatus() { + const { signatureImporters, requiredSigners, broadcasting, txid } = + this.props; + + if (broadcasting) return "broadcast-pending"; + if (txid && txid.length > 0) return "unconfirmed"; + + const rs = requiredSigners || 0; + const signedCount = signatureImporters + ? Object.values(signatureImporters).filter( + (importer) => importer?.finalized && importer?.signature?.length > 0, + ).length + : 0; + + const isFullySigned = signedCount >= rs && rs > 0; + const hasPartial = signedCount > 0 && signedCount < rs; + + return isFullySigned ? "ready" : hasPartial ? "partial" : "draft"; + } + render() { const { - feeRate, fee, inputsTotalSats, editTransaction, @@ -203,24 +213,9 @@ class TransactionPreview extends React.Component { unsignedPSBT, inputs, outputs, + spendingStep, } = this.props; - // Get wallet script type for fingerprint analysis - const walletScriptType = this.props.addressType || ""; - const outputsForAnalysis = (outputs || []).map((o) => ({ - scriptType: o.scriptType, - amount: o.amount, // BTC as string/number - address: o.address, - })); - const fingerprint = walletFingerprintAnalysis( - outputsForAnalysis, - walletScriptType, - ); - const fingerprintMsg = - fingerprint.reason || - "This output matches your wallet's address type and is likely to be identified as change by on-chain observers."; - const tooltipSx = { verticalAlign: "middle" }; - return ( @@ -230,81 +225,20 @@ class TransactionPreview extends React.Component { {/* Signature Status Section */} - - -

Inputs

- -

Outputs

- - - - - Address - Amount (BTC) - Script Type - - - - {outputs && - outputs.map((output, idx) => { - const isPoisoned = - fingerprint.hasWalletFingerprinting && - fingerprint.poisonedOutputIndex === idx; - return ( - - - {output.address} - {isPoisoned && ( - - - - )} - - - {output.amount} - - - - - - ); - })} - -
+ {/* Transaction Flow Diagram - Comprehensive View */} + + - - -

Fee

-
{BigNumber(fee).toFixed(8)} BTC
-
- -

Fee Rate

-
{feeRate} sats/byte
-
- -

Total

-
{satoshisToBitcoins(BigNumber(inputsTotalSats || 0))} BTC
-
-
+ @@ -322,15 +256,17 @@ class TransactionPreview extends React.Component { Edit Transaction - - - + {spendingStep === SPEND_STEP_PREVIEW && ( + + + + )} {unsignedPSBT && ( - - - - ) : ( - - - - ))} - - {network && ( - + <> + *": { borderBottom: isExpanded ? "unset" : undefined }, + backgroundColor: isExpanded + ? theme.palette.action.selected + : "inherit", + transition: "background-color 0.2s ease", + }} + onClick={expandable ? onToggleExpand : undefined} + > + {/* Expand/Collapse Icon */} + {expandable && ( + { e.stopPropagation(); - // Let parent handle block explorer navigation - onClickTransaction?.(tx.txid); + onToggleExpand(); }} > - + {isExpanded ? : } - + )} - {/* Render custom actions if provided */} - {renderActions && renderActions(tx)} - - + + + + { + e.stopPropagation(); // Prevent row click from firing + navigator.clipboard + .writeText(tx.txid) + .then(() => { + onCopySuccess(); + }) + .catch((err) => { + console.error("Could not copy text: ", err); + }); + }} + style={{ cursor: "pointer" }} + /> + + {tx.isSpent && ( + + + + + + + )} + + + {formatRelativeTime(tx.status.blockTime)} + {tx.vsize || tx.size} + + + + + + + + + + {/* Accelerate button for pending transactions */} + {canAccelerate && + onAccelerateTransaction && + (!tx.fee ? ( + + e.stopPropagation()}> + + + + + + ) : ( + e.stopPropagation()}> + + + ))} + e.stopPropagation()}> + {network && ( + + { + e.stopPropagation(); + // Let parent handle block explorer navigation + onClickTransaction?.(tx.txid); + }} + > + + + + )} + {/* Render custom actions if provided */} + {renderActions && renderActions(tx)} + + + + {/* Expanded row with Flow Diagram */} + {expandable && ( + + + + + {isLoadingDetails && !flowDiagramProps ? ( + + + + Loading transaction details... + + + ) : flowDiagramProps ? ( + + + + ) : ( + + Unable to load transaction details + + )} + + + + + )} + ); }; export const TransactionTable: React.FC = ({ transactions, + rawTransactions, onSort, sortBy, sortDirection, network, + walletAddresses = [], onClickTransaction, onAccelerateTransaction, renderActions, showAcceleration = false, // Default to false for backward compatibility + expandable = false, // Default to false for backward compatibility }) => { const [snackbarOpen, setSnackbarOpen] = useState(false); + const [expandedTxid, setExpandedTxid] = useState(null); + + // Get dynamic columns based on showAcceleration and expandable + const columns = getColumns(showAcceleration, expandable); + + // Create a map of txid to raw transaction for quick lookup + const rawTxMap = React.useMemo(() => { + const map = new Map(); + if (rawTransactions) { + rawTransactions.forEach((tx) => { + map.set(tx.txid, tx); + }); + } + return map; + }, [rawTransactions]); - // Get dynamic columns based on showAcceleration - const columns = getColumns(showAcceleration); + const handleToggleExpand = (txid: string) => { + setExpandedTxid(expandedTxid === txid ? null : txid); + }; return ( <> @@ -422,12 +596,18 @@ export const TransactionTable: React.FC = ({ handleToggleExpand(tx.txid)} network={network} + walletAddresses={walletAddresses} onClickTransaction={onClickTransaction} onAccelerateTransaction={onAccelerateTransaction} onCopySuccess={() => setSnackbarOpen(true)} renderActions={renderActions} + colSpan={columns.length} /> )) )} diff --git a/apps/coordinator/src/components/Wallet/TransactionsTab/types.ts b/apps/coordinator/src/components/Wallet/TransactionsTab/types.ts index 848674b1b1..8908ee37cd 100644 --- a/apps/coordinator/src/components/Wallet/TransactionsTab/types.ts +++ b/apps/coordinator/src/components/Wallet/TransactionsTab/types.ts @@ -25,14 +25,17 @@ export interface Transaction { export interface TransactionTableProps { transactions: Transaction[]; + rawTransactions?: Transaction[]; // Raw transactions with vin/vout for flow diagram onSort: (property: SortBy) => void; // Changed from string to SortBy to match the hook sortBy: SortBy; // Changed from string to SortBy for consistency sortDirection: SortDirection; // Use the proper type instead of "asc" | "desc" network?: string; + walletAddresses?: string[]; // For identifying change outputs in flow diagram onClickTransaction?: (txid: string) => void; onAccelerateTransaction?: (tx: TransactionT) => void; renderActions?: (tx: TransactionT) => React.ReactNode; showAcceleration?: boolean; // Add this prop to control acceleration button visibility + expandable?: boolean; // Enable expandable rows with flow diagram } // How our Transaction Table's should look like diff --git a/apps/coordinator/src/components/Wallet/WalletSpend.jsx b/apps/coordinator/src/components/Wallet/WalletSpend.jsx index ab7c1cdf66..d100e31187 100644 --- a/apps/coordinator/src/components/Wallet/WalletSpend.jsx +++ b/apps/coordinator/src/components/Wallet/WalletSpend.jsx @@ -205,6 +205,20 @@ class WalletSpend extends React.Component { {spendingStep === SPEND_STEP_SIGN && ( + {/* Keep TransactionPreview visible during signing */} + + {}} + /> + diff --git a/apps/coordinator/src/hooks/useTransactionDetails.ts b/apps/coordinator/src/hooks/useTransactionDetails.ts new file mode 100644 index 0000000000..344222564c --- /dev/null +++ b/apps/coordinator/src/hooks/useTransactionDetails.ts @@ -0,0 +1,100 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useGetClient } from "hooks/client"; +import { useCallback } from "react"; + +const TRANSACTION_DETAILS_KEY = "transaction-details"; + +/** + * Hook to fetch full transaction details including prevout data. + * This is used for the Transaction Flow Diagram where we need input values. + * + * For public clients (mempool.space), we fetch the raw transaction which includes + * prevout data with input values and addresses. + * + * This is fetched on-demand (when user expands a transaction row) to avoid + * the overhead of fetching prevout data for all transactions in the list. + */ +export const useTransactionDetails = ( + txid: string | null, + enabled: boolean, +) => { + const blockchainClient = useGetClient(); + + return useQuery( + [TRANSACTION_DETAILS_KEY, txid], + async () => { + if (!txid || !blockchainClient) return null; + + // For public clients, fetch directly from the API to get raw data with prevout + if (blockchainClient.type === "public") { + try { + // Fetch raw transaction from mempool.space which includes prevout + const response = await blockchainClient.Get(`/tx/${txid}`); + return response; + } catch (error) { + console.error("Failed to fetch transaction details:", error); + return null; + } + } + + // For private clients, use the standard getTransaction + // (prevout data won't be available, but we handle that gracefully) + try { + const tx = await blockchainClient.getTransaction(txid); + return tx; + } catch (error) { + console.error("Failed to fetch transaction details:", error); + return null; + } + }, + { + enabled: enabled && !!txid && !!blockchainClient, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes - transaction details don't change + cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes + }, + ); +}; + +/** + * Hook to prefetch transaction details on hover. + * This makes the expand feel instant since data is already cached. + */ +export const usePrefetchTransactionDetails = () => { + const queryClient = useQueryClient(); + const blockchainClient = useGetClient(); + + const prefetch = useCallback( + (txid: string) => { + if (!txid || !blockchainClient) return; + + // Only prefetch if not already in cache + const cached = queryClient.getQueryData([TRANSACTION_DETAILS_KEY, txid]); + if (cached) return; + + queryClient.prefetchQuery( + [TRANSACTION_DETAILS_KEY, txid], + async () => { + if (blockchainClient.type === "public") { + try { + return await blockchainClient.Get(`/tx/${txid}`); + } catch { + return null; + } + } + try { + return await blockchainClient.getTransaction(txid); + } catch { + return null; + } + }, + { + staleTime: 5 * 60 * 1000, + cacheTime: 10 * 60 * 1000, + }, + ); + }, + [blockchainClient, queryClient], + ); + + return prefetch; +}; diff --git a/apps/coordinator/src/reducers/transactionReducer.js b/apps/coordinator/src/reducers/transactionReducer.js index cda46aa0e5..01f9f63c37 100644 --- a/apps/coordinator/src/reducers/transactionReducer.js +++ b/apps/coordinator/src/reducers/transactionReducer.js @@ -48,6 +48,7 @@ import { SET_SPEND_STEP, SPEND_STEP_CREATE, SET_ENABLE_RBF, + SET_BROADCASTING, } from "../actions/transactionActions"; import { RESET_NODES_SPEND } from "../actions/walletActions"; import { Transaction } from "bitcoinjs-lib"; @@ -106,6 +107,7 @@ export const initialState = () => ({ signingKeys: [0, 0], // default 2 required signers spendingStep: SPEND_STEP_CREATE, unsignedPSBT: "", + broadcasting: false, transactions: { transactions: [], isLoading: false, // Array to store actual transactions @@ -455,6 +457,8 @@ export default (state = initialState(), action) => { return updateState(state); case SET_TXID: return updateState(state, { txid: action.value }); + case SET_BROADCASTING: + return updateState(state, { broadcasting: action.value }); case SET_IS_WALLET: return updateState(state, { isWallet: true }); case RESET_TRANSACTION: diff --git a/apps/coordinator/src/utils/transactionFlowUtils.ts b/apps/coordinator/src/utils/transactionFlowUtils.ts new file mode 100644 index 0000000000..05089e35bb --- /dev/null +++ b/apps/coordinator/src/utils/transactionFlowUtils.ts @@ -0,0 +1,266 @@ +import { satoshisToBitcoins, bitcoinsToSatoshis } from "@caravan/bitcoin"; +import BigNumber from "bignumber.js"; + +/** + * Transform historical transaction data to TransactionFlowDiagram props + * + * This handles both public client transactions (with prevout data) and + * private client transactions, providing the best data available. + * + * NOTE: For historical transactions, input values may not be available because + * the `prevout` data is stripped during client normalization. In this case, + * we calculate the total input value from outputs + fee, but individual input + * values will be marked as unknown. + */ + +export interface FlowDiagramInput { + txid: string; + index: number; + amountSats: string; + valueUnknown?: boolean; // Flag to indicate input value couldn't be determined + multisig?: { + name?: string; + }; +} + +export interface FlowDiagramOutput { + address: string; + amount: string; // in BTC + scriptType?: string; +} + +export interface FlowDiagramProps { + inputs: FlowDiagramInput[]; + outputs: FlowDiagramOutput[]; + fee: string; // in BTC + changeAddress?: string; + inputsTotalSats: BigNumber; + status: + | "draft" + | "partial" + | "ready" + | "broadcast-pending" + | "unconfirmed" + | "confirmed" + | "finalized" + | "rbf" + | "dropped" + | "conflicted" + | "rejected" + | "unknown"; + confirmations?: number; +} + +/** + * Detect script type from address prefix (heuristic) + */ +const detectScriptTypeFromAddress = (address?: string): string | undefined => { + if (!address) return undefined; + + // P2WPKH/P2WSH (native segwit) - bc1q or tb1q/bc1p/tb1p + if (address.startsWith("bc1q") || address.startsWith("tb1q")) { + // Could be P2WPKH (20 byte hash) or P2WSH (32 byte hash) + // Bech32 addresses with 42 chars are typically P2WPKH, 62 chars are P2WSH + return address.length === 42 ? "P2WPKH" : "P2WSH"; + } + + // P2TR (taproot) - bc1p or tb1p + if (address.startsWith("bc1p") || address.startsWith("tb1p")) { + return "P2TR"; + } + + // P2SH (could be P2SH-P2WPKH or P2SH-P2WSH) - starts with 3 (mainnet) or 2 (testnet) + if (address.startsWith("3") || address.startsWith("2")) { + return "P2SH"; + } + + // P2PKH (legacy) - starts with 1 (mainnet) or m/n (testnet) + if ( + address.startsWith("1") || + address.startsWith("m") || + address.startsWith("n") + ) { + return "P2PKH"; + } + + return undefined; +}; + +/** + * Transform a raw transaction from the blockchain client into + * props suitable for the TransactionFlowDiagram component. + * + * @param tx - Raw transaction from blockchain client + * @param walletAddresses - Array of wallet addresses to identify change outputs + * @returns FlowDiagramProps ready for the component + */ +export const transformTransactionToFlowDiagram = ( + tx: any, + walletAddresses: string[] = [], +): FlowDiagramProps => { + // First, transform outputs to calculate total output value + // Handle both normalized format (BTC string) and raw mempool format (satoshis number) + const outputs: FlowDiagramOutput[] = (tx.vout || []).map((output: any) => { + let amountBtc: string; + + if (typeof output.value === "string") { + // Already in BTC format (normalized) + amountBtc = output.value; + } else if (typeof output.value === "number") { + // Could be satoshis (raw mempool) or BTC (normalized) + // If > 21M, definitely satoshis; if < 21, definitely BTC + // Between 21 and 21M is ambiguous but rare - assume satoshis for raw data + if (output.value > 21) { + amountBtc = satoshisToBitcoins(output.value.toString()); + } else { + amountBtc = output.value.toFixed(8); + } + } else { + amountBtc = "0"; + } + + const address = + output.scriptPubkeyAddress || + output.scriptpubkey_address || + output.address; + const scriptType = detectScriptTypeFromAddress(address); + + return { + address: address || "Unknown", + amount: amountBtc, + scriptType, + }; + }); + + // Calculate total outputs in satoshis + const totalOutputsSats = outputs.reduce( + (sum, output) => sum.plus(BigNumber(bitcoinsToSatoshis(output.amount))), + BigNumber(0), + ); + + // Get fee in satoshis - tx.fee might already be in sats or could be in BTC + let feeSats = BigNumber(0); + if (tx.fee !== null && tx.fee !== undefined) { + // If fee > 1, it's likely already in satoshis; otherwise it might be BTC + if (tx.fee > 1) { + feeSats = BigNumber(tx.fee); + } else { + feeSats = BigNumber(bitcoinsToSatoshis(tx.fee.toString())); + } + } + + // Calculate total input value = total outputs + fee + const calculatedInputsTotalSats = totalOutputsSats.plus(feeSats); + + // Check if we have prevout data for inputs (raw mempool.space data includes this) + const hasPrevoutData = (tx.vin || []).some( + (input: any) => input.prevout?.value !== undefined, + ); + + // Transform inputs + const inputs: FlowDiagramInput[] = (tx.vin || []).map( + (input: any, idx: number) => { + let amountSats = "0"; + let valueUnknown = true; + + // Try to get amount from prevout if available + // Raw mempool.space data has prevout.value in satoshis + if (input.prevout?.value !== undefined) { + amountSats = input.prevout.value.toString(); + valueUnknown = false; + } else if (!hasPrevoutData && (tx.vin || []).length === 1) { + // If single input and no prevout data, we know the total + amountSats = calculatedInputsTotalSats.toString(); + valueUnknown = false; + } + + // Detect script type from prevout address if available + // Raw mempool uses snake_case, normalized might use camelCase + const prevoutAddress = + input.prevout?.scriptpubkey_address || + input.prevout?.scriptPubkeyAddress; + const scriptType = detectScriptTypeFromAddress(prevoutAddress); + + return { + txid: input.txid || `unknown-${idx}`, + index: input.vout ?? idx, + amountSats, + valueUnknown, + multisig: scriptType + ? { + name: scriptType.toLowerCase(), + } + : undefined, + }; + }, + ); + + // Use calculated total for inputs (since individual values may be unknown) + const inputsTotalSats = hasPrevoutData + ? inputs.reduce( + (sum, input) => sum.plus(BigNumber(input.amountSats)), + BigNumber(0), + ) + : calculatedInputsTotalSats; + + // Fee in BTC + const feeBtc = satoshisToBitcoins(feeSats.toString()); + + // Determine change address - any output that goes to a wallet address + // and is not the only output (heuristic) + let changeAddress: string | undefined; + if (outputs.length > 1) { + const walletOutput = outputs.find( + (o) => o.address && walletAddresses.includes(o.address), + ); + if (walletOutput) { + changeAddress = walletOutput.address; + } + } + + // Determine status based on confirmation state + let status: FlowDiagramProps["status"] = "unknown"; + if (tx.status) { + if (tx.status.confirmed) { + // If we have block height info, we could calculate confirmations + // For now, just mark as confirmed + status = "confirmed"; + } else { + status = "unconfirmed"; + } + } + + // Calculate confirmations if we have block info + let confirmations: number | undefined; + if (tx.status?.confirmed && tx.status?.blockHeight) { + // We'd need current block height to calculate this + // For now, leave it undefined - can be enhanced later + confirmations = undefined; + } + + return { + inputs, + outputs, + fee: feeBtc, + changeAddress, + inputsTotalSats, + status, + confirmations, + }; +}; + +/** + * Format satoshis to BTC with proper precision + */ +export const formatSats = (sats: number | string | BigNumber): string => { + const btc = satoshisToBitcoins(sats.toString()); + return `${btc} BTC`; +}; + +/** + * Format address for display (truncate middle) + */ +export const formatAddress = (address: string): string => { + if (address.length <= 20) return address; + return `${address.slice(0, 10)}...${address.slice(-8)}`; +};