From ef4ab05e7a20818422ed278085fb9e6400b35de9 Mon Sep 17 00:00:00 2001 From: Justin Moore Date: Wed, 28 Jul 2021 12:24:53 -0700 Subject: [PATCH] fix(wallet): adds utxo selection to manual spend on wallet interface (#223) * fix(wallet): adds utxo selection to manual spend on wallet interface * fix(wallet): pr review feedback * fix(wallet): clears everything on edit transaction * fix(wallet): fixes single utxo selection bug Co-authored-by: Walden Raines --- package-lock.json | 8 +- package.json | 2 +- src/components/ScriptExplorer/UTXOSet.jsx | 142 ++++++++++++++++++---- src/components/Wallet/AddressExpander.jsx | 13 +- src/components/Wallet/Node.jsx | 60 ++++++++- src/components/Wallet/NodeSet.jsx | 8 +- src/components/Wallet/WalletSpend.jsx | 12 +- 7 files changed, 202 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 97f1b3bd..fad5c03f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23438,15 +23438,15 @@ } }, "unchained-wallets": { - "version": "0.1.20", - "resolved": "https://registry.npmjs.org/unchained-wallets/-/unchained-wallets-0.1.20.tgz", - "integrity": "sha512-po2MgzA1quBaLzMv45q2c/sfShM0JKLaGPOpRzEQnVthyW5Skv5xrSvyHSw0yD7ali+WNg3C0dolQsTpgy3bUA==", + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/unchained-wallets/-/unchained-wallets-0.1.21.tgz", + "integrity": "sha512-psRt5ATqu8ztguXZwdri5ub8rfdy4u+0bv9VdYdQNLPUWyvkaWp1g1XWR+iewNmEiLMVxcq8KsZLf67JwxZ6yg==", "requires": { "@babel/polyfill": "^7.7.0", "@ledgerhq/hw-app-btc": "^5.34.1", "@ledgerhq/hw-transport-node-hid": "^5.34.0", "@ledgerhq/hw-transport-u2f": "^5.34.0", - "@ledgerhq/hw-transport-webusb": "^5.51.3", + "@ledgerhq/hw-transport-webusb": "5.53.0", "bignumber.js": "^8.1.1", "bitcoinjs-lib": "^4.0.5", "bowser": "^2.6.1", diff --git a/package.json b/package.json index 45650634..72c2885c 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,6 @@ "redux-thunk": "^2.3.0", "reselect": "^4.0.0", "unchained-bitcoin": "^0.1.6", - "unchained-wallets": "^0.1.20" + "unchained-wallets": "^0.1.21" } } diff --git a/src/components/ScriptExplorer/UTXOSet.jsx b/src/components/ScriptExplorer/UTXOSet.jsx index b0d3ca54..6b6e59cc 100644 --- a/src/components/ScriptExplorer/UTXOSet.jsx +++ b/src/components/ScriptExplorer/UTXOSet.jsx @@ -32,42 +32,94 @@ class UTXOSet extends React.Component { super(props); this.state = { inputsSatsSelected: props.inputsTotalSats, - inputs: props.inputs.map((input) => { + localInputs: props.inputs.map((input) => { return { ...input, - checked: true, + checked: props.selectAll, }; }), toggleAll: true, }; } + componentDidUpdate(prevProps) { + // This function exists because we need to respond to the parent node having + // its select/spend checkbox clicked (toggling select-all or select-none). + // None of this needs to happen on the redeem script interface. + const { multisig } = this.props; + if (multisig) { + const { node, existingTransactionInputs } = this.props; + const { localInputs } = this.state; + const prevMyInputsBeingSpent = this.filterInputs( + localInputs, + prevProps.existingTransactionInputs, + true + ).length; + const myInputsBeingSpent = this.filterInputs( + localInputs, + existingTransactionInputs, + true + ).length; + + const isFullSpend = myInputsBeingSpent === localInputs.length; + + // If the spend bool on the node changes, toggleAll the checks. + // but that's not quite enough because if a single UTXO is selected + // then it is also marked from not spend -> spend ... so don't want + // to toggleAll in that case. Furthermore, if you have 5 UTXOs and + // 2 selected and *then* click select all ... we also need to toggelAll. + if ( + (prevProps.node.spend !== node.spend || + myInputsBeingSpent !== prevMyInputsBeingSpent) && + isFullSpend + ) { + this.toggleAll(node.spend); + } + } + } + + filterInputs = (localInputs, transactionStoreInputs, filterToMyInputs) => { + return localInputs.filter((input) => { + const included = transactionStoreInputs.filter((utxo) => { + return utxo.txid === input.txid && utxo.index === input.index; + }); + return filterToMyInputs ? included.length > 0 : included.length === 0; + }); + }; + toggleInput = (inputIndex) => { - const { inputs } = this.state; + const { localInputs } = this.state; this.setState({ toggleAll: false }); - inputs[inputIndex].checked = !inputs[inputIndex].checked; + localInputs[inputIndex].checked = !localInputs[inputIndex].checked; - this.setInputsAndUpdateDisplay(inputs); + this.setInputsAndUpdateDisplay(localInputs); }; - toggleAll = () => { - const { inputs, toggleAll } = this.state; + toggleAll = (setTo = null) => { + const { localInputs, toggleAll } = this.state; const toggled = !toggleAll; - inputs.forEach((input) => { + localInputs.forEach((input) => { const i = input; - i.checked = toggled; + i.checked = setTo === null ? toggled : setTo; return i; }); - this.setInputsAndUpdateDisplay(inputs); + this.setInputsAndUpdateDisplay(localInputs); this.setState({ toggleAll: toggled }); }; - setInputsAndUpdateDisplay = (inputs) => { - const { setInputs, multisig, bip32Path } = this.props; - let inputsToSpend = inputs.filter((input) => input.checked); + setInputsAndUpdateDisplay = (incomingInputs) => { + const { + setInputs, + multisig, + bip32Path, + existingTransactionInputs, + setSpendCheckbox, + } = this.props; + const { localInputs } = this.state; + let inputsToSpend = incomingInputs.filter((input) => input.checked); if (multisig) { inputsToSpend = inputsToSpend.map((utxo) => { return { ...utxo, multisig, bip32Path }; @@ -80,15 +132,49 @@ class UTXOSet extends React.Component { this.setState({ inputsSatsSelected: satsSelected, }); - if (inputsToSpend.length > 0) { - setInputs(inputsToSpend); + let totalInputsToSpend = inputsToSpend; + + // The following is only relevant on the wallet interface + if (multisig) { + // There are 3 total sets of inputs to care about: + // 1. localInputs - all inputs from this node/address + // 2. inputsToSpend - equal to or subset of those from #1 (inputs marked checked==true) + // 3. existingTransactionInputs - all inputs from all nodes/addresses + + // Check if #3 contains any inputs not associated with this component + const notMyInputs = this.filterInputs( + existingTransactionInputs, + localInputs, + false + ); + + if (notMyInputs.length > 0) { + totalInputsToSpend = inputsToSpend.concat(notMyInputs); + } + + // Now we push a change up to the top level node so it can update its checkbox + const numLocalInputsToSpend = inputsToSpend.length; + if (numLocalInputsToSpend === 0) { + setSpendCheckbox(false); + } else if (numLocalInputsToSpend < localInputs.length) { + setSpendCheckbox("indeterminate"); + } else { + setSpendCheckbox(true); + } + } + + if (totalInputsToSpend.length > 0) { + setInputs(totalInputsToSpend); + } else if (multisig) { + // If we do this on redeem script interface, the panel will disappear + setInputs([]); } }; renderInputs = () => { const { network, showSelection, finalizedOutputs } = this.props; - const { inputs } = this.state; - return inputs.map((input, inputIndex) => { + const { localInputs } = this.state; + return localInputs.map((input, inputIndex) => { const confirmedStyle = `${styles.utxoTxid}${ input.confirmed ? "" : ` ${styles.unconfirmed}` }`; @@ -131,22 +217,22 @@ class UTXOSet extends React.Component { render() { const { - inputs, inputsTotalSats, showSelection = true, + hideSelectAllInHeader, finalizedOutputs, } = this.props; - const { inputsSatsSelected, toggleAll } = this.state; + const { inputsSatsSelected, toggleAll, localInputs } = this.state; return ( <> - {`Available Inputs (${inputs.length})`}{" "} + {`Available Inputs (${localInputs.length})`}{" "}

The following UTXOs will be spent as inputs in a new transaction.

- {showSelection && ( + {showSelection && !hideSelectAllInHeader && ( )} + {hideSelectAllInHeader && } Number TXID Index @@ -189,19 +276,32 @@ UTXOSet.propTypes = { multisig: PropTypes.oneOfType([PropTypes.shape({}), PropTypes.bool]), bip32Path: PropTypes.string, showSelection: PropTypes.bool, + hideSelectAllInHeader: PropTypes.bool, + selectAll: PropTypes.bool, finalizedOutputs: PropTypes.bool.isRequired, + node: PropTypes.shape({ + spend: PropTypes.bool, + }), + existingTransactionInputs: PropTypes.arrayOf(PropTypes.shape({})), + setSpendCheckbox: PropTypes.func, }; UTXOSet.defaultProps = { multisig: false, bip32Path: "", showSelection: true, + hideSelectAllInHeader: false, + selectAll: true, + node: {}, + existingTransactionInputs: [], + setSpendCheckbox: () => {}, }; function mapStateToProps(state) { return { ...state.settings, finalizedOutputs: state.spend.transaction.finalizedOutputs, + existingTransactionInputs: state.spend.transaction.inputs, }; } diff --git a/src/components/Wallet/AddressExpander.jsx b/src/components/Wallet/AddressExpander.jsx index b2e4cf92..757be94e 100644 --- a/src/components/Wallet/AddressExpander.jsx +++ b/src/components/Wallet/AddressExpander.jsx @@ -217,8 +217,8 @@ class AddressExpander extends React.Component { }; expandContent = () => { - const { client, node } = this.props; - const { utxos, balanceSats, multisig, bip32Path } = node; + const { client, node, setSpendCheckbox } = this.props; + const { utxos, balanceSats, multisig, bip32Path, spend } = node; const { expandMode } = this.state; if (client.type === "public" && expandMode === MODE_WATCH) @@ -235,7 +235,10 @@ class AddressExpander extends React.Component { inputsTotalSats={balanceSats} multisig={multisig} bip32Path={bip32Path} - showSelection={false} // need a little more polish before we enable this + hideSelectAllInHeader + selectAll={spend} + node={node} + setSpendCheckbox={setSpendCheckbox} /> ); @@ -418,15 +421,18 @@ AddressExpander.propTypes = { multisig: PropTypes.shape({ address: PropTypes.string, }), + spend: PropTypes.bool, utxos: PropTypes.arrayOf(PropTypes.shape({})), }).isRequired, network: PropTypes.string, requiredSigners: PropTypes.number.isRequired, totalSigners: PropTypes.number.isRequired, + setSpendCheckbox: PropTypes.func, }; AddressExpander.defaultProps = { network: NETWORKS.TESTNET, + setSpendCheckbox: () => {}, }; function mapStateToProps(state) { @@ -436,6 +442,7 @@ function mapStateToProps(state) { totalSigners: state.settings.totalSigners, client: state.client, extendedPublicKeyImporters: state.quorum.extendedPublicKeyImporters, + transaction: state.spend.transaction, }; } diff --git a/src/components/Wallet/Node.jsx b/src/components/Wallet/Node.jsx index 15739f76..f38108cd 100644 --- a/src/components/Wallet/Node.jsx +++ b/src/components/Wallet/Node.jsx @@ -16,14 +16,45 @@ import { import { WALLET_MODES } from "../../actions/walletActions"; class Node extends React.Component { + constructor(props) { + super(props); + this.state = { + indeterminate: false, + checked: false, + }; + } + componentDidMount = () => { this.generate(); }; + // Passing this fn down to the UTXOSet so we can get updates here, from there. + setSpendCheckbox = (value) => { + const { spend } = this.props; + if (value === "indeterminate") { + this.setState({ indeterminate: true, checked: false }); + this.markSpending(true); + } else if (value === spend) { + // handles select/de-select all as well as have selected some and click select all + this.setState({ indeterminate: false, checked: value }); + this.markSpending(value); + } else { + // handles the case of de-selecting one-by-one until there's nothing left selected + // or there's only one utxo and we're selecting to spend it or not instead of at + // the top level select-all or deselect-all + this.setState({ indeterminate: false, checked: value }); + this.markSpending(value); + } + }; + + markSpending = (value) => { + const { change, bip32Path, updateNode } = this.props; + updateNode(change, { spend: value, bip32Path }); + }; + render = () => { const { bip32Path, - spend, fetchedUTXOs, balanceSats, multisig, @@ -31,6 +62,7 @@ class Node extends React.Component { walletMode, addressKnown, } = this.props; + const { indeterminate, checked } = this.state; const spending = walletMode === WALLET_MODES.SPEND; return ( @@ -40,8 +72,9 @@ class Node extends React.Component { id={bip32Path} name="spend" onChange={this.handleSpend} - checked={spend} + checked={checked} disabled={!fetchedUTXOs || balanceSats.isEqualTo(0)} + indeterminate={indeterminate} /> )} @@ -71,7 +104,12 @@ class Node extends React.Component { renderAddress = () => { const { braidNode } = this.props; - return ; + return ( + + ); }; generate = () => { @@ -95,7 +133,21 @@ class Node extends React.Component { feeRate, } = this.props; let newInputs; - if (e.target.checked) { + + if (e.target.getAttribute("data-indeterminate")) { + // remove any inputs that are ours + newInputs = inputs.filter((input) => { + const newUtxos = utxos.filter((utxo) => { + return utxo.txid === input.txid && utxo.index === input.index; + }); + return newUtxos.length === 0; + }); + // then add all ours back + newInputs = newInputs.concat( + utxos.map((utxo) => ({ ...utxo, multisig, bip32Path })) + ); + this.setState({ indeterminate: false, checked: true }); + } else if (e.target.checked) { newInputs = inputs.concat( utxos.map((utxo) => ({ ...utxo, multisig, bip32Path })) ); diff --git a/src/components/Wallet/NodeSet.jsx b/src/components/Wallet/NodeSet.jsx index 63233b33..7fa5d1cb 100644 --- a/src/components/Wallet/NodeSet.jsx +++ b/src/components/Wallet/NodeSet.jsx @@ -27,7 +27,7 @@ class NodeSet extends React.Component { this.state = { page: 0, nodesPerPage: 10, - spend: false, + select: false, filterIncludeSpent: false, filterIncludeZeroBalance: false, orderBy: "bip32Path", @@ -156,7 +156,7 @@ class NodeSet extends React.Component { }; renderNodes = () => { - const { page, nodesPerPage, spend } = this.state; + const { page, nodesPerPage, select } = this.state; const { addNode, updateNode } = this.props; const startingIndex = page * nodesPerPage; const nodesRows = []; @@ -173,7 +173,7 @@ class NodeSet extends React.Component { addNode={addNode} updateNode={updateNode} change={change} - spend={spend} + select={select} /> ); nodesRows.push(nodeRow); @@ -209,7 +209,7 @@ class NodeSet extends React.Component {
- {spending && Spend?} + {spending && Select} { const { updateAutoSpend, resetNodesSpend, deleteChangeOutput } = this.props; updateAutoSpend(!event.target.checked); - updateAutoSpend(!event.target.checked); resetNodesSpend(); deleteChangeOutput(); };