Skip to content
This repository has been archived by the owner on Apr 25, 2024. It is now read-only.

Commit

Permalink
feat(hermit): hermit PSBT signing for pre-product key recovery (#308)
Browse files Browse the repository at this point in the history
* build(wallet): much is wired but buidl won't yet sign PSBTs from caravan

* build(wallet): new Hermit now works with Caravan

* build(wallet): store `unsignedPSBT` in redux

* build(wallet): shows a Download Signed PSBT after importing signature via hermit

* build(wallet): fix psbt import and swap to custom unchained-bitcoin

* build(wallet): updates signature parsing from psbt to use new method

* chore(wallet): clean up and fix linting errors

* chore(wallet): edit show unsigned PSBT to hide text but allow copying

* chore(wallet): clean up hermit signing interactions

* build(wallet): poc for ppk spend in caravan w hermit signing

* chore(wallet): revert breaking change

* build(wallet): remove hardcoded psbt and bip - grab and set it from what was uploaded

* chore(wallet): clean up and fix linting errors

* build(wallet): make sure we pass through the unsigned psbt provided

* build(wallet): add PoC for just interfacing with hermit via PSBT

* chore(wallet): simplify hermit psbt interface to bare minimum

* chore(wallet): revert local dev velocity increase

* chore(wallet): playing around

* chore(wallet): poc in place

* refactor(scriptexplorer): delete separate psbt interface

* refactor(scriptexplorer): finish removal of psbt link

* feat(scriptexplorer): proof of concept with network hardcoded

* build(wallet): got hermit signing working for redeem_script and wallet interfaces

* chore(wallet): removing poc code

* chore(address): fix typo

* build(hermit): fix hardcoded testnet network

* chore(hermit): add date to footer yurt canary

* chore(wallet): remove a footer canary

* chore(wallet): uncomment import psbt button

* chore(hermit): remove pubkey importer

* chore(hermit): run eslint and fix imports

* chore(hermit): clarify and cleanup comments

* chore(hermit): swap out hardcoded testnet

* chore(hermit): run prettier

* chore(hermit): update comment for future work

---------

Co-authored-by: Dhruv Bansal <[email protected]>
  • Loading branch information
2 people authored and robertshuford committed Oct 23, 2023
1 parent 2922b0e commit 04ca0ce
Show file tree
Hide file tree
Showing 28 changed files with 1,119 additions and 235 deletions.
146 changes: 146 additions & 0 deletions src/actions/transactionActions.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
};
}
2 changes: 2 additions & 0 deletions src/components/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -37,6 +38,7 @@ const App = () => (
<Route path="/address" component={CreateAddress} />
<Redirect from="/spend" to="/script" />
<Route path="/script" component={ScriptExplorer} />
<Route path="/hermit-psbt" component={HermitPsbtInterface} />
<Route path="/wallet" component={Wallet} />
<Route path="/help" component={Help} />
<Route path="/" component={Help} />
Expand Down
90 changes: 0 additions & 90 deletions src/components/CreateAddress/HermitPublicKeyImporter.jsx

This file was deleted.

26 changes: 4 additions & 22 deletions src/components/CreateAddress/PublicKeyImporter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -116,7 +115,6 @@ class PublicKeyImporter extends React.Component {
<MenuItem value="">{"< Select method >"}</MenuItem>
<MenuItem value={TREZOR}>Trezor</MenuItem>
<MenuItem value={LEDGER}>Ledger</MenuItem>
<MenuItem value={HERMIT}>Hermit</MenuItem>
<MenuItem value={XPUB}>Derive from extended public key</MenuItem>
<MenuItem value={TEXT}>Enter as text</MenuItem>
</TextField>
Expand Down Expand Up @@ -146,18 +144,6 @@ class PublicKeyImporter extends React.Component {
/>
);
}
if (publicKeyImporter.method === HERMIT) {
return (
<HermitPublicKeyImporter
network={network}
defaultBIP32Path={defaultBIP32Path}
validatePublicKey={this.validatePublicKey}
enableChangeMethod={this.enableChangeMethod}
disableChangeMethod={this.disableChangeMethod}
onImport={this.handleImport}
/>
);
}
if (publicKeyImporter.method === XPUB) {
return (
<ExtendedPublicKeyPublicKeyImporter
Expand Down Expand Up @@ -205,13 +191,10 @@ class PublicKeyImporter extends React.Component {
setFinalized(number, true);
};

reset = (shouldResetBIP32Path) => {
const { number, setPublicKey, resetBIP32Path, setFinalized } = this.props;
reset = () => {
const { number, setPublicKey, setFinalized } = this.props;
setPublicKey(number, "");
setFinalized(number, false);
if (shouldResetBIP32Path) {
resetBIP32Path(number);
}
};

//
Expand Down Expand Up @@ -274,7 +257,7 @@ class PublicKeyImporter extends React.Component {
color="secondary"
size="small"
onClick={() => {
this.reset(publicKeyImporter.method === HERMIT);
this.reset();
}}
>
Remove Public Key
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 04ca0ce

Please sign in to comment.