Skip to content

Commit

Permalink
BG-8540: Add support for recovering migrated SafeHD BCH wallets (#24)
Browse files Browse the repository at this point in the history
* BG-8540: Add support for recovering migrated SafeHD BCH wallets

Signed-off-by: Tyler Levine <tyler@bitgo.com>

* Save correct tx when saving transaction

Signed-off-by: Tyler Levine <tyler@bitgo.com>

* Update gitignore

Signed-off-by: Tyler Levine <tyler@bitgo.com>

* Clean up console logs

Signed-off-by: Tyler Levine <tyler@bitgo.com>

* Remove unused save transaction functionality, add additional info in warning

Signed-off-by: Tyler Levine <tyler@bitgo.com>

* Fix typo

Signed-off-by: Tyler Levine <tyler@bitgo.com>
  • Loading branch information
Tyler Levine authored and typokign committed Nov 29, 2018
1 parent 54673c7 commit dbb9ad7
Show file tree
Hide file tree
Showing 8 changed files with 326 additions and 6 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ electron-build/
src/images/profile_pic_2.jpeg

out/
dist/
dist/
.idea/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "BitGoWalletRecoveryWizard",
"author": "BitGo, Inc.",
"description": "A UI-based desktop app for BitGo Recoveries",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"main": "src/main.js",
"homepage": "./",
Expand Down
4 changes: 3 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import './App.css';
class App extends Component {
state = { isLoggedIn: false, loginBypass: false, bitgo: null };

updateLoginState = (isLoggedIn) => (bitgoInstance) => {
updateLoginState = (isLoggedIn) => (bitgoInstance, utxoLibInstance) => {
if (isLoggedIn && !bitgoInstance) {
throw new Error('If logging in, please pass in an authenticated BitGoJS instance.');
}

bitgoInstance.utxoLib = utxoLibInstance;

this.setState({ isLoggedIn, bitgo: bitgoInstance, loginBypass: false });
}

Expand Down
303 changes: 303 additions & 0 deletions src/components/migrated-bch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
import React, { Component } from 'react';
import { InputField } from './form-components';
import { Form, Button, Row, Col, Alert } from 'reactstrap';
import { address, HDNode, Transaction, TransactionBuilder } from 'bitgo-utxo-lib';

import * as _ from 'lodash';

import ErrorMessage from './error-message';

import tooltips from 'constants/tooltips';

import moment from 'moment';

const fs = window.require('fs');
const { dialog } = window.require('electron').remote;
const formTooltips = tooltips.migratedBch;

class MigratedBchRecoveryForm extends Component {
state = {
walletId: '',
recoveryAddress: '',
passphrase: '',
prv: '',
recoveryTx: null,
logging: [''],
error: '',
recovering: false,
twofa: '',
};

collectLog = (...args) => {
const { logging } = this.state;
const newLogging = logging.concat(args);
this.setState({ logging: newLogging });
};

updateRecoveryInfo = (field) => (value) => {
this.setState({ [field]: value });
};

resetRecovery = () => {
this.setState({
walletId: '',
tokenAddress: '',
recoveryAddress: '',
passphrase: '',
prv: '',
recoveryTx: null,
logging: [''],
error: '',
recovering: false,
twofa: '',
});
};

createRecoveryTx = async (bch, migratedWallet) => {

const OUTPUT_SIZE = 34;

const { bitgo } = this.props;
const {
walletId,
recoveryAddress,
passphrase,
feeRate = 5000,
} = this.state;

try {
address.fromBase58Check(recoveryAddress);
} catch (e) {
throw new Error('Invalid destination address, only base 58 is supported');
}

const maximumSpendable = await migratedWallet.maximumSpendable({ feeRate });
const spendableAmount = parseInt(maximumSpendable.maximumSpendable, 10);

const v1Wallet = await bitgo.wallets().get({ id: walletId });

// Account for paygo fee plus fee for paygo output
const payGoDeduction = Math.floor(spendableAmount * 0.01) + (OUTPUT_SIZE * (feeRate / 1000));
const txAmount = spendableAmount - payGoDeduction;

let txPrebuild;
try {
txPrebuild = await migratedWallet.prebuildTransaction({
recipients: [{
address: recoveryAddress,
amount: txAmount
}],
feeRate,
noSplitChange: true
});
} catch (e) {
console.error('Got error building tx:');
throw e;
}

const utxoLib = bitgo.utxoLib;

if (!utxoLib) {
throw new Error('could not get utxo lib reference from bitgo object');
}

const signingKeychain = await v1Wallet.getAndPrepareSigningKeychain({ walletPassphrase: passphrase });
const rootExtKey = HDNode.fromBase58(signingKeychain.xprv, bch.network);
const hdPath = utxoLib.hdPath(rootExtKey);

// sign the transaction
let transaction = Transaction.fromHex(txPrebuild.txHex, bch.network);

if (transaction.ins.length !== txPrebuild.txInfo.unspents.length) {
throw new Error('length of unspents array should equal to the number of transaction inputs');
}

const txb = TransactionBuilder.fromTransaction(transaction, bch.network);
txb.setVersion(2);

const sigHashType = Transaction.SIGHASH_ALL | Transaction.SIGHASH_BITCOINCASHBIP143;
for (let inputIndex = 0; inputIndex < transaction.ins.length; ++inputIndex) {
// get the current unspent
const currentUnspent = txPrebuild.txInfo.unspents[inputIndex];
if (currentUnspent.chain === undefined || currentUnspent.index === undefined) {
console.warn(`missing chain or index for unspent: ${currentUnspent.id}. skipping...`);
continue;
}
const chainPath = '/' + currentUnspent.chain + '/' + currentUnspent.index;
const subPath = signingKeychain.walletSubPath || '/0/0';
const path = signingKeychain.path + subPath + chainPath;
// derive the correct key
const privKey = hdPath.deriveKey(path);
const value = currentUnspent.value;

// do the signature flow
const subscript = new Buffer(currentUnspent.redeemScript, 'hex');
try {
txb.sign(inputIndex, privKey, subscript, sigHashType, value);
} catch (e) {
console.log(`got exception while signing unspent ${JSON.stringify(currentUnspent)}`);
console.trace(e);
throw e;
}

// now, let's verify the signature
transaction = txb.buildIncomplete();
const isSignatureVerified = bch.verifySignature(transaction, inputIndex, value);
if (!isSignatureVerified) {
throw new Error(`Could not verify signature on input #${inputIndex}`);
}
}

const tx = txb.buildIncomplete();
return {
hex: tx.toHex(),
id: tx.getId()
}
};

performRecovery = async () => {
const { bitgo } = this.props;
this.setState({ error: '', recovering: true });

const bch = bitgo.coin('bch');
const bchWallets = await bch.wallets().list();
const migratedWallet = _.find(bchWallets.wallets, w => w._wallet.migratedFrom === this.state.walletId);

if (!migratedWallet) {
throw new Error('could not find a bch wallet which was migrated from ' + this.state.walletId);
}

console.info('found bch wallet: ', migratedWallet.id());

let recoveryTx;
try {
recoveryTx = await this.createRecoveryTx(bch, migratedWallet);
} catch (e) {
if (e.message === 'insufficient balance') { // this is terribly unhelpful
e.message = 'Insufficient balance to recover';
}
this.collectLog(e.message);
this.setState({ error: e.message, recovering: false });
}

if (!recoveryTx || !recoveryTx.hex) {
console.error('Failed to create half-signed recovery transaction');
return;
}

let needsUnlock = false;
try {
await migratedWallet.submitTransaction({
txHex: recoveryTx.hex
});
} catch (e) {
if (e.message === 'needs unlock') {
// try again after unlocking
needsUnlock = true;
} else {
this.setState({ error: e.message, recovering: false });
throw e;
}
}

if (needsUnlock) {
try {
await bitgo.unlock({ otp: this.state.twofa });
await migratedWallet.submitTransaction({
txHex: recoveryTx.hex
});
console.info(`successfully submitted transaction ${recoveryTx.id} to bitgo`);
} catch (e) {
// failed even after unlock - this is fatal
console.log('got error on submit after unlock');
console.error(e);
this.setState({ error: e.message, recovering: false });
throw e;
}
}

// recovery tx was successfully submitted
this.setState({ recoveryTx, recovering: false });
};

render() {
const coin = this.props.bitgo.env === 'prod' ? 'bch' : 'tbch';

return (
<div>
<h1 className='content-header'>Migrated Bitcoin Cash Recoveries</h1>
<p className='subtitle'>This tool will help you recover Bitcoin Cash from migrated wallets which are no longer officially supported by BitGo.</p>
<Alert color='warning'>
<p>
Transactions submitted using this tool are irreversible. Please double check your destination address to ensure it is correct.
</p>
<br />
<p>
Additionally, we recommend creating a policy on your migrated BCH wallet which whitelists only the destination address,
and removing all other policies on the wallet. This will ensure that accidental sends to addresses other than the destination address will not be processed immediately, and will instead result in a pending approval, which you may then cancel.
</p>
</Alert>
<hr />
<Form>
<InputField
label='Original Bitcoin Wallet ID'
name='walletId'
onChange={this.updateRecoveryInfo}
value={this.state.walletId}
tooltipText={formTooltips.walletId}
disallowWhiteSpace={true}
/>
<InputField
label='Destination Address'
name='recoveryAddress'
onChange={this.updateRecoveryInfo}
value={this.state.recoveryAddress}
tooltipText={formTooltips.recoveryAddress}
disallowWhiteSpace={true}
format='address'
coin={this.props.bitgo.coin(coin)}
/>
<InputField
label='Wallet Passphrase'
name='passphrase'
onChange={this.updateRecoveryInfo}
value={this.state.passphrase}
tooltipText={formTooltips.passphrase}
isPassword={true}
/>
<InputField
label='2FA Code'
name='twofa'
onChange={this.updateRecoveryInfo}
value={this.state.twofa}
tooltipText={formTooltips.twofa}
isPassword={true}
/>
{this.state.error && <ErrorMessage>{this.state.error}</ErrorMessage>}
{this.state.recoveryTx && <p className='recovery-logging'>Success! Recovery transaction has been submitted. Transaction ID: {this.state.recoveryTx.id}</p>}
<Row>
<Col xs={12}>
{!this.state.recoveryTx && !this.state.recovering &&
<Button onClick={this.performRecovery} className='bitgo-button'>
Recover Bitcoin Cash
</Button>
}
{!this.state.recoveryTx && this.state.recovering &&
<Button disabled={true} className='bitgo-button'>
Recovering...
</Button>
}
{this.state.recoveryTx && !this.state.recovering && !this.state.error &&
<Button disabled={true} className='bitgo-button'>
Recovery Successful
</Button>
}
</Col>
</Row>
</Form>
</div>
)
}
}

export default MigratedBchRecoveryForm;
8 changes: 8 additions & 0 deletions src/constants/nav.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CrossChainRecoveryForm from 'components/cross-chain';
import NonBitGoRecoveryForm from 'components/non-bitgo';
import UnsupportedTokenRecoveryForm from 'components/unsupported-token';
import MigratedBchRecoveryForm from 'components/migrated-bch';

export default {
main: [
Expand All @@ -24,6 +25,13 @@ export default {
description: 'Recover wallets using the user and backup key (sign a transaction without BitGo).',
needsLogin: false,
NavComponent: NonBitGoRecoveryForm
},
{
title: 'Migrated Bitcoin Cash Recoveries',
url: '/migratedbch',
description: 'Recover unsupported migrated Bitcoin Cash wallets',
needsLogin: true,
NavComponent: MigratedBchRecoveryForm
}
]
};
6 changes: 6 additions & 0 deletions src/constants/tooltips.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ export default {
scan: 'The amount of addresses without transactions to scan before stopping the tool.',
tokenAddress: 'The address of the smart contract of the token to recover. This is unique to each token, and is NOT your wallet address.',
krsProvider: 'The Key Recovery Service that you chose to manage your backup key. If you have the encrypted backup key, you may leave this blank.'
},
migratedBch: {
walletId: 'The ID (base address) of the v1 BTC wallet which this BCH wallet was migrated from. If you are having trouble locating this ID, please contact support@bitgo.com.',
recoveryAddress: 'The address of the new wallet where you would like your recovered funds to be sent.',
passphrase: 'The wallet passphrase of the migrated wallet.',
twofa: 'Second factor authentication (2FA) code',
}
}
2 changes: 1 addition & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const template = [{

function createWindow() {
// Create the browser window.
mainWindow = new BrowserWindow({ width: 1366, height: 768, resizable: true, minWidth: 1366, minHeight: 768 });
mainWindow = new BrowserWindow({ width: 1500, height: 768, resizable: true, minWidth: 1366, minHeight: 768 });

// and load the index.html of the app.
const startUrl = process.env.ELECTRON_START_URL || url.format({
Expand Down
4 changes: 2 additions & 2 deletions src/views/login/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ class Login extends Component {
const authResponse = await bitgo.authenticate({ username, password, otp });
bitgo.sessionInfo = authResponse;

// Successfully logged in, so update the app (and give it bitgo instance)
this.props.finishLogin(bitgo);
// Successfully logged in, so update the app (and give it bitgo and utxo lib instances)
this.props.finishLogin(bitgo, BitGoJS.bitcoin);
} catch (e) {
this.setState({ loginInProgress: false, error: 'There was an error logging in. Please check your username, password and OTP, and try again. '});
}
Expand Down

0 comments on commit dbb9ad7

Please sign in to comment.