diff --git a/lib/api_client.js b/lib/api_client.js index b3b41b9..59e79d5 100644 --- a/lib/api_client.js +++ b/lib/api_client.js @@ -102,6 +102,8 @@ var APIClient = function(options) { options.apiNetwork = options.apiNetwork || normalizedNetwork[3]; self.bitcoinCash = options.network === "BCC"; + self.chain = options.network; + self.rskTestnet = normalizedNetwork[4]; self.regtest = options.regtest; self.testnet = options.testnet; self.network = networkFromOptions(self); @@ -132,12 +134,17 @@ var APIClient = function(options) { }; +APIClient.BITCOIN_NETWORK = "BTC"; +APIClient.BITCOIN_CASH_NETWORK = "BCH"; +APIClient.ROOTSTOCK_NETWORK = "RSK"; + APIClient.normalizeNetworkFromOptions = function(options) { /* jshint -W071, -W074 */ var network = 'BTC'; var testnet = false; var regtest = false; var apiNetwork = "BTC"; + var rskTestnet = false; var prefix; var done = false; @@ -145,19 +152,21 @@ APIClient.normalizeNetworkFromOptions = function(options) { if (options.network) { var lower = options.network.toLowerCase(); - var m = lower.match(/^([rt])?(btc|bch|bcc)$/); + var m = lower.match(/^([rt])?(btc|bch|bcc|rsk)$/); if (!m) { throw new Error("Invalid network [" + options.network + "]"); } if (m[2] === 'btc') { network = "BTC"; + } else if (m[2] === 'rsk') { + network = "RSK"; } else { network = "BCC"; } prefix = m[1]; - if (prefix) { + if (prefix && network !== "RSK") { // if there's a prefix then we're "done", won't apply options.regtest and options.testnet after done = true; if (prefix === 'r') { @@ -166,6 +175,11 @@ APIClient.normalizeNetworkFromOptions = function(options) { } else if (prefix === 't') { testnet = true; } + } else if (prefix && network === "RSK") { + done = true; + if (prefix === 'r' || prefix === 't') { + rskTestnet = true; + } } } @@ -183,7 +197,7 @@ APIClient.normalizeNetworkFromOptions = function(options) { apiNetwork = (prefix || "") + network; - return [network, testnet, regtest, apiNetwork]; + return [network, testnet, regtest, apiNetwork, rskTestnet]; }; APIClient.updateHostOptions = function(options) { @@ -1277,34 +1291,61 @@ APIClient.prototype.initWallet = function(options, cb) { throw new Error("Backup key returned from server didn't match our own copy"); } } - var backupPublicKey = bitcoin.HDNode.fromBase58(result.backup_public_key[0], self.network); - var blocktrailPublicKeys = _.mapValues(result.blocktrail_public_keys, function(blocktrailPublicKey) { - return bitcoin.HDNode.fromBase58(blocktrailPublicKey[0], self.network); - }); - var primaryPublicKeys = _.mapValues(result.primary_public_keys, function(primaryPublicKey) { + + var backupPublicKey,blocktrailPublicKeys,primaryPublicKeys, wallet; + + primaryPublicKeys = _.mapValues(result.primary_public_keys, function(primaryPublicKey) { return bitcoin.HDNode.fromBase58(primaryPublicKey[0], self.network); }); - // initialize wallet - var wallet = new Wallet( - self, - identifier, - options.walletVersion, - result.primary_mnemonic, - result.encrypted_primary_seed, - result.encrypted_secret, - primaryPublicKeys, - backupPublicKey, - blocktrailPublicKeys, - keyIndex, - result.segwit || 0, - self.testnet, - self.regtest, - result.checksum, - result.upgrade_key_index, - options.useCashAddress, - options.bypassNewAddressCheck - ); + // Initializing a rootstock wallet does not require backup public key and blocktrail public key. + if (self.chain === APIClient.ROOTSTOCK_NETWORK) { + wallet = new Wallet( + self, + identifier, + options.walletVersion, + result.primary_mnemonic, + result.encrypted_primary_seed, + result.encrypted_secret, + primaryPublicKeys, + backupPublicKey, + blocktrailPublicKeys, + keyIndex, + result.segwit || 0, + self.testnet, + self.regtest, + result.checksum, + result.upgrade_key_index, + options.useCashAddress, + options.bypassNewAddressCheck + ); + } else { + backupPublicKey = bitcoin.HDNode.fromBase58(result.backup_public_key[0], self.network); + blocktrailPublicKeys = _.mapValues(result.blocktrail_public_keys, function(blocktrailPublicKey) { + return bitcoin.HDNode.fromBase58(blocktrailPublicKey[0], self.network); + }); + + // initialize wallet + wallet = new Wallet( + self, + identifier, + options.walletVersion, + result.primary_mnemonic, + result.encrypted_primary_seed, + result.encrypted_secret, + primaryPublicKeys, + backupPublicKey, + blocktrailPublicKeys, + keyIndex, + result.segwit || 0, + self.testnet, + self.regtest, + result.checksum, + result.upgrade_key_index, + options.useCashAddress, + options.bypassNewAddressCheck + ); + } wallet.recoverySecret = result.recovery_secret; @@ -1351,7 +1392,6 @@ APIClient.CREATE_WALLET_PROGRESS_DONE = 100; */ APIClient.prototype.createNewWallet = function(options, cb) { /* jshint -W071, -W074 */ - var self = this; if (typeof options !== "object") { @@ -1655,7 +1695,6 @@ APIClient.prototype._createNewWalletV3 = function(options) { if (options.primaryPrivateKey) { throw new blocktrail.WalletInitError("Can't specify; Primary PrivateKey"); } - // seed should be provided or generated options.primarySeed = options.primarySeed || randomBytes(Wallet.WALLET_ENTROPY_BITS / 8); @@ -1944,6 +1983,58 @@ APIClient.prototype.getNewDerivation = function(identifier, path, cb) { return self.blocktrailClient.post("/wallet/" + identifier + "/path", null, {path: path}, cb); }; +/** + * use the API to get account details for an Rootstock Address + * + * the return object has the following format: + * { + * "confirmed_balance" => 32746327, + * "address" => "0xaddress", + * "path" => "m/44'/1'/0'/0/13", + * "nonce" => 3, + * } + * + * @param identifier string the wallet identifier + * @param address string address associated with account + * @param options + * @param [cb] function callback(err, utxos, fee, change) + * @returns {q.Promise} + */ +APIClient.prototype.getRskAccount = function(identifier, address, options, cb) { + var self = this; + + if (typeof options === "function") { + cb = options; + options = {}; + } + + options = options || {}; + + var deferred = q.defer(); + deferred.promise.spreadNodeify(cb); + + var params = { + address: address + }; + + deferred.resolve( + self.blocktrailClient.post("/wallet/" + identifier + "/account", params).then( + function(result) { + return [ + String(result.confirmed_balance), + result.address, + result.path, + result.nonce + ]; + }, + function(err) { + throw err; + } + ) + ); + + return deferred.promise; +}; /** * delete the wallet @@ -2124,6 +2215,30 @@ APIClient.prototype.sendTransaction = function(identifier, txHex, paths, checkFe ); }; +APIClient.prototype.sendRskTransaction = function(identifier, txHex, twoFactorToken, options, cb) { + var self = this; + + if (typeof twoFactorToken === "function") { + cb = twoFactorToken; + twoFactorToken = null; + } else if (typeof options === "function") { + cb = options; + options = {}; + } + + var data = { + two_factor_token: twoFactorToken, + raw_transaction: txHex + }; + + + return self.blocktrailClient.post( + "/wallet/" + identifier + "/send", + data, + cb + ); +}; + /** * setup a webhook for this wallet * diff --git a/lib/wallet.js b/lib/wallet.js index d1d245e..31366df 100644 --- a/lib/wallet.js +++ b/lib/wallet.js @@ -11,6 +11,12 @@ var EncryptionMnemonic = require('./encryption_mnemonic'); var SizeEstimation = require('./size_estimation'); var bip39 = require('bip39'); +// Libraries for rootstock, ethereum key management. +var ethKey = require('ethereumjs-wallet/hdkey'); +var ethUtil = require('ethereumjs-util'); +var ethTransaction = require('ethereumjs-tx'); +var web3 = require('web3'); + var SignMode = { SIGN: "sign", DONT_SIGN: "dont_sign" @@ -90,9 +96,13 @@ var Wallet = function( } } - assert(backupPublicKey instanceof bitcoin.HDNode); assert(_.every(primaryPublicKeys, function(primaryPublicKey) { return primaryPublicKey instanceof bitcoin.HDNode; })); - assert(_.every(blocktrailPublicKeys, function(blocktrailPublicKey) { return blocktrailPublicKey instanceof bitcoin.HDNode; })); + + // If this is not rootstock, make sure back up and blocktrail keys are present. + if (self.sdk.chain !== "RSK") { + assert(backupPublicKey instanceof bitcoin.HDNode); + assert(_.every(blocktrailPublicKeys, function(blocktrailPublicKey) { return blocktrailPublicKey instanceof bitcoin.HDNode; })); + } // v1 self.primaryMnemonic = primaryMnemonic; @@ -148,6 +158,7 @@ Wallet.PAY_PROGRESS_DONE = 100; Wallet.CHAIN_BTC_DEFAULT = 0; Wallet.CHAIN_BTC_SEGWIT = 2; Wallet.CHAIN_BCC_DEFAULT = 1; +Wallet.CHAIN_RSK_DEFAULT = 5; Wallet.FEE_STRATEGY_FORCE_FEE = blocktrail.FEE_STRATEGY_FORCE_FEE; Wallet.FEE_STRATEGY_BASE_FEE = blocktrail.FEE_STRATEGY_BASE_FEE; @@ -156,6 +167,10 @@ Wallet.FEE_STRATEGY_OPTIMAL = blocktrail.FEE_STRATEGY_OPTIMAL; Wallet.FEE_STRATEGY_LOW_PRIORITY = blocktrail.FEE_STRATEGY_LOW_PRIORITY; Wallet.FEE_STRATEGY_MIN_RELAY_FEE = blocktrail.FEE_STRATEGY_MIN_RELAY_FEE; +Wallet.RSK_STANDARD_TRANSACTION_GAS_LIMIT = 21000; +Wallet.RSK_MINIMUM_GAS_PRICE = 0; + + Wallet.prototype.isSegwit = function() { return !!this.segwit; }; @@ -695,6 +710,79 @@ Wallet.prototype.upgradeKeyIndex = function(keyIndex, cb) { return deferred.promise; }; +/** + * generate a new derived private key and return the rootstock address for it + * + * @param [chainIdx] int + * @param [cb] function callback(err, address) + * @returns {q.Promise} + */ +Wallet.prototype.getRskAddress = function(chainIdx, cb) { + var self = this; + + // chainIdx is optional + if (typeof chainIdx === "function") { + cb = chainIdx; + chainIdx = null; + } + + var deferred = q.defer(); + deferred.promise.spreadNodeify(cb); + + // Only enter if it's not an integer + if (chainIdx !== parseInt(chainIdx, 10)) { + // deal with undefined or null, assume defaults + if (typeof chainIdx === "undefined" || chainIdx === null) { + chainIdx = Wallet.CHAIN_RSK_DEFAULT; + } else { + // was a variable but not integer + deferred.reject(new Error("Invalid chain index")); + return deferred.promise; + } + } + + // Get Rootstock address from server. + deferred.resolve(self.sdk.getNewDerivation(self.identifier, "M/" + self.keyIndex + "'/" + chainIdx) + .then(function(newDerivation) { + var path = newDerivation.path; + var addressFromServer = newDerivation.address; + + // Verify Rootstock address client-side. + var verifyAddress = self.getRskAddressByPath(path); + + // Check that server side address is equal to our client side address. + if (verifyAddress !== addressFromServer) { + deferred.reject(new Error("Failed to verify address [" + addressFromServer + "] !== [" + verifyAddress + "]")); + } + return [verifyAddress, path]; + })); + + return deferred.promise; +}; + +/** + * get rootstock address for specified path + * + * @param path + * @returns string + */ +Wallet.prototype.getRskAddressByPath = function(path) { + var self = this; + + // Get the derived primary public key. + var derivedPrimaryPublicKey = self.getPrimaryPublicKey(path); + + // Encode the derived key to base58. + var encodedPublicKey = derivedPrimaryPublicKey.toBase58(); + + // Instantiate an HD ethereum key using the extended public key. + var derivedEthereumKey = ethKey.fromExtendedKey(encodedPublicKey); + + // Use the public key to encode an ethereum address (without address checksum). + var address = derivedEthereumKey.getWallet().getAddressString(); + return address; +}; + /** * generate a new derived private key and return the new address for it * @@ -1886,4 +1974,272 @@ Wallet.deriveByPath = function(hdKey, path, keyPath) { } }; +/** + * Performs steps to create a valid transaction for rootstock networks. + * + * @param pay {value, to} value of transaction to create and recipient address + * @param feeStrategy {number} + * @param twoFactorToken + * @param options + * @param cb + * @returns {promise} + */ +Wallet.prototype.rskPay = function(pay, feeStrategy, twoFactorToken, options, cb) { + /* jshint -W071 */ + var self = this; + + if (typeof feeStrategy === "function") { + cb = feeStrategy; + feeStrategy = null; + } else if (typeof twoFactorToken === "function") { + cb = twoFactorToken; + twoFactorToken = null; + } else if (typeof options === "function") { + cb = options; + options = {}; + } + + feeStrategy = feeStrategy || Wallet.RSK_MINIMUM_GAS_PRICE; + options = options || {}; + + var deferred = q.defer(); + deferred.promise.nodeify(cb); + + if (self.locked) { + deferred.reject(new blocktrail.WalletLockedError("Wallet needs to be unlocked to send coins")); + return deferred.promise; + } + + q.nextTick(function() { + self.buildRskTransaction(pay, feeStrategy, options).then( + function(r) { return r; }, + function(e) { deferred.reject(e); }, + ).spread( + function(transaction) { + return self.sendRskTransaction(transaction.tx, twoFactorToken, options) + .then(function(result) { + if (!result || !result['complete'] || result['complete'] === 'false') { + deferred.reject(new Error("Failed to send transaction.")); + } else { + return transaction.txId; + } + }); + }, + function(e) { + throw e; + } + ) + .then( + function(r) { deferred.resolve(r); }, + function(e) { deferred.reject(e); } + ) + ; + }); + + return deferred.promise; +}; + +/** + * Builds a standard rootstock transaction and serializes the valid transaction. + * Standard transactions should only pay to other externally owned accounts. + * Standard transactions cannot interact with methods defined by contract accounts. + * + * @todo: Implement dynamic fee policy. + * @param pay {to, value} + * @param feeStrategy {number} + * @param options + * @param cb + * @returns {promise} + */ +Wallet.prototype.buildRskTransaction = function(pay,feeStrategy, options, cb) { + /* jshint -W071 */ + var self = this; + + if (typeof feeStrategy === "function") { + cb = feeStrategy; + feeStrategy = null; + } else if (typeof options === "function") { + cb = options; + options = {}; + } + + // If no feeStrategy is provided, use the minimum gas price. + feeStrategy = feeStrategy || Wallet.RSK_MINIMUM_GAS_PRICE; + options = options || {}; + + var deferred = q.defer(); + deferred.promise.nodeify(cb); + + var path = "M/" + self.keyIndex + "'/" + Wallet.CHAIN_RSK_DEFAULT + "/" + 0; + var accountAddress = self.getRskAddressByPath(path); + + q.nextTick(function() { + deferred.resolve( + // Get details about the account we plan to spend from. + self.getRskAccount(accountAddress).spread(function(confirmed_balance,address,path,nonce){ + + var txb, transaction,tx, txId; + var deferred = q.defer(); + async.waterfall([ + /** + * init transaction builder + * + * @param cb + */ + function(cb) { + // ChainId 30 is Rootstock Mainnet + // ChainId 31 is Rootstock Testnet + + if (self.sdk.rskTestnet) { + txb = new ethTransaction(null,31); + } else { + txb = new ethTransaction(null,30); + } + + cb(); + }, + /** + * add transaction params + * + * @param cb + */ + function(cb) { + + value = web3.utils.toWei(pay.value); + txb.nonce = nonce; + txb.gasPrice = feeStrategy; + txb.gasLimit = Wallet.RSK_STANDARD_TRANSACTION_GAS_LIMIT; + txb.value = web3.utils.toHex(value); + txb.to = pay.to; + + // Check if transaction value < confirmed balance of account. + { + var gasLimit = web3.utils.toBN(Wallet.RSK_STANDARD_TRANSACTION_GAS_LIMIT); + var gasPrice = web3.utils.toBN((feeStrategy ? feeStrategy : 1)); + var maximumGasFee = gasLimit.mul(gasPrice) + var totalTxValue = web3.utils.toBN(value).add(maximumGasFee); + var balance = web3.utils.toBN(confirmed_balance); + if (totalTxValue.gt(balance)) { + throw new Error("Total transaction value is more than account value."); + } + } + + cb(); + }, + /** + * sign transaction + * + * @param cb + */ + function(cb) { + var privKey; + + path = path.replace("M", "m"); + + if (self.primaryPrivateKey) { + privKey = Wallet.deriveByPath(self.primaryPrivateKey, path, "m"); + } else { + throw new Error("No master privateKey present"); + } + + txb.sign(ethUtil.toBuffer("0x" + privKey.keyPair.d.toBuffer().toString('hex'))); + + // Validate transaction before serialization. + if (!txb.validate()) { + throw new Error("Unable to create a valid transaction."); + } + + var serializedTx = txb.serialize(); + tx = serializedTx.toString('hex'); + txId = new ethTransaction(tx).hash().toString('hex'); + + transaction = { + tx: tx, + txId: "0x" + txId + }; + + // Check if the provided account matches the finished valid transaction. + if (address !== "0x" + txb.from.toString('hex')) { + throw new Error("Problem signing the transaction."); + } + + cb(); + }, + ], function(err) { + if (err) { + deferred.reject(new blocktrail.WalletSendError(err)); + return; + } + + deferred.resolve([transaction]); + }); + + return deferred.promise; + }) + ); + }); + + return deferred.promise; +}; + +/** + * Prepares to broadcast serialized rootstock transaction. + * + * @param tx + * @param twoFactorToken + * @param options + * @param cb + * @returns {promise} + */ +Wallet.prototype.sendRskTransaction = function(tx, twoFactorToken, options, cb) { + var self = this; + + if (typeof twoFactorToken === "function") { + cb = twoFactorToken; + twoFactorToken = null; + } else if (typeof options === "function") { + cb = options; + options = {}; + } + + var deferred = q.defer(); + deferred.promise.nodeify(cb); + + self.sdk.sendRskTransaction(self.identifier, tx, twoFactorToken, options) + .then( + function(result) { + deferred.resolve(result); + }, + function(e) { + if (e.requires_2fa) { + deferred.reject(new blocktrail.WalletMissing2FAError()); + } else if (e.message.match(/Invalid two_factor_token/)) { + deferred.reject(new blocktrail.WalletInvalid2FAError()); + } else { + deferred.reject(e); + } + } + ) + ; + + return deferred.promise; +}; + +Wallet.prototype.getRskAccount = function(address, options, cb) { + var self = this; + + if (typeof options === "function") { + cb = options; + options = {}; + } + return self.sdk.getRskAccount(self.identifier, address, options, cb); +}; + +Wallet.prototype.decodeRskAddress = function(address) { + return ( + ethUtil.isValidAddress(address) ? + {address: address, decoded: address, type: "rsk"} : + new blocktrail.InvalidAddressError("Invalid Rootstock Address.")); +}; + module.exports = Wallet; diff --git a/package.json b/package.json index b462b32..fa83385 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dependencies": { "assert-plus": "0.1.5", "async": "0.9.0", + "big-integer": "^1.6.39", "bip39": "git://github.com/blocktrail/bip39.git#sjcl-browser-bip39", "bitcoinjs-lib": "git://github.com/blocktrail/bitcoinjs-lib.git#137add06e7bba65efa0e52cf10391177e614f13f", "bitcoinjs-message": "^1.0.1", @@ -57,15 +58,23 @@ "create-hmac": "^1.1.3", "crypto-js": "^3.1.5", "debug": "^2.6.9", + "ethereumjs-tx": "^1.3.7", + "ethereumjs-util": "^6.0.0", + "ethereumjs-wallet": "^0.6.2", + "grunt-contrib-connect": "^1.0.2", + "is-arrayish": "^0.3.2", "lodash": "~4.17.2", "pkginfo": "^0.4.1", "promise": "^6.1.0", "q": "1.0.1", "randombytes": "^2.0.1", "sjcl": "git://github.com/blocktrail/sjcl.git#minify-library", + "spdx-exceptions": "^2.2.0", + "spdx-license-ids": "^3.0.1", "superagent": "^3.8.1", "superagent-http-signature": "0.1.3", "superagent-promise": "^0.2.0", + "web3": "^1.0.0-beta.36", "webworkify": "^1.4.0" }, "author": { @@ -87,7 +96,7 @@ "browserify": "*", "browserify-versionify": "^1.0.6", "coveralls": "^2.13.1", - "grunt": "~0.4.2", + "grunt": "^0.4.2", "grunt-browserify": "git://github.com/jmreidy/grunt-browserify.git#4f96beb75d27fdebc4359e08e7db4c514f6265a8", "grunt-contrib-concat": "~0.5.1", "grunt-contrib-connect": "^0.7.1",