diff --git a/src/actions/transactionActions.js b/src/actions/transactionActions.js index a793707a..0a59e85f 100644 --- a/src/actions/transactionActions.js +++ b/src/actions/transactionActions.js @@ -1,7 +1,10 @@ import BigNumber from "bignumber.js"; +import { reverseBuffer } from "bitcoinjs-lib/src/bufferutils"; import { estimateMultisigTransactionFee, satoshisToBitcoins, + networkData, + autoLoadPSBT, } from "unchained-bitcoin"; import { getSpendableSlices, getConfirmedBalance } from "../selectors/wallet"; import { DUST_IN_BTC } from "../utils/constants"; @@ -29,6 +32,8 @@ export const RESET_TRANSACTION = "RESET_TRANSACTION"; export const SET_IS_WALLET = "SET_IS_WALLET"; export const SET_CHANGE_OUTPUT_INDEX = "SET_CHANGE_OUTPUT_INDEX"; export const SET_CHANGE_OUTPUT_MULTISIG = "SET_CHANGE_OUTPUT_MULTISIG"; +export const SET_UNSIGNED_PSBT = "SET_UNSIGNED_PSBT"; +export const RESET_PSBT = "RESET_PSBT"; export const UPDATE_AUTO_SPEND = "UPDATE_AUTO_SPEND"; export const SET_CHANGE_ADDRESS = "SET_CHANGE_ADDRESS"; export const SET_SIGNING_KEY = "SET_SIGNING_KEY"; @@ -180,6 +185,19 @@ export function setBalanceError(message) { }; } +export function setUnsignedPSBT(value) { + return { + type: SET_UNSIGNED_PSBT, + value, + }; +} + +export function resetPSBT() { + return { + type: RESET_PSBT, + }; +} + export function resetTransaction() { return { type: RESET_TRANSACTION, @@ -277,3 +295,131 @@ export function setMaxSpendOnOutput(outputIndex) { return dispatch(setOutputAmount(outputIndex, spendAllAmount.toFixed())); }; } + +export function importPSBT(psbtText) { + return (dispatch, getState) => { + let state = getState(); + const { network } = state.settings; + const psbt = autoLoadPSBT(psbtText, { network: networkData(network) }); + if (!psbt) { + throw new Error("Could not parse PSBT."); + } + + if (psbt.txInputs.length === 0) { + throw new Error("PSBT does not contain any inputs."); + } + if (psbt.txOutputs.length === 0) { + throw new Error("PSBT does not contain any outputs."); + } + + dispatch(resetOutputs()); + dispatch(setUnsignedPSBT(psbt.toBase64())); + + const createInputIdentifier = (txid, index) => `${txid}:${index}`; + + const inputIdentifiers = new Set( + psbt.txInputs.map((input) => { + const txid = reverseBuffer(input.hash).toString("hex"); + return createInputIdentifier(txid, input.index); + }) + ); + + const inputs = []; + getSpendableSlices(state).forEach((slice) => { + Object.entries(slice.utxos).forEach(([, utxo]) => { + const inputIdentifier = createInputIdentifier(utxo.txid, utxo.index); + if (inputIdentifiers.has(inputIdentifier)) { + const input = { + ...utxo, + multisig: slice.multisig, + bip32Path: slice.bip32Path, + change: slice.change, + }; + inputs.push(input); + } + }); + }); + + if (inputs.length === 0) { + throw new Error("PSBT does not contain any UTXOs from this wallet."); + } + if (inputs.length !== psbt.txInputs.length) { + throw new Error( + `Only ${inputs.length} of ${psbt.txInputs.length} PSBT inputs are UTXOs in this wallet.` + ); + } + + dispatch(setInputs(inputs)); + + let outputsTotalSats = new BigNumber(0); + psbt.txOutputs.forEach((output, outputIndex) => { + const number = outputIndex + 1; + outputsTotalSats = outputsTotalSats.plus(BigNumber(output.value)); + if (number > 1) { + dispatch(addOutput()); + } + + if (output.script) { + dispatch(setChangeOutputIndex(number)); + dispatch(setChangeAddressAction(output.address)); + } + dispatch(setOutputAddress(number, output.address)); + dispatch( + setOutputAmount(number, satoshisToBitcoins(output.value).toFixed(8)) + ); + }); + + state = getState(); + const inputsTotalSats = BigNumber(state.spend.transaction.inputsTotalSats); + const feeSats = inputsTotalSats - outputsTotalSats; + const fee = satoshisToBitcoins(feeSats).toFixed(8); + dispatch(setFee(fee)); + + dispatch(finalizeOutputs(true)); + + // In the future, if we want to support loading in signatures + // (or sets of signatures) included in a PSBT, we likely need to do + // that work here. Initial implementation just ignores any signatures + // included with the uploaded PSBT. + }; +} + +export function importHermitPSBT(psbtText) { + return (dispatch, getState) => { + const state = getState(); + const { network } = state.settings; + const psbt = autoLoadPSBT(psbtText, { network: networkData(network) }); + if (!psbt) { + throw new Error("Could not parse PSBT."); + } + + if (psbt.txInputs.length === 0) { + throw new Error("PSBT does not contain any inputs."); + } + if (psbt.txOutputs.length === 0) { + throw new Error("PSBT does not contain any outputs."); + } + + dispatch(resetOutputs()); + dispatch(setUnsignedPSBT(psbt.toBase64())); + // To extend this support beyond the bare bones here, it will be necessary to handle + // any included signatures if this PSBT is already partially signed. However, for now + // we just skip over that, treating every PSBT as if it is unsigned whether it has + // any signatures included or not. + }; +} + +// There are two implicit constraints on legacyPSBT support as written +// 1. All UTXOs being spent are from the same redeem script (e.g. single address spend) +// 2. There is no change - we are sweeping all funds to a single address. e.g. len(psbt.txOutputs) == 1 +export function importLegacyPSBT(psbtText) { + return (dispatch, getState) => { + const state = getState(); + const { network } = state.settings; + const psbt = autoLoadPSBT(psbtText, { network: networkData(network) }); + if (!psbt) { + throw new Error("Could not parse PSBT."); + } + return psbt; + }; +} diff --git a/src/components/App.jsx b/src/components/App.jsx index 10fda278..107d3206 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -18,6 +18,7 @@ import Wallet from "./Wallet"; import CreateAddress from "./CreateAddress"; import TestSuiteRun from "./TestSuiteRun"; import ScriptExplorer from "./ScriptExplorer"; +import HermitPsbtInterface from "./Hermit/HermitPsbtInterface"; import Navbar from "./Navbar"; import Footer from "./Footer"; import ErrorBoundary from "./ErrorBoundary"; @@ -37,6 +38,7 @@ const App = () => ( + diff --git a/src/components/CreateAddress/HermitPublicKeyImporter.jsx b/src/components/CreateAddress/HermitPublicKeyImporter.jsx deleted file mode 100644 index da248e86..00000000 --- a/src/components/CreateAddress/HermitPublicKeyImporter.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { HERMIT, ExportPublicKey } from "unchained-wallets"; -import { validateBIP32Path } from "unchained-bitcoin"; - -// Components -import { FormGroup, FormHelperText } from "@mui/material"; - -import HermitReader from "../Hermit/HermitReader"; - -class HermitPublicKeyImporter extends React.Component { - constructor(props) { - super(props); - this.state = { - publicKeyError: "", - }; - } - - interaction = () => { - const { network, defaultBIP32Path } = this.props; - return ExportPublicKey({ - keystore: HERMIT, - network, - bip32Path: defaultBIP32Path, - }); - }; - - render = () => { - const { publicKeyError } = this.state; - return ( - - - {publicKeyError} - - ); - }; - - setError = (value) => { - this.setState({ publicKeyError: value }); - }; - - handleReaderStart = () => { - const { disableChangeMethod } = this.props; - disableChangeMethod(); - }; - - handleReaderSuccess = (data) => { - const { validatePublicKey, onImport, enableChangeMethod } = this.props; - const { pubkey: nextPublicKey, bip32Path: nextBIP32Path } = data; - - enableChangeMethod(); - - const bip32PathError = validateBIP32Path(nextBIP32Path); - if (bip32PathError) { - this.setError(bip32PathError); - return; - } - - const publicKeyError = validatePublicKey(nextPublicKey); - if (publicKeyError) { - this.setError(publicKeyError); - return; - } - - onImport({ publicKey: nextPublicKey, bip32Path: nextBIP32Path }); - }; - - handleReaderClear = () => { - const { enableChangeMethod } = this.props; - this.setError(""); - enableChangeMethod(); - }; -} - -HermitPublicKeyImporter.propTypes = { - network: PropTypes.string.isRequired, - defaultBIP32Path: PropTypes.string.isRequired, - validatePublicKey: PropTypes.func.isRequired, - enableChangeMethod: PropTypes.func.isRequired, - disableChangeMethod: PropTypes.func.isRequired, - onImport: PropTypes.func.isRequired, -}; - -export default HermitPublicKeyImporter; diff --git a/src/components/CreateAddress/PublicKeyImporter.jsx b/src/components/CreateAddress/PublicKeyImporter.jsx index 4228c520..300ca077 100644 --- a/src/components/CreateAddress/PublicKeyImporter.jsx +++ b/src/components/CreateAddress/PublicKeyImporter.jsx @@ -2,7 +2,7 @@ import React from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; import { validatePublicKey as baseValidatePublicKey } from "unchained-bitcoin"; -import { TREZOR, LEDGER, HERMIT } from "unchained-wallets"; +import { TREZOR, LEDGER } from "unchained-wallets"; // Components import { @@ -21,7 +21,6 @@ import { ArrowUpward, ArrowDownward } from "@mui/icons-material"; import Copyable from "../Copyable"; import TextPublicKeyImporter from "./TextPublicKeyImporter"; import ExtendedPublicKeyPublicKeyImporter from "./ExtendedPublicKeyPublicKeyImporter"; -import HermitPublicKeyImporter from "./HermitPublicKeyImporter"; import HardwareWalletPublicKeyImporter from "./HardwareWalletPublicKeyImporter"; import EditableName from "../EditableName"; import Conflict from "./Conflict"; @@ -116,7 +115,6 @@ class PublicKeyImporter extends React.Component { {"< Select method >"} Trezor Ledger - Hermit Derive from extended public key Enter as text @@ -146,18 +144,6 @@ class PublicKeyImporter extends React.Component { /> ); } - if (publicKeyImporter.method === HERMIT) { - return ( - - ); - } if (publicKeyImporter.method === XPUB) { return ( { - const { number, setPublicKey, resetBIP32Path, setFinalized } = this.props; + reset = () => { + const { number, setPublicKey, setFinalized } = this.props; setPublicKey(number, ""); setFinalized(number, false); - if (shouldResetBIP32Path) { - resetBIP32Path(number); - } }; // @@ -274,7 +257,7 @@ class PublicKeyImporter extends React.Component { color="secondary" size="small" onClick={() => { - this.reset(publicKeyImporter.method === HERMIT); + this.reset(); }} > Remove Public Key @@ -356,7 +339,6 @@ PublicKeyImporter.propTypes = { }).isRequired, publicKeyImporters: PropTypes.shape({}).isRequired, defaultBIP32Path: PropTypes.string.isRequired, - resetBIP32Path: PropTypes.func.isRequired, addressType: PropTypes.string.isRequired, setName: PropTypes.func.isRequired, setBIP32Path: PropTypes.func.isRequired, diff --git a/src/components/Hermit/HermitDisplayer.jsx b/src/components/Hermit/HermitDisplayer.jsx index 4ec65f8f..51fc3df7 100644 --- a/src/components/Hermit/HermitDisplayer.jsx +++ b/src/components/Hermit/HermitDisplayer.jsx @@ -1,25 +1,116 @@ -import React from "react"; +import React, { Component } from "react"; import PropTypes from "prop-types"; + import QRCode from "qrcode.react"; -import Copyable from "../Copyable"; +import { Grid, Button } from "@mui/material"; -const HermitDisplayer = (props) => { - const { width, string } = props; +export const HermitQRCode = ({ + width = 640, // px + index, + parts, + onCancel, +}) => { return ( - - - +
+
+ QR Code{" "} + + {index + 1} of {parts.length} + +
+
+ +
+ +

Scan the QR codes above into Hermit.

+ + + + + +
); }; -HermitDisplayer.propTypes = { - string: PropTypes.string, +HermitQRCode.defaultProps = { + width: 640, +}; + +HermitQRCode.propTypes = { width: PropTypes.number, + index: PropTypes.number.isRequired, + parts: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + onCancel: PropTypes.func.isRequired, }; +class HermitDisplayer extends Component { + constructor(props) { + super(props); + this.state = { + currentIndex: 0, + timer: null, + keepPlaying: true, + }; + } + + componentDidMount() { + const { rate } = this.props; + this.setState({ + timer: window.setTimeout(this.incrementIndex, rate), + }); + } + + componentWillUnmount() { + const { timer } = this.state; + window.clearTimeout(timer); + this.setState({ + keepPlaying: false, + }); + } + + incrementIndex = () => { + const { rate, parts } = this.props; + const { currentIndex, keepPlaying } = this.state; + if (keepPlaying) { + this.setState({ + currentIndex: currentIndex === parts.length - 1 ? 0 : currentIndex + 1, + timer: window.setTimeout(this.incrementIndex, rate), + }); + } + }; + + render() { + const { width, parts, onCancel } = this.props; + const { currentIndex } = this.state; + return ( + + ); + } +} + HermitDisplayer.defaultProps = { - string: "", - width: 120, + width: 120, // in pixels + rate: 200, // ms per QR code displayed +}; + +HermitDisplayer.propTypes = { + rate: PropTypes.number, + parts: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + onCancel: PropTypes.func.isRequired, + width: PropTypes.number, }; export default HermitDisplayer; diff --git a/src/components/Hermit/HermitExtendedPublicKeyImporter.jsx b/src/components/Hermit/HermitExtendedPublicKeyImporter.jsx index e8cb9b76..5d44b351 100644 --- a/src/components/Hermit/HermitExtendedPublicKeyImporter.jsx +++ b/src/components/Hermit/HermitExtendedPublicKeyImporter.jsx @@ -59,7 +59,7 @@ class HermitExtendedPublicKeyImporter extends React.Component { enableChangeMethod, } = this.props; enableChangeMethod(); - const { xpub, bip32Path } = data; + const { xpub, bip32Path } = this.interaction().parse(data); validateAndSetBIP32Path( bip32Path, () => { diff --git a/src/components/Hermit/HermitPsbtInterface.jsx b/src/components/Hermit/HermitPsbtInterface.jsx new file mode 100644 index 00000000..ee3612d0 --- /dev/null +++ b/src/components/Hermit/HermitPsbtInterface.jsx @@ -0,0 +1,174 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +// Components +import { Box, Button, FormHelperText, Grid } from "@mui/material"; +import "../styles.css"; +import { + autoSelectCoins as autoSelectCoinsAction, + resetNodesSpend as resetNodesSpendAction, + updateChangeSliceAction, + updateDepositSliceAction, +} from "../../actions/walletActions"; +import { + addOutput, + deleteChangeOutput as deleteChangeOutputAction, + finalizeOutputs as finalizeOutputsAction, + importHermitPSBT as importHermitPSBTAction, + setChangeAddressAction, + setFeeRate as setFeeRateAction, + setInputs as setInputsAction, + setOutputAddress, + setSpendStep as setSpendStepAction, + updateAutoSpendAction, +} from "../../actions/transactionActions"; +import AddressTypePicker from "../AddressTypePicker"; +import NetworkPicker from "../NetworkPicker"; +import ClientPicker from "../ClientPicker"; +import HermitSignatureImporterPsbt from "./HermitSignatureImporterPsbt"; + +// eslint-disable-next-line react/prefer-stateless-function +class HermitPsbtInterface extends React.Component { + constructor(props) { + super(props); + this.state = { + importPSBTDisabled: false, + importPSBTError: "", + }; + } + + setPSBTToggleAndError = (importPSBTDisabled, errorMessage) => { + this.setState({ + importPSBTDisabled, + importPSBTError: errorMessage, + }); + }; + + render = () => { + return ( + + + + {this.renderBody()} + + + + + + + + + + + + + + + ); + }; + + renderBody = () => { + const { unsignedPSBT } = this.props; + const { importPSBTDisabled, importPSBTError } = this.state; + return ( + + {!unsignedPSBT && ( + + + + )} + + {unsignedPSBT && ( + + + + )} + + ); + }; + + handleImportPSBT = ({ target }) => { + const { importHermitPSBT } = this.props; + + this.setPSBTToggleAndError(true, ""); + + try { + if (target.files.length === 0) { + this.setPSBTToggleAndError(false, "No PSBT provided."); + return; + } + if (target.files.length > 1) { + this.setPSBTToggleAndError(false, "Multiple PSBTs provided."); + return; + } + + const fileReader = new FileReader(); + fileReader.onload = (event) => { + try { + const psbtText = event.target.result; + importHermitPSBT(psbtText); + this.setPSBTToggleAndError(false, ""); + } catch (e) { + this.setPSBTToggleAndError(false, e.message); + } + }; + fileReader.readAsText(target.files[0]); + } catch (e) { + this.setPSBTToggleAndError(false, e.message); + } + }; +} + +HermitPsbtInterface.propTypes = { + importHermitPSBT: PropTypes.func.isRequired, + unsignedPSBT: PropTypes.string.isRequired, +}; + +function mapStateToProps(state) { + return { + unsignedPSBT: state.spend.transaction.unsignedPSBT, + }; +} + +const mapDispatchToProps = { + autoSelectCoins: autoSelectCoinsAction, + deleteChangeOutput: deleteChangeOutputAction, + updateAutoSpend: updateAutoSpendAction, + setInputs: setInputsAction, + updateChangeSlice: updateChangeSliceAction, + updateDepositSlice: updateDepositSliceAction, + setAddress: setOutputAddress, + resetNodesSpend: resetNodesSpendAction, + setFeeRate: setFeeRateAction, + addOutput, + finalizeOutputs: finalizeOutputsAction, + setChangeAddress: setChangeAddressAction, + setSpendStep: setSpendStepAction, + importHermitPSBT: importHermitPSBTAction, +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(HermitPsbtInterface); diff --git a/src/components/Hermit/HermitReader.jsx b/src/components/Hermit/HermitReader.jsx index 4ed966c5..d0545177 100644 --- a/src/components/Hermit/HermitReader.jsx +++ b/src/components/Hermit/HermitReader.jsx @@ -1,8 +1,14 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; -import { PENDING, ACTIVE } from "unchained-wallets"; +import { PENDING, ACTIVE, BCURDecoder } from "unchained-wallets"; import { QrReader } from "react-qr-reader"; -import { Grid, Button, Box, FormHelperText } from "@mui/material"; +import { + Grid, + Button, + Box, + FormHelperText, + LinearProgress, +} from "@mui/material"; import Copyable from "../Copyable"; const QR_CODE_READER_DELAY = 300; // ms? @@ -10,14 +16,19 @@ const QR_CODE_READER_DELAY = 300; // ms? class HermitReader extends Component { constructor(props) { super(props); + this.decoder = new BCURDecoder(); // FIXME do we need useMemo ? this.state = { status: PENDING, error: "", + totalParts: 0, + partsReceived: 0, + percentageReceived: 0, }; } render = () => { - const { status, error } = this.state; + const { status, error, percentageReceived, partsReceived, totalParts } = + this.state; const { interaction, width, startText } = this.props; if (status === PENDING) { @@ -36,7 +47,7 @@ class HermitReader extends Component { -

When you are ready, scan the QR code produced by Hermit:

+

When you are ready, scan the QR codes produced by Hermit.

+
+ + + + + {percentageReceived === 0 ? ( + + +

Waiting for first QR code...

+
+ ) : ( + + +

+ Scanned {partsReceived} of {totalParts} QR codes... +

+
+ )} + + + +
- +
); } @@ -113,15 +143,29 @@ class HermitReader extends Component { } }; - handleScan = (data) => { - const { onSuccess, interaction } = this.props; - if (data) { - try { - const result = interaction.parse(data); - onSuccess(result); - this.setState({ status: "success" }); - } catch (e) { - this.handleError(e); + handleScan = (qrCodeString) => { + const { onSuccess } = this.props; + if (qrCodeString) { + this.decoder.receivePart(qrCodeString); + const progress = this.decoder.progress(); + const newPercentageReceived = + progress.totalParts > 0 + ? (progress.partsReceived / progress.totalParts) * 100 + : 0; + this.setState({ + partsReceived: progress.partsReceived, + totalParts: progress.totalParts, + percentageReceived: newPercentageReceived, + }); + + if (this.decoder.isComplete()) { + if (this.decoder.isSuccess()) { + const data = this.decoder.data(); + onSuccess(data); + } else { + const errorMessage = this.decoder.errorMessage(); + this.setState({ status: "error", error: errorMessage }); + } } } }; @@ -131,8 +175,12 @@ class HermitReader extends Component { this.setState({ status: PENDING, error: "", + totalParts: 0, + partsReceived: 0, + percentageReceived: 0, }); if (onClear) { + this.decoder.reset(); onClear(); } }; diff --git a/src/components/Hermit/HermitSignatureImporter.jsx b/src/components/Hermit/HermitSignatureImporter.jsx index 7812b751..58e2ff01 100644 --- a/src/components/Hermit/HermitSignatureImporter.jsx +++ b/src/components/Hermit/HermitSignatureImporter.jsx @@ -1,5 +1,12 @@ import React from "react"; import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import { + networkData, + parseSignatureArrayFromPSBT, + unsignedMultisigPSBT, +} from "unchained-bitcoin"; import { HERMIT, PENDING, @@ -7,9 +14,11 @@ import { SignMultisigTransaction, } from "unchained-wallets"; import { Grid, Box, TextField, Button, FormHelperText } from "@mui/material"; +import { Psbt } from "bitcoinjs-lib"; import HermitReader from "./HermitReader"; import HermitDisplayer from "./HermitDisplayer"; import InteractionMessages from "../InteractionMessages"; +import { setUnsignedPSBT as setUnsignedPSBTAction } from "../../actions/transactionActions"; class HermitSignatureImporter extends React.Component { constructor(props) { @@ -17,31 +26,135 @@ class HermitSignatureImporter extends React.Component { this.state = { bip32PathError: "", signatureError: "", - status: this.interaction(true).isSupported() ? PENDING : UNSUPPORTED, + status: this.interaction().isSupported() ? PENDING : UNSUPPORTED, + displaySignatureRequest: false, }; } - interaction = () => { - const { signatureImporter, network, inputs, outputs } = this.props; - const bip32Paths = inputs.map((input) => { - if (typeof input.bip32Path === "undefined") - return signatureImporter.bip32Path; // pubkey path - return `${signatureImporter.bip32Path}${input.bip32Path.slice(1)}`; // xpub/pubkey slice away the m, keep / - }); + // from gh buidl-bitcoin/buidl-python/blob/d79e9808e8ca60975d315be41293cb40d968626d/buidl/helper.py#L350-L379 - return SignMultisigTransaction({ - keystore: HERMIT, + childToPath = (child) => { + let hardenedPath = child; + let toReturn = `/${child}`; + if (hardenedPath >= 0x80000000) { + hardenedPath -= 0x80000000; + toReturn = `/${hardenedPath}'`; + } + return toReturn; + }; + + parseBinaryPath = (binPath) => { + let path = "m"; + let pathData = Buffer.from(binPath); + while (pathData.length > 0) { + const childNum = Buffer.from(pathData.slice(0, 4)).readUIntLE(0, 4); + path += this.childToPath(childNum); + pathData = pathData.subarray(4); + } + return path; + }; + + interaction = () => { + const { + unsignedPsbt, network, inputs, outputs, - bip32Paths, + setUnsignedPSBT, + unsignedPsbtFromState, + } = this.props; + let psbtToSign; + + // We need to be flexible here because this signature importer is used in multiple places + // And the user *could* have uploaded their own PSBT, and that uploaded PSBT *could* also + // be a scaffolded PSBT without any inputs. + + if (unsignedPsbtFromState === "" && inputs.length > 0) { + psbtToSign = unsignedMultisigPSBT( + network, + inputs, + outputs, + true + ).toBase64(); + + setUnsignedPSBT(psbtToSign); + + return SignMultisigTransaction({ + keystore: HERMIT, + psbt: psbtToSign, + }); + } + + const psbt = Psbt.fromBase64( + unsignedPsbt === "" ? unsignedPsbtFromState : unsignedPsbt, + { + network: networkData(network), + } + ); + + // if the unsignedPsbt doesn't have any inputs/outputs, that means we're in the ppk recovery case + // so we need to add in the inputs and outputs from the redux store and then use *that* as the unsigned psbt + if (psbt.data.inputs.length === 0) { + psbt.setVersion(1); + + const b32d = psbt.data.globalMap.unknownKeyVals[1]; + + const derivation = b32d.value + .slice(1) + .toString("hex") + .split("de") + .map((p) => [ + Buffer.from(p.slice(0, 8), "hex"), + this.parseBinaryPath(Buffer.from(p.slice(8), "hex")), + ]); + + psbt.addInputs( + Object.values(inputs).map((i) => ({ + hash: i.txid, + index: i.index, + nonWitnessUtxo: Buffer.from(i.transactionHex, "hex"), + redeemScript: i.multisig.redeem.output, + bip32Derivation: i.multisig.redeem.pubkeys.map((pk, idx) => { + return { + masterFingerprint: derivation[idx][0], + path: derivation[idx][1], + pubkey: pk, + }; + }), + })) + ); + psbt.addOutputs( + Object.values(outputs).map((o) => ({ + address: o.address, + value: o.amountSats.toNumber(), + })) + ); + + psbtToSign = psbt.toBase64(); + setUnsignedPSBT(psbtToSign); + } else { + psbtToSign = unsignedPsbt === "" ? unsignedPsbtFromState : unsignedPsbt; + } + + return SignMultisigTransaction({ + keystore: HERMIT, + psbt: psbtToSign, }); }; + handleShowSignatureRequest = () => { + this.setState({ displaySignatureRequest: true }); + }; + + handleHideSignatureRequest = () => { + this.setState({ displaySignatureRequest: false }); + }; + render = () => { const { signatureImporter, disableChangeMethod, resetBIP32Path } = this.props; - const { bip32PathError, signatureError, status } = this.state; + const { bip32PathError, signatureError, status, displaySignatureRequest } = + this.state; const interaction = this.interaction(); if (status === UNSUPPORTED) { return ( @@ -84,13 +197,28 @@ class HermitSignatureImporter extends React.Component { Use the default value if you don’t understand BIP32 paths. - - - - + {displaySignatureRequest ? ( + + + + - + ) : ( + + )} - - {signatureError}
@@ -112,17 +239,37 @@ class HermitSignatureImporter extends React.Component { }; import = (signature) => { - const { validateAndSetSignature, enableChangeMethod } = this.props; + const { + validateAndSetSignature, + enableChangeMethod, + unsignedPsbtFromState, + network, + } = this.props; this.setState({ signatureError: "" }); enableChangeMethod(); - validateAndSetSignature(signature, (signatureError) => { - this.setState({ signatureError }); + const signedPsbt = this.interaction().parse(signature); + // Signed PSBT from Hermit may be an extremely stripped down version + const unsignedPsbtStateObject = Psbt.fromBase64(unsignedPsbtFromState, { + network: networkData(network), }); + const reconstitutedPsbt = unsignedPsbtStateObject.combine( + Psbt.fromBase64(signedPsbt, { network: networkData(network) }) + ); + + const signatureArray = parseSignatureArrayFromPSBT( + reconstitutedPsbt.toBase64() + ); + validateAndSetSignature( + signatureArray, + (signatureError) => { + this.setState({ signatureError }); + }, + reconstitutedPsbt.toBase64() + ); }; clear = () => { - const { resetBIP32Path, enableChangeMethod } = this.props; - resetBIP32Path(); + const { enableChangeMethod } = this.props; this.setState({ signatureError: "" }); enableChangeMethod(); }; @@ -151,18 +298,42 @@ class HermitSignatureImporter extends React.Component { } HermitSignatureImporter.propTypes = { - network: PropTypes.string.isRequired, - inputs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - outputs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, signatureImporter: PropTypes.shape({ bip32Path: PropTypes.string, }).isRequired, resetBIP32Path: PropTypes.func.isRequired, + setUnsignedPSBT: PropTypes.func.isRequired, defaultBIP32Path: PropTypes.string.isRequired, validateAndSetBIP32Path: PropTypes.func.isRequired, validateAndSetSignature: PropTypes.func.isRequired, enableChangeMethod: PropTypes.func.isRequired, disableChangeMethod: PropTypes.func.isRequired, + unsignedPsbt: PropTypes.string, + unsignedPsbtFromState: PropTypes.string.isRequired, + network: PropTypes.string, + inputs: PropTypes.arrayOf(PropTypes.shape({})), + outputs: PropTypes.arrayOf(PropTypes.shape({})), +}; + +HermitSignatureImporter.defaultProps = { + unsignedPsbt: "", + network: "", + inputs: [], + outputs: [], +}; + +function mapStateToProps(state) { + return { + ...state.spend.transaction, + unsignedPsbtFromState: state.spend.transaction.unsignedPSBT, + }; +} + +const mapDispatchToProps = { + setUnsignedPSBT: setUnsignedPSBTAction, }; -export default HermitSignatureImporter; +export default connect( + mapStateToProps, + mapDispatchToProps +)(HermitSignatureImporter); diff --git a/src/components/Hermit/HermitSignatureImporterPsbt.jsx b/src/components/Hermit/HermitSignatureImporterPsbt.jsx new file mode 100644 index 00000000..953ac442 --- /dev/null +++ b/src/components/Hermit/HermitSignatureImporterPsbt.jsx @@ -0,0 +1,96 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { parseSignatureArrayFromPSBT } from "unchained-bitcoin"; +import { + HERMIT, + PENDING, + UNSUPPORTED, + SignMultisigTransaction, +} from "unchained-wallets"; +import { Grid, Box, Button } from "@mui/material"; +import HermitDisplayer from "./HermitDisplayer"; +import InteractionMessages from "../InteractionMessages"; + +class HermitSignatureImporterPsbt extends React.Component { + constructor(props) { + super(props); + this.state = { + status: this.interaction(true).isSupported() ? PENDING : UNSUPPORTED, + displaySignatureRequest: true, + }; + } + + interaction = () => { + const { unsignedPsbt } = this.props; + + return SignMultisigTransaction({ + keystore: HERMIT, + psbt: unsignedPsbt, + }); + }; + + handleShowSignatureRequest = () => { + this.setState({ displaySignatureRequest: true }); + }; + + handleHideSignatureRequest = () => { + this.setState({ displaySignatureRequest: false }); + }; + + render = () => { + const { status, displaySignatureRequest } = this.state; + const interaction = this.interaction(); + if (status === UNSUPPORTED) { + return ( + + ); + } + return ( + + + {displaySignatureRequest ? ( + + + + + + ) : ( + + )} + + + ); + }; + + import = (signature) => { + const { validateAndSetSignature } = this.props; + const signedPsbt = this.interaction().parse(signature); + const signatureArray = parseSignatureArrayFromPSBT(signedPsbt); + validateAndSetSignature(signatureArray, () => {}, signedPsbt); + }; +} + +HermitSignatureImporterPsbt.propTypes = { + signatureImporter: PropTypes.shape({ + bip32Path: PropTypes.string, + }).isRequired, + validateAndSetSignature: PropTypes.func.isRequired, + unsignedPsbt: PropTypes.string.isRequired, +}; + +export default HermitSignatureImporterPsbt; diff --git a/src/components/Hermit/index.js b/src/components/Hermit/index.js index 93800e8b..0d5df73b 100644 --- a/src/components/Hermit/index.js +++ b/src/components/Hermit/index.js @@ -1,4 +1,5 @@ import HermitReader from "./HermitReader"; import HermitDisplayer from "./HermitDisplayer"; +import HermitPsbtInterface from "./HermitPsbtInterface"; -export { HermitDisplayer, HermitReader }; +export { HermitDisplayer, HermitReader, HermitPsbtInterface }; diff --git a/src/components/ImportAddressesButton.jsx b/src/components/ImportAddressesButton.jsx index 69c97c4e..4c2dca77 100644 --- a/src/components/ImportAddressesButton.jsx +++ b/src/components/ImportAddressesButton.jsx @@ -40,7 +40,7 @@ function ImportAddressesButton({ addresses = [], client, importCallback }) { const classes = useStyles(); // when addresses prop has changed, we want to check its status // Address management currently isn't optimized and so they are added to the store - // one at at time. This effect is run each time that updates. Eventually, + // one at a time. This effect is run each time that updates. Eventually, // this should be optimized to happen all at once. For now though, once we find // a single address that can be imported then enable the import button and // we won't run this anymore. This is mostly for wallet view, not script entry diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 0e5e4a7c..5e9a9527 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -71,6 +71,7 @@ const Navbar = () => { { href: "/wallet", title: "Wallet" }, { href: "/address", title: "Create Address" }, { href: "/script", title: "Script Explorer" }, + { href: "/hermit-psbt", title: "Hermit PSBT Interface" }, { href: "/test", title: "Test Suite" }, { href: "/help", title: "Help" }, ]; diff --git a/src/components/ScriptExplorer/ConfirmOwnership.jsx b/src/components/ScriptExplorer/ConfirmOwnership.jsx index 84770b45..ab12557d 100644 --- a/src/components/ScriptExplorer/ConfirmOwnership.jsx +++ b/src/components/ScriptExplorer/ConfirmOwnership.jsx @@ -2,7 +2,7 @@ import React from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; import { validatePublicKey, validateBIP32Path } from "unchained-bitcoin"; -import { TREZOR, LEDGER, HERMIT } from "unchained-wallets"; +import { TREZOR, LEDGER } from "unchained-wallets"; // Components import CheckIcon from "@mui/icons-material/Check"; @@ -23,7 +23,6 @@ import { TextField, } from "@mui/material"; import HardwareWalletPublicKeyImporter from "../CreateAddress/HardwareWalletPublicKeyImporter"; -import HermitPublicKeyImporter from "../CreateAddress/HermitPublicKeyImporter"; // Actions import { @@ -163,19 +162,6 @@ class ConfirmOwnership extends React.Component { renderImportByMethod = () => { const { network, publicKeyImporter, defaultBIP32Path } = this.props; - if (publicKeyImporter.method === HERMIT) { - return ( - - ); - } if ( publicKeyImporter.method === TREZOR || publicKeyImporter.method === LEDGER @@ -220,7 +206,6 @@ class ConfirmOwnership extends React.Component { {"< Select method >"} Trezor Ledger - Hermit diff --git a/src/components/ScriptExplorer/ScriptEntry.jsx b/src/components/ScriptExplorer/ScriptEntry.jsx index 0d2da138..db27be30 100644 --- a/src/components/ScriptExplorer/ScriptEntry.jsx +++ b/src/components/ScriptExplorer/ScriptEntry.jsx @@ -9,6 +9,7 @@ import { validateHex, multisigRequiredSigners, multisigTotalSigners, + toHexString, } from "unchained-bitcoin"; import { Box, @@ -27,12 +28,21 @@ import MultisigDetails from "../MultisigDetails"; import ImportAddressesButton from "../ImportAddressesButton"; // Actions -import { setFrozen as setFrozenAction } from "../../actions/settingsActions"; +import { + setFrozen as setFrozenAction, + setNetwork as setNetworkAction, +} from "../../actions/settingsActions"; import { choosePerformSpend as chosePerformSpendAction, setRequiredSigners as setRequiredSignersAction, setTotalSigners as setTotalSignersAction, setInputs as setInputsAction, + importLegacyPSBT as importPSBTAction, + setOutputAddress as setOutputAddressAction, + setOutputAmount as setOutputAmountAction, + setFee as setFeeAction, + finalizeOutputs as finalizeOutputsAction, + setUnsignedPSBT as setUnsignedPSBTAction, } from "../../actions/transactionActions"; import { chooseConfirmOwnership as chooseConfirmOwnershipAction, @@ -47,6 +57,8 @@ class ScriptEntry extends React.Component { scriptError: "", fetchUTXOsError: "", fetchedUTXOs: false, + importPSBTDisabled: false, + importPSBTError: "", }; } @@ -88,20 +100,23 @@ class ScriptEntry extends React.Component { }; handleScriptChange = (event) => { - const scriptHex = event.target.value; + let scriptHex = event; let scriptError = ""; + if (event.target) { + scriptHex = event.target.value; - if (scriptHex === "") { - scriptError = `${this.scriptTitle()} script cannot be blank.`; - } + if (scriptHex === "") { + scriptError = `${this.scriptTitle()} script cannot be blank.`; + } - if ( - scriptError === "" && - (scriptHex.includes("\n") || - scriptHex.includes("\t") || - scriptHex.includes(" ")) - ) { - scriptError = `${this.scriptTitle()} script should not contain spaces, tabs, or newlines.`; + if ( + scriptError === "" && + (scriptHex.includes("\n") || + scriptHex.includes("\t") || + scriptHex.includes(" ")) + ) { + scriptError = `${this.scriptTitle()} script should not contain spaces, tabs, or newlines.`; + } } if (scriptError === "") { @@ -256,11 +271,73 @@ class ScriptEntry extends React.Component { setFrozen(true); }; + setPSBTToggleAndError = (importPSBTDisabled, errorMessage) => { + this.setState({ + importPSBTDisabled, + importPSBTError: errorMessage, + }); + }; + + handleImportPSBT = ({ target }) => { + const { importLegacyPSBT, setNetwork, setUnsignedPSBT, network } = + this.props; + + this.setPSBTToggleAndError(true, ""); + + try { + if (target.files.length === 0) { + this.setPSBTToggleAndError(false, "No PSBT provided."); + return; + } + if (target.files.length > 1) { + this.setPSBTToggleAndError(false, "Multiple PSBTs provided."); + return; + } + + const fileReader = new FileReader(); + fileReader.onload = (event) => { + try { + const psbtText = event.target.result; + const psbt = importLegacyPSBT(psbtText); + + setUnsignedPSBT(psbt.toBase64()); + if (psbt?.data?.inputs.length > 0) { + const redeemScriptHex = + psbt.data.inputs[0].redeemScript.toString("hex"); + this.setPSBTToggleAndError(false, ""); + + this.handleScriptChange(redeemScriptHex); + } else { + const redeemScriptHex = toHexString( + psbt.data.globalMap.unknownKeyVals[0].value + ); + this.handleScriptChange(redeemScriptHex); + } + + setNetwork(network); + this.renderDetails(); + this.performSpend(); + } catch (e) { + this.setPSBTToggleAndError(false, e.message); + } + }; + fileReader.readAsText(target.files[0]); + } catch (e) { + this.setPSBTToggleAndError(false, e.message); + } + }; + // // Render // render() { - const { scriptHex, scriptError, fetchedUTXOs } = this.state; + const { + scriptHex, + scriptError, + fetchedUTXOs, + importPSBTDisabled, + importPSBTError, + } = this.state; return ( @@ -282,6 +359,30 @@ class ScriptEntry extends React.Component { /> + + + + {scriptHex !== "" && !this.hasScriptError() ? ( this.renderDetails() ) : ( @@ -307,10 +408,13 @@ ScriptEntry.propTypes = { }).isRequired, network: PropTypes.string.isRequired, setFrozen: PropTypes.func.isRequired, + setNetwork: PropTypes.func.isRequired, setInputs: PropTypes.func.isRequired, setOwnershipMultisig: PropTypes.func.isRequired, setRequiredSigners: PropTypes.func.isRequired, setTotalSigners: PropTypes.func.isRequired, + importLegacyPSBT: PropTypes.func.isRequired, + setUnsignedPSBT: PropTypes.func.isRequired, }; function mapStateToProps(state) { @@ -332,6 +436,13 @@ const mapDispatchToProps = { chooseConfirmOwnership: chooseConfirmOwnershipAction, setOwnershipMultisig: setOwnershipMultisigAction, setFrozen: setFrozenAction, + importLegacyPSBT: importPSBTAction, + setNetwork: setNetworkAction, + setAddress: setOutputAddressAction, + setAmount: setOutputAmountAction, + setFee: setFeeAction, + setUnsignedPSBT: setUnsignedPSBTAction, + finalizeOutputs: finalizeOutputsAction, }; export default connect(mapStateToProps, mapDispatchToProps)(ScriptEntry); diff --git a/src/components/ScriptExplorer/SignatureImporter.jsx b/src/components/ScriptExplorer/SignatureImporter.jsx index e58b8692..e6d50241 100644 --- a/src/components/ScriptExplorer/SignatureImporter.jsx +++ b/src/components/ScriptExplorer/SignatureImporter.jsx @@ -19,6 +19,7 @@ import { Box, FormControl, TextField, + Grid, } from "@mui/material"; import Copyable from "../Copyable"; import TextSignatureImporter from "./TextSignatureImporter"; @@ -36,6 +37,7 @@ import { setSignatureImporterComplete, } from "../../actions/signatureImporterActions"; import { setSigningKey as setSigningKeyAction } from "../../actions/transactionActions"; +import { downloadFile } from "../../utils"; const TEXT = "text"; const UNKNOWN = "unknown"; @@ -47,6 +49,7 @@ class SignatureImporter extends React.Component { super(props); this.state = { disableChangeMethod: false, + signedPsbt: "", }; } @@ -137,6 +140,7 @@ class SignatureImporter extends React.Component { fee, isWallet, extendedPublicKeyImporter, + unsignedPsbt, extendedPublicKeys, requiredSigners, addressType, @@ -185,6 +189,7 @@ class SignatureImporter extends React.Component { outputs={outputs} inputsTotalSats={inputsTotalSats} fee={fee} + unsignedPsbt={unsignedPsbt} extendedPublicKeyImporter={extendedPublicKeyImporter} validateAndSetBIP32Path={this.validateAndSetBIP32Path} resetBIP32Path={this.resetBIP32Path} @@ -291,6 +296,7 @@ class SignatureImporter extends React.Component { renderSignature = () => { const { signatureImporter, txid } = this.props; + const { signedPsbt } = this.state; const signatureJSON = JSON.stringify(signatureImporter.signature); return (
@@ -298,6 +304,17 @@ class SignatureImporter extends React.Component { + {signedPsbt && ( + + + + )}
); } @@ -53,11 +60,13 @@ UnsignedTransaction.propTypes = { unsignedTransaction: PropTypes.shape({ toHex: PropTypes.func, }).isRequired, + unsignedPSBT: PropTypes.string.isRequired, }; function mapStateToProps(state) { return { unsignedTransaction: state.spend.transaction.unsignedTransaction, + unsignedPSBT: state.spend.transaction.unsignedPSBT, }; } diff --git a/src/components/Wallet/TransactionPreview.jsx b/src/components/Wallet/TransactionPreview.jsx index f455f2ef..428873de 100644 --- a/src/components/Wallet/TransactionPreview.jsx +++ b/src/components/Wallet/TransactionPreview.jsx @@ -14,6 +14,7 @@ import { TableCell, Grid, } from "@mui/material"; +import { downloadFile } from "../../utils"; import UnsignedTransaction from "../UnsignedTransaction"; import { setChangeOutputMultisig as setChangeOutputMultisigAction } from "../../actions/transactionActions"; @@ -152,6 +153,10 @@ class TransactionPreview extends React.Component { ).toString(); }; + handleDownloadPSBT = (psbtBase64) => { + downloadFile(psbtBase64, "transaction.psbt"); + }; + render = () => { const { feeRate, @@ -159,6 +164,7 @@ class TransactionPreview extends React.Component { inputsTotalSats, editTransaction, handleSignTransaction, + unsignedPSBT, } = this.props; return ( @@ -208,6 +214,17 @@ class TransactionPreview extends React.Component { Sign Transaction + {unsignedPSBT && ( + + + + )} @@ -229,11 +246,16 @@ TransactionPreview.propTypes = { outputs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, handleSignTransaction: PropTypes.func.isRequired, setChangeOutputMultisig: PropTypes.func.isRequired, + unsignedPSBT: PropTypes.string.isRequired, }; function mapStateToProps(state) { return { changeOutputIndex: state.spend.transaction.changeOutputIndex, + network: state.settings.network, + inputs: state.spend.transaction.inputs, + outputs: state.spend.transaction.outputs, + unsignedPSBT: state.spend.transaction.unsignedPSBT, }; } diff --git a/src/components/Wallet/WalletSign.jsx b/src/components/Wallet/WalletSign.jsx index 36c290ae..e311a696 100644 --- a/src/components/Wallet/WalletSign.jsx +++ b/src/components/Wallet/WalletSign.jsx @@ -13,6 +13,7 @@ import { setRequiredSigners as setRequiredSignersAction, resetTransaction as resetTransactionAction, setSpendStep as setSpendStepAction, + resetPSBT as resetPSBTAction, SPEND_STEP_CREATE, } from "../../actions/transactionActions"; import { @@ -78,9 +79,10 @@ class WalletSign extends React.Component { }; handleReturn = () => { - const { resetTransaction, resetWalletView } = this.props; + const { resetTransaction, resetWalletView, resetPSBT } = this.props; resetTransaction(); resetWalletView(); + resetPSBT(); }; handleCancel = (event) => { @@ -153,6 +155,7 @@ WalletSign.propTypes = { requiredSigners: PropTypes.number.isRequired, resetTransaction: PropTypes.func.isRequired, resetWalletView: PropTypes.func.isRequired, + resetPSBT: PropTypes.func.isRequired, setRequiredSigners: PropTypes.func.isRequired, setSpendStep: PropTypes.func.isRequired, signatureImporters: PropTypes.shape({}).isRequired, @@ -178,6 +181,7 @@ const mapDispatchToProps = { updateTxSlices: updateTxSlicesAction, resetTransaction: resetTransactionAction, resetWalletView: resetWalletViewAction, + resetPSBT: resetPSBTAction, setSpendStep: setSpendStepAction, }; diff --git a/src/components/Wallet/WalletSpend.jsx b/src/components/Wallet/WalletSpend.jsx index 1bd9999c..fb25f23b 100644 --- a/src/components/Wallet/WalletSpend.jsx +++ b/src/components/Wallet/WalletSpend.jsx @@ -9,6 +9,7 @@ import { Grid, Switch, FormControlLabel, + FormHelperText, Button, } from "@mui/material"; import { @@ -30,6 +31,7 @@ import { SPEND_STEP_SIGN, setSpendStep as setSpendStepAction, deleteChangeOutput as deleteChangeOutputAction, + importPSBT as importPSBTAction, } from "../../actions/transactionActions"; import { naiveCoinSelection } from "../../utils"; import NodeSet from "./NodeSet"; @@ -45,6 +47,14 @@ class WalletSpend extends React.Component { feeAmount = new BigNumber(0); + constructor(props) { + super(props); + this.state = { + importPSBTDisabled: false, + importPSBTError: "", + }; + } + componentDidUpdate = (prevProps) => { const { finalizedOutputs } = this.props; if (finalizedOutputs && !prevProps.finalizedOutputs) { @@ -126,6 +136,44 @@ class WalletSpend extends React.Component { deleteChangeOutput(); }; + setPSBTToggleAndError = (importPSBTDisabled, errorMessage) => { + this.setState({ + importPSBTDisabled, + importPSBTError: errorMessage, + }); + }; + + handleImportPSBT = ({ target }) => { + const { importPSBT } = this.props; + + this.setPSBTToggleAndError(true, ""); + + try { + if (target.files.length === 0) { + this.setPSBTToggleAndError(false, "No PSBT provided."); + return; + } + if (target.files.length > 1) { + this.setPSBTToggleAndError(false, "Multiple PSBTs provided."); + return; + } + + const fileReader = new FileReader(); + fileReader.onload = (event) => { + try { + const psbtText = event.target.result; + importPSBT(psbtText); + this.setPSBTToggleAndError(false, ""); + } catch (e) { + this.setPSBTToggleAndError(false, e.message); + } + }; + fileReader.readAsText(target.files[0]); + } catch (e) { + this.setPSBTToggleAndError(false, e.message); + } + }; + render() { const { autoSpend, @@ -140,6 +188,7 @@ class WalletSpend extends React.Component { inputsTotalSats, outputs, } = this.props; + const { importPSBTDisabled, importPSBTError } = this.state; return ( @@ -183,6 +232,29 @@ class WalletSpend extends React.Component { Preview Transaction + + + )} {spendingStep === SPEND_STEP_PREVIEW && ( @@ -244,6 +316,7 @@ WalletSpend.propTypes = { spendingStep: PropTypes.number, updateAutoSpend: PropTypes.func.isRequired, updateNode: PropTypes.func.isRequired, + importPSBT: PropTypes.func.isRequired, }; WalletSpend.defaultProps = { @@ -281,6 +354,7 @@ const mapDispatchToProps = { finalizeOutputs: finalizeOutputsAction, setChangeAddress: setChangeAddressAction, setSpendStep: setSpendStepAction, + importPSBT: importPSBTAction, }; export default connect(mapStateToProps, mapDispatchToProps)(WalletSpend); diff --git a/src/components/Wallet/index.jsx b/src/components/Wallet/index.jsx index 76c8150b..45918da9 100644 --- a/src/components/Wallet/index.jsx +++ b/src/components/Wallet/index.jsx @@ -455,7 +455,7 @@ class CreateWallet extends React.Component { * Callback function to pass to the address importer * after addresses have been imported we want * @param {Array} importedAddresses - * @param {boolean} rescan - whether or not a rescan is being performed + * @param {boolean} rescan - whether a rescan is being performed */ async afterImportAddresses(importedAddresses, rescan) { // if rescan is true then there's no point in fetching diff --git a/src/reducers/transactionReducer.js b/src/reducers/transactionReducer.js index 48197e7a..95d5894f 100644 --- a/src/reducers/transactionReducer.js +++ b/src/reducers/transactionReducer.js @@ -33,6 +33,8 @@ import { SET_IS_WALLET, SET_CHANGE_OUTPUT_INDEX, SET_CHANGE_OUTPUT_MULTISIG, + SET_UNSIGNED_PSBT, + RESET_PSBT, UPDATE_AUTO_SPEND, SET_SIGNING_KEY, SET_CHANGE_ADDRESS, @@ -94,6 +96,7 @@ export const initialState = () => ({ updatesComplete: true, signingKeys: [0, 0], // default 2 required signers spendingStep: SPEND_STEP_CREATE, + unsignedPSBT: "", }); function updateInputs(state, action) { @@ -325,7 +328,7 @@ function resetTransactionState(state) { function validateTransaction(state) { let newState = { ...state }; - // TODO: need less hacky way to supress error + // TODO: need less hacky way to suppress error if ( newState.outputs.find( (output) => output.addressError !== "" || output.amountError !== "" @@ -399,6 +402,8 @@ export default (state = initialState(), action) => { return validateTransaction(updateOutputAddress(state, action)); case SET_OUTPUT_AMOUNT: return validateTransaction(updateOutputAmount(state, action)); + case SET_UNSIGNED_PSBT: + return updateState(state, { unsignedPSBT: action.value }); case DELETE_OUTPUT: return validateTransaction(deleteOutput(state, action)); case SET_FEE_RATE: @@ -409,6 +414,8 @@ export default (state = initialState(), action) => { return finalizeOutputs(state, action); case RESET_OUTPUTS: return outputInitialStateForMode(state); + case RESET_PSBT: + return updateState(state); case SET_TXID: return updateState(state, { txid: action.value }); case SET_IS_WALLET: diff --git a/src/tests/extendedPublicKeys.jsx b/src/tests/extendedPublicKeys.jsx index 13a3e272..98fe2632 100644 --- a/src/tests/extendedPublicKeys.jsx +++ b/src/tests/extendedPublicKeys.jsx @@ -12,7 +12,11 @@ import Test from "./Test"; class ExportExtendedPublicKeyTest extends Test { // eslint-disable-next-line class-methods-use-this postprocess(result) { - return result.pubkey ? result.pubkey : result; + let tempResult = result; + if (this.params.keystore === HERMIT) { + tempResult = this.interaction().parse(result); + } + return tempResult.pubkey ? tempResult.pubkey : tempResult; } name() { @@ -42,7 +46,7 @@ class ExportExtendedPublicKeyTest extends Test { TEST_FIXTURES.keys.open_source.nodes[this.params.bip32Path]; if (this.params.keystore === HERMIT) { - return { xpub, bip32Path: this.params.bip32Path }; + return { xpub, rootFingerprint, bip32Path: this.params.bip32Path }; } if (this.params.network === MAINNET || this.params.keystore === TREZOR) return { xpub: xpub || tpub, rootFingerprint }; diff --git a/src/tests/hermit.js b/src/tests/hermit.js index b920676c..a66bb35d 100644 --- a/src/tests/hermit.js +++ b/src/tests/hermit.js @@ -1,9 +1,6 @@ import { HERMIT } from "unchained-wallets"; -import publicKeyTests from "./publicKeys"; import extendedPublicKeyTests from "./extendedPublicKeys"; import { signingTests } from "./signing"; -export default publicKeyTests(HERMIT) - .concat(extendedPublicKeyTests(HERMIT)) - .concat(signingTests(HERMIT)); +export default extendedPublicKeyTests(HERMIT).concat(signingTests(HERMIT)); diff --git a/src/tests/signing.jsx b/src/tests/signing.jsx index 59287751..8e3745d0 100644 --- a/src/tests/signing.jsx +++ b/src/tests/signing.jsx @@ -25,7 +25,11 @@ class SignMultisigTransactionTest extends Test { // eslint-disable-next-line class-methods-use-this postprocess(result) { - return result.signatures ? result.signatures : result; + let tempResult = result; + if (this.params.keystore === HERMIT) { + tempResult = this.interaction().parse(result); + } + return tempResult.signatures ? tempResult.signatures : tempResult; } // eslint-disable-next-line class-methods-use-this @@ -143,6 +147,19 @@ class SignMultisigTransactionTest extends Test { } interaction() { + if (this.params.keystore === HERMIT) { + const psbtBase64 = unsignedMultisigPSBT( + this.params.network, + this.params.inputs, + this.params.outputs, + true + ).toBase64(); + return SignMultisigTransaction({ + keystore: this.params.keystore, + psbt: psbtBase64, + returnSignatureArray: true, + }); + } return SignMultisigTransaction({ keystore: this.params.keystore, network: this.params.network,