diff --git a/lib/primitives/mtx.js b/lib/primitives/mtx.js index ced667492..2cddb79ea 100644 --- a/lib/primitives/mtx.js +++ b/lib/primitives/mtx.js @@ -1409,12 +1409,13 @@ class CoinSelector { /** * Create a coin selector. * @constructor - * @param {TX} tx + * @param {MTX} tx * @param {Object?} options */ constructor(tx, options) { this.tx = tx.clone(); + this.view = tx.view; this.coins = []; this.outputValue = 0; this.index = 0; @@ -1538,11 +1539,13 @@ class CoinSelector { if (options.inputs) { assert(Array.isArray(options.inputs)); + + const lastIndex = this.inputs.size; for (let i = 0; i < options.inputs.length; i++) { const prevout = options.inputs[i]; assert(prevout && typeof prevout === 'object'); const {hash, index} = prevout; - this.inputs.set(Outpoint.toKey(hash, index), i); + this.inputs.set(Outpoint.toKey(hash, index), lastIndex + i); } } @@ -1676,31 +1679,7 @@ class CoinSelector { fund() { // Ensure all preferred inputs first. - if (this.inputs.size > 0) { - const coins = []; - - for (let i = 0; i < this.inputs.size; i++) - coins.push(null); - - for (const coin of this.coins) { - const {hash, index} = coin; - const key = Outpoint.toKey(hash, index); - const i = this.inputs.get(key); - - if (i != null) { - coins[i] = coin; - this.inputs.delete(key); - } - } - - if (this.inputs.size > 0) - throw new Error('Could not resolve preferred inputs.'); - - for (const coin of coins) { - this.tx.addCoin(coin); - this.chosen.push(coin); - } - } + this.resolveInputCoins(); if (this.isFull()) return; @@ -1803,6 +1782,56 @@ class CoinSelector { this.fee = this.hardFee; this.fund(); } + + resolveInputCoins() { + if (this.inputs.size === 0) + return; + + const coins = []; + + for (let i = 0 ; i < this.inputs.size; i++) { + coins.push(null); + } + + // first resolve from coinview if possible. + for (const key of this.inputs.keys()) { + const prevout = Outpoint.fromKey(key); + + if (this.view.hasEntry(prevout)) { + const coinEntry = this.view.getEntry(prevout); + const i = this.inputs.get(key); + + if (i != null) { + assert(!coins[i]); + coins[i] = coinEntry.toCoin(prevout); + this.inputs.delete(key); + } + } + } + + // Now try to resolve from the passed coins array. + if (this.inputs.size > 0) { + for (const coin of this.coins) { + const {hash, index} = coin; + const key = Outpoint.toKey(hash, index); + const i = this.inputs.get(key); + + if (i != null) { + assert(!coins[i]); + coins[i] = coin; + this.inputs.delete(key); + } + } + } + + if (this.inputs.size > 0) + throw new Error('Could not resolve preferred inputs.'); + + for (const coin of coins) { + this.tx.addCoin(coin); + this.chosen.push(coin); + } + } } /** diff --git a/test/mtx-test.js b/test/mtx-test.js index a1a9c9857..70dea97bd 100644 --- a/test/mtx-test.js +++ b/test/mtx-test.js @@ -1,10 +1,13 @@ 'use strict'; const assert = require('bsert'); +const random = require('bcrypto/lib/random'); const CoinView = require('../lib/coins/coinview'); const WalletCoinView = require('../lib/wallet/walletcoinview'); +const Coin = require('../lib/primitives/coin'); const MTX = require('../lib/primitives/mtx'); const Path = require('../lib/wallet/path'); +const MemWallet = require('./util/memwallet'); const mtx1json = require('./data/mtx1.json'); const mtx2json = require('./data/mtx2.json'); @@ -134,4 +137,631 @@ describe('MTX', function() { assert.strictEqual(estimate, actual); }); }); + + describe('Fund', function() { + const wallet1 = new MemWallet(); + const wallet2 = new MemWallet(); + + const coins1 = [ + dummyCoin(wallet1.getAddress(), 1000000), + dummyCoin(wallet1.getAddress(), 1000000), + dummyCoin(wallet1.getAddress(), 1000000), + dummyCoin(wallet1.getAddress(), 1000000), + dummyCoin(wallet1.getAddress(), 1000000) + ]; + + const last1 = coins1[coins1.length - 1]; + const last2 = coins1[coins1.length - 2]; + + /** + * Test matrix + * fund w/o inputs, just coins + * fund with preferred inputs - no view && coins + * fund with preferred inputs - view && no coins + * fund with preferred inputs - view && coins + * fund with preferred inputs - no view && coins - error + * + * fund with existing inputs - no view && coins + * fund with existing inputs - view && no coins + * fund with existing inputs - view && coins + * fund with existing inputs - no view && no coins - error + * + * fund with both inputs - no view && coins(1e, 1p) + * fund with both inputs - view(1e, 1p) && no coins + * fund with both inputs - view(1e, 1p) && coins(1e, 1p) + * fund with both inputs (1e, 1p) - no view(1e) && no coins(1e) - error. + * fund with both inputs (1e, 1p) - no view(1p) && no coins(1p) - error. + * fund with both inputs (1e, 1p) - no view && no coins - error. + */ + + it('should fund mtx', async () => { + const mtx = new MTX(); + + mtx.addOutput(wallet2.getAddress(), 1500000); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange() + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + }); + + it('should add all preferred coins regardless of value', async () => { + const mtx = new MTX(); + + // 1 preferred is enough. + mtx.addOutput(wallet2.getAddress(), 1000000); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + hardFee: 0, + + // Use all coins as preferred, but one. + inputs: coins1.slice(0, -1).map(coin => ({ + hash: coin.hash, + index: coin.index + })) + }); + + // all of them got used. + assert.strictEqual(mtx.inputs.length, coins1.length - 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 1000000); + assert.strictEqual(mtx.outputs[1].value, 3000000); + }); + + it('should fund with preferred inputs - coins', async () => { + const mtx = new MTX(); + const coin = last1; + + mtx.addOutput(wallet2.getAddress(), 1500000); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin.hash, + index: coin.index + }] + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, coin.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, coin.index); + + assert(mtx.view.hasEntry({ + hash: coin.hash, + index: coin.index + })); + }); + + it('should fund with preferred inputs - view', async () => { + const mtx = new MTX(); + const coin = dummyCoin(wallet1.getAddress(), 1000000); + + mtx.addOutput(wallet2.getAddress(), 1500000); + mtx.view.addCoin(coin); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin.hash, + index: coin.index + }] + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, coin.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, coin.index); + + assert(mtx.view.hasEntry({ + hash: coin.hash, + index: coin.index + })); + }); + + it('should fund with preferred inputs - coins && view', async () => { + const mtx = new MTX(); + const viewCoin = dummyCoin(wallet1.getAddress(), 1000000); + const lastCoin = last1; + + mtx.addOutput(wallet2.getAddress(), 1500000); + mtx.view.addCoin(viewCoin); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: viewCoin.hash, + index: viewCoin.index + }, { + hash: last1.hash, + index: last1.index + }] + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, viewCoin.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, viewCoin.index); + assert.bufferEqual(mtx.inputs[1].prevout.hash, lastCoin.hash); + assert.strictEqual(mtx.inputs[1].prevout.index, lastCoin.index); + + assert(mtx.view.hasEntry({ + hash: viewCoin.hash, + index: viewCoin.index + })); + + assert(mtx.view.hasEntry({ + hash: lastCoin.hash, + index: lastCoin.index + })); + }); + + it('should not fund with preferred inputs and no coin info', async () => { + const mtx = new MTX(); + const coin = dummyCoin(wallet1.getAddress(), 1000000); + + mtx.addOutput(wallet2.getAddress(), 1500000); + + let err; + + try { + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin.hash, + index: coin.index + }] + }); + } catch (e) { + err = e; + } + + assert(err); + assert.strictEqual(err.message, 'Could not resolve preferred inputs.'); + }); + + it('should fund with existing inputs view - coins', async () => { + const mtx = new MTX(); + const coin = last1; + + mtx.addInput({ + prevout: { + hash: coin.hash, + index: coin.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 1500000); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange() + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, coin.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, coin.index); + + assert(mtx.view.hasEntry({ + hash: coin.hash, + index: coin.index + })); + }); + + it('should fund with existing inputs view - view', async () => { + const mtx = new MTX(); + const coin = dummyCoin(wallet1.getAddress(), 1000000); + + mtx.addInput({ + prevout: { + hash: coin.hash, + index: coin.index + } + }); + + mtx.view.addCoin(coin); + + mtx.addOutput(wallet2.getAddress(), 1500000); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange() + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, coin.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, coin.index); + + assert(mtx.view.hasEntry({ + hash: coin.hash, + index: coin.index + })); + }); + + it('should fund with existing inputs view - coins && view', async () => { + const mtx = new MTX(); + const viewCoin = dummyCoin(wallet1.getAddress(), 1000000); + const lastCoin = last1; + + mtx.addInput({ + prevout: { + hash: viewCoin.hash, + index: viewCoin.index + } + }); + + mtx.addInput({ + prevout: { + hash: last1.hash, + index: last1.index + } + }); + + mtx.view.addCoin(viewCoin); + + mtx.addOutput(wallet2.getAddress(), 1500000); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange() + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, viewCoin.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, viewCoin.index); + assert.bufferEqual(mtx.inputs[1].prevout.hash, lastCoin.hash); + assert.strictEqual(mtx.inputs[1].prevout.index, lastCoin.index); + + assert(mtx.view.hasEntry({ + hash: viewCoin.hash, + index: viewCoin.index + })); + + assert(mtx.view.hasEntry({ + hash: lastCoin.hash, + index: lastCoin.index + })); + }); + + it('should not fund with existing inputs and no coin info', async () => { + const mtx = new MTX(); + const coin = dummyCoin(wallet1.getAddress(), 1000000); + + mtx.addInput({ + prevout: { + hash: coin.hash, + index: coin.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 1500000); + + let err; + + try { + await mtx.fund(coins1, { + changeAddress: wallet1.getChange() + }); + } catch (e) { + err = e; + } + + assert(err); + assert.strictEqual(err.message, 'Could not resolve preferred inputs.'); + }); + + it('should fund with preferred & existing inputs - coins', async () => { + const mtx = new MTX(); + const coin1 = last1; + const coin2 = last2; + + mtx.addInput({ + prevout: { + hash: coin1.hash, + index: coin1.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 1500000); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin2.hash, + index: coin2.index + }] + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, coin1.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, coin1.index); + assert.bufferEqual(mtx.inputs[1].prevout.hash, coin2.hash); + assert.strictEqual(mtx.inputs[1].prevout.index, coin2.index); + + assert(mtx.view.hasEntry({ + hash: coin1.hash, + index: coin1.index + })); + + assert(mtx.view.hasEntry({ + hash: coin2.hash, + index: coin2.index + })); + }); + + it('should fund with preferred & existing inputs - view', async () => { + const mtx = new MTX(); + const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + + mtx.addInput({ + prevout: { + hash: coin1.hash, + index: coin1.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 1500000); + mtx.view.addCoin(coin1); + mtx.view.addCoin(coin2); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin2.hash, + index: coin2.index + }] + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + + assert.bufferEqual(mtx.inputs[0].prevout.hash, coin1.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, coin1.index); + assert.bufferEqual(mtx.inputs[1].prevout.hash, coin2.hash); + assert.strictEqual(mtx.inputs[1].prevout.index, coin2.index); + + assert(mtx.view.hasEntry({ + hash: coin1.hash, + index: coin1.index + })); + + assert(mtx.view.hasEntry({ + hash: coin2.hash, + index: coin2.index + })); + }); + + it('should fund with preferred & existing inputs', async () => { + const mtx = new MTX(); + // existing + const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast1 = last1; + + // preferred + const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast2 = last2; + + mtx.addInput({ + prevout: { + hash: coin1.hash, + index: coin1.index + } + }); + mtx.addInput({ + prevout: { + hash: coinLast1.hash, + index: coinLast1.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 5000000); + mtx.view.addCoin(coin1); + mtx.view.addCoin(coin2); + + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin2.hash, + index: coin2.index + }, { + hash: coinLast2.hash, + index: coinLast2.index + }] + }); + + assert.strictEqual(mtx.inputs.length, 6); + assert.strictEqual(mtx.outputs.length, 2); + + // first comes existing + assert.bufferEqual(mtx.inputs[0].prevout.hash, coin1.hash); + assert.strictEqual(mtx.inputs[0].prevout.index, coin1.index); + assert.bufferEqual(mtx.inputs[1].prevout.hash, coinLast1.hash); + assert.strictEqual(mtx.inputs[1].prevout.index, coinLast1.index); + + // then comes preferred + assert.bufferEqual(mtx.inputs[2].prevout.hash, coin2.hash); + assert.strictEqual(mtx.inputs[2].prevout.index, coin2.index); + assert.bufferEqual(mtx.inputs[3].prevout.hash, coinLast2.hash); + assert.strictEqual(mtx.inputs[3].prevout.index, coinLast2.index); + + assert(mtx.view.hasEntry({ + hash: coin1.hash, + index: coin1.index + })); + + assert(mtx.view.hasEntry({ + hash: coin2.hash, + index: coin2.index + })); + + assert(mtx.view.hasEntry({ + hash: coinLast1.hash, + index: coinLast1.index + })); + + assert(mtx.view.hasEntry({ + hash: coinLast2.hash, + index: coinLast2.index + })); + }); + + it('should not fund with missing coin info (both)', async () => { + const mtx = new MTX(); + // existing + const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast1 = last1; + + // preferred + const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast2 = last2; + + mtx.addInput({ + prevout: { + hash: coin1.hash, + index: coin1.index + } + }); + mtx.addInput({ + prevout: { + hash: coinLast1.hash, + index: coinLast1.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 5000000); + + let err; + try { + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin2.hash, + index: coin2.index + }, { + hash: coinLast2.hash, + index: coinLast2.index + }] + }); + } catch (e) { + err = e; + } + + assert(err); + // inputs are resolved first, so it should throw there. + assert.strictEqual(err.message, 'Could not resolve preferred inputs.'); + }); + + it('should not fund with missing coin info(only existing)', async () => { + const mtx = new MTX(); + // existing + const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast1 = last1; + + // preferred + const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast2 = last2; + + mtx.addInput({ + prevout: { + hash: coin1.hash, + index: coin1.index + } + }); + mtx.addInput({ + prevout: { + hash: coinLast1.hash, + index: coinLast1.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 5000000); + mtx.view.addCoin(coin1); + + let err; + try { + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin2.hash, + index: coin2.index + }, { + hash: coinLast2.hash, + index: coinLast2.index + }] + }); + } catch (e) { + err = e; + } + + assert(err); + // preferred is missing. + assert.strictEqual(err.message, 'Could not resolve preferred inputs.'); + }); + + it('should not fund with missing coin info(only preferred)', async () => { + const mtx = new MTX(); + // existing + const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast1 = last1; + + // preferred + const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coinLast2 = last2; + + mtx.addInput({ + prevout: { + hash: coin1.hash, + index: coin1.index + } + }); + mtx.addInput({ + prevout: { + hash: coinLast1.hash, + index: coinLast1.index + } + }); + + mtx.addOutput(wallet2.getAddress(), 5000000); + mtx.view.addCoin(coin2); + + let err; + try { + await mtx.fund(coins1, { + changeAddress: wallet1.getChange(), + inputs: [{ + hash: coin2.hash, + index: coin2.index + }, { + hash: coinLast2.hash, + index: coinLast2.index + }] + }); + } catch (e) { + err = e; + } + + assert(err); + // preferred is missing. + assert.strictEqual(err.message, 'Could not resolve preferred inputs.'); + }); + }); }); + +function dummyCoin(address, value) { + const hash = random.randomBytes(32); + const index = 0; + + return new Coin({address, value, hash, index}); +}