From e11b6168565afba1b0ef3e822f045c819ba438ed Mon Sep 17 00:00:00 2001 From: Boyma Fahnbulleh Date: Thu, 11 Apr 2019 12:30:07 -0700 Subject: [PATCH 1/6] rpc: allow creation of unsigned auction txs --- lib/wallet/rpc.js | 402 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 333 insertions(+), 69 deletions(-) diff --git a/lib/wallet/rpc.js b/lib/wallet/rpc.js index 2b4580948..9da100305 100644 --- a/lib/wallet/rpc.js +++ b/lib/wallet/rpc.js @@ -156,6 +156,7 @@ class RPC extends RPCBase { this.add('sendfrom', this.sendFrom); this.add('sendmany', this.sendMany); this.add('sendtoaddress', this.sendToAddress); + this.add('createsendtoaddress', this.createSendToAddress); this.add('setaccount', this.setAccount); this.add('settxfee', this.setTXFee); this.add('signmessage', this.signMessage); @@ -187,6 +188,16 @@ class RPC extends RPCBase { this.add('sendfinalize', this.sendFinalize); this.add('sendrevoke', this.sendRevoke); this.add('importnonce', this.importNonce); + this.add('createopen', this.createOpen); + this.add('createbid', this.createBid); + this.add('createreveal', this.createReveal); + this.add('createredeem', this.createRedeem); + this.add('createupdate', this.createUpdate); + this.add('createrenewal', this.createRenewal); + this.add('createtransfer', this.createTransfer); + this.add('createcancel', this.createCancel); + this.add('createfinalize', this.createFinalize); + this.add('createrevoke', this.createRevoke); // Compat this.add('getauctions', this.getNames); @@ -1426,34 +1437,65 @@ class RPC extends RPCBase { } async sendToAddress(args, help) { - if (help || args.length < 2 || args.length > 5) { - throw new RPCError(errs.MISC_ERROR, - 'sendtoaddress "address" amount' - + ' ( "comment" "comment-to" subtractfeefromamount )'); - } + const opts = this._validateSendToAddress(args, help, 'sendtoaddress'); + const wallet = this.wallet; + const options = { + account: opts.account, + subtractFee: opts.subtract, + outputs: [{ + address: opts.addr, + value: opts.value + }] + }; + + const tx = await wallet.send(options); + + return tx.txid(); + } + + async createSendToAddress(args, help) { + const opts = this._validateSendToAddress(args, help, 'createsendtoaddress'); const wallet = this.wallet; + + const options = { + account: opts.account, + subtractFee: opts.subtract, + outputs: [{ + address: opts.addr, + value: opts.value + }] + }; + + const mtx = await wallet.createTX(options); + + return mtx.getJSON(this.network); + } + + _validateSendToAddress(args, help, method) { + const msg = `${method} "address" amount ` + + '( "comment" "comment-to" subtractfeefromamount "account" )'; + + if (help || args.length < 2 || args.length > 6) + throw new RPCError(errs.MISC_ERROR, msg); + const valid = new Validator(args); const str = valid.str(0); const value = valid.ufixed(1, EXP); const subtract = valid.bool(4, false); + const account = valid.str(5); const addr = parseAddress(str, this.network); if (!addr || value == null) throw new RPCError(errs.TYPE_ERROR, 'Invalid parameter.'); - const options = { - subtractFee: subtract, - outputs: [{ - address: addr, - value: value - }] + return { + subtract, + addr, + value, + account }; - - const tx = await wallet.send(options); - - return tx.txid(); } async setAccount(args, help) { @@ -1901,31 +1943,73 @@ class RPC extends RPCBase { } async sendOpen(args, help) { - if (help || args.length < 1 || args.length > 2) - throw new RPCError(errs.MISC_ERROR, 'sendopen "name" ( force )'); + const opts = this._validateOpen(args, help, 'sendopen'); + const wallet = this.wallet; + const tx = await wallet.sendOpen(opts.name, opts.force, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + async createOpen(args, help) { + const opts = this._validateOpen(args, help, 'createopen'); const wallet = this.wallet; + const mtx = await wallet.createOpen(opts.name, opts.force, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateOpen(args, help, method) { + const msg = `${method} "name" ( force "account" )`; + + if (help || args.length < 1 || args.length > 3) + throw new RPCError(errs.MISC_ERROR, msg); + const valid = new Validator(args); const name = valid.str(0); const force = valid.bool(1, false); + const account = valid.str(2); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); - const tx = await wallet.sendOpen(name, force); - - return tx.getJSON(this.network); + return {name, force, account}; } async sendBid(args, help) { - if (help || args.length !== 3) - throw new RPCError(errs.MISC_ERROR, 'sendbid "name" bid value'); + const opts = this._validateBid(args, help, 'sendbid'); + const wallet = this.wallet; + const tx = await wallet.sendBid(opts.name, opts.bid, opts.value, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + async createBid(args, help) { + const opts = this._validateBid(args, help, 'createbid'); const wallet = this.wallet; + const mtx = await wallet.createBid(opts.name, opts.bid, opts.value, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateBid(args, help, method) { + const msg = `${method} "name" bid value ( "account" )`; + + if (help || args.length < 3 || args.length > 4) + throw new RPCError(errs.MISC_ERROR, msg); + const valid = new Validator(args); const name = valid.str(0); const bid = valid.ufixed(1, EXP); const value = valid.ufixed(2, EXP); + const account = valid.str(3); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); @@ -1936,61 +2020,144 @@ class RPC extends RPCBase { if (bid > value) throw new RPCError(errs.TYPE_ERROR, 'Invalid bid.'); - const tx = await wallet.sendBid(name, bid, value); - - return tx.getJSON(this.network); + return { + name, + bid, + value, + account + }; } async sendReveal(args, help) { - if (help || args.length > 1) - throw new RPCError(errs.MISC_ERROR, 'sendreveal "name"'); - + const opts = this._validateReveal(args, help, 'sendreveal'); const wallet = this.wallet; - const valid = new Validator(args); - const name = valid.str(0); - if (!name) { + if (!opts.name) { const tx = await wallet.sendRevealAll(); return tx.getJSON(this.network); } - if (!rules.verifyName(name)) + if (!rules.verifyName(opts.name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); - const tx = await wallet.sendReveal(name); + const tx = await wallet.sendReveal(opts.name, { account: opts.account }); return tx.getJSON(this.network); } - async sendRedeem(args, help) { - if (help || args.length > 1) - throw new RPCError(errs.MISC_ERROR, 'sendredeem "name"'); - + async createReveal(args, help) { + const opts = this._validateReveal(args, help, 'createreveal'); const wallet = this.wallet; + + if (!opts.name) { + const mtx = await wallet.createRevealAll({ + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + if (!rules.verifyName(opts.name)) + throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); + + const mtx = await wallet.createReveal(opts.name, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateReveal(args, help, method) { + if (help || args.length > 2) + throw new RPCError(errs.MISC_ERROR, `${method} "name" ( "account" )`); + const valid = new Validator(args); const name = valid.str(0); + const account = valid.str(1); - if (!name) { - const tx = await wallet.sendRedeemAll(); + return {name, account}; + } + + async sendRedeem(args, help) { + const opts = this._validateRedeem(args, help, 'sendredeem'); + const wallet = this.wallet; + + if (!opts.name) { + const tx = await wallet.sendRedeemAll({ account: opts.account }); return tx.getJSON(this.network); } - if (!rules.verifyName(name)) + if (!rules.verifyName(opts.name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); - const tx = await wallet.sendRedeem(name); + const tx = await wallet.sendRedeem(opts.name, { + account: opts.account + }); return tx.getJSON(this.network); } + async createRedeem(args, help) { + const opts = this._validateRedeem(args, help, 'createredeem'); + const wallet = this.wallet; + + if (!opts.name) { + const mtx = await wallet.createRedeemAll({ + account: opts.account + }); + return mtx.getJSON(this.network); + } + + if (!rules.verifyName(opts.name)) + throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); + + const mtx = await wallet.createRedeem(opts.name, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateRedeem(args, help, method) { + if (help || args.length < 1 || args.length > 2) + throw new RPCError(errs.MISC_ERROR, `${method} "name" ( "account" )`); + + const valid = new Validator(args); + const name = valid.str(0); + const account = valid.str(1); + + return {name, account}; + } + async sendUpdate(args, help) { - if (help || args.length !== 2) - throw new RPCError(errs.MISC_ERROR, 'sendupdate "name" "data"'); + const opts = this._validateUpdate(args, help, 'sendupdate'); + const wallet = this.wallet; + const tx = await wallet.sendUpdate(opts.name, opts.resource, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + async createUpdate(args, help) { + const opts = this._validateUpdate(args, help, 'createupdate'); const wallet = this.wallet; + const mtx = await wallet.createUpdate(opts.name, opts.resource, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateUpdate(args, help, method) { + if (help || args.length < 2 || args.length > 3) + throw new RPCError(errs.MISC_ERROR, method + + '"name" "data" ( "account" )'); + const valid = new Validator(args); const name = valid.str(0); const data = valid.obj(1); + const account = valid.str(2); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); @@ -1999,35 +2166,75 @@ class RPC extends RPCBase { throw new RPCError(errs.TYPE_ERROR, 'Invalid hex string.'); const resource = Resource.fromJSON(data); - const tx = await wallet.sendUpdate(name, resource); - return tx.getJSON(this.network); + return { + name, + resource, + account + }; } async sendRenewal(args, help) { - if (help || args.length !== 1) - throw new RPCError(errs.MISC_ERROR, 'sendrenewal "name"'); + const wallet = this.wallet; + const opts = this._validateRenewal(args, help, 'sendrenewal'); + const tx = await wallet.sendRenewal(opts.name, { account: opts.account }); + + return tx.getJSON(this.network); + } + async createRenewal(args, help) { const wallet = this.wallet; + const opts = this._validateRenewal(args, help, 'createrenewal'); + const mtx = await wallet.createRenewal(opts.name, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateRenewal(args, help, method) { + if (help || args.length < 1 || args.length > 2) + throw new RPCError(errs.MISC_ERROR, `${method} "name" ( "account" )`); + const valid = new Validator(args); const name = valid.str(0); + const account = valid.str(1); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); - const tx = await wallet.sendRenewal(name); - - return tx.getJSON(this.network); + return {name, account}; } async sendTransfer(args, help) { - if (help || args.length !== 2) - throw new RPCError(errs.MISC_ERROR, 'sendtransfer "name" "address"'); + const opts = this._validateTransfer(args, help, 'sendtransfer'); + const wallet = this.wallet; + const tx = await wallet.sendTransfer(opts.name, opts.address, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + async createTransfer(args, help) { + const opts = this._validateTransfer(args, help, 'createtransfer'); const wallet = this.wallet; + const tx = await wallet.createTransfer(opts.name, opts.address, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + + _validateTransfer(args, help, method) { + if (help || args.length < 2 || args.length > 3) + throw new RPCError(errs.MISC_ERROR, method + + '"name" "address" ( "account" )'); + const valid = new Validator(args); const name = valid.str(0); const addr = valid.str(1); + const account = valid.str(2); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); @@ -2036,57 +2243,114 @@ class RPC extends RPCBase { throw new RPCError(errs.TYPE_ERROR, 'Invalid address.'); const address = parseAddress(addr, this.network); - const tx = await wallet.sendTransfer(name, address); - return tx.getJSON(this.network); + return { + name, + address, + account + }; } async sendCancel(args, help) { - if (help || args.length !== 1) - throw new RPCError(errs.MISC_ERROR, 'sendcancel "name"'); + const opts = this._validateCancel(args, help, 'sendcancel'); + const wallet = this.wallet; + const tx = await wallet.sendCancel(opts.name, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + async createCancel(args, help) { + const opts = this._validateCancel(args, help, 'createcancel'); const wallet = this.wallet; + const mtx = await wallet.createCancel(opts.name, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateCancel(args, help, method) { + if (help || args.length < 1 || args.length > 2) + throw new RPCError(errs.MISC_ERROR, `${method} "name" ( "account" )`); + const valid = new Validator(args); const name = valid.str(0); + const account = valid.str(1); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); - const tx = await wallet.sendCancel(name); - - return tx.getJSON(this.network); + return {name, account}; } async sendFinalize(args, help) { - if (help || args.length !== 1) - throw new RPCError(errs.MISC_ERROR, 'sendfinalize "name"'); + const opts = this._validateFinalize(args, help, 'sendfinalize'); + const wallet = this.wallet; + const tx = await wallet.sendFinalize(opts.name, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + async createFinalize(args, help) { + const opts = this._validateFinalize(args, help, 'createfinalize'); const wallet = this.wallet; + const mtx = await wallet.createFinalize(opts.name, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateFinalize(args, help, method) { + if (help || args.length < 1 || args.length > 2) + throw new RPCError(errs.MISC_ERROR, `${method} "name" ( "account" )`); + const valid = new Validator(args); const name = valid.str(0); + const account = valid.str(1); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); - const tx = await wallet.sendFinalize(name); - - return tx.getJSON(this.network); + return {name, account}; } async sendRevoke(args, help) { - if (help || args.length !== 1) - throw new RPCError(errs.MISC_ERROR, 'sendrevoke "name"'); + const opts = this._validateRevoke(args, help, 'sendrevoke'); + const wallet = this.wallet; + const tx = await wallet.sendRevoke(opts.name, { + account: opts.account + }); + + return tx.getJSON(this.network); + } + async createRevoke(args, help) { + const opts = this._validateRevoke(args, help, 'createrevoke'); const wallet = this.wallet; + const mtx = await wallet.createRevoke(opts.name, { + account: opts.account + }); + + return mtx.getJSON(this.network); + } + + _validateRevoke(args, help, method) { + if (help || args.length < 1 || args.length > 2) + throw new RPCError(errs.MISC_ERROR, `${method} "name" ( "account" )`); + const valid = new Validator(args); const name = valid.str(0); + const account = valid.str(1); if (!name || !rules.verifyName(name)) throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); - const tx = await wallet.sendRevoke(name); - - return tx.getJSON(this.network); + return {name, account}; } async importNonce(args, help) { From a01f1b92e91547c169012d7e80e9f11dcda39e75 Mon Sep 17 00:00:00 2001 From: Boyma Fahnbulleh Date: Thu, 18 Apr 2019 18:09:08 -0700 Subject: [PATCH 2/6] wallet: new classes WalletCoinView & Paths This commit includes a new class WalletCoinView and a helper class Paths. WalletCoinView inherits from CoinView and adds the ability to store HD paths on coin viewpoint objects. --- lib/coins/coinview.js | 22 ++++ lib/wallet/paths.js | 93 ++++++++++++++++ lib/wallet/wallet.js | 58 ++++++++++ lib/wallet/walletcoinview.js | 199 +++++++++++++++++++++++++++++++++++ 4 files changed, 372 insertions(+) create mode 100644 lib/wallet/paths.js create mode 100644 lib/wallet/walletcoinview.js diff --git a/lib/coins/coinview.js b/lib/coins/coinview.js index 8d6b9e928..eff167f69 100644 --- a/lib/coins/coinview.js +++ b/lib/coins/coinview.js @@ -298,6 +298,17 @@ class CoinView extends View { return coins.getOutput(index); } + /** + * Get an HD path by prevout. + * Implemented in {@link WalletCoinView}. + * @param {Outpoint} prevout + * @returns {null} + */ + + getPath(prevout) { + return null; + } + /** * Get coins height by prevout. * @param {Outpoint} prevout @@ -378,6 +389,17 @@ class CoinView extends View { return this.getOutput(input.prevout); } + /** + * Get a single path by input. + * Implemented in {@link WalletCoinView}. + * @param {Input} input + * @returns {null} + */ + + getPathFor(input) { + return null; + } + /** * Get coins height by input. * @param {Input} input diff --git a/lib/wallet/paths.js b/lib/wallet/paths.js new file mode 100644 index 000000000..af1035ea0 --- /dev/null +++ b/lib/wallet/paths.js @@ -0,0 +1,93 @@ +/*! + * paths.js - paths object for hsd + * Copyright (c) 2019, Boyma Fahnbulleh (MIT License). + * https://github.com/handshake-org/hsd + */ + +'use strict'; + +const assert = require('bsert'); + +/** + * Paths + * Represents the HD paths for coins in a single transaction. + * @alias module:wallet.Paths + * @property {Map[]} outputs - Paths. + */ + +class Paths { + /** + * Create paths + * @constructor + */ + + constructor() { + this.paths = new Map(); + } + + /** + * Add a single entry to the collection. + * @param {Number} index + * @param {Path} path + * @returns {Path} + */ + + add(index, path) { + assert((index >>> 0) === index); + assert(path); + this.paths.set(index, path); + return path; + } + + /** + * Test whether the collection has a path. + * @param {Number} index + * @returns {Boolean} + */ + + has(index) { + return this.paths.has(index); + } + + /** + * Get a path. + * @param {Number} index + * @returns {Path|null} + */ + + get(index) { + return this.paths.get(index) || null; + } + + /** + * Remove a path and return it. + * @param {Number} index + * @returns {Path|null} + */ + + remove(index) { + const path = this.get(index); + + if (!path) + return null; + + this.paths.delete(index); + + return path; + } + + /** + * Test whether there are paths. + * @returns {Boolean} + */ + + isEmpty() { + return this.paths.size === 0; + } +} + +/* + * Expose + */ + +module.exports = Paths; diff --git a/lib/wallet/wallet.js b/lib/wallet/wallet.js index 14b41a891..1a780c896 100644 --- a/lib/wallet/wallet.js +++ b/lib/wallet/wallet.js @@ -19,6 +19,8 @@ const common = require('./common'); const Address = require('../primitives/address'); const MTX = require('../primitives/mtx'); const Script = require('../script/script'); +const CoinView = require('../coins/coinview'); +const WalletCoinView = require('./walletcoinview'); const WalletKey = require('./walletkey'); const HD = require('../hd/hd'); const Output = require('../primitives/output'); @@ -3206,6 +3208,10 @@ class Wallet extends EventEmitter { assert(mtx.verifyInputs(this.wdb.height + 1, this.network), 'TX failed context check.'); + // Set the HD paths. + if (options.paths === true) + mtx.view = await this.getWalletCoinView(mtx, mtx.view); + const total = await this.template(mtx); if (total === 0) @@ -3668,6 +3674,58 @@ class Wallet extends EventEmitter { return this.txdb.getCoinView(tx); } + /** + * Get a wallet coin viewpoint with HD paths. + * @param {TX} tx + * @param {CoinView?} view - Coins to be used in wallet coin viewpoint. + * @returns {Promise} - Returns {@link WalletCoinView}. + */ + + async getWalletCoinView(tx, view) { + if (!(view instanceof CoinView)) + view = new CoinView(); + + if (!tx.hasCoins(view)) + view = await this.txdb.getCoinView(tx); + + view = WalletCoinView.fromCoinView(view); + + for (const input of tx.inputs) { + const prevout = input.prevout; + const coin = view.getCoin(prevout); + + if (!coin) + continue; + + const path = await this.getPath(coin.address); + + if (!path) + continue; + + const account = await this.getAccount(path.account); + + if (!account) + continue; + + // The account index in the db may be wrong. + // We must read it from the stored xpub to be + // sure of its correctness. + // + // For more details see: + // https://github.com/bcoin-org/bcoin/issues/698. + path.account = account.accountKey.childIndex; + + // Unharden the account index, if necessary. + if (path.account & HD.common.HARDENED) + path.account ^= HD.common.HARDENED; + + // Add path to the viewpoint. + view.addPath(prevout, path); + } + + return view; + } + /** * Get a historical coin viewpoint. * @param {TX} tx diff --git a/lib/wallet/walletcoinview.js b/lib/wallet/walletcoinview.js new file mode 100644 index 000000000..711b97caf --- /dev/null +++ b/lib/wallet/walletcoinview.js @@ -0,0 +1,199 @@ +/*! + * walletcoinview.js - wallet coin viewpoint object for hsd + * Copyright (c) 2019, Boyma Fahnbulleh (MIT License). + * https://github.com/handshake-org/hsd + */ + +'use strict'; + +const assert = require('bsert'); +const {BufferMap} = require('buffer-map'); +const Paths = require('./paths'); +const CoinView = require('../coins/coinview'); + +/** + * Wallet Coin View + * Represents a wallet, coin viewpoint: a snapshot of {@link Coins} objects + * and the HD paths for their associated keys. + * @alias module:wallet.WalletCoinView + * @property {Object} map + * @property {Object} paths + * @property {UndoCoins} undo + */ + +class WalletCoinView extends CoinView { + /** + * Create a wallet coin view. + * @constructor + */ + + constructor() { + super(); + this.paths = new BufferMap(); + } + + /** + * Inject properties from coin view object. + * @private + * @param {CoinView} view + */ + + fromCoinView(view) { + assert(view instanceof CoinView, 'View must be instance of CoinView'); + this.map = view.map; + this.undo = view.undo; + this.bits = view.bits; + return this; + } + + /** + * Instantiate wallet coin view from coin view. + * @param {CoinView} view + * @returns {WalletCoinView} + */ + + static fromCoinView(view) { + return new this().fromCoinView(view); + } + + /** + * Add paths to the collection. + * @param {Hash} hash + * @param {Paths} path + * @returns {Paths|null} + */ + + addPaths(hash, paths) { + this.paths.set(hash, paths); + return paths; + } + + /** + * Get paths. + * @param {Hash} hash + * @returns {Paths} paths + */ + + getPaths(hash) { + return this.paths.get(hash); + } + + /** + * Test whether the view has a paths entry. + * @param {Hash} hash + * @returns {Boolean} + */ + + hasPaths(hash) { + return this.paths.has(hash); + } + + /** + * Ensure existence of paths object in the collection. + * @param {Hash} hash + * @returns {Coins} + */ + + ensurePaths(hash) { + const paths = this.paths.get(hash); + + if (paths) + return paths; + + return this.addPaths(hash, new Paths()); + } + + /** + * Remove paths from the collection. + * @param {Paths} paths + * @returns {Paths|null} + */ + + removePaths(hash) { + const paths = this.paths.get(hash); + + if (!paths) + return null; + + this.paths.delete(hash); + + return paths; + } + + /** + * Add an HD path to the collection. + * @param {Outpoint} prevout + * @param {Path} path + * @returns {Path|null} + */ + + addPath(prevout, path) { + const {hash, index} = prevout; + const paths = this.ensurePaths(hash); + return paths.add(index, path); + } + + /** + * Get an HD path by prevout. + * @param {Outpoint} prevout + * @returns {Path|null} + */ + + getPath(prevout) { + const {hash, index} = prevout; + const paths = this.getPaths(hash); + + if (!paths) + return null; + + return paths.get(index); + } + + /** + * Remove an HD path. + * @param {Outpoint} prevout + * @returns {Path|null} + */ + + removePath(prevout) { + const {hash, index} = prevout; + const paths = this.getPaths(hash); + + if (!paths) + return null; + + return paths.remove(index); + } + + /** + * Test whether the view has a path by prevout. + * @param {Outpoint} prevout + * @returns {Boolean} + */ + + hasPath(prevout) { + const {hash, index} = prevout; + const paths = this.getPaths(hash); + + if (!paths) + return false; + + return paths.has(index); + } + + /** + * Get a single path by input. + * @param {Input} input + * @returns {Path|null} + */ + + getPathFor(input) { + return this.getPath(input.prevout); + } +} + +/* + * Expose + */ + +module.exports = WalletCoinView; From 4009e3829590cf779433fc8f8576507f0b5696d0 Mon Sep 17 00:00:00 2001 From: Boyma Fahnbulleh Date: Thu, 18 Apr 2019 18:09:59 -0700 Subject: [PATCH 3/6] path: generate full derviation path This commit exposes a new 'network' argument to `toPath`. Passing the network type will generate a full HD path instead of the current, abbreviated path. --- lib/wallet/path.js | 62 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/lib/wallet/path.js b/lib/wallet/path.js index 711bf716d..59d1f9fe9 100644 --- a/lib/wallet/path.js +++ b/lib/wallet/path.js @@ -9,6 +9,7 @@ const assert = require('bsert'); const bio = require('bufio'); const Address = require('../primitives/address'); +const Network = require('../protocol/network'); const {encoding} = bio; /** @@ -212,14 +213,23 @@ class Path extends bio.Struct { /** * Convert path object to string derivation path. + * @param {String|Network?} network - Network type. * @returns {String} */ - toPath() { + toPath(network) { if (this.keyType !== Path.types.HD) return null; - return `m/${this.account}'/${this.branch}/${this.index}`; + let prefix = 'm'; + + if (network) { + const purpose = 44; + network = Network.get(network); + prefix += `/${purpose}'/${network.keyPrefix.coinType}'`; + } + + return `${prefix}/${this.account}'/${this.branch}/${this.index}`; } /** @@ -233,18 +243,62 @@ class Path extends bio.Struct { /** * Convert path to a json-friendly object. + * @param {String|Network?} network - Network type. * @returns {Object} */ - getJSON() { + getJSON(network) { return { name: this.name, account: this.account, change: this.branch === 1, - derivation: this.toPath() + derivation: this.toPath(network) }; } + /** + * Inject properties from a json object. + * @param {Object} json + * @returns {Path} + */ + + static fromJSON(json) { + return new this().fromJSON(json); + } + + /** + * Inject properties from a json object. + * @param {Object} json + * @returns {Path} + */ + + fromJSON(json) { + assert(json && typeof json === 'object'); + assert(json.derivation && typeof json.derivation === 'string'); + + // Note: this object is mutated below. + const path = json.derivation.split('/'); + + // Note: "m/X'/X'/X'/X/X" or "m/X'/X/X". + assert (path.length === 4 || path.length === 6); + + const index = parseInt(path.pop(), 10); + const branch = parseInt(path.pop(), 10); + const account = parseInt(path.pop(), 10); + + assert(account === json.account); + assert(branch === 0 || branch === 1); + assert(Boolean(branch) === json.change); + assert((index >>> 0) === index); + + this.name = json.name; + this.account = account; + this.branch = branch; + this.index = index; + + return this; + } + /** * Inspect the path. * @returns {String} From f8344ba2cb26fdab1b90bb0688438ddf9708c875 Mon Sep 17 00:00:00 2001 From: Boyma Fahnbulleh Date: Sat, 20 Apr 2019 17:21:41 -0700 Subject: [PATCH 4/6] mtx: support HD paths --- lib/primitives/input.js | 6 ++++-- lib/primitives/mtx.js | 13 +++++++++++++ lib/primitives/tx.js | 3 ++- lib/wallet/rpc.js | 13 +++++++++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/lib/primitives/input.js b/lib/primitives/input.js index 22aa90b31..e920cfe1d 100644 --- a/lib/primitives/input.js +++ b/lib/primitives/input.js @@ -169,10 +169,11 @@ class Input extends bio.Struct { * for JSON serialization. * @param {Network} network * @param {Coin} coin + * @param {Path} path * @returns {Object} */ - getJSON(network, coin) { + getJSON(network, coin, path) { network = Network.get(network); let addr; @@ -187,7 +188,8 @@ class Input extends bio.Struct { witness: this.witness.toJSON(), sequence: this.sequence, address: addr, - coin: coin ? coin.getJSON(network, true) : undefined + coin: coin ? coin.getJSON(network, true) : undefined, + path: path ? path.getJSON(network) : undefined }; } diff --git a/lib/primitives/mtx.js b/lib/primitives/mtx.js index 650cc26d9..f64480cfe 100644 --- a/lib/primitives/mtx.js +++ b/lib/primitives/mtx.js @@ -16,6 +16,8 @@ const Output = require('./output'); const Coin = require('./coin'); const Outpoint = require('./outpoint'); const CoinView = require('../coins/coinview'); +const Path = require('../wallet/path'); +const WalletCoinView = require('../wallet/walletcoinview'); const Address = require('./address'); const consensus = require('../protocol/consensus'); const policy = require('../protocol/policy'); @@ -1343,6 +1345,17 @@ class MTX extends TX { coin.index = prevout.index; this.view.addCoin(coin); + + if (!input.path) + continue; + + if(!(this.view instanceof WalletCoinView)) + this.view = WalletCoinView.fromCoinView(this.view); + + const outpoint = Outpoint.fromJSON(prevout); + const path = Path.fromJSON(input.path); + + this.view.addPath(outpoint, path); } return this; diff --git a/lib/primitives/tx.js b/lib/primitives/tx.js index 230f617f1..789385373 100644 --- a/lib/primitives/tx.js +++ b/lib/primitives/tx.js @@ -1748,7 +1748,8 @@ class TX extends bio.Struct { version: this.version, inputs: this.inputs.map((input) => { const coin = view ? view.getCoinFor(input) : null; - return input.getJSON(network, coin); + const path = view ? view.getPathFor(input) : null; + return input.getJSON(network, coin, path); }), outputs: this.outputs.map((output) => { return output.getJSON(network); diff --git a/lib/wallet/rpc.js b/lib/wallet/rpc.js index 9da100305..1d5c009fd 100644 --- a/lib/wallet/rpc.js +++ b/lib/wallet/rpc.js @@ -1459,6 +1459,7 @@ class RPC extends RPCBase { const wallet = this.wallet; const options = { + paths: true, account: opts.account, subtractFee: opts.subtract, outputs: [{ @@ -1956,6 +1957,7 @@ class RPC extends RPCBase { const opts = this._validateOpen(args, help, 'createopen'); const wallet = this.wallet; const mtx = await wallet.createOpen(opts.name, opts.force, { + paths: true, account: opts.account }); @@ -1993,6 +1995,7 @@ class RPC extends RPCBase { const opts = this._validateBid(args, help, 'createbid'); const wallet = this.wallet; const mtx = await wallet.createBid(opts.name, opts.bid, opts.value, { + paths: true, account: opts.account }); @@ -2051,6 +2054,7 @@ class RPC extends RPCBase { if (!opts.name) { const mtx = await wallet.createRevealAll({ + paths: true, account: opts.account }); @@ -2061,6 +2065,7 @@ class RPC extends RPCBase { throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); const mtx = await wallet.createReveal(opts.name, { + paths: true, account: opts.account }); @@ -2103,6 +2108,7 @@ class RPC extends RPCBase { if (!opts.name) { const mtx = await wallet.createRedeemAll({ + paths: true, account: opts.account }); return mtx.getJSON(this.network); @@ -2112,6 +2118,7 @@ class RPC extends RPCBase { throw new RPCError(errs.TYPE_ERROR, 'Invalid name.'); const mtx = await wallet.createRedeem(opts.name, { + paths: true, account: opts.account }); @@ -2143,6 +2150,7 @@ class RPC extends RPCBase { const opts = this._validateUpdate(args, help, 'createupdate'); const wallet = this.wallet; const mtx = await wallet.createUpdate(opts.name, opts.resource, { + paths: true, account: opts.account }); @@ -2186,6 +2194,7 @@ class RPC extends RPCBase { const wallet = this.wallet; const opts = this._validateRenewal(args, help, 'createrenewal'); const mtx = await wallet.createRenewal(opts.name, { + paths: true, account: opts.account }); @@ -2220,6 +2229,7 @@ class RPC extends RPCBase { const opts = this._validateTransfer(args, help, 'createtransfer'); const wallet = this.wallet; const tx = await wallet.createTransfer(opts.name, opts.address, { + paths: true, account: opts.account }); @@ -2265,6 +2275,7 @@ class RPC extends RPCBase { const opts = this._validateCancel(args, help, 'createcancel'); const wallet = this.wallet; const mtx = await wallet.createCancel(opts.name, { + paths: true, account: opts.account }); @@ -2299,6 +2310,7 @@ class RPC extends RPCBase { const opts = this._validateFinalize(args, help, 'createfinalize'); const wallet = this.wallet; const mtx = await wallet.createFinalize(opts.name, { + paths: true, account: opts.account }); @@ -2333,6 +2345,7 @@ class RPC extends RPCBase { const opts = this._validateRevoke(args, help, 'createrevoke'); const wallet = this.wallet; const mtx = await wallet.createRevoke(opts.name, { + paths: true, account: opts.account }); From 9fdbb14ceb7c576a1d52f9ef30b866129fb1c0dc Mon Sep 17 00:00:00 2001 From: Boyma Fahnbulleh Date: Thu, 9 May 2019 11:06:13 -0700 Subject: [PATCH 5/6] test: new tests for alternative signer support --- test/auction-rpc-test.js | 317 +++++++++++++++++++++++++++++++++++++++ test/coin-test.js | 74 +++++++-- test/coins-test.js | 95 +++++++++++- test/data/coin1.json | 14 ++ test/data/coin1.raw | Bin 0 -> 41 bytes test/data/mtx1.json | 53 +++++++ test/data/mtx2.json | 47 ++++++ test/data/tx1-undo.raw | Bin 0 -> 32 bytes test/data/tx1.raw | Bin 0 -> 215 bytes test/input-test.js | 39 ++++- test/mtx-test.js | 65 ++++++++ test/path-test.js | 71 +++++++++ test/util/common.js | 5 +- 13 files changed, 756 insertions(+), 24 deletions(-) create mode 100644 test/auction-rpc-test.js create mode 100644 test/data/coin1.json create mode 100644 test/data/coin1.raw create mode 100644 test/data/mtx1.json create mode 100644 test/data/mtx2.json create mode 100644 test/data/tx1-undo.raw create mode 100644 test/data/tx1.raw create mode 100644 test/mtx-test.js create mode 100644 test/path-test.js diff --git a/test/auction-rpc-test.js b/test/auction-rpc-test.js new file mode 100644 index 000000000..3812ae798 --- /dev/null +++ b/test/auction-rpc-test.js @@ -0,0 +1,317 @@ +/* eslint-env mocha */ +/* eslint prefer-arrow-callback: "off" */ + +'use strict'; + +const assert = require('bsert'); +const bio = require('bufio'); +const plugin = require('../lib/wallet/plugin'); +const rules = require('../lib/covenants/rules'); +const common = require('./util/common'); +const {ChainEntry, FullNode, KeyRing, MTX, Network, Path} = require('..'); +const {NodeClient, WalletClient} = require('hs-client'); + +class TestUtil { + constructor(options) { + if (!options) + options = Object.create(null); + + if (!options.host) + options.host = 'localhost'; + + if (!options.nport) + options.nport = 14037; + + if (!options.wport) + options.wport = 14039; + + this.network = Network.get('regtest'); + + this.txs = {}; + + this.blocks = {}; + + this.node = new FullNode({ + memory: true, + workers: true, + network: this.network.type + }); + + this.node.use(plugin); + + this.nclient = new NodeClient({ + host: options.host, + port: options.nport + }); + + this.wclient = new WalletClient({ + host: options.host, + port: options.wport + }); + } + + /** + * Execute an RPC using the wallet client. + * @param {String} method - RPC method + * @param {Array} params - method parameters + * @returns {Promise} - Returns a two item array with the RPC's return value + * or null as the first item and an error or null as the second item. + */ + + async wrpc(method, params = []) { + return this.wclient.execute(method, params) + .then(data => data) + .catch((err) => { + throw new Error(err); + }); + } + + /** + * Execute an RPC using the node client. + * @param {String} method - RPC method + * @param {Array} params - method parameters + * @returns {Promise} - Returns a two item array with the + * RPC's return value or null as the first item and an error or + * null as the second item. + */ + + async nrpc(method, params = []) { + return this.nclient.execute(method, params) + .then(data => data) + .catch((err) => { + throw new Error(err); + }); + } + + /** + * Open the util and all its child objects. + */ + + async open() { + assert(!this.opened, 'TestUtil is already open.'); + this.opened = true; + + await this.node.ensure(); + await this.node.open(); + await this.node.connect(); + this.node.startSync(); + + await this.nclient.open(); + await this.wclient.open(); + + this.node.plugins.walletdb.wdb.on('confirmed', ((details, tx) => { + const txid = tx.txid(); + + if (!this.txs[txid]) + this.txs[txid] = txid; + })); + + this.nclient.bind('block connect', (data) => { + const br = bio.read(data); + const entry = (new ChainEntry()).read(br); + const hash = entry.hash.toString('hex'); + + if (!this.blocks[hash]) + this.blocks[hash] = hash; + }); + } + + /** + * Close util and all its child objects. + */ + + async close() { + assert(this.opened, 'TestUtil is not open.'); + this.opened = false; + + await this.nclient.close(); + await this.wclient.close(); + await this.node.close(); + } + + async confirmTX(txid, timeout = 5000) { + return common.forValue(this.txs, txid, txid, timeout); + } + + async confirmBlock(hash, timeout = 5000) { + return common.forValue(this.blocks, hash, hash, timeout); + } +} + +describe('Auction RPCs', function() { + this.timeout(60000); + + const util = new TestUtil(); + const name = rules.grindName(2, 0, Network.get('regtest')); + let winner, loser; + + const mineBlocks = async (num, wallet, account = 'default') => { + const address = (await wallet.createAddress(account)).address; + const hashes = await util.nrpc('generatetoaddress', [num, address]); + await util.confirmBlock(hashes.pop()); + }; + + // This function is a helper which: + // - validates HD path of provided mtx + // - signs the mtx using the corresponding private key + // - submits and mines the signed tx, if submit argument is true + const processJSON = async (json, submit, wallet = winner) => { + const mtx = MTX.fromJSON(json); + + for (let i = 0; i < mtx.inputs.length; i++) { + const input = mtx.inputs[i]; + const coin = mtx.view.getCoinFor(input); + const path = mtx.view.getPathFor(input); + const address = coin.address.toString('regtest'); + const key = await wallet.getKey(address); + + // Assert HD path. + assert.ok(path instanceof Path); + assert.deepStrictEqual(path.name, key.name); + assert.deepStrictEqual(path.account, key.account); + assert.deepStrictEqual(path.branch, key.branch); + assert.deepStrictEqual(path.index, key.index); + + // Sign mtx. + const secret = (await wallet.getWIF(address)).privateKey; + const ring = KeyRing.fromSecret(secret); + mtx.sign(ring); + }; + + // Verify mtx. + assert(mtx.verify()); + + // Submit and mine mtx, if necessary. + if (submit) { + await util.nrpc('sendrawtransaction', [mtx.encode().toString('hex')]); + await mineBlocks(1, wallet); + await util.confirmTX(mtx.txid()); + } + + return mtx; + }; + + before(async () => { + util.node.network.coinbaseMaturity = 1; + await util.open(); + await util.wclient.createWallet('loser'); + winner = await util.wclient.wallet('primary'); + loser = await util.wclient.wallet('loser'); + await mineBlocks(5, winner); + await mineBlocks(5, loser); + }); + + after(async () => { + util.node.network.coinbaseMaturity = 2; + await util.close(); + }); + + it('should create OPEN with signing paths', async () => { + // Create, assert, submit and mine OPEN. + const submit = true; + const json = await util.wrpc('createopen', [name]); + await processJSON(json, submit, winner); + + // Mine past OPEN period. + await mineBlocks(util.network.names.treeInterval, winner); + }); + + it('should create BID with signing paths', async () => { + // Create loser's BID. + await util.wrpc('selectwallet', [loser.id]); + assert(await util.wrpc('sendbid', [name, 4, 10])); + + // Create, assert, submit and mine winner's BID. + await util.wrpc('selectwallet', [winner.id]); + const submit = true; + const json = await util.wrpc('createbid', [name, 5, 10]); + await processJSON(json, submit); + + // Mine past BID period. + await mineBlocks(util.network.names.biddingPeriod, winner); + }); + + it('should create REVEAL with signing paths', async () => { + // Create loser's REVEAL. + await util.wrpc('selectwallet', [loser.id]); + assert(await util.wrpc('sendreveal', [name])); + + // Create, assert, submit and mine REVEAL. + await util.wrpc('selectwallet', [winner.id]); + const submit = true; + const json = await util.wrpc('createreveal', [name]); + await processJSON(json, submit); + + // Mine past REVEAL period. + await mineBlocks(util.network.names.revealPeriod, winner); + }); + + it('should create REDEEM with signing paths', async () => { + // Create, assert, submit and mine REDEEM. + await util.wrpc('selectwallet', [loser.id]); + const submit = true; + const json = await util.wrpc('createredeem', [name]); + await processJSON(json, submit, loser); + }); + + it('should create REGISTER with signing paths', async () => { + // Create, assert, submit and mine REGISTER. + await util.wrpc('selectwallet', [winner.id]); + const submit = true; + const json = await util.wrpc('createupdate', [name, { + version: 0, + ttl: 6000, + compat: true, + canonical: 'example.com' + }]); + await processJSON(json, submit); + + // Mine some blocks. + await mineBlocks(util.network.names.treeInterval, winner); + }); + + it('should create RENEW with signing paths', async () => { + // Create, assert, submit and mine RENEW. + const submit = true; + const json = await util.wrpc('createrenewal', [name]); + await processJSON(json, submit); + }); + + it('should create TRANSFER with signing paths', async () => { + // Create, assert, submit and mine TRANSFER. + const submit = true; + const address = (await loser.createAddress('default')).address; + const json = await util.wrpc('createtransfer', [name, address]); + await processJSON(json, submit); + }); + + it('Should create TRANSFER cancellation with signing paths', async () => { + // Create, assert, submit and mine TRANSFER cancellation. + const submit = true; + const json = await util.wrpc('createcancel', [name]); + await processJSON(json, submit); + }); + + it('should create FINALIZE with signing paths', async () => { + // Submit TRANSFER. + const address = (await loser.createAddress('default')).address; + await util.wrpc('selectwallet', [winner.id]); + assert(await util.wrpc('sendtransfer', [name, address])); + + // Mine past TRANSFER lockup period. + await mineBlocks(util.network.names.transferLockup, winner); + + // Create, assert, submit and mine FINALIZE. + const submit = true; + const json = await util.wrpc('createfinalize', [name]); + await processJSON(json, submit); + }); + + it('should create REVOKE with signing paths', async () => { + // Create, assert, submit and mine REVOKE. + await util.wrpc('selectwallet', [loser.id]); + const submit = true; + const json = await util.wrpc('createrevoke', [name]); + await processJSON(json, submit, loser, true); + }); +}); diff --git a/test/coin-test.js b/test/coin-test.js index 508b55135..f785b93b4 100644 --- a/test/coin-test.js +++ b/test/coin-test.js @@ -4,14 +4,18 @@ 'use strict'; -const Coin = require('../lib/primitives/coin'); +const {BufferWriter} = require('bufio'); const assert = require('bsert'); -const common = require('../test/util/common'); -const KeyRing = require('../lib/primitives/keyring'); +const nodejsUtil = require('util'); const random = require('bcrypto/lib/random'); -const rules = require('../lib/covenants/rules'); -const {types, typesByVal} = rules; -const {BufferWriter} = require('bufio'); + +const Coin = require('../lib/primitives/coin'); +const KeyRing = require('../lib/primitives/keyring'); +const common = require('../test/util/common'); +const {types, typesByVal} = require('../lib/covenants/rules'); + +const tx1 = common.readTX('tx1'); +const coin1 = common.readFile('coin1.raw'); describe('Coin', function() { it('should serialize and deserialize from JSON', () => { @@ -21,7 +25,6 @@ describe('Coin', function() { for (const network of networks) { const addr = key.getAddress().toString(network); const item = random.randomBytes(32); - const json = { version: 0, height: 0, @@ -35,16 +38,59 @@ describe('Coin', function() { } }; - const coin = Coin.fromJSON(json, network); - const bw = new BufferWriter(coin.getSize()); - coin.write(bw); + const fromJSON = Coin.fromJSON(json, network); + const bw = new BufferWriter(fromJSON.getSize()); + fromJSON.write(bw); - const coin2 = Coin.fromRaw(bw.render()); - const json2 = coin2.getJSON(network); + const fromRaw = Coin.fromRaw(bw.render()).getJSON(network); - for (const [key, value] of Object.entries(json)) { - assert.deepEqual(value, json2[key]); + for (const [key, want] of Object.entries(json)) { + const got = fromRaw[key]; + assert.deepEqual(want, got); } } }); + + it('should instantiate from tx', () => { + const [tx] = tx1.getTX(); + const json = require('./data/coin1.json'); + const want = Coin.fromJSON(json); + const got = Coin.fromTX(tx, 0, -1); + + assert.deepEqual(want.version, got.version); + assert.deepEqual(want.height, got.height); + assert.deepEqual(want.value, got.value); + assert.deepEqual(want.address, got.address); + assert.deepEqual(want.covenant, got.covenant); + assert.deepEqual(want.coinbase, got.coinbase); + assert.deepEqual(want.coinbase, got.coinbase); + }); + + it('should instantiate from raw', () => { + const json = require('./data/coin1.json'); + const want = Coin.fromJSON(json); + const got = Coin.fromRaw(coin1); + + assert.deepEqual(want.version, got.version); + assert.deepEqual(want.height, got.height); + assert.deepEqual(want.value, got.value); + assert.deepEqual(want.address, got.address); + assert.deepEqual(want.covenant, got.covenant); + assert.deepEqual(want.coinbase, got.coinbase); + assert.deepEqual(want.coinbase, got.coinbase); + }); + + it('should inspect Coin', () => { + const coin = new Coin(); + const fmt = nodejsUtil.format(coin); + assert(typeof fmt === 'string'); + assert(fmt.includes('version')); + assert(fmt.includes('height')); + assert(fmt.includes('value')); + assert(fmt.includes('address')); + assert(fmt.includes('covenant')); + assert(fmt.includes('coinbase')); + assert(fmt.includes('hash')); + assert(fmt.includes('index')); + }); }); diff --git a/test/coins-test.js b/test/coins-test.js index 8b47d2e35..ac856d3b3 100644 --- a/test/coins-test.js +++ b/test/coins-test.js @@ -4,14 +4,101 @@ 'use strict'; -const bio = require('bufio'); const assert = require('bsert'); -const Output = require('../lib/primitives/output'); +const bio = require('bufio'); +const CoinEntry = require('../lib/coins/coinentry'); +const CoinView = require('../lib/coins/coinview'); const Input = require('../lib/primitives/input'); +const Output = require('../lib/primitives/output'); const Outpoint = require('../lib/primitives/outpoint'); -const CoinView = require('../lib/coins/coinview'); -const CoinEntry = require('../lib/coins/coinentry'); const common = require('./util/common'); +const tx1 = common.readTX('tx1'); + +function reserialize(coin) { + const raw = coin.toRaw(); + const entry = CoinEntry.fromRaw(raw); + entry.raw = null; + return CoinEntry.fromRaw(entry.toRaw()); +} + +function deepCoinsEqual(a, b) { + assert.strictEqual(a.version, b.version); + assert.strictEqual(a.height, b.height); + assert.strictEqual(a.coinbase, b.coinbase); + assert.strictEqual(a.value, b.value); + assert.strictEqual(a.covenant, b.covenant); + assert.strictEqual(a.address, b.address); + assert.bufferEqual(a.raw, b.raw); +} + describe('Coins', function() { + it('should instantiate coinview from tx', () => { + const [tx] = tx1.getTX(); + const hash = tx.hash(); + const view = new CoinView(); + const prevout = new Outpoint(hash, 0); + const input = Input.fromOutpoint(prevout); + + view.addTX(tx, 1); + + const coins = view.get(hash); + assert.strictEqual(coins.outputs.size, tx.outputs.length); + + const entry = coins.get(0); + assert(entry); + assert.strictEqual(entry.version, 0); + assert.strictEqual(entry.height, 1); + assert.strictEqual(entry.coinbase, false); + assert.strictEqual(entry.raw, null); + assert(entry.output instanceof Output); + assert.strictEqual(entry.spent, false); + + const output = view.getOutputFor(input); + assert(output); + + deepCoinsEqual(entry, reserialize(entry)); + }); + + it('should spend an output', () => { + const [tx] = tx1.getTX(); + const hash = tx.hash(); + const view = new CoinView(); + + view.addTX(tx, 1); + + const coins = view.get(hash); + assert(coins); + + const length = coins.outputs.size; + + view.spendEntry(new Outpoint(hash, 0)); + + assert.strictEqual(view.get(hash), coins); + + const entry = coins.get(0); + assert(entry); + assert(entry.spent); + + deepCoinsEqual(entry, reserialize(entry)); + assert.strictEqual(coins.outputs.size, length); + + assert.strictEqual(view.undo.items.length, 1); + }); + + it('should handle coin view', () => { + const [tx, view] = tx1.getTX(); + + const size = view.getSize(tx); + const bw = bio.write(size); + const raw = view.write(bw, tx).render(); + const br = bio.read(raw); + const res = CoinView.read(br, tx); + const prev = tx.inputs[0].prevout; + const coins = res.get(prev.hash); + + assert.strictEqual(coins.outputs.size, 1); + assert.strictEqual(coins.get(1), null); + deepCoinsEqual(coins.get(0), reserialize(coins.get(0))); + }); }); diff --git a/test/data/coin1.json b/test/data/coin1.json new file mode 100644 index 000000000..e63cf821d --- /dev/null +++ b/test/data/coin1.json @@ -0,0 +1,14 @@ +{ + "version": 0, + "height": -1, + "value": 99997200, + "address": "rs1qxps2ljf5604tgyz7pvecuq6twwt4k9qsxcd27y", + "covenant": { + "type": 0, + "action": "NONE", + "items": [] + }, + "coinbase": false, + "hash": "dc73eb199e8a70f69de74aecc5a1233e3a9a6e14ea62d0a08b17aca0a7f916ad", + "index": 0 +} diff --git a/test/data/coin1.raw b/test/data/coin1.raw new file mode 100644 index 0000000000000000000000000000000000000000..7e1043438a25f7e2cf7ce7b5ea2a5f34356cb4b5 GIT binary patch literal 41 tcmZQzU|{$U1OnH-vI3b53?c>z>ra|oezip)j@!78*}Hgpw1@yu8~{HT4X*$I literal 0 HcmV?d00001 diff --git a/test/data/mtx1.json b/test/data/mtx1.json new file mode 100644 index 000000000..1800f098f --- /dev/null +++ b/test/data/mtx1.json @@ -0,0 +1,53 @@ +{ + "hash": "dc73eb199e8a70f69de74aecc5a1233e3a9a6e14ea62d0a08b17aca0a7f916ad", + "witnessHash": "b3f4428bde7742db4c6a65b70a1a3f9b64c32cc1d061ca352db00606f9938bb4", + "fee": 2800, + "rate": 22764, + "mtime": 1558044979, + "version": 0, + "inputs": [{ + "prevout": { + "hash": "135a7ab2b078101a4b5c74ccb13ec33aafbfe3cb08d69f79bb6a07183ed4be4d", + "index": 0 + }, + "witness": ["", "03253ea6d6486d1b9cc3ab01a9a321d65c350c6c26a9c536633e2ef36163316bf2"], + "sequence": 4294967295, + "coin": { + "version": 0, + "height": 2, + "value": 2000000000, + "address": "rs1q4rvs9pp9496qawp2zyqpz3s90fjfk362q92vq8", + "covenant": { + "type": 0, + "action": "NONE", + "items": [] + }, + "coinbase": true + }, + "path": { + "name": "default", + "account": 0, + "change": false, + "derivation": "m/44'/5355'/0'/0/0" + } + }], + "outputs": [{ + "value": 99997200, + "address": "rs1qxps2ljf5604tgyz7pvecuq6twwt4k9qsxcd27y", + "covenant": { + "type": 0, + "action": "NONE", + "items": [] + } + }, { + "value": 1900000000, + "address": "rs1qsfcpg4w2qwzx6ht5aa30dj5vddwvkdfq82dlae", + "covenant": { + "type": 0, + "action": "NONE", + "items": [] + } + }], + "locktime": 0, + "hex": "0000000001135a7ab2b078101a4b5c74ccb13ec33aafbfe3cb08d69f79bb6a07183ed4be4d00000000ffffffff0210d6f5050000000000143060afc934d3eab4105e0b338e034b73975b1410000000b33f7100000000001482701455ca03846d5d74ef62f6ca8c6b5ccb352000000000000002002103253ea6d6486d1b9cc3ab01a9a321d65c350c6c26a9c536633e2ef36163316bf2" +} diff --git a/test/data/mtx2.json b/test/data/mtx2.json new file mode 100644 index 000000000..bcc38514f --- /dev/null +++ b/test/data/mtx2.json @@ -0,0 +1,47 @@ +{ + "hash": "dc73eb199e8a70f69de74aecc5a1233e3a9a6e14ea62d0a08b17aca0a7f916ad", + "witnessHash": "b3f4428bde7742db4c6a65b70a1a3f9b64c32cc1d061ca352db00606f9938bb4", + "fee": 2800, + "rate": 22764, + "mtime": 1558044979, + "version": 0, + "inputs": [{ + "prevout": { + "hash": "135a7ab2b078101a4b5c74ccb13ec33aafbfe3cb08d69f79bb6a07183ed4be4d", + "index": 0 + }, + "witness": ["", "03253ea6d6486d1b9cc3ab01a9a321d65c350c6c26a9c536633e2ef36163316bf2"], + "sequence": 4294967295, + "coin": { + "version": 0, + "height": 2, + "value": 2000000000, + "address": "rs1q4rvs9pp9496qawp2zyqpz3s90fjfk362q92vq8", + "covenant": { + "type": 0, + "action": "NONE", + "items": [] + }, + "coinbase": true + } + }], + "outputs": [{ + "value": 99997200, + "address": "rs1qxps2ljf5604tgyz7pvecuq6twwt4k9qsxcd27y", + "covenant": { + "type": 0, + "action": "NONE", + "items": [] + } + }, { + "value": 1900000000, + "address": "rs1qsfcpg4w2qwzx6ht5aa30dj5vddwvkdfq82dlae", + "covenant": { + "type": 0, + "action": "NONE", + "items": [] + } + }], + "locktime": 0, + "hex": "0000000001135a7ab2b078101a4b5c74ccb13ec33aafbfe3cb08d69f79bb6a07183ed4be4d00000000ffffffff0210d6f5050000000000143060afc934d3eab4105e0b338e034b73975b1410000000b33f7100000000001482701455ca03846d5d74ef62f6ca8c6b5ccb352000000000000002002103253ea6d6486d1b9cc3ab01a9a321d65c350c6c26a9c536633e2ef36163316bf2" +} diff --git a/test/data/tx1-undo.raw b/test/data/tx1-undo.raw new file mode 100644 index 0000000000000000000000000000000000000000..c6fde1b87f46bfbb98880e11d2d3e3abaaccc72d GIT binary patch literal 32 lcmZRWVp`4s0wODJGPS6#EaBUsCCDJ?##)s!+ue(S0RV!O2n7HD literal 0 HcmV?d00001 diff --git a/test/data/tx1.raw b/test/data/tx1.raw new file mode 100644 index 0000000000000000000000000000000000000000..6b34ed7e4ecf47dd4b5591136f284a017b960c47 GIT binary patch literal 215 zcmZQzU|?Vrj;h+Up+Z2)JEr8!M!UmS>-RrC&2ephOD`$4{L_j{hSB?LKdjRz7uHhW!HLqW9m~(T zTW&6n&)7XlX6Yp^h7BhR`}nQgi?S literal 0 HcmV?d00001 diff --git a/test/input-test.js b/test/input-test.js index c86dc28ba..f8110edfe 100644 --- a/test/input-test.js +++ b/test/input-test.js @@ -4,10 +4,41 @@ 'use strict'; -const bio = require('bufio'); -const Input = require('../lib/primitives/input'); const assert = require('bsert'); -const common = require('./util/common'); +const MTX = require('../lib/primitives/mtx'); -describe('Input', function() { +const mtx1json = require('./data/mtx1.json'); +const mtx2json = require('./data/mtx2.json'); +const mtx1 = MTX.fromJSON(mtx1json); +const mtx2 = MTX.fromJSON(mtx2json); + +describe('MTX', function() { + it('should serialize path', () => { + const input = mtx1.inputs[0]; + const view = mtx1.view; + const coin = view.getCoinFor(input); + const path = view.getPathFor(input); + const json = input.getJSON('regtest', coin, path); + const got = json.path; + const want = { + name: 'default', + account: 0, + change: false, + derivation: 'm/44\'/5355\'/0\'/0/0' + }; + + assert.deepStrictEqual(got, want); + }); + + it('should not serialize path', () => { + const input = mtx2.inputs[0]; + const view = mtx2.view; + const coin = view.getCoinFor(input); + const path = view.getPathFor(input); + const json = input.getJSON('regtest', coin, path); + const got = json.path; + const want = undefined; + + assert.deepStrictEqual(got, want); + }); }); diff --git a/test/mtx-test.js b/test/mtx-test.js new file mode 100644 index 000000000..e5dd39ae2 --- /dev/null +++ b/test/mtx-test.js @@ -0,0 +1,65 @@ +/* eslint-env mocha */ +/* eslint prefer-arrow-callback: "off" */ +/* eslint no-unused-vars: "off" */ + +'use strict'; + +const assert = require('bsert'); +const bio = require('bufio'); +const CoinView = require('../lib/coins/coinview'); +const WalletCoinView = require('../lib/wallet/walletcoinview'); +const MTX = require('../lib/primitives/mtx'); +const Path = require('../lib/wallet/path'); +const common = require('./util/common'); + +const mtx1json = require('./data/mtx1.json'); +const mtx2json = require('./data/mtx2.json'); +const mtx1 = MTX.fromJSON(mtx1json); +const mtx2 = MTX.fromJSON(mtx2json); + +describe('MTX', function() { + it('should serialize wallet coin view', () => { + const json = mtx1.getJSON('regtest'); + const got = json.inputs[0].path; + const want = { + name: 'default', + account: 0, + change: false, + derivation: 'm/44\'/5355\'/0\'/0/0' + }; + + assert.deepStrictEqual(got, want); + }); + + it('should deserialize wallet coin view', () => { + const view = mtx1.view; + const input = mtx1.inputs[0]; + const got = view.getPathFor(input); + const want = new Path(); + want.name = 'default'; + want.account = 0; + want.branch = 0; + want.index = 0; + + assert.ok(view instanceof WalletCoinView); + assert.deepStrictEqual(got, want); + }); + + it('should serialize coin view', () => { + const json = mtx2.getJSON('regtest'); + const got = json.inputs[0].path; + const want = undefined; + + assert.deepStrictEqual(got, want); + }); + + it('should deserialize coin view', () => { + const view = mtx2.view; + const input = mtx2.inputs[0]; + const got = view.getPathFor(input); + const want = null; + + assert.ok(view instanceof CoinView); + assert.deepStrictEqual(got, want); + }); +}); diff --git a/test/path-test.js b/test/path-test.js new file mode 100644 index 000000000..7098c2d22 --- /dev/null +++ b/test/path-test.js @@ -0,0 +1,71 @@ +/* eslint-env mocha */ +/* eslint prefer-arrow-callback: "off" */ +/* eslint no-unused-vars: "off" */ + +'use strict'; + +const assert = require('bsert'); +const MTX = require('../lib/primitives/mtx'); +const Path = require('../lib/wallet/path'); + +const mtx1json = require('./data/mtx1.json'); +const mtx1 = MTX.fromJSON(mtx1json); + +describe('MTX', function() { + it('should serialize path', () => { + const input = mtx1.inputs[0]; + const view = mtx1.view; + const path = view.getPathFor(input); + + { + const got = path.getJSON(); + const want = { + name: 'default', + account: 0, + change: false, + derivation: 'm/0\'/0/0' + }; + + assert.deepStrictEqual(got, want); + } + + { + const got = path.getJSON('regtest'); + const want = { + name: 'default', + account: 0, + change: false, + derivation: 'm/44\'/5355\'/0\'/0/0' + }; + + assert.deepStrictEqual(got, want); + } + }); + + it('should deserialize path', () => { + const path1 = Path.fromJSON({ + name: 'default', + account: 0, + change: true, + derivation: 'm/0\'/1/1' + }); + + const path2 = new Path().fromJSON({ + name: 'default', + account: 0, + change: true, + derivation: 'm/44\'/5355\'/0\'/1/1' + }); + + assert.deepStrictEqual(path1, path2); + + const got = path1; + const want = new Path(); + want.name = 'default'; + want.account = 0; + want.branch = 1; + want.index = 1; + + assert.deepStrictEqual(got, want); + }); +}); diff --git a/test/util/common.js b/test/util/common.js index 88542ebaf..050fbcf98 100644 --- a/test/util/common.js +++ b/test/util/common.js @@ -129,8 +129,9 @@ function serializeUndo(items) { const bw = bio.write(); for (const item of items) { - bw.writeI64(item.value); - bw.writeVarBytes(item.script.encode()); + bw.writeU64(item.value); + item.address.write(bw); + item.covenant.write(bw); } return bw.render(); From a451b90459cc377c9c3709cd2adde3cbb3c705ce Mon Sep 17 00:00:00 2001 From: Boyma Fahnbulleh Date: Tue, 11 Jun 2019 11:58:20 -0700 Subject: [PATCH 6/6] http: support HD paths --- lib/wallet/http.js | 1 + test/wallet-http-test.js | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lib/wallet/http.js b/lib/wallet/http.js index b98084010..4c2ca6287 100644 --- a/lib/wallet/http.js +++ b/lib/wallet/http.js @@ -1633,6 +1633,7 @@ class TransactionOptions { this.subtractFee = valid.bool('subtractFee'); this.subtractIndex = valid.i32('subtractIndex'); this.depth = valid.u32(['confirmations', 'depth']); + this.paths = valid.bool('paths'); this.outputs = []; if (valid.has('outputs')) { diff --git a/test/wallet-http-test.js b/test/wallet-http-test.js index 63addb583..9ecfcb80c 100644 --- a/test/wallet-http-test.js +++ b/test/wallet-http-test.js @@ -114,6 +114,25 @@ describe('Wallet HTTP', function() { assert.equal(tx.locktime, 0); }); + it('should create a transaction with HD paths', async () => { + const tx = await wallet.createTX({ + paths: true, + outputs: [{ address: cbAddress, value: 1e4 }] + }); + + assert.ok(tx); + assert.ok(tx.inputs); + + for (let i = 0; i < tx.inputs.length; i++) { + const path = tx.inputs[i].path; + + assert.ok(typeof path.name === 'string'); + assert.ok(typeof path.account === 'number'); + assert.ok(typeof path.change === 'boolean'); + assert.ok(typeof path.derivation === 'string'); + } + }); + it('should create a transaction with a locktime', async () => { const locktime = 8e6;