diff --git a/lib/utils/coinselector.js b/lib/utils/coinselector.js index 48c4d59d..fd3a0977 100644 --- a/lib/utils/coinselector.js +++ b/lib/utils/coinselector.js @@ -49,7 +49,7 @@ class AbstractCoinSource { /** * @param {BufferMap} inputs - * @param {Coin[]} coins + * @param {Coin[]} coins - Coin per input. * @returns {Promise} */ diff --git a/lib/wallet/wallet.js b/lib/wallet/wallet.js index c8815b83..8bc0f2ef 100644 --- a/lib/wallet/wallet.js +++ b/lib/wallet/wallet.js @@ -13,6 +13,7 @@ const base58 = require('bcrypto/lib/encoding/base58'); const bio = require('bufio'); const blake2b = require('bcrypto/lib/blake2b'); const cleanse = require('bcrypto/lib/cleanse'); +const {BufferSet} = require('buffer-map'); const TXDB = require('./txdb'); const Path = require('./path'); const common = require('./common'); @@ -38,12 +39,16 @@ const reserved = require('../covenants/reserved'); const {ownership} = require('../covenants/ownership'); const {states} = require('../covenants/namestate'); const {types} = rules; -const {BufferSet} = require('buffer-map'); const Coin = require('../primitives/coin'); const Outpoint = require('../primitives/outpoint'); +const { + AbstractCoinSource, + CoinSelector +} = require('../utils/coinselector'); /** @typedef {import('bdb').DB} DB */ /** @typedef {ReturnType} Batch */ +/** @typedef {import('buffer-map').BufferMap} BufferMap */ /** @typedef {import('../types').Base58String} Base58String */ /** @typedef {import('../types').Hash} Hash */ /** @typedef {import('../types').Amount} Amount */ @@ -1226,12 +1231,10 @@ class Wallet extends EventEmitter { } /** - * Fill a transaction with inputs without a lock. - * @private - * @see MTX#selectCoins - * @see MTX#fill + * Fill a transactions with inputs without a lock. * @param {MTX} mtx * @param {Object} [options] + * @returns {Promise} */ async fill(mtx, options = {}) { @@ -1245,18 +1248,10 @@ class Wallet extends EventEmitter { if (rate == null) rate = await this.wdb.estimateFee(options.blocks); - let coins = options.coins || []; - assert(Array.isArray(coins)); - if (options.smart) { - const smartCoins = await this.getSmartCoins(options.account); - coins = coins.concat(smartCoins); - } else { - let availableCoins = await this.getCoins(options.account); - availableCoins = this.txdb.filterLocked(availableCoins); - coins = coins.concat(availableCoins); - } - - await mtx.fund(coins, { + const selected = await this.select(mtx, { + // we use options.account to maintain the same behaviour + account: await this.ensureIndex(options.account), + smart: options.smart, selection: options.selection, round: options.round, depth: options.depth, @@ -1270,6 +1265,53 @@ class Wallet extends EventEmitter { maxFee: options.maxFee, estimate: prev => this.estimateSize(prev) }); + + mtx.fill(selected); + return; + } + + /** + * Select coins for the transaction. + * @param {MTX} mtx + * @param {Object} [options] + * @returns {Promise} + */ + + async select(mtx, options) { + const selection = options.selection || 'dbvalue'; + + switch (selection) { + case 'all': + case 'random': + case 'value': + case 'age': { + let coins = options.coins || []; + + assert(Array.isArray(coins)); + if (options.smart) { + const smartCoins = await this.getSmartCoins(options.account); + coins = coins.concat(smartCoins); + } else { + let availableCoins = await this.getCoins(options.account); + availableCoins = this.txdb.filterLocked(availableCoins); + coins = coins.concat(availableCoins); + } + + return mtx.selectCoins(coins, options); + } + } + + const source = new WalletCoinSource(this, options); + await source.init(); + + if (selection === 'dball') + options.selectAll = true; + + const selector = new CoinSelector(mtx, source, options); + await selector.select(); + await source.end(); + + return selector; } /** @@ -1965,9 +2007,6 @@ class Wallet extends EventEmitter { const nameHash = bidOutput.covenant.getHash(0); const height = bidOutput.covenant.getU32(1); - const coins = []; - coins.push(bidCoin); - const blind = bidOutput.covenant.getHash(3); const bv = await this.getBlind(blind); if (!bv) @@ -1980,10 +2019,11 @@ class Wallet extends EventEmitter { output.value = value; output.covenant.setReveal(nameHash, height, nonce); - reveal.addOutpoint(Outpoint.fromTX(bid, bidOuputIndex)); + reveal.addCoin(bidCoin); reveal.outputs.push(output); - await this.fill(reveal, { ...options, coins: coins }); + await this.fill(reveal, { ...options }); + assert( reveal.inputs.length === 1, 'Pre-signed REVEAL must not require additional inputs' @@ -5590,6 +5630,160 @@ class Wallet extends EventEmitter { } } +/** + * Coin source for wallet. + * @alias module:wallet.CoinSource + */ + +class WalletCoinSource extends AbstractCoinSource { + /** + * @param {Wallet} wallet + * @param {Object} options + */ + + constructor(wallet, options) { + super(); + + this.wallet = wallet; + this.wdb = wallet.wdb; + this.txdb = wallet.txdb; + + this.iter = null; + this.done = false; + + this.account = 0; + this.selection = 'dbvalue'; + this.smart = false; + this.skipDust = true; + + if (options) + this.fromOptions(options); + } + + fromOptions(options = {}) { + if (options.account != null) { + assert(typeof options.account === 'number', + 'Account must be a number.'); + this.account = options.account; + } + + if (options.selection != null) { + assert(typeof options.selection === 'string', + 'Selection must be a string.'); + this.selection = options.selection; + } + + if (options.smart != null) { + assert(typeof options.smart === 'boolean', + 'Smart must be a boolean.'); + + this.smart = options.smart; + } + + return this; + } + + async init() { + switch (this.selection) { + case 'dbvalue': + case 'dball': { + this.iter = this.txdb.getAccountCreditIterByValue(this.account, { + reverse: true + }); + break; + } + case 'dbage': { + this.iter = this.txdb.getAccountCreditIterByHeight(this.account); + break; + } + default: + throw new Error(`Invalid selection: ${this.selection}`); + } + } + + /** + * Are we done. + * @returns {Boolean} + */ + + hasNext() { + return !this.done; + } + + /** + * @returns {Promise} + */ + + async next() { + if (this.done) + return null; + + // look for an usable credit. + for (;;) { + const item = await this.iter.next(); + + if (item.done) { + this.done = true; + return null; + } + + /** @type {Credit} */ + const credit = item.value; + + if (credit.spent) + continue; + + const {coin} = credit; + + if (this.txdb.isLocked(coin)) + continue; + + if (this.smart && coin.height === -1 && !credit.own) + continue; + + return coin; + } + } + + async end() { + if (!this.iter) + return; + + this.iter.return(); + } + + /** + * Resolve coins. + * @param {BufferMap} inputs + * @param {Coin[]} coins - Coin per input. + * @returns {Promise} + */ + + async resolveInputsToCoins(inputs, coins) { + for (const [key, idx] of inputs.entries()) { + if (coins[idx] != null) + continue; + + const outpoint = Outpoint.fromKey(key); + + if (this.account !== -1) { + const hasCoin = await this.txdb.hasCoinByAccount( + this.account, + outpoint.hash, + outpoint.index + ); + + if (!hasCoin) + continue; + } + + const coin = await this.txdb.getCoin(outpoint.hash, outpoint.index); + coins[idx] = coin; + inputs.delete(key); + } + } +} + /* * Expose */ diff --git a/test/wallet-coinselection-test.js b/test/wallet-coinselection-test.js index c96fb575..a8683936 100644 --- a/test/wallet-coinselection-test.js +++ b/test/wallet-coinselection-test.js @@ -1955,6 +1955,36 @@ describe('Wallet Coin Selection', function() { ] }; + const reselect = (tests, selection) => { + return tests.map((t) => { + const options = { + ...t.options, + selection + }; + + return { + ...t, + options + }; + }); + }; + + // Selection `value` and `dbvalue` are the same. + SELECTION_TESTS['dbvalue'] = reselect(SELECTION_TESTS['value'], 'dbvalue'); + SELECTION_TESTS['dbvalue + smart'] = reselect(SELECTION_TESTS['value + smart'], 'dbvalue'); + SELECTION_TESTS['dbvalue + existing coins and inputs'] = reselect( + SELECTION_TESTS['value + existing coins and inputs'], 'dbvalue'); + + // Same with `age` and `dbage`. + SELECTION_TESTS['dbage'] = reselect(SELECTION_TESTS['age'], 'dbage'); + SELECTION_TESTS['dbage + smart'] = reselect(SELECTION_TESTS['age + smart'], 'dbage'); + SELECTION_TESTS['dbage + existing inputs'] = reselect( + SELECTION_TESTS['age + existing inputs'], 'dbage'); + + SELECTION_TESTS['dball'] = reselect(SELECTION_TESTS['all'], 'dball'); + SELECTION_TESTS['dball + existing inputs'] = reselect( + SELECTION_TESTS['all + existing inputs'], 'dball'); + for (const [name, testCase] of Object.entries(SELECTION_TESTS)) { describe(`Wallet Coin Selection by ${name}`, function() { // fund wallet. diff --git a/test/wallet-test.js b/test/wallet-test.js index 1d09ce5c..82ecb51a 100644 --- a/test/wallet-test.js +++ b/test/wallet-test.js @@ -3277,6 +3277,7 @@ describe('Wallet', function() { const fund = 10e6; // Store height of auction OPEN to be used in second bid. // The main test wallet, and wallet that will receive the FINALIZE. + /** @type {Wallet} */ let wallet; let unsentReveal;