diff --git a/app/background/node/service.js b/app/background/node/service.js index 0d12fa33f..8b7006eb6 100644 --- a/app/background/node/service.js +++ b/app/background/node/service.js @@ -191,7 +191,7 @@ export class NodeService extends EventEmitter { this.networkName = networkName; this.network = network; - this.apiKey = await this.getAPIKey(); + this.apiKey = null;// await this.getAPIKey(); this.noDns = await this.getNoDns(); this.spv = await this.getSpvMode(); } diff --git a/app/background/wallet/client.js b/app/background/wallet/client.js index 23ea9cd86..6cf9778d4 100644 --- a/app/background/wallet/client.js +++ b/app/background/wallet/client.js @@ -66,4 +66,5 @@ export const clientStub = ipcRendererInjector => makeClient(ipcRendererInjector, 'isReady', 'createClaim', 'sendClaim', + 'addSharedKey' ]); diff --git a/app/background/wallet/service.js b/app/background/wallet/service.js index 40086043c..c3b167ee0 100644 --- a/app/background/wallet/service.js +++ b/app/background/wallet/service.js @@ -93,7 +93,7 @@ class WalletService { this.node = plugin; this.network = plugin.network; this.networkName = this.network.type; - this.walletApiKey = apiKey; + this.walletApiKey = null;//apiKey; dispatchToMainWindow({ type: SET_WALLET_NETWORK, @@ -342,7 +342,14 @@ class WalletService { }); }; - createNewWallet = async (name, passphrase, isLedger, xPub) => { + createNewWallet = async ( + name, + passphrase, + isLedger, + xPub, + m, + n + ) => { this.setWallet(name); let res; @@ -360,6 +367,8 @@ class WalletService { passphrase, watchOnly: false, mnemonic: mnemonic.getPhrase().trim(), + m, + n }); } @@ -800,6 +809,10 @@ class WalletService { ); }; + addSharedKey = async (account, xpub) => { + return this.client.addSharedKey(this.name, account, xpub); + }; + isLocked = () => this._ledgerProxy( () => false, async () => { @@ -1687,6 +1700,7 @@ const methods = { isReady: service.isReady, createClaim: service.createClaim, sendClaim: service.sendClaim, + addSharedKey: service.addSharedKey, }; export async function start(server) { diff --git a/app/components/ReceiveModal/receive.scss b/app/components/ReceiveModal/receive.scss index 860dc81ee..64326a73a 100644 --- a/app/components/ReceiveModal/receive.scss +++ b/app/components/ReceiveModal/receive.scss @@ -83,6 +83,7 @@ font-size: 0.7rem; font-family: 'Roboto Mono', monospace; font-weight: 500; + margin: 5px; } &__disclaimer { diff --git a/app/components/Sidebar/index.js b/app/components/Sidebar/index.js index 5324d171b..1255f179d 100644 --- a/app/components/Sidebar/index.js +++ b/app/components/Sidebar/index.js @@ -26,6 +26,7 @@ const nodeClient = clientStub(() => require('electron').ipcRenderer); rescanHeight: state.wallet.rescanHeight, address: state.wallet.address, updateAvailable: state.app.updateAvailable, + accountInfo: state.wallet.accountInfo, }), dispatch => ({ @@ -51,6 +52,7 @@ class Sidebar extends Component { network: PropTypes.string.isRequired, address: PropTypes.string.isRequired, updateAvailable: PropTypes.object, + accountInfo: PropTypes.object.isRequired, }; static contextType = I18nContext; @@ -71,14 +73,46 @@ class Sidebar extends Component { renderNav() { const {t} = this.context; - const title = this.props.walletWatchOnly - ? `Ledger Wallet (${this.props.walletId})` - : `Wallet (${this.props.walletId})`; + const {watchOnly, type, initialized} = this.props.accountInfo; + + let title = 'Wallet'; + if (watchOnly) + title = 'Ledger Wallet'; + else if (type === 'multisig') + title = 'Multisig Wallet'; + title += ` (${this.props.walletId})`; + + if (!initialized) { + return( + +
{title}
+
+ + ⚠️ Multisig + +
+
+ ); + } return (
{title}
+ { + type === 'multisig' && + + Multisig + + } `sidebar__action ${isActive ? "sidebar__action--selected" : ''}`} diff --git a/app/components/Topbar/index.js b/app/components/Topbar/index.js index 234247ee2..ea13d6f46 100644 --- a/app/components/Topbar/index.js +++ b/app/components/Topbar/index.js @@ -26,6 +26,7 @@ import * as walletActions from '../../ducks/walletActions'; spendableBalance: state.wallet.balance.spendable, walletId: state.wallet.wid, walletWatchOnly: state.wallet.watchOnly, + accountInfo: state.wallet.accountInfo, }; }, dispatch => ({ @@ -47,6 +48,7 @@ class Topbar extends Component { lockWallet: PropTypes.func.isRequired, spendableBalance: PropTypes.number, walletWatchOnly: PropTypes.bool.isRequired, + accountInfo: PropTypes.object.isRequired, }; static contextType = I18nContext; @@ -121,9 +123,12 @@ class Topbar extends Component { const { spendableBalance, walletId } = this.props; const { isShowingSettingMenu } = this.state; - const walletName = this.props.walletWatchOnly - ? `${walletId} (Ledger)` - : walletId; + const {watchOnly, type} = this.props.accountInfo; + let walletName = walletId; + if (watchOnly) + walletName += ' (Ledger)'; + else if (type === 'multisig') + walletName += ' (Multisig)'; return (
{ changeDepth, receiveDepth, accountKey, + accountInfo = {}, } = opts; return { @@ -49,6 +50,7 @@ export const setWallet = opts => { changeDepth, receiveDepth, accountKey, + accountInfo }, }; }; @@ -102,6 +104,7 @@ export const fetchWallet = () => async (dispatch, getState) => { changeDepth: accountInfo.changeDepth, receiveDepth: accountInfo.receiveDepth, accountKey: accountInfo.accountKey, + accountInfo, // TODO: remove all the above crap and just pass account object })); }; diff --git a/app/ducks/walletReducer.js b/app/ducks/walletReducer.js index 2c292c8df..07b63408e 100644 --- a/app/ducks/walletReducer.js +++ b/app/ducks/walletReducer.js @@ -82,6 +82,7 @@ export default function walletReducer(state = getInitialState(), {type, payload} receiveDepth: payload.receiveDepth, accountKey: payload.accountKey, initialized: typeof payload.initialized === 'undefined' ? state.initialized : payload.initialized, + accountInfo: payload.accountInfo, }; case SET_BALANCE: return { diff --git a/app/pages/Account/index.js b/app/pages/Account/index.js index 186db3dbb..4bc9c5cd5 100644 --- a/app/pages/Account/index.js +++ b/app/pages/Account/index.js @@ -31,6 +31,7 @@ const analytics = aClientStub(() => require("electron").ipcRenderer); isFetching: state.wallet.isFetching, network: state.wallet.network, hnsPrice: state.node.hnsPrice, + accountInfo: state.wallet.accountInfo, }), (dispatch) => ({ fetchWallet: () => dispatch(walletActions.fetchWallet()), @@ -62,6 +63,7 @@ export default class Account extends Component { finalizeMany: PropTypes.func.isRequired, renewMany: PropTypes.func.isRequired, history: PropTypes.object.isRequired, + accountInfo: PropTypes.object.isRequired, }; static contextType = I18nContext; @@ -86,6 +88,11 @@ export default class Account extends Component { constructor(props) { super(props); this.updateStatsAndBalance = throttle(this.updateStatsAndBalance, 15000, { trailing: true }); + + if ( this.props.accountInfo.type === 'multisig' + && !this.props.accountInfo.initialized) { + this.props.history.push('/multisig'); + } } componentDidMount() { diff --git a/app/pages/App/index.js b/app/pages/App/index.js index 107d803c7..2f9ed77cc 100644 --- a/app/pages/App/index.js +++ b/app/pages/App/index.js @@ -38,6 +38,7 @@ import Exchange from '../Exchange'; import SignMessage from "../SignMessage"; import VerifyMessage from "../VerifyMessage"; import {fetchLocale, initHip2, checkForUpdates} from "../../ducks/app"; +import Multisig from "../Multisig"; import {I18nContext} from "../../utils/i18n"; const connClient = cClientStub(() => require('electron').ipcRenderer); const settingClient = sClientStub(() => require('electron').ipcRenderer); @@ -251,6 +252,12 @@ class App extends Component { path="/exchange" render={this.routeRenderer(t('headingExchange'), Exchange, true)} /> + diff --git a/app/pages/Multisig/index.js b/app/pages/Multisig/index.js new file mode 100644 index 000000000..63c6f41a3 --- /dev/null +++ b/app/pages/Multisig/index.js @@ -0,0 +1,137 @@ +import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; +import { withRouter } from "react-router"; +import { connect } from "react-redux"; +import {clientStub as wClientStub} from "../../background/wallet/client"; +import * as networks from "hsd/lib/protocol/networks"; +import { clientStub as aClientStub } from "../../background/analytics/client"; +import * as walletActions from "../../ducks/walletActions"; +import { showError, showSuccess } from "../../ducks/notifications"; +import * as nameActions from "../../ducks/names"; +import * as nodeActions from "../../ducks/node"; +import {HeaderItem, HeaderRow, Table, TableItem, TableRow} from "../../components/Table"; +import CopyButton from "../../components/CopyButton"; + +// I'm getting pretty tired of creating brand new stylesheets +// for every single view! +import "../../components/NameClaimModal/name-claim-modal.scss"; + +const walletClient = wClientStub(() => require('electron').ipcRenderer); + + +@withRouter +@connect( + (state) => ({ + accountInfo: state.wallet.accountInfo, + wallet: state.wallet, + }), + (dispatch) => ({ + fetchWallet: () => dispatch(walletActions.fetchWallet()), + }) +) +export default class Multisig extends Component { + static propTypes = { + accountInfo: PropTypes.object.isRequired, + wallet: PropTypes.object.isRequired, + fetchWallet: PropTypes.func.isRequired, + }; + + constructor(props) { + super(props); + } + + render() { + return ( +
+
+ Multisig wallet policy: {this.props.accountInfo.m}-of-{this.props.accountInfo.n} +
+ + + Signer + Account Key (xpub) + + + + {this.props.wallet.wid} (me) + + {this.props.accountInfo.accountKey} + + + + {this.renderOtherSigners()} + +
+
+ ); + } + + renderOtherSigners() { + const rows = this.props.accountInfo.n - 1; // Our own key is already there + const keys = this.props.accountInfo.keys; + const out = []; + + for (let i = 0; i < rows; i++) { + let key = keys[i]; + + if (!key) { + key = + + } + + out.push( + + Signer #{i+2} + + {key} + + + ); + } + + return out; + } +} + +class KeyInput extends Component { + static propTypes = { + fetchWallet: PropTypes.func, + } + + state = { + errorMessage: null, + } + + async addKey(xpub) { + this.setState({errorMessage: null}) + try { + await walletClient.addSharedKey('default', xpub); // account, xpub + await this.props.fetchWallet(); + } catch (e) {console.log(e) + this.setState({errorMessage: e.message}); + } + } + + handleChange = () => { + this.setState({errorMessage: null}); + } + + render() { + return ( +
+ e.key === 'Enter' && this.addKey(e.target.value)} + placeholder="xpub..." + /> +
+ {this.state.errorMessage} +
+
+ ); + } +} diff --git a/app/pages/Onboarding/ConnectLedger/index.js b/app/pages/Onboarding/ConnectLedger/index.js index 0224a655f..46c268783 100644 --- a/app/pages/Onboarding/ConnectLedger/index.js +++ b/app/pages/Onboarding/ConnectLedger/index.js @@ -82,7 +82,14 @@ class ConnectLedger extends React.Component { // set a small timeout to clearly show that this is // a two-phase process. setTimeout(async () => { - await walletClient.createNewWallet(this.props.walletName, this.props.passphrase, true, xpub); + await walletClient.createNewWallet( + this.props.walletName, + this.props.passphrase, + true, // is Ledger + xpub, + 1, // m + 1 // n + ); await this.props.completeInitialization(this.props.walletName, this.props.passphrase); this.props.history.push('/account'); }, 2000); diff --git a/app/pages/Onboarding/CreateMultisig/index.js b/app/pages/Onboarding/CreateMultisig/index.js new file mode 100644 index 000000000..2fbbc851e --- /dev/null +++ b/app/pages/Onboarding/CreateMultisig/index.js @@ -0,0 +1,86 @@ +import React from 'react'; +import classNames from 'classnames'; +import { withRouter } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { clientStub as lClientStub } from '../../../background/ledger/client'; +import walletClient from '../../../utils/walletClient'; +import { connect } from 'react-redux'; +import * as walletActions from '../../../ducks/walletActions'; + + +@withRouter +@connect((state) => ({ + network: state.wallet.network, +}), (dispatch) => ({ + completeInitialization: (name, passphrase) => dispatch( + walletActions.completeInitialization(name, passphrase) + ), +})) +export default class CreateMultisig extends React.Component { + static propTypes = { + walletName: PropTypes.string.isRequired, + passphrase: PropTypes.string.isRequired, + onBack: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + completeInitialization: PropTypes.func.isRequired, + network: PropTypes.string.isRequired, + }; + + state = { + errorMessage: null, + m: 2, + n: 2 + }; + + render() { + const { + walletName, + passphrase, + onBack, + onCancel, + completeInitialization, + network + } = this.props; + + return ( +
+ Total number of participants: + this.setState({n: e.target.value})} + /> + Required signatures per transaction: + this.setState({m: e.target.value})} + /> + +
+ ); + } +} diff --git a/app/pages/Onboarding/CreateNewAccount/index.js b/app/pages/Onboarding/CreateNewAccount/index.js index 77710a4a1..29aefa345 100644 --- a/app/pages/Onboarding/CreateNewAccount/index.js +++ b/app/pages/Onboarding/CreateNewAccount/index.js @@ -9,6 +9,7 @@ import BackUpSeedWarning from '../BackUpSeedWarning/index'; import CopySeed from '../../CopySeed/index'; import ConfirmSeed from '../ConfirmSeed/index'; import SetName from '../SetName/index'; +import CreateMultisig from '../CreateMultisig/index'; import * as walletActions from '../../../ducks/walletActions'; import '../ImportSeedEnterMnemonic/importenter.scss'; import '../ImportSeedWarning/importwarning.scss'; @@ -27,6 +28,7 @@ const BACK_UP_SEED_WARNING = 4; const COPY_SEEDPHRASE = 5; const CONFIRM_SEEDPHRASE = 6; const LEDGER_CONNECT = 7; +const MULTISIG = 8; class CreateNewAccount extends Component { static propTypes = { @@ -40,6 +42,7 @@ class CreateNewAccount extends Component { state = { currentStep: TERMS_OF_USE, + stepNumber: 1, name: '', seedphrase: '', passphrase: '', @@ -47,26 +50,46 @@ class CreateNewAccount extends Component { }; render() { - const totalSteps = this.props.match.params.loc === 'ledger' ? 4 : 6; + // null, 'ledger', 'multisig' + const variation = this.props.match.params.loc; + + let totalSteps = 6; + switch (variation) { + case 'ledger': + totalSteps = 4; + break; + case 'multisig': + break; + } switch (this.state.currentStep) { case TERMS_OF_USE: return ( this.setState({currentStep: SET_NAME})} + onAccept={() => this.setState({ + currentStep: SET_NAME, + stepNumber: this.state.stepNumber + 1 + })} onBack={() => this.props.history.push('/funding-options')} /> ); case SET_NAME: return ( this.setState({currentStep: TERMS_OF_USE})} + onBack={() => this.setState({ + currentStep: TERMS_OF_USE, + stepNumber: this.state.stepNumber - 1 + })} onNext={(name) => { - this.setState({currentStep: CREATE_PASSWORD, name}); + this.setState({ + currentStep: CREATE_PASSWORD, + name, + stepNumber: this.state.stepNumber + 1 + }); }} onCancel={() => this.props.history.push('/funding-options')} /> @@ -74,24 +97,36 @@ class CreateNewAccount extends Component { case CREATE_PASSWORD: return ( this.setState({ currentStep: SET_NAME, + stepNumber: this.state.stepNumber - 1 }) } onNext={async (passphrase) => { const optInState = await analytics.getOptIn(); - let currentStep = - this.props.match.params.loc === 'ledger' - ? LEDGER_CONNECT - : BACK_UP_SEED_WARNING; + + let currentStep = BACK_UP_SEED_WARNING; + switch (variation) { + case 'ledger': + currentStep = LEDGER_CONNECT; + break; + case 'multisig': + currentStep = MULTISIG; + break; + } + if (optInState === 'NOT_CHOSEN') { currentStep = OPT_IN_ANALYTICS; } - this.setState({currentStep, passphrase}); + this.setState({ + currentStep, + passphrase, + stepNumber: this.state.stepNumber + 1 + }); }} onCancel={() => this.props.history.push('/funding-options')} /> @@ -99,28 +134,57 @@ class CreateNewAccount extends Component { case OPT_IN_ANALYTICS: return ( this.setState({currentStep: CREATE_PASSWORD})} + onBack={() => this.setState({ + currentStep: CREATE_PASSWORD, + stepNumber: this.state.stepNumber - 1 + })} onNext={async (optInState) => { await analytics.setOptIn(optInState); + + let currentStep = BACK_UP_SEED_WARNING; + switch (variation) { + case 'ledger': + currentStep = LEDGER_CONNECT; + break; + case 'multisig': + currentStep = MULTISIG; + break; + } + this.setState({ - currentStep: - this.props.match.params.loc === 'ledger' - ? LEDGER_CONNECT - : BACK_UP_SEED_WARNING, + currentStep, + stepNumber: this.state.stepNumber + 1 }); }} onCancel={() => this.props.history.push('/funding-options')} /> ); + case MULTISIG: + return( + + this.setState({ + currentStep: CREATE_PASSWORD, + stepNumber: this.state.stepNumber - 1 + }) + } + onCancel={() => this.props.history.push('/funding-options')} + /> + ); case LEDGER_CONNECT: return ( - this.setState({ currentStep: CREATE_PASSWORD }) + this.setState({ + currentStep: CREATE_PASSWORD, + stepNumber: this.state.stepNumber - 1 + }) } onCancel={() => this.props.history.push('/funding-options')} /> @@ -128,10 +192,13 @@ class CreateNewAccount extends Component { case BACK_UP_SEED_WARNING: return ( - this.setState({ currentStep: CREATE_PASSWORD }) + this.setState({ + currentStep: CREATE_PASSWORD, + stepNumber: this.state.stepNumber - 1 + }) } onNext={async () => { this.setState({ isLoading: true }); @@ -145,14 +212,18 @@ class CreateNewAccount extends Component { this.state.name, this.state.passphrase, false, // isLedger - null // xpub (Ledger only) + null, // xpub (Ledger only) + 1, // m + 1 // n ); } const {phrase} = await walletClient.revealSeed(this.state.passphrase); + this.setState({ currentStep: COPY_SEEDPHRASE, seedphrase: phrase, isLoading: false, + stepNumber: this.state.stepNumber + 1 }); }} onCancel={() => this.props.history.push('/funding-options')} @@ -162,21 +233,30 @@ class CreateNewAccount extends Component { case COPY_SEEDPHRASE: return ( this.setState({ currentStep: BACK_UP_SEED_WARNING })} - onNext={() => this.setState({ currentStep: CONFIRM_SEEDPHRASE })} + onBack={() => this.setState({ + currentStep: BACK_UP_SEED_WARNING, + stepNumber: this.state.stepNumber - 1 + })} + onNext={() => this.setState({ + currentStep: CONFIRM_SEEDPHRASE, + stepNumber: this.state.stepNumber + 1 + })} onCancel={() => this.props.history.push('/funding-options')} /> ); case CONFIRM_SEEDPHRASE: return ( this.setState({ currentStep: COPY_SEEDPHRASE })} + onBack={() => this.setState({ + currentStep: COPY_SEEDPHRASE, + stepNumber: this.state.stepNumber - 1 + })} onNext={async () => { await this.props.completeInitialization( this.state.name, diff --git a/app/pages/Onboarding/FundAccessOptions/index.js b/app/pages/Onboarding/FundAccessOptions/index.js index 7e2af9887..e145970a0 100644 --- a/app/pages/Onboarding/FundAccessOptions/index.js +++ b/app/pages/Onboarding/FundAccessOptions/index.js @@ -51,6 +51,13 @@ class FundAccessOptions extends Component { > {t('obMainConnectLedger')} + {!!this.props.wallets.length && (