// Copyright (C) 2018 Zilliqa
//
// This file is part of zilliqa-js
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
import bip39 from 'bip39';
import hdkey from 'hdkey';
import { Signer, Provider, RPCMethod } from '@zilliqa-js/core';
import * as zcrypto from '@zilliqa-js/crypto';
import { Account } from './account';
import { Transaction } from './transaction';
import { BN } from '@zilliqa-js/util';
export class Wallet extends Signer {
accounts: { [address: string]: Account } = {};
defaultAccount?: Account;
provider: Provider;
/**
* constructor
*
* Takes an array of Account objects and instantiates a Wallet instance.
*
* @param {Account[]} accounts
*/
constructor(provider: Provider, accounts: Account[] = []) {
super();
if (accounts.length) {
this.accounts = accounts.reduce((acc, account) => {
return { ...acc, [account.address]: account };
}, {} as any);
}
this.provider = provider;
this.defaultAccount = accounts[0];
}
/**
* create
*
* Creates a new keypair with a randomly-generated private key. The new
* account is accessible by address.
*
* @returns {string} - address of the new account
*/
create(): string {
const privateKey = zcrypto.schnorr.generatePrivateKey();
const newAccount = new Account(privateKey);
this.accounts = { ...this.accounts, [newAccount.address]: newAccount };
if (!this.defaultAccount) {
this.defaultAccount = newAccount;
}
return newAccount.address;
}
/**
* addByPrivateKey
*
* Adds an account to the wallet by private key.
*
* @param {string} privateKey - hex-encoded private key
* @returns {string} - the corresponing address, computer from the private
* key.
*/
addByPrivateKey(privateKey: string): string {
const newAccount = new Account(privateKey);
this.accounts = { ...this.accounts, [newAccount.address]: newAccount };
if (!this.defaultAccount) {
this.defaultAccount = newAccount;
}
return newAccount.address;
}
/**
* addByKeystore
*
* Adds an account by keystore. This method is asynchronous and returns
* a Promise, in order not to block on the underlying decryption
* operation.
*
* @param {string} keystore
* @param {string} passphrase
* @returns {Promise}
*/
async addByKeystore(keystore: string, passphrase: string): Promise {
const newAccount = await Account.fromFile(keystore, passphrase);
this.accounts = { ...this.accounts, [newAccount.address]: newAccount };
if (!this.defaultAccount) {
this.defaultAccount = newAccount;
}
return newAccount.address;
}
/**
* addByMnemonic
*
* Adds an `Account` by use of a mnemonic as specified in BIP-32 and BIP-39
*
* @param {string} phrase - 12-word mnemonic phrase
* @param {number} index=0 - the number of the child key to add
* @returns {string} - the corresponding address
*/
addByMnemonic(phrase: string, index: number = 0): string {
if (!this.isValidMnemonic(phrase)) {
throw new Error(`Invalid mnemonic phrase: ${phrase}`);
}
const seed = bip39.mnemonicToSeed(phrase);
const hdKey = hdkey.fromMasterSeed(seed);
const childKey = hdKey.derive(`m/44'/313'/0'/0/${index}`);
const privateKey = childKey.privateKey.toString('hex');
return this.addByPrivateKey(privateKey);
}
/**
* addByMnemonicLedger
*
* Adds an `Account` by use of a mnemonic as specified in BIP-32 and BIP-39
* The key derivation path used in Ledger is different from that of
* addByMnemonic.
*
* @param {string} phrase - 12-word mnemonic phrase
* @param {number} index=0 - the number of the child key to add
* @returns {string} - the corresponding address
*/
addByMnemonicLedger(phrase: string, index: number = 0): string {
if (!this.isValidMnemonic(phrase)) {
throw new Error(`Invalid mnemonic phrase: ${phrase}`);
}
const seed = bip39.mnemonicToSeed(phrase);
const hdKey = hdkey.fromMasterSeed(seed);
const childKey = hdKey.derive(`m/44'/313'/${index}'/0'/0'`);
const privateKey = childKey.privateKey.toString('hex');
return this.addByPrivateKey(privateKey);
}
/**
* export
*
* Exports the specified account as a keystore file.
*
* @param {string} address
* @param {string} passphrase
* @param {KDF} kdf='scrypt'
* @returns {Promise}
*/
export(
address: string,
passphrase: string,
kdf: zcrypto.KDF = 'scrypt',
): Promise {
if (!this.accounts[address]) {
throw new Error(`No account with address ${address} exists`);
}
return this.accounts[address].toFile(passphrase, kdf);
}
/**
* remove
*
* Removes an account from the wallet and returns boolean to indicate
* failure or success.
*
* @param {string} address
* @returns {boolean}
*/
remove(address: string): boolean {
if (this.accounts[address]) {
const { [address]: toRemove, ...rest } = this.accounts;
this.accounts = rest;
return true;
}
return false;
}
/**
* setDefault
*
* Sets the default account of the wallet.
*
* @param {string} address
*/
setDefault(address: string) {
this.defaultAccount = this.accounts[address];
}
/**
* sign
*
* signs an unsigned transaction with the default account.
*
* @param {Transaction} tx
* @param {boolean} offlineSign
* @returns {Transaction}
*/
sign(tx: Transaction, offlineSign?: boolean): Promise {
if (tx.txParams && tx.txParams.pubKey) {
// attempt to find the address
const senderAddress = zcrypto.getAddressFromPublicKey(tx.txParams.pubKey);
if (!this.accounts[senderAddress]) {
throw new Error(
`Could not sign the transaction with ${senderAddress} as it does not exist`,
);
}
return this.signWith(tx, senderAddress, offlineSign);
}
if (!this.defaultAccount) {
throw new Error('This wallet has no default account.');
}
return this.signWith(tx, this.defaultAccount.address, offlineSign);
}
async signBatch(txList: Transaction[]): Promise {
const batchResults = [];
if (!this.defaultAccount) {
throw new Error('This wallet has no default account.');
}
try {
// nonce is assumed to come from default account
const signer = this.accounts[this.defaultAccount.address];
const balance = await this.provider.send(
RPCMethod.GetBalance,
signer.address.replace('0x', '').toLowerCase(),
);
if (balance.result === undefined) {
throw new Error('Could not get balance');
}
if (typeof balance.result.nonce !== 'number') {
throw new Error('Could not get nonce');
}
const nextNonce = balance.result.nonce + 1;
for (let index = 0; index < txList.length; index++) {
// increment nonce for each new transaction
const currentNonce = index + nextNonce;
const withNonceTx = txList[index].map((txObj) => {
return {
...txObj,
nonce: currentNonce,
pubKey: signer.publicKey,
};
});
const signedTx = await this.sign(withNonceTx);
batchResults.push(signedTx);
}
} catch (err) {
throw err;
}
return batchResults;
}
/**
* signWith
*
* @param {Transaction} tx
* @param {string} account
* @param {boolean} offlineSign
* @returns {Transaction}
*/
async signWith(
tx: Transaction,
account: string,
offlineSign?: boolean,
): Promise {
if (!this.accounts[account]) {
throw new Error(
'The selected account does not exist on this Wallet instance.',
);
}
const signer = this.accounts[account];
const gasPrice = tx.txParams.gasPrice;
const gasLimit = new BN(tx.txParams.gasLimit.toString());
const debt = gasPrice.mul(gasLimit).add(tx.txParams.amount);
let currNonce: number = 0;
try {
if (!tx.txParams.nonce) {
if (offlineSign) {
throw new Error(
'No nonce detected in tx params when signing in offline mode',
);
}
if (typeof offlineSign === 'undefined' || !offlineSign) {
// retrieve latest nonce
const balance = await this.provider.send(
RPCMethod.GetBalance,
signer.address.replace('0x', '').toLowerCase(),
);
if (balance.result === undefined) {
throw new Error('Could not get balance');
}
const bal = new BN(balance.result.balance);
if (debt.gt(bal)) {
throw new Error(
'You do not have enough funds, need ' +
debt.toString() +
' but only have ' +
bal.toString(),
);
}
if (typeof balance.result.nonce !== 'number') {
throw new Error('Could not get nonce');
}
currNonce = balance.result.nonce;
}
const withNonce = tx.map((txObj) => {
return {
...txObj,
nonce: txObj.nonce || currNonce + 1,
pubKey: signer.publicKey,
};
});
return withNonce.map((txObj) => {
// @ts-ignore
return {
...txObj,
signature: signer.signTransaction(withNonce.bytes),
};
});
}
const withPublicKey = tx.map((txObj) => {
return {
...txObj,
pubKey: signer.publicKey,
};
});
return withPublicKey.map((txObj) => {
return {
...txObj,
signature: signer.signTransaction(tx.bytes),
};
});
} catch (err) {
throw err;
}
}
private isValidMnemonic(phrase: string): boolean {
if (phrase.trim().split(/\s+/g).length < 12) {
return false;
}
return bip39.validateMnemonic(phrase);
}
}