diff --git a/lib/coins/coinview.js b/lib/coins/coinview.js index 93daef990..45102a0ce 100644 --- a/lib/coins/coinview.js +++ b/lib/coins/coinview.js @@ -193,7 +193,7 @@ class CoinView extends View { /** * Spend an output. - * @param {Outpoint} prevout + * @param {Outpoint|Coin} prevout * @returns {CoinEntry|null} */ @@ -216,7 +216,7 @@ class CoinView extends View { /** * Remove an output. - * @param {Outpoint} prevout + * @param {Outpoint|Coin} prevout * @returns {CoinEntry|null} */ @@ -232,7 +232,7 @@ class CoinView extends View { /** * Test whether the view has an entry by prevout. - * @param {Outpoint} prevout + * @param {Outpoint|Coin} prevout * @returns {Boolean} */ @@ -248,7 +248,7 @@ class CoinView extends View { /** * Get a single entry by prevout. - * @param {Outpoint} prevout + * @param {Outpoint|Coin} prevout * @returns {CoinEntry|null} */ diff --git a/lib/primitives/covenant.js b/lib/primitives/covenant.js index db983e980..3ac9869d3 100644 --- a/lib/primitives/covenant.js +++ b/lib/primitives/covenant.js @@ -383,17 +383,17 @@ class Covenant extends bio.Struct { /** * Set covenant to BID. * @param {Hash} nameHash - * @param {Number} start + * @param {Number} height * @param {Buffer} rawName * @param {Hash} blind * @returns {Covenant} */ - setBid(nameHash, start, rawName, blind) { + setBid(nameHash, height, rawName, blind) { this.type = types.BID; this.items = []; this.pushHash(nameHash); - this.pushU32(start); + this.pushU32(height); this.push(rawName); this.pushHash(blind); diff --git a/lib/primitives/mtx.js b/lib/primitives/mtx.js index cfea27a89..f5b7980cb 100644 --- a/lib/primitives/mtx.js +++ b/lib/primitives/mtx.js @@ -8,7 +8,6 @@ const assert = require('bsert'); const {encoding} = require('bufio'); -const {BufferMap} = require('buffer-map'); const Script = require('../script/script'); const TX = require('./tx'); const Input = require('./input'); @@ -18,15 +17,18 @@ 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'); -const Amount = require('../ui/amount'); const Stack = require('../script/stack'); const rules = require('../covenants/rules'); const util = require('../utils/util'); const {types} = rules; +const { + CoinSelector, + InMemoryCoinSource +} = require('../utils/coinselector'); + /** @typedef {import('../types').SighashType} SighashType */ /** @typedef {import('../types').Hash} Hash */ /** @typedef {import('../types').Amount} AmountValue */ @@ -34,6 +36,8 @@ const {types} = rules; /** @typedef {import('../protocol/network')} Network */ /** @typedef {import('../workers/workerpool')} WorkerPool */ /** @typedef {import('./keyring')} KeyRing */ +/** @typedef {import('./address')} Address */ +/** @typedef {import('../utils/coinselector')} coinselector */ /** * MTX @@ -227,6 +231,18 @@ class MTX extends TX { return output; } + /** + * Get the value of the change output. + * @returns {AmountValue} value - Returns -1 if no change output. + */ + + getChangeValue() { + if (this.changeIndex === -1) + return -1; + + return this.outputs[this.changeIndex].value; + } + /** * Verify all transaction inputs. * @param {VerifyFlags?} [flags=STANDARD_VERIFY_FLAGS] @@ -1018,14 +1034,26 @@ class MTX extends TX { /** * Select necessary coins based on total output value. * @param {Coin[]} coins - * @param {Object?} options + * @param {Object} options * @returns {Promise} * @throws on not enough funds available. */ - selectCoins(coins, options) { - const selector = new CoinSelector(this, options); - return selector.select(coins); + async selectCoins(coins, options) { + const source = new InMemoryCoinSource({ + coins, + selection: options.selection + }); + + await source.init(); + + if (options.selection === 'all') + options.selectAll = true; + + const selector = new CoinSelector(this, source, options); + await selector.select(); + + return selector; } /** @@ -1112,7 +1140,9 @@ class MTX extends TX { /** * Select coins and fill the inputs. * @param {Coin[]} coins - * @param {Object} options - See {@link MTX#selectCoins} options. + * @param {Object} options - See + * {@link CoinSelectorOptions} and + * {@link CoinSourceOptions} options. * @returns {Promise} */ @@ -1122,7 +1152,17 @@ class MTX extends TX { // Select necessary coins. const select = await this.selectCoins(coins, options); + this.fill(select); + return select; + } + /** + * Fill transaction with the selected inputs. + * @param {CoinSelector} select + * @returns {void} + */ + + fill(select) { // Make sure we empty the input array. this.inputs.length = 0; @@ -1153,8 +1193,6 @@ class MTX extends TX { this.changeIndex = this.outputs.length - 1; assert.strictEqual(this.getFee(), select.fee); } - - return select; } /** @@ -1175,7 +1213,14 @@ class MTX extends TX { const inputs = []; /** @type {Output[]} */ const outputs = []; - // [Input, Output][] + + /** + * @typedef {Array} Linked + * @property {Input} 0 + * @property {Output} 1 + */ + + /** @type {Linked[]} */ const linked = []; let i = 0; @@ -1411,525 +1456,26 @@ class MTX extends TX { } } -/** - * Coin Selector - * @alias module:primitives.CoinSelector - */ - -class CoinSelector { - /** - * Create a coin selector. - * @constructor - * @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; - this.chosen = []; - this.change = 0; - this.fee = CoinSelector.MIN_FEE; - - this.selection = 'value'; - this.subtractFee = false; - this.subtractIndex = -1; - this.height = -1; - this.depth = -1; - this.hardFee = -1; - this.rate = CoinSelector.FEE_RATE; - this.maxFee = -1; - this.round = false; - this.coinbaseMaturity = 400; - this.changeAddress = null; - this.inputs = new BufferMap(); - - // Needed for size estimation. - this.estimate = null; - - this.injectInputs(); - - if (options) - this.fromOptions(options); - } - - /** - * Initialize selector options. - * @param {Object} options - * @private - */ - - fromOptions(options) { - if (options.selection) { - assert(typeof options.selection === 'string'); - this.selection = options.selection; - } - - if (options.subtractFee != null) { - if (typeof options.subtractFee === 'number') { - assert(Number.isSafeInteger(options.subtractFee)); - assert(options.subtractFee >= -1); - this.subtractIndex = options.subtractFee; - this.subtractFee = this.subtractIndex !== -1; - } else { - assert(typeof options.subtractFee === 'boolean'); - this.subtractFee = options.subtractFee; - } - } - - if (options.subtractIndex != null) { - assert(Number.isSafeInteger(options.subtractIndex)); - assert(options.subtractIndex >= -1); - this.subtractIndex = options.subtractIndex; - this.subtractFee = this.subtractIndex !== -1; - } - - if (options.height != null) { - assert(Number.isSafeInteger(options.height)); - assert(options.height >= -1); - this.height = options.height; - } - - if (options.confirmations != null) { - assert(Number.isSafeInteger(options.confirmations)); - assert(options.confirmations >= -1); - this.depth = options.confirmations; - } - - if (options.depth != null) { - assert(Number.isSafeInteger(options.depth)); - assert(options.depth >= -1); - this.depth = options.depth; - } - - if (options.hardFee != null) { - assert(Number.isSafeInteger(options.hardFee)); - assert(options.hardFee >= -1); - this.hardFee = options.hardFee; - } - - if (options.rate != null) { - assert(Number.isSafeInteger(options.rate)); - assert(options.rate >= 0); - this.rate = options.rate; - } - - if (options.maxFee != null) { - assert(Number.isSafeInteger(options.maxFee)); - assert(options.maxFee >= -1); - this.maxFee = options.maxFee; - } - - if (options.round != null) { - assert(typeof options.round === 'boolean'); - this.round = options.round; - } - - if (options.coinbaseMaturity != null) { - assert((options.coinbaseMaturity >>> 0) === options.coinbaseMaturity); - this.coinbaseMaturity = options.coinbaseMaturity; - } - - if (options.changeAddress) { - const addr = options.changeAddress; - if (typeof addr === 'string') { - this.changeAddress = Address.fromString(addr); - } else { - assert(addr instanceof Address); - this.changeAddress = addr; - } - } - - if (options.estimate) { - assert(typeof options.estimate === 'function'); - this.estimate = options.estimate; - } - - 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), lastIndex + i); - } - } - - return this; - } - - /** - * Attempt to inject existing inputs. - * @private - */ - - injectInputs() { - if (this.tx.inputs.length > 0) { - for (let i = 0; i < this.tx.inputs.length; i++) { - const {prevout} = this.tx.inputs[i]; - this.inputs.set(prevout.toKey(), i); - } - } - } - - /** - * Initialize the selector with coins to select from. - * @param {Coin[]} coins - */ - - init(coins) { - this.coins = coins.slice(); - this.outputValue = this.tx.getOutputValue(); - this.index = 0; - this.chosen = []; - this.change = 0; - this.fee = CoinSelector.MIN_FEE; - this.tx.inputs.length = 0; - - switch (this.selection) { - case 'all': - case 'random': - this.coins.sort(sortRandom); - break; - case 'age': - this.coins.sort(sortAge); - break; - case 'value': - this.coins.sort(sortValue); - break; - default: - throw new FundingError(`Bad selection type: ${this.selection}.`); - } - } - - /** - * Calculate total value required. - * @returns {AmountValue} - */ - - total() { - if (this.subtractFee) - return this.outputValue; - return this.outputValue + this.fee; - } - - /** - * Test whether the selector has - * completely funded the transaction. - * @returns {Boolean} - */ - - isFull() { - return this.tx.getInputValue() >= this.total(); - } - - /** - * Test whether a coin is spendable - * with regards to the options. - * @param {Coin} coin - * @returns {Boolean} - */ - - isSpendable(coin) { - if (this.tx.view.hasEntry(coin)) - return false; - - if (coin.covenant.isNonspendable()) - return false; - - if (this.height === -1) - return true; - - if (coin.coinbase) { - if (coin.height === -1) - return false; - - if (this.height + 1 < coin.height + this.coinbaseMaturity) - return false; - - return true; - } - - if (this.depth === -1) - return true; - - const depth = coin.getDepth(this.height); - - if (depth < this.depth) - return false; - - return true; - } - - /** - * Get the current fee based on a size. - * @param {Number} size - * @returns {AmountValue} - */ - - getFee(size) { - // This is mostly here for testing. - // i.e. A fee rounded to the nearest - // kb is easier to predict ahead of time. - if (this.round) - return policy.getRoundFee(size, this.rate); - - return policy.getMinFee(size, this.rate); - } - - /** - * Fund the transaction with more - * coins if the `output value + fee` - * total was updated. - */ - - fund() { - // Ensure all preferred inputs first. - this.resolveInputCoins(); - - if (this.isFull()) - return; - - while (this.index < this.coins.length) { - const coin = this.coins[this.index++]; - - if (!this.isSpendable(coin)) - continue; - - this.tx.addCoin(coin); - this.chosen.push(coin); - - if (this.selection === 'all') - continue; - - if (this.isFull()) - break; - } - } - - /** - * Initiate selection from `coins`. - * @param {Coin[]} coins - * @returns {Promise} - */ - - async select(coins) { - this.init(coins); - - if (this.hardFee !== -1) { - this.selectHard(); - } else { - // This is potentially asynchronous: - // it may invoke the size estimator - // required for redeem scripts (we - // may be calling out to a wallet - // or something similar). - await this.selectEstimate(); - } - - if (!this.isFull()) { - // Still failing to get enough funds. - throw new FundingError( - 'Not enough funds.', - this.tx.getInputValue(), - this.total()); - } - - // How much money is left after filling outputs. - this.change = this.tx.getInputValue() - this.total(); - - return this; - } - - /** - * Initialize selection based on size estimate. - */ - - async selectEstimate() { - // Set minimum fee and do - // an initial round of funding. - this.fee = CoinSelector.MIN_FEE; - this.fund(); - - // Add dummy output for change. - const change = new Output(); - - if (this.changeAddress) { - change.address = this.changeAddress; - } else { - // In case we don't have a change address, - // we use a fake p2pkh output to gauge size. - change.address.fromPubkeyhash(Buffer.allocUnsafe(20)); - } - - this.tx.outputs.push(change); - - // Keep recalculating the fee and funding - // until we reach some sort of equilibrium. - do { - const size = await this.tx.estimateSize(this.estimate); - - this.fee = this.getFee(size); - - if (this.maxFee > 0 && this.fee > this.maxFee) - throw new FundingError('Fee is too high.'); - - // Failed to get enough funds, add more coins. - if (!this.isFull()) - this.fund(); - } while (!this.isFull() && this.index < this.coins.length); - } - - /** - * Initiate selection based on a hard fee. - */ - - selectHard() { - 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); - } - } -} - -/** - * Default fee rate - * for coin selection. - * @const {Amount} - * @default - */ - -CoinSelector.FEE_RATE = 10000; - -/** - * Minimum fee to start with - * during coin selection. - * @const {Amount} - * @default - */ - -CoinSelector.MIN_FEE = 10000; - -/** - * Funding Error - * An error thrown from the coin selector. - * @ignore - * @extends Error - * @property {String} message - Error message. - * @property {Amount} availableFunds - * @property {Amount} requiredFunds - */ - -class FundingError extends Error { - /** - * Create a funding error. - * @constructor - * @param {String} msg - * @param {AmountValue} [available] - * @param {AmountValue} [required] - */ - - constructor(msg, available, required) { - super(); - - this.type = 'FundingError'; - this.message = msg; - this.availableFunds = -1; - this.requiredFunds = -1; - - if (available != null) { - this.message += ` (available=${Amount.coin(available)},`; - this.message += ` required=${Amount.coin(required)})`; - this.availableFunds = available; - this.requiredFunds = required; - } - - if (Error.captureStackTrace) - Error.captureStackTrace(this, FundingError); - } -} - /* * Helpers */ -function sortAge(a, b) { - a = a.height === -1 ? 0x7fffffff : a.height; - b = b.height === -1 ? 0x7fffffff : b.height; - return a - b; -} - -function sortRandom(a, b) { - return Math.random() > 0.5 ? 1 : -1; -} - -function sortValue(a, b) { - if (a.height === -1 && b.height !== -1) - return 1; - - if (a.height !== -1 && b.height === -1) - return -1; - - return b.value - a.value; -} +/** + * @param {Input} a + * @param {Input} b + * @returns {Number} + */ function sortInputs(a, b) { return a.compare(b); } +/** + * @param {Output} a + * @param {Output} b + * @returns {Number} + */ + function sortOutputs(a, b) { return a.compare(b); } @@ -1942,9 +1488,6 @@ function sortLinked(a, b) { * Expose */ -exports = MTX; -exports.MTX = MTX; -exports.Selector = CoinSelector; -exports.FundingError = FundingError; +MTX.MTX = MTX; -module.exports = exports; +module.exports = MTX; diff --git a/lib/utils/coinselector.js b/lib/utils/coinselector.js new file mode 100644 index 000000000..fd3a0977a --- /dev/null +++ b/lib/utils/coinselector.js @@ -0,0 +1,711 @@ +/*! + * coinselector.js - Coin Selector + * Copyright (c) 2017-2018, Christopher Jeffrey (MIT License). + * Copyright (c) 2025, Nodari Chkuaselidze (MIT License) + * https://github.com/handshake-org/hsd + */ + +'use strict'; + +const assert = require('bsert'); +const Amount = require('../ui/amount'); +const Address = require('../primitives/address'); +const Output = require('../primitives/output'); +const Outpoint = require('../primitives/outpoint'); +const policy = require('../protocol/policy'); +const {BufferMap} = require('buffer-map'); + +/** @typedef {import('../types').Amount} AmountValue */ +/** @typedef {import('../types').Hash} Hash */ +/** @typedef {import('../coins/coinview')} CoinView */ +/** @typedef {import('../primitives/mtx').MTX} MTX */ +/** @typedef {import('../primitives/coin')} Coin */ + +class AbstractCoinSource { + /** + * Initialize the coin source. + * @returns {Promise} + */ + + async init() { + throw new Error('Abstract method.'); + } + + /** + * @returns {Boolean} + */ + + hasNext() { + throw new Error('Abstract method.'); + } + + /** + * @returns {Promise} + */ + + next() { + throw new Error('Abstract method.'); + } + + /** + * @param {BufferMap} inputs + * @param {Coin[]} coins - Coin per input. + * @returns {Promise} + */ + + async resolveInputsToCoins(inputs, coins) { + throw new Error('Abstract method.'); + } +} + +/** @typedef {'all'|'random'|'age'|'value'} MemSelectionType */ + +/** + * @typedef {Object} CoinSourceOptions + * @property {MemSelectionType} [selection] - Selection type. + * @property {Coin[]} [coins] - Coins to select from. + */ + +/** + * Coin Source with coins. + * @alias module:utils.CoinSource + */ + +class InMemoryCoinSource extends AbstractCoinSource { + /** + * @param {CoinSourceOptions} [options] + */ + + constructor(options = {}) { + super(); + + /** @type {Coin[]} */ + this.coins = []; + + /** @type {MemSelectionType} */ + this.selection = 'value'; + + this.index = -1; + + if (options) + this.fromOptions(options); + } + + /** + * @param {CoinSourceOptions} options + * @returns {this} + */ + + fromOptions(options = {}) { + if (options.coins != null) { + assert(Array.isArray(options.coins), 'Coins must be an array.'); + this.coins = options.coins.slice(); + } + + if (options.selection != null) { + assert(typeof options.selection === 'string', + 'Selection must be a string.'); + this.selection = options.selection; + } + + return this; + } + + async init() { + this.index = 0; + + switch (this.selection) { + case 'all': + case 'random': + shuffle(this.coins); + break; + case 'age': + this.coins.sort(sortAge); + break; + case 'value': + this.coins.sort(sortValue); + break; + default: + throw new FundingError(`Bad selection type: ${this.selection}`); + } + } + + hasNext() { + return this.index < this.coins.length; + } + + /** + * @returns {Promise} + */ + + async next() { + if (!this.hasNext()) + return null; + + return this.coins[this.index++]; + } + + /** + * @param {BufferMap} inputs + * @param {Coin[]} coins + * @returns {Promise} + */ + + async resolveInputsToCoins(inputs, coins) { + for (const coin of this.coins) { + const {hash, index} = coin; + const key = Outpoint.toKey(hash, index); + const i = inputs.get(key); + + if (i != null) { + assert(!coins[i]); + coins[i] = coin; + inputs.delete(key); + } + } + } +} + +/** + * @typedef {Object} InputOption + * @property {Hash} hash + * @property {Number} index + */ + +/** + * @typedef {Object} CoinSelectorOptions + * @property {Address} [changeAddress] - Change address. + * @property {Boolean} [subtractFee] - Subtract fee from output. + * @property {Number} [subtractIndex] - Index of output to subtract fee from. + * @property {Number} [height] - Current chain height. + * @property {Number} [depth] - Minimum confirmation depth of coins to spend. + * @property {Number} [confirmations] - depth alias. + * @property {Number} [coinbaseMaturity] - When do CBs become spendable. + * @property {Number} [hardFee] - Fixed fee. + * @property {Number} [rate] - Rate of dollarydoo per kB. + * @property {Number} [maxFee] - Maximum fee we are willing to pay. + * @property {Boolean} [round] - Round to the nearest kilobyte. + * @property {Function?} [estimate] - Input script size estimator. + * @property {Boolean} [selectAll] - Select all coins. + * @property {InputOption[]} [inputs] - Inputs to use for funding. + */ + +/** + * Coin Selector + * @alias module:utils.CoinSelector + * @property {MTX} tx - clone of the original mtx. + * @property {CoinView} view - reference to the original view. + */ + +class CoinSelector { + /** + * @param {MTX} mtx + * @param {AbstractCoinSource} source + * @param {CoinSelectorOptions?} [options] + */ + + constructor(mtx, source, options = {}) { + this.original = mtx; + /** @type {MTX} */ + this.tx = mtx.clone(); + /** @type {CoinView} */ + this.view = mtx.view; + this.source = source; + this.outputValue = 0; + this.fee = CoinSelector.MIN_FEE; + + /** @type {Coin[]} */ + this.chosen = []; + + this.selectAll = false; + this.subtractFee = false; + this.subtractIndex = -1; + this.height = -1; + this.depth = -1; + this.hardFee = -1; + this.rate = CoinSelector.FEE_RATE; + this.maxFee = -1; + this.round = false; + this.coinbaseMaturity = 400; + this.changeAddress = null; + this.estimate = null; + + /** @type {BufferMap} */ + this.inputs = new BufferMap(); + + this.injectInputs(); + + if (options) + this.fromOptions(options); + } + + /** + * @param {CoinSelectorOptions} [options] + * @returns {this} + */ + + fromOptions(options = {}) { + if (options.subtractFee != null) { + if (typeof options.subtractFee === 'number') { + assert(Number.isSafeInteger(options.subtractFee)); + assert(options.subtractFee >= -1); + this.subtractIndex = options.subtractFee; + this.subtractFee = this.subtractIndex !== -1; + } else { + assert(typeof options.subtractFee === 'boolean'); + this.subtractFee = options.subtractFee; + } + } + + if (options.subtractIndex != null) { + assert(Number.isSafeInteger(options.subtractIndex)); + assert(options.subtractIndex >= -1); + this.subtractIndex = options.subtractIndex; + this.subtractFee = this.subtractIndex !== -1; + } + + if (options.height != null) { + assert(Number.isSafeInteger(options.height)); + assert(options.height >= -1); + this.height = options.height; + } + + if (options.confirmations != null) { + assert(Number.isSafeInteger(options.confirmations)); + assert(options.confirmations >= -1); + this.depth = options.confirmations; + } + + if (options.depth != null) { + assert(Number.isSafeInteger(options.depth)); + assert(options.depth >= -1); + this.depth = options.depth; + } + + if (options.hardFee != null) { + assert(Number.isSafeInteger(options.hardFee)); + assert(options.hardFee >= -1); + this.hardFee = options.hardFee; + } + + if (options.rate != null) { + assert(Number.isSafeInteger(options.rate)); + assert(options.rate >= 0); + this.rate = options.rate; + } + + if (options.maxFee != null) { + assert(Number.isSafeInteger(options.maxFee)); + assert(options.maxFee >= -1); + this.maxFee = options.maxFee; + } + + if (options.round != null) { + assert(typeof options.round === 'boolean'); + this.round = options.round; + } + + if (options.coinbaseMaturity != null) { + assert((options.coinbaseMaturity >>> 0) === options.coinbaseMaturity); + this.coinbaseMaturity = options.coinbaseMaturity; + } + + if (options.changeAddress) { + const addr = options.changeAddress; + if (typeof addr === 'string') { + this.changeAddress = Address.fromString(addr); + } else { + assert(addr instanceof Address); + this.changeAddress = addr; + } + } + + if (options.estimate) { + assert(typeof options.estimate === 'function'); + this.estimate = options.estimate; + } + + if (options.selectAll != null) { + assert(typeof options.selectAll === 'boolean'); + this.selectAll = options.selectAll; + } + + 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), lastIndex + i); + } + } + + return this; + } + + /** + * Attempt to inject existing inputs. + * @private + */ + + injectInputs() { + if (this.tx.inputs.length > 0) { + for (let i = 0; i < this.tx.inputs.length; i++) { + const {prevout} = this.tx.inputs[i]; + this.inputs.set(prevout.toKey(), i); + } + } + } + + /** + * Initialize the selector with coins to select from. + */ + + init() { + this.outputValue = this.tx.getOutputValue(); + this.chosen = []; + this.change = 0; + this.fee = CoinSelector.MIN_FEE; + this.tx.inputs.length = 0; + } + + /** + * Calculate total value required. + * @returns {AmountValue} + */ + + total() { + if (this.subtractFee) + return this.outputValue; + + return this.outputValue + this.fee; + } + + /** + * Test whether filler + * completely funded the transaction. + * @returns {Boolean} + */ + + isFull() { + return this.tx.getInputValue() >= this.total(); + } + + /** + * Test whether a coin is spendable + * with regards to the options. + * @param {Coin} coin + * @returns {Boolean} + */ + + isSpendable(coin) { + if (this.tx.view.hasEntry(coin)) + return false; + + if (coin.covenant.isNonspendable()) + return false; + + if (this.height === -1) + return true; + + if (coin.coinbase) { + if (coin.height === -1) + return false; + + if (this.height + 1 < coin.height + this.coinbaseMaturity) + return false; + + return true; + } + + if (this.depth === -1) + return true; + + const depth = coin.getDepth(this.height); + + if (depth < this.depth) + return false; + + return true; + } + + /** + * Get the current fee based on a size. + * @param {Number} size + * @returns {AmountValue} + */ + + getFee(size) { + // This is mostly here for testing. + // i.e. A fee rounded to the nearest + // kb is easier to predict ahead of time. + if (this.round) + return policy.getRoundFee(size, this.rate); + + return policy.getMinFee(size, this.rate); + } + + /** + * Fund the transaction with more + * coins if the `output value + fee` + * total was updated. + * @returns {Promise} + */ + + async fund() { + // Ensure all preferred inputs first. + await this.resolveInputCoins(); + + if (this.isFull() && !this.selectAll) + return; + + for (;;) { + const coin = await this.source.next(); + + if (!coin) + break; + + if (!this.isSpendable(coin)) + continue; + + this.tx.addCoin(coin); + this.chosen.push(coin); + + if (this.selectAll) + continue; + + if (this.isFull()) + break; + } + } + + /** + * Initialize selection based on size estimate. + */ + + async selectEstimate() { + // Set minimum fee and do + // an initial round of funding. + this.fee = CoinSelector.MIN_FEE; + await this.fund(); + + // Add dummy output for change. + const change = new Output(); + + if (this.changeAddress) { + change.address = this.changeAddress; + } else { + // In case we don't have a change address, + // we use a fake p2pkh output to gauge size. + change.address.fromPubkeyhash(Buffer.allocUnsafe(20)); + } + + this.tx.outputs.push(change); + + // Keep recalculating the fee and funding + // until we reach some sort of equilibrium. + do { + const size = await this.tx.estimateSize(this.estimate); + + this.fee = this.getFee(size); + + if (this.maxFee > 0 && this.fee > this.maxFee) + throw new FundingError('Fee is too high.'); + + // Failed to get enough funds, add more coins. + if (!this.isFull()) + await this.fund(); + } while (!this.isFull() && this.source.hasNext()); + } + + /** + * Collect coins for the transaction. + * @returns {Promise} + */ + + async selectHard() { + this.fee = this.hardFee; + await this.fund(); + } + + /** + * Fill the transaction with inputs. + * @returns {Promise} + */ + + async select() { + this.init(); + + if (this.hardFee !== -1) { + await this.selectHard(); + } else { + // This is potentially asynchronous: + // it may invoke the size estimator + // required for redeem scripts (we + // may be calling out to a wallet + // or something similar). + await this.selectEstimate(); + } + + if (!this.isFull()) { + // Still failing to get enough funds. + throw new FundingError( + 'Not enough funds.', + this.tx.getInputValue(), + this.total()); + } + + // How much money is left after filling outputs. + this.change = this.tx.getInputValue() - this.total(); + + return this; + } + + async resolveInputCoins() { + if (this.inputs.size === 0) + return; + + /** @type {Coin[]} */ + 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); + } + } + } + + if (this.inputs.size > 0) + await this.source.resolveInputsToCoins(this.inputs, coins); + + 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); + } + } +} + +/** + * Default fee rate + * for coin selection. + * @const {Amount} + * @default + */ + +CoinSelector.FEE_RATE = 10000; + +/** + * Minimum fee to start with + * during coin selection. + * @const {Amount} + * @default + */ + +CoinSelector.MIN_FEE = 10000; + +/** + * Funding Error + * An error thrown from the coin selector. + * @ignore + * @extends Error + * @property {String} message - Error message. + * @property {Amount} availableFunds + * @property {Amount} requiredFunds + */ + +class FundingError extends Error { + /** + * Create a funding error. + * @constructor + * @param {String} msg + * @param {AmountValue} [available] + * @param {AmountValue} [required] + */ + + constructor(msg, available, required) { + super(); + + this.type = 'FundingError'; + this.message = msg; + this.availableFunds = -1; + this.requiredFunds = -1; + + if (available != null) { + this.message += ` (available=${Amount.coin(available)},`; + this.message += ` required=${Amount.coin(required)})`; + this.availableFunds = available; + this.requiredFunds = required; + } + + if (Error.captureStackTrace) + Error.captureStackTrace(this, FundingError); + } +} + +/* + * Helpers + */ + +/** + * @param {Coin} a + * @param {Coin} b + * @returns {Number} + */ + +function sortAge(a, b) { + const ah = a.height === -1 ? 0x7fffffff : a.height; + const bh = b.height === -1 ? 0x7fffffff : b.height; + return ah - bh; +} + +/** + * @param {Coin[]} coins + * @returns {Coin[]} + */ + +function shuffle(coins) { + for (let i = coins.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [coins[i], coins[j]] = [coins[j], coins[i]]; + } + return coins; +} + +/** + * @param {Coin} a + * @param {Coin} b + * @returns {Number} + */ + +function sortValue(a, b) { + if (a.height === -1 && b.height !== -1) + return 1; + + if (a.height !== -1 && b.height === -1) + return -1; + + return b.value - a.value; +} + +exports.AbstractCoinSource = AbstractCoinSource; +exports.InMemoryCoinSource = InMemoryCoinSource; +exports.CoinSelector = CoinSelector; +exports.FundingError = FundingError; diff --git a/lib/wallet/layout.js b/lib/wallet/layout.js index 31a38f882..5fc921b78 100644 --- a/lib/wallet/layout.js +++ b/lib/wallet/layout.js @@ -118,6 +118,20 @@ exports.wdb = { * p[hash] -> dummy (pending tx) * P[account][tx-hash] -> dummy (pending tx by account) * + * Coin Selection + * -------------- + * Sv[value][tx-hash][index] -> dummy (Confirmed coins by value) + * Sv[account][value][tx-hash][index] -> dummy + * (Confirmed coins by account + value) + * + * Su[value][tx-hash][index] -> dummy (Unconfirmed coins by value) + * SU[account][value][tx-hash][index] -> dummy + * (Unconfirmed coins by account + value) + * + * Sh[tx-hash][index] -> dummy (coins by account + Height) + * SH[account][height][tx-hash][index] -> dummy + * (coins by account + Height) + * * Count and Time Index * -------------------- * Ol - Latest Unconfirmed Index @@ -161,6 +175,20 @@ exports.txdb = { d: bdb.key('d', ['hash256', 'uint32']), s: bdb.key('s', ['hash256', 'uint32']), + // Coin Selector + // confirmed by Value + Sv: bdb.key('Sv', ['uint64', 'hash256', 'uint32']), + // confirmed by account + Value + SV: bdb.key('SV', ['uint32', 'uint64', 'hash256', 'uint32']), + // Unconfirmed by value + Su: bdb.key('Su', ['uint64', 'hash256', 'uint32']), + // Unconfirmed by account + value + SU: bdb.key('SU', ['uint32', 'uint64', 'hash256', 'uint32']), + // by height + Sh: bdb.key('Sh', ['uint32', 'hash256', 'uint32']), + // by account + height + SH: bdb.key('SH', ['uint32', 'uint32', 'hash256', 'uint32']), + // Transaction t: bdb.key('t', ['hash256']), T: bdb.key('T', ['uint32', 'hash256']), diff --git a/lib/wallet/txdb.js b/lib/wallet/txdb.js index 7d0b92ca0..c5f76c973 100644 --- a/lib/wallet/txdb.js +++ b/lib/wallet/txdb.js @@ -197,6 +197,84 @@ class TXDB { b.del(layout.d.encode(spender.hash, spender.index)); } + /** + * Coin selection index credit. + * @param {Batch} b + * @param {Credit} credit + * @param {Path} path + * @param {Number?} oldHeight + */ + + indexCSCredit(b, credit, path, oldHeight) { + const {coin} = credit; + + if (coin.isUnspendable() || coin.covenant.isNonspendable()) + return; + + // value index + if (coin.height === -1) { + // index coin by value + b.put(layout.Su.encode(coin.value, coin.hash, coin.index), null); + + // index coin by account + value. + b.put(layout.SU.encode( + path.account, coin.value, coin.hash, coin.index), null); + } else { + // index coin by value + b.put(layout.Sv.encode(coin.value, coin.hash, coin.index), null); + + // index coin by account + value. + b.put(layout.SV.encode( + path.account, coin.value, coin.hash, coin.index), null); + } + + // cleanup old value indexes. + if (oldHeight && oldHeight === -1) { + b.del(layout.Su.encode(coin.value, coin.hash, coin.index)); + b.del(layout.SU.encode(path.account, coin.value, coin.hash, coin.index)); + } else if (oldHeight && oldHeight !== -1) { + b.del(layout.Sv.encode(coin.value, coin.hash, coin.index)); + b.del(layout.SV.encode(path.account, coin.value, coin.hash, coin.index)); + } + + // handle height indexes + // index coin by account + height + const height = coin.height === -1 ? UNCONFIRMED_HEIGHT : coin.height; + b.put(layout.Sh.encode(height, coin.hash, coin.index), null); + b.put(layout.SH.encode(path.account, height, coin.hash, coin.index), null); + + if (oldHeight != null) { + const height = oldHeight === -1 ? UNCONFIRMED_HEIGHT : oldHeight; + b.del(layout.Sh.encode(height, coin.hash, coin.index)); + b.del(layout.SH.encode(path.account, height, coin.hash, coin.index)); + } + } + + /** + * Unindex Credit. + * @param {Batch} b + * @param {Credit} credit + * @param {Path} path + */ + + unindexCSCredit(b, credit, path) { + const {coin} = credit; + + // Remove coin by account + value. + if (coin.height === -1) { + b.del(layout.Su.encode(coin.value, coin.hash, coin.index)); + b.del(layout.SU.encode(path.account, coin.value, coin.hash, coin.index)); + } else { + b.del(layout.Sv.encode(coin.value, coin.hash, coin.index)); + b.del(layout.SV.encode(path.account, coin.value, coin.hash, coin.index)); + } + + // Remove coin by account + height + const height = coin.height === -1 ? UNCONFIRMED_HEIGHT : coin.height; + b.del(layout.Sh.encode(height, coin.hash, coin.index)); + b.del(layout.SH.encode(path.account, height, coin.hash, coin.index)); + } + /** * Spend credit by spender/input record. * Add undo coin to the input record. @@ -1118,6 +1196,7 @@ class TXDB { this.unlockBalances(state, credit, path, height); await this.removeCredit(b, credit, path); + this.unindexCSCredit(b, credit, path); view.addCoin(coin); } @@ -1162,6 +1241,7 @@ class TXDB { } await this.saveCredit(b, credit, path); + this.indexCSCredit(b, credit, path, null); await this.watchOpensEarly(b, output); } @@ -1340,6 +1420,7 @@ class TXDB { // entirely, now that we know it's also // been removed on-chain. await this.removeCredit(b, credit, path); + this.unindexCSCredit(b, credit, path); view.addCoin(coin); } @@ -1397,6 +1478,7 @@ class TXDB { credit.coin.height = height; await this.saveCredit(b, credit, path); + this.indexCSCredit(b, credit, path, -1); } // Handle names. @@ -1521,6 +1603,7 @@ class TXDB { credit.spent = false; await this.saveCredit(b, credit, path); + this.indexCSCredit(b, credit, path, null); } } @@ -1553,6 +1636,7 @@ class TXDB { } await this.removeCredit(b, credit, path); + this.unindexCSCredit(b, credit, path); } // Undo name state. @@ -1783,6 +1867,7 @@ class TXDB { credit.spent = true; own = true; await this.saveCredit(b, credit, path); + this.indexCSCredit(b, credit, path, null); } } @@ -1834,6 +1919,7 @@ class TXDB { // Update coin height and confirmed // balance. Save once again. + const oldHeight = credit.coin.height; credit.coin.height = -1; // If the coin was not discovered now, it means @@ -1846,6 +1932,7 @@ class TXDB { } await this.saveCredit(b, credit, path); + this.indexCSCredit(b, credit, path, oldHeight); } // Unconfirm will also index OPENs as the transaction is now part of the @@ -3372,8 +3459,8 @@ class TXDB { * Filter array of coins or outpoints * for only unlocked ones. * jsdoc can't express this type. - * @param {Coin[]|Outpoint[]} coins - * @returns {Coin[]|Outpoint[]} + * @param {Coin[]} coins + * @returns {Coin[]} */ filterLocked(coins) { @@ -3726,6 +3813,170 @@ class TXDB { return coins; } + /** + * Get credits iterator sorted by value. + * @param {Number} acct + * @param {Object} [options] + * @param {Number} [options.minValue=0] + * @param {Number} [options.maxValue=MAX_MONEY] + * @param {Number} [options.limit=-1] + * @param {Boolean} [options.reverse=false] + * @param {Boolean} [options.inclusive=true] + * @returns {AsyncGenerator} + */ + + async *getAccountCreditIterByValue(acct, options = {}) { + assert(typeof acct === 'number'); + assert(options && typeof options === 'object'); + + const { + minValue = 0, + maxValue = consensus.MAX_MONEY, + inclusive = true, + reverse = false, + limit + } = options; + + assert(typeof minValue === 'number'); + assert(typeof maxValue === 'number'); + assert(minValue <= maxValue); + + const iterOpts = { + limit: limit, + reverse: reverse, + keys: true, + values: false + }; + + const greater = inclusive ? 'gte' : 'gt'; + const lesser = inclusive ? 'lte' : 'lt'; + + let prefix, hashIdx; + let min, max; + + if (acct === -1) { + prefix = layout.Sv; + min = prefix.min(minValue); + max = prefix.max(maxValue); + hashIdx = 1; + } else { + prefix = layout.SV; + min = prefix.min(acct, minValue); + max = prefix.max(acct, maxValue); + hashIdx = 2; + } + + iterOpts[greater] = min; + iterOpts[lesser] = max; + + const iter = this.bucket.iterator(iterOpts); + + for await (const key of iter.keysAsync()) { + const decoded = prefix.decode(key); + const hash = decoded[hashIdx]; + const index = decoded[hashIdx + 1]; + const credit = await this.getCredit(hash, index); + + assert(credit); + yield credit; + } + + // now process unconfirmed. + if (acct === -1) { + prefix = layout.Su; + min = prefix.min(minValue); + max = prefix.max(maxValue); + } else { + prefix = layout.SU; + min = prefix.min(acct, minValue); + max = prefix.max(acct, maxValue); + } + + iterOpts[greater] = min; + iterOpts[lesser] = max; + + const ucIter = this.bucket.iterator(iterOpts); + + for await (const key of ucIter.keysAsync()) { + const decoded = prefix.decode(key); + const hash = decoded[hashIdx]; + const index = decoded[hashIdx + 1]; + const credit = await this.getCredit(hash, index); + + assert(credit); + yield credit; + } + } + + /** + * Get credits iterator sorted by height. + * @param {Number} acct + * @param {Object} [options] + * @param {Number} [options.minHeight=0] + * @param {Number} [options.maxHeight=UNCONFIRMED_HEIGHT] + * @param {Number} [options.limit=-1] + * @param {Boolean} [options.reverse=false] + * @param {Boolean} [options.inclusive=true] + * @returns {AsyncGenerator} + */ + + async *getAccountCreditIterByHeight(acct, options = {}) { + assert(typeof acct === 'number'); + assert(options && typeof options === 'object'); + + const { + minHeight = 0, + maxHeight = UNCONFIRMED_HEIGHT, + inclusive = true, + reverse = false, + limit + } = options; + + assert(typeof minHeight === 'number'); + assert(typeof maxHeight === 'number'); + assert(minHeight <= maxHeight); + + const iterOpts = { + limit, + reverse, + keys: true, + values: false + }; + + const greater = inclusive ? 'gte' : 'gt'; + const lesser = inclusive ? 'lte' : 'lt'; + + let prefix, hashIdx; + let min, max; + + if (acct === -1) { + prefix = layout.Sh; + min = prefix.min(minHeight); + max = prefix.max(maxHeight); + hashIdx = 1; + } else { + prefix = layout.SH; + min = prefix.min(acct, minHeight); + max = prefix.max(acct, maxHeight); + hashIdx = 2; + } + + iterOpts[greater] = min; + iterOpts[lesser] = max; + + const iter = this.bucket.iterator(iterOpts); + + for await (const key of iter.keysAsync()) { + const decoded = prefix.decode(key); + const hash = decoded[hashIdx]; + const index = decoded[hashIdx + 1]; + const credit = await this.getCredit(hash, index); + + assert(credit); + yield credit; + } + } + /** * Get a coin viewpoint. * @param {TX} tx diff --git a/lib/wallet/wallet.js b/lib/wallet/wallet.js index 264e5ecc3..fee4712e2 100644 --- a/lib/wallet/wallet.js +++ b/lib/wallet/wallet.js @@ -13,6 +13,8 @@ const base58 = require('bcrypto/lib/encoding/base58'); const bio = require('bufio'); const blake2b = require('bcrypto/lib/blake2b'); const cleanse = require('bcrypto/lib/cleanse'); +const bufmap = require('buffer-map'); +const BufferSet = bufmap.BufferSet; const TXDB = require('./txdb'); const Path = require('./path'); const common = require('./common'); @@ -38,9 +40,12 @@ 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 */ @@ -1201,7 +1206,7 @@ class Wallet extends EventEmitter { * @param {(String|Number)?} options.account - If no account is * specified, coins from the entire wallet will be filled. * @param {String?} options.selection - Coin selection priority. Can - * be `age`, `random`, or `all`. (default=age). + * be `age`, `random`, or `all`. (default=value). * @param {Boolean} options.round - Whether to round to the nearest * kilobyte for fee calculation. * See {@link TX#getMinFee} vs. {@link TX#getRoundFee}. @@ -1226,20 +1231,15 @@ 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) { - if (!options) - options = {}; - + async fill(mtx, options = {}) { const acct = options.account || 0; - const change = await this.changeAddress(acct); + const change = await this.changeAddress(acct === -1 ? 0 : acct); if (!change) throw new Error('Account not found.'); @@ -1248,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, @@ -1273,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 || 'value'; + + 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; } /** @@ -1968,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) @@ -1983,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' @@ -5151,6 +5188,36 @@ class Wallet extends EventEmitter { return this.txdb.getCredits(account); } + /** + * Get credits iterator sorted by value. + * @param {Number} acct + * @param {Object} [options] + * @param {Number} [options.minValue=0] + * @param {Number} [options.maxValue=MAX_MONEY] + * @param {Number} [options.limit=-1] + * @param {Boolean} [options.reverse=false] + * @returns {AsyncGenerator} + */ + + getAccountCreditIterByValue(acct, options = {}) { + return this.txdb.getAccountCreditIterByValue(acct, options); + } + + /** + * Get credits iterator sorted by height. + * @param {Number} acct + * @param {Object} [options] + * @param {Number} [options.minHeight=0] + * @param {Number} [options.maxHeight=UNCONFIRMED_HEIGHT] + * @param {Number} [options.limit=-1] + * @param {Boolean} [options.reverse=false] + * @returns {AsyncGenerator} + */ + + getAccountCreditIterByHeight(acct, options = {}) { + return this.txdb.getAccountCreditIterByHeight(acct, options); + } + /** * Get "smart" coins. * @param {(String|Number)?} acct @@ -5563,6 +5630,164 @@ 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 {bufmap.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); + + if (!coin) + continue; + + coins[idx] = coin; + inputs.delete(key); + } + } +} + /* * Expose */ diff --git a/lib/wallet/walletdb.js b/lib/wallet/walletdb.js index a2527e5dd..1ac57569a 100644 --- a/lib/wallet/walletdb.js +++ b/lib/wallet/walletdb.js @@ -92,7 +92,7 @@ class WalletDB extends EventEmitter { /** @type {bdb.DB} */ this.db = bdb.create(this.options); this.name = 'wallet'; - this.version = 4; + this.version = 5; // chain state. this.hasStateCache = false; diff --git a/test/data/migrations/wallet-4-bid-reveal-gen.js b/test/data/migrations/wallet-4-bid-reveal-gen.js index b42934dfe..bd8aa777a 100644 --- a/test/data/migrations/wallet-4-bid-reveal-gen.js +++ b/test/data/migrations/wallet-4-bid-reveal-gen.js @@ -10,6 +10,7 @@ const WalletDB = require('../../../lib/wallet/walletdb'); const MTX = require('../../../lib/primitives/mtx'); const wutils = require('../../../test/util/wallet'); const rules = require('../../../lib/covenants/rules'); +const {deterministicInput} = require('../../../test/util/primitives'); const layout = { wdb: { @@ -66,16 +67,16 @@ let timeCounter = 0; // fund wallets const mtx1 = new MTX(); - mtx1.addInput(wutils.deterministicInput(txID++)); + mtx1.addInput(deterministicInput(txID++)); mtx1.addOutput(await wallet1.receiveAddress(0), 10e6); const mtx2 = new MTX(); - mtx2.addInput(wutils.deterministicInput(txID++)); + mtx2.addInput(deterministicInput(txID++)); mtx2.addOutput(await wallet1.receiveAddress(1), 10e6); // fund second wallet. const mtx3 = new MTX(); - mtx3.addInput(wutils.deterministicInput(txID++)); + mtx3.addInput(deterministicInput(txID++)); mtx3.addOutput(await wallet2.receiveAddress(), 10e6); await wdb.addBlock(wutils.nextEntry(wdb), [ diff --git a/test/mtx-test.js b/test/mtx-test.js index 70dea97bd..e67badefb 100644 --- a/test/mtx-test.js +++ b/test/mtx-test.js @@ -1,13 +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 primutils = require('./util/primitives'); +const {randomP2PKAddress, makeCoin} = primutils; const mtx1json = require('./data/mtx1.json'); const mtx2json = require('./data/mtx2.json'); @@ -138,16 +138,318 @@ describe('MTX', function() { }); }); - describe('Fund', function() { + describe('Fund with in memory coin selectors', function() { + const createCoins = (values) => { + return values.map(value => makeCoin({ value })); + }; + + it('should fund with sorted values', async () => { + const coins = createCoins([1e6, 2e6, 3e6, 4e6, 5e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 7e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0 + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 7e6); + assert.strictEqual(mtx.outputs[1].value, 2e6); + }); + + it('should fund with random selection', async () => { + const coins = createCoins([1e6, 1e6, 1e6, 1e6, 1e6, 1e6, 1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 5e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + selection: 'random' + }); + + assert.strictEqual(mtx.inputs.length, 6); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 5e6); + assert.strictEqual(mtx.getFee(), 1e5); + assert.strictEqual(mtx.outputs[1].value, 9e5); + }); + + it('should fund with all selection type', async () => { + const coins = createCoins([1e6, 2e6, 3e6, 4e6, 5e6, 6e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 2e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0, + selection: 'all' + }); + + assert.strictEqual(mtx.inputs.length, 6); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 2e6); + assert.strictEqual(mtx.getFee(), 0); + assert.strictEqual(mtx.outputs[1].value, 19e6); + }); + + it('should fund with age-based selection', async () => { + const coins = [ + makeCoin({ value: 2e6, height: 100 }), + makeCoin({ value: 3e6, height: 200 }), + makeCoin({ value: 1e6, height: 50 }) + ]; + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + selection: 'age' + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.getFee(), 1e5); + // Should select the oldest (lowest height) coins first + assert.strictEqual(mtx.inputs[0].prevout.hash.equals(coins[2].hash), true); + assert.strictEqual(mtx.inputs[1].prevout.hash.equals(coins[0].hash), true); + }); + + it('should fund with value-based selection', async () => { + const coins = [ + makeCoin({ value: 1e6 }), + makeCoin({ value: 5e6 }), + makeCoin({ value: 2e6 }) + ]; + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 4e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + selection: 'value' + }); + + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.getFee(), 1e5); + // Should select the highest value coin first + assert.strictEqual(mtx.inputs[0].prevout.hash.equals(coins[1].hash), true); + }); + + it('should handle subtractFee option', async () => { + const coins = createCoins([2e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 5e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5, + subtractFee: true + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.outputs[0].value, 4.9e6); // 5e6 - 1e5 = 4.9e6 + assert.strictEqual(mtx.getFee(), 1e5); + }); + + it('should handle subtractIndex option', async () => { + const coins = createCoins([3e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 3e6); + mtx.addOutput(randomP2PKAddress(), 3e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 2e5, + subtractFee: true, + subtractIndex: 1 + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 3e6); + assert.strictEqual(mtx.outputs[1].value, 2.8e6); // 3e6 - 2e5 = 2.8e6 + assert.strictEqual(mtx.getFee(), 2e5); + }); + + it('should throw with insufficient funds', async () => { + const coins = createCoins([1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 5e6); + + let err; + try { + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0 + }); + } catch (e) { + err = e; + } + + assert(err); + assert.strictEqual(err.message.includes('Not enough funds'), true); + }); + + it('should throw when fee is too high', async () => { + const coins = createCoins([1e6, 1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 2e6); + + let err; + try { + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + rate: 1e6, // Extremely high fee rate + maxFee: 1e5 // But with a low maxFee + }); + } catch (e) { + err = e; + } + + assert(err); + assert.strictEqual(err.message.includes('Fee is too high'), true); + }); + + it('should handle dust change', async () => { + const coins = createCoins([1e6, 1e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1.999e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e3 + }); + + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.getFee(), 1e3); + assert.strictEqual(mtx.changeIndex, -1); + }); + + it('should fund with exact amount needed', async () => { + const coins = createCoins([1e6, 2e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 3e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 0 + }); + + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.outputs[0].value, 3e6); + assert.strictEqual(mtx.getFee(), 0); + assert.strictEqual(mtx.changeIndex, -1); + }); + + it('should add coin based on minimum required', async () => { + const wallet = new MemWallet(); + const coins = [ + makeCoin({ address: wallet.getAddress(), value: 1e5 }), + makeCoin({ address: wallet.getAddress(), value: 2e5 }), + makeCoin({ address: wallet.getAddress(), value: 5e5 }), + makeCoin({ address: wallet.getAddress(), value: 1e6 }), + makeCoin({ address: wallet.getAddress(), value: 2e6 }) + ]; + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1.5e6); + + await mtx.fund(coins, { + changeAddress: wallet.getChange(), + hardFee: 1e4 + }); + + // Should select the 2e6 coin (largest value first selection) + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 1.5e6); + assert.strictEqual(mtx.outputs[1].value, 2e6 - 1.5e6 - 1e4); + assert.bufferEqual(mtx.inputs[0].prevout.hash, coins[4].hash); + }); + + it('should combine multiple coins when necessary', async () => { + const coins = createCoins([1e5, 2e5, 3e5, 4e5, 5e5]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 1e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 5e4 + }); + + // Should need to combine multiple coins to reach 1e6 + 5e4 + assert.ok(mtx.inputs.length > 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 1e6); + assert.strictEqual(mtx.getFee(), 5e4); + }); + + it('should correctly set changeIndex', async () => { + const coins = createCoins([5e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 2e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + hardFee: 1e5 + }); + + assert.strictEqual(mtx.inputs.length, 1); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.changeIndex, 1); + assert.strictEqual(mtx.outputs[1].value, 2.9e6); // 5e6 - 2e6 - 1e5 = 2.9e6 + }); + + it('should handle fee rates properly', async () => { + const coins = createCoins([1e6, 2e6, 3e6]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 4e6); + + await mtx.fund(coins, { + changeAddress: randomP2PKAddress(), + rate: 5000 // dollarydoos per kb + }); + + // The exact fee will depend on the estimated tx size + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 2); + assert.ok(mtx.getFee() > 0); + assert.ok(mtx.getFee() < 1e5); // Reasonable upper bound for test + }); + }); + + describe('Fund preferred & existing', 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) + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }), + makeCoin({ address: wallet1.getAddress(), value: 1000000 }) ]; const last1 = coins1[coins1.length - 1]; @@ -239,7 +541,10 @@ describe('MTX', function() { it('should fund with preferred inputs - view', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addOutput(wallet2.getAddress(), 1500000); mtx.view.addCoin(coin); @@ -266,7 +571,10 @@ describe('MTX', function() { it('should fund with preferred inputs - coins && view', async () => { const mtx = new MTX(); - const viewCoin = dummyCoin(wallet1.getAddress(), 1000000); + const viewCoin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const lastCoin = last1; mtx.addOutput(wallet2.getAddress(), 1500000); @@ -304,7 +612,10 @@ describe('MTX', function() { it('should not fund with preferred inputs and no coin info', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addOutput(wallet2.getAddress(), 1500000); @@ -357,7 +668,10 @@ describe('MTX', function() { it('should fund with existing inputs view - view', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addInput({ prevout: { @@ -388,7 +702,10 @@ describe('MTX', function() { it('should fund with existing inputs view - coins && view', async () => { const mtx = new MTX(); - const viewCoin = dummyCoin(wallet1.getAddress(), 1000000); + const viewCoin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const lastCoin = last1; mtx.addInput({ @@ -434,7 +751,10 @@ describe('MTX', function() { it('should not fund with existing inputs and no coin info', async () => { const mtx = new MTX(); - const coin = dummyCoin(wallet1.getAddress(), 1000000); + const coin = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addInput({ prevout: { @@ -502,8 +822,14 @@ describe('MTX', function() { 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); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); mtx.addInput({ prevout: { @@ -546,11 +872,17 @@ describe('MTX', function() { it('should fund with preferred & existing inputs', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -620,11 +952,17 @@ describe('MTX', function() { it('should not fund with missing coin info (both)', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -666,11 +1004,17 @@ describe('MTX', function() { it('should not fund with missing coin info(only existing)', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -713,11 +1057,17 @@ describe('MTX', function() { it('should not fund with missing coin info(only preferred)', async () => { const mtx = new MTX(); // existing - const coin1 = dummyCoin(wallet1.getAddress(), 1000000); + const coin1 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast1 = last1; // preferred - const coin2 = dummyCoin(wallet1.getAddress(), 1000000); + const coin2 = makeCoin({ + address: wallet1.getAddress(), + value: 1000000 + }); const coinLast2 = last2; mtx.addInput({ @@ -758,10 +1108,3 @@ describe('MTX', function() { }); }); }); - -function dummyCoin(address, value) { - const hash = random.randomBytes(32); - const index = 0; - - return new Coin({address, value, hash, index}); -} diff --git a/test/util/primitives.js b/test/util/primitives.js new file mode 100644 index 000000000..163de528a --- /dev/null +++ b/test/util/primitives.js @@ -0,0 +1,217 @@ +'use strict'; + +const assert = require('bsert'); +const blake2b = require('bcrypto/lib/blake2b'); +const random = require('bcrypto/lib/random'); +const rules = require('../../lib/covenants/rules'); +const Input = require('../../lib/primitives/input'); +const Address = require('../../lib/primitives/address'); +const Output = require('../../lib/primitives/output'); +const Outpoint = require('../../lib/primitives/outpoint'); +const Coin = require('../../lib/primitives/coin'); +const Covenant = require('../../lib/primitives/covenant'); + +/** @typedef {import('../../lib/types').Hash} Hash */ + +exports.coinbaseInput = () => { + return Input.fromOutpoint(new Outpoint()); +}; + +exports.dummyInput = () => { + const hash = random.randomBytes(32); + return Input.fromOutpoint(new Outpoint(hash, 0)); +}; + +exports.deterministicInput = (id) => { + const hash = blake2b.digest(fromU32(id)); + return Input.fromOutpoint(new Outpoint(hash, 0)); +}; + +/** + * @typedef {Object} OutputOptions + * @property {Number} value + * @property {Address} [address] + * @property {CovenantOptions} [covenant] + */ + +/** + * @param {OutputOptions} options + * @returns {Output} + */ + +exports.makeOutput = (options) => { + const address = options.address || exports.randomP2PKAddress(); + const output = new Output(); + output.address = address; + output.value = options.value; + + if (options.covenant) + output.covenant = exports.makeCovenant(options.covenant); + + return output; +}; + +/** + * @typedef {Object} CovenantOptions + * @property {String} [name] + * @property {Hash} [nameHash] + * @property {Covenant.types} [type=Covenant.types.NONE] + * @property {Number} [height] + * @property {Array} [args] - leftover args for the covenant except + * for nameHash, name and height. + */ + +/** + * @param {CovenantOptions} options + * @returns {Covenant} + */ + +exports.makeCovenant = (options) => { + const covenant = new Covenant(); + covenant.type = options.type || Covenant.types.NONE; + + const args = options.args || []; + const height = options.height || 0; + let nameHash = options.nameHash; + let name = options.name; + + if (name) { + nameHash = rules.hashName(name); + } else if (!nameHash) { + name = randomString(30); + nameHash = rules.hashName(name); + } + + switch (covenant.type) { + case Covenant.types.NONE: + break; + case Covenant.types.OPEN: { + assert(args.length === 0, 'Pass `options.name` instead.'); + const rawName = Buffer.from(name, 'ascii'); + covenant.setOpen(nameHash, rawName); + break; + } + case Covenant.types.BID: { + assert(args.length < 1, 'Pass [blind?] instead.'); + const blind = args[0] || random.randomBytes(32); + const rawName = Buffer.from(name, 'ascii'); + covenant.setBid(nameHash, height, rawName, blind); + break; + } + case Covenant.types.REVEAL: { + assert(args.length < 1, 'Pass [nonce?] instead.'); + const nonce = args[0] || random.randomBytes(32); + covenant.setReveal(nameHash, height, nonce); + break; + } + case Covenant.types.REDEEM: { + assert(args.length === 0, 'No args for redeem.'); + covenant.setRedeem(nameHash, height); + break; + } + case Covenant.types.REGISTER: { + assert(args.length < 2, 'Pass [record?, blockHash?] instead.'); + const record = args[0] || Buffer.alloc(0); + const blockHash = args[1] || random.randomBytes(32); + covenant.setRegister(nameHash, height, record, blockHash); + break; + } + case Covenant.types.UPDATE: { + assert(args.length < 1, 'Pass [resource?] instead.'); + const resource = args[0] || Buffer.alloc(0); + covenant.setUpdate(nameHash, height, resource); + break; + } + case Covenant.types.RENEW: { + assert(args.length < 1, 'Pass [blockHash?] instead.'); + const blockHash = args[0] || random.randomBytes(32); + covenant.setRenew(nameHash, height, blockHash); + break; + } + case Covenant.types.TRANSFER: { + assert(args.length < 1, 'Pass [address?] instead.'); + const address = args[0] || exports.randomP2PKAddress(); + covenant.setTransfer(nameHash, height, address); + break; + } + case Covenant.types.FINALIZE: { + assert(args.length < 4, 'Pass [flags?, claimed?, renewal?, blockHash?] instead.'); + const rawName = Buffer.from(name, 'ascii'); + const flags = args[0] || 0; + const claimed = args[1] || 0; + const renewal = args[2] || 0; + const blockHash = args[3] || random.randomBytes(32); + + covenant.setFinalize( + nameHash, + height, + rawName, + flags, + claimed, + renewal, + blockHash + ); + break; + } + case Covenant.types.REVOKE: { + assert(args.length === 0, 'No args for revoke.'); + covenant.setRevoke(nameHash, height); + break; + } + default: + throw new Error(`Invalid covenant type ${covenant.type}.`); + } + + return covenant; +}; + +exports.randomP2PKAddress = () => { + const key = random.randomBytes(33); + return Address.fromPubkey(key); +}; + +/** + * @typedef {Object} CoinOptions + * @param {String} [options.version=1] + * @param {String} [options.height=-1] + * @param {String} [options.value=0] + * @param {String} [options.address] + * @param {Object} [options.covenant] + * @param {Boolean} [options.coinbase=false] + * @param {Buffer} [options.hash] + * @param {Number} [options.index=0] + */ + +/** + * @param {CoinOptions} options + * @returns {Coin} + */ + +exports.makeCoin = (options) => { + return Coin.fromOptions({ + hash: options.hash || random.randomBytes(32), + address: options.address || Address.fromPubkey(random.randomBytes(33)), + ...options + }); +}; + +function fromU32(num) { + const data = Buffer.allocUnsafe(4); + data.writeUInt32LE(num, 0, true); + return data; +} + +function randomString(len) { + assert((len >>> 0) === len); + + let s = ''; + + for (let i = 0; i < len; i++) { + const n = Math.random() * (0x7b - 0x61) + 0x61; + const c = Math.floor(n); + + s += String.fromCharCode(c); + } + + return s; +} diff --git a/test/util/wallet.js b/test/util/wallet.js index 05b173a40..8de95dcb4 100644 --- a/test/util/wallet.js +++ b/test/util/wallet.js @@ -2,11 +2,16 @@ const assert = require('bsert'); const blake2b = require('bcrypto/lib/blake2b'); -const random = require('bcrypto/lib/random'); const ChainEntry = require('../../lib/blockchain/chainentry'); -const Input = require('../../lib/primitives/input'); -const Outpoint = require('../../lib/primitives/outpoint'); +const MTX = require('../../lib/primitives/mtx'); const {ZERO_HASH} = require('../../lib/protocol/consensus'); +const primutils = require('./primitives'); +const {coinbaseInput, dummyInput, makeOutput} = primutils; + +/** @typedef {import('../../lib/types').Amount} Amount */ +/** @typedef {import('../../lib/covenants/rules').types} covenantTypes */ +/** @typedef {import('../../lib/primitives/output')} Output */ +/** @typedef {import('../../lib/wallet/wallet')} Wallet */ const walletUtils = exports; @@ -35,16 +40,6 @@ walletUtils.fakeBlock = (height, prevSeed = 0, seed = prevSeed) => { }; }; -walletUtils.dummyInput = () => { - const hash = random.randomBytes(32); - return Input.fromOutpoint(new Outpoint(hash, 0)); -}; - -walletUtils.deterministicInput = (id) => { - const hash = blake2b.digest(fromU32(id)); - return Input.fromOutpoint(new Outpoint(hash, 0)); -}; - walletUtils.nextBlock = (wdb, prevSeed = 0, seed = prevSeed) => { return walletUtils.fakeBlock(wdb.state.height + 1, prevSeed, seed); }; @@ -88,3 +83,129 @@ walletUtils.dumpWDB = async (wdb, prefixes) => { return filtered; }; + +/** + * @typedef {Object} OutputInfo + * @property {String} [address] + * @property {Number} [account=0] - address generation account. + * @property {Amount} [value] + * @property {covenantTypes} [covenant] + * @property {Boolean} [coinbase=false] + */ + +/** + * @param {Wallet} wallet + * @param {primutils.OutputOptions} outputInfo + * @param {Object} options + * @param {Boolean} [options.createAddress=true] - create address if not provided. + * @returns {Promise} + */ + +async function mkOutput(wallet, outputInfo, options = {}) { + const info = { ...outputInfo }; + + const { + createAddress = true + } = options; + + if (!info.address && !createAddress) { + info.address = await wallet.receiveAddress(outputInfo.account || 0); + } else if (!info.address && createAddress) { + const walletKey = await wallet.createReceive(outputInfo.account || 0); + info.address = walletKey.getAddress(); + } + + return makeOutput(info); +} + +/** + * Create Inbound TX Options + * @typedef {Object} InboundTXOptions + * @property {Boolean} [txPerOutput=true] + * @property {Boolean} [createAddress=true] + */ + +/** + * Create funding MTXs for a wallet. + * @param {Wallet} wallet + * @param {OutputInfo[]} outputInfos + * @param {Boolean} [txPerOutput=true] + * @param {InboundTXOptions} options + * @returns {Promise} + */ + +walletUtils.createInboundTXs = async function createInboundTXs(wallet, outputInfos, options = {}) { + assert(Array.isArray(outputInfos)); + + const { + txPerOutput = true, + createAddress = true + } = options; + + let hadCoinbase = false; + + const txs = []; + + let mtx = new MTX(); + + for (const info of outputInfos) { + if (txPerOutput) + mtx = new MTX(); + + if (info.coinbase && hadCoinbase) + throw new Error('Coinbase already added.'); + + if (info.coinbase && !hadCoinbase) { + if (!txPerOutput) + hadCoinbase = true; + mtx.addInput(coinbaseInput()); + } else if (!hadCoinbase) { + mtx.addInput(dummyInput()); + } + + const output = await mkOutput(wallet, info, { createAddress }); + mtx.addOutput(output); + + if (output.covenant.isLinked()) + mtx.addInput(dummyInput()); + + if (txPerOutput) + txs.push(mtx.toTX()); + } + + if (!txPerOutput) + txs.push(mtx.toTX()); + + return txs; +}; + +/** + * Fund wallet options + * @typedef {Object} FundOptions + * @property {Boolean} [txPerOutput=true] + * @property {Boolean} [createAddress=true] + * @property {Boolean} [blockPerTX=false] + */ + +/** + * @param {Wallet} wallet + * @param {OutputInfo[]} outputInfos + * @param {FundOptions} options + * @returns {Promise} + */ + +walletUtils.fundWallet = async function fundWallet(wallet, outputInfos, options = {}) { + const txs = await walletUtils.createInboundTXs(wallet, outputInfos, options); + + if (!options.blockPerTX) { + await wallet.wdb.addBlock(walletUtils.nextBlock(wallet.wdb), txs); + return txs; + } + + for (const tx of txs) { + await wallet.wdb.addTX(tx); + await wallet.wdb.addBlock(walletUtils.nextBlock(wallet.wdb), [tx]); + } + + return txs; +}; diff --git a/test/wallet-chainstate-test.js b/test/wallet-chainstate-test.js index 43f6b16d4..b92bb72f7 100644 --- a/test/wallet-chainstate-test.js +++ b/test/wallet-chainstate-test.js @@ -7,10 +7,8 @@ const MTX = require('../lib/primitives/mtx'); const WorkerPool = require('../lib/workers/workerpool'); const WalletDB = require('../lib/wallet/walletdb'); const wutils = require('./util/wallet'); -const { - dummyInput, - nextEntry -} = wutils; +const {nextEntry} = wutils; +const {dummyInput} = require('./util/primitives'); const enabled = true; const size = 2; diff --git a/test/wallet-coinselection-test.js b/test/wallet-coinselection-test.js index b909644d9..73d3ef185 100644 --- a/test/wallet-coinselection-test.js +++ b/test/wallet-coinselection-test.js @@ -1,72 +1,2272 @@ 'use strict'; const assert = require('bsert'); -const {BlockMeta} = require('../lib/wallet/records'); -const util = require('../lib/utils/util'); +const {BufferMap} = require('buffer-map'); const Network = require('../lib/protocol/network'); const MTX = require('../lib/primitives/mtx'); +const Covenant = require('../lib/primitives/covenant'); +const Coin = require('../lib/primitives/coin'); +const Input = require('../lib/primitives/input'); const WalletDB = require('../lib/wallet/walletdb'); const policy = require('../lib/protocol/policy'); +const wutils = require('./util/wallet'); +const primutils = require('./util/primitives'); +const {randomP2PKAddress} = primutils; +const { + nextBlock, + curBlock, + createInboundTXs, + fundWallet +} = wutils; + +/** @typedef {import('../lib/wallet/wallet')} Wallet */ +/** @typedef {import('../lib/primitives/tx')} TX */ +/** @typedef {import('./util/primitives').CoinOptions} CoinOptions */ +/** @typedef {wutils.OutputInfo} OutputInfo */ + +const UNCONFIRMED_HEIGHT = 0xffffffff; // Use main instead of regtest because (deprecated) // CoinSelector.MAX_FEE was network agnostic const network = Network.get('main'); -function dummyBlock(tipHeight) { - const height = tipHeight + 1; - const hash = Buffer.alloc(32); - hash.writeUInt16BE(height); +const DEFAULT_ACCOUNT = 'default'; +const ALT_ACCOUNT = 'alt'; + +describe('Wallet Coin Selection', function() { + const TX_START_BAK = network.txStart; + /** @type {WalletDB?} */ + let wdb; + /** @type {Wallet?} */ + let wallet; + + const beforeFn = async () => { + network.txStart = 0; + wdb = new WalletDB({ network }); - const prevHash = Buffer.alloc(32); - prevHash.writeUInt16BE(tipHeight); + await wdb.open(); + await wdb.addBlock(nextBlock(wdb), []); + wallet = wdb.primary; - const dummyBlock = { - hash, - height, - time: util.now(), - prevBlock: prevHash + await wallet.createAccount({ + name: ALT_ACCOUNT + }); }; - return dummyBlock; -} + const afterFn = async () => { + network.txStart = TX_START_BAK; + await wdb.close(); + + wdb = null; + wallet = null; + }; + + const indexes = [ + 'value-asc', + 'value-desc', + 'height-asc', + 'height-desc' + ]; + + for (const indexType of indexes) { + describe(`Coin Selection Indexes (${indexType})`, function() { + const TX_OPTIONS = [ + { value: 2e6, address: randomP2PKAddress() }, + // address will be generated using wallet. + { value: 1e6, covenant: { type: Covenant.types.OPEN } }, + { value: 5e6, covenant: { type: Covenant.types.REDEEM } }, + { value: 2e6 }, + // alt account + { value: 4e6, account: ALT_ACCOUNT }, + { value: 6e6, account: ALT_ACCOUNT, covenant: { type: Covenant.types.OPEN } }, + { value: 3e6, account: ALT_ACCOUNT, covenant: { type: Covenant.types.REDEEM } }, + // non spendable coins must not get indexed. + { value: 4e6, covenant: { type: Covenant.types.BID } }, + { value: 5e6, covenant: { type: Covenant.types.REVEAL } }, + { value: 6e6, covenant: { type: Covenant.types.REGISTER } }, + { value: 7e6, covenant: { type: Covenant.types.UPDATE } }, + { value: 8e6, covenant: { type: Covenant.types.RENEW } }, + { value: 9e6, covenant: { type: Covenant.types.TRANSFER } }, + { value: 10e6, covenant: { type: Covenant.types.FINALIZE } }, + { value: 11e6, covenant: { type: Covenant.types.REVOKE } } + ]; + + const ACCT_0_COINS = 3; + const ACCT_0_FUNDS = 1e6 + 2e6 + 5e6; + const ACCT_1_COINS = 3; + const ACCT_1_FUNDS = 3e6 + 4e6 + 6e6; + const TOTAL_COINS = ACCT_0_COINS + ACCT_1_COINS; + const TOTAL_FUNDS = ACCT_0_FUNDS + ACCT_1_FUNDS; + + let isSorted, getCredits; + const sumCredits = credits => credits.reduce((acc, c) => acc + c.coin.value, 0); + + before(() => { + switch (indexType) { + case 'value-asc': + isSorted = isSortedByValueAsc; + getCredits = (wallet, acct = 0, opts = {}) => { + return collectIter(wallet.getAccountCreditIterByValue(acct, opts)); + }; + break; + case 'value-desc': + isSorted = isSortedByValueDesc; + getCredits = (wallet, acct = 0, opts = {}) => { + return collectIter(wallet.getAccountCreditIterByValue(acct, { + ...opts, + reverse: true + })); + }; + break; + case 'height-asc': + isSorted = isSortedByHeightAsc; + getCredits = (wallet, acct = 0, opts = {}) => { + return collectIter(wallet.getAccountCreditIterByHeight(acct, opts)); + }; + break; + case 'height-desc': + isSorted = isSortedByHeightDesc; + getCredits = (wallet, acct = 0, opts = {}) => { + return collectIter(wallet.getAccountCreditIterByHeight(acct, { + ...opts, + reverse: true + })); + }; + break; + default: + throw new Error('Invalid index type.'); + } + }); + + beforeEach(beforeFn); + afterEach(afterFn); + + it('should index unconfirmed tx output', async () => { + const txs = await createInboundTXs(wallet, TX_OPTIONS); + + for (const tx of txs) + await wallet.wdb.addTX(tx); + + const credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + const credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + const both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert.strictEqual(credit.coin.height, -1); + assert.strictEqual(credit.spent, false); + } + }); + + it('should index unconfirmed tx input', async () => { + const currentBlock = curBlock(wdb); + await fundWallet(wallet, TX_OPTIONS, { + blockPerTX: true + }); + + const spendAll = await wallet.createTX({ + hardFee: 0, + outputs: [{ value: TOTAL_FUNDS, address: randomP2PKAddress() }] + }); + + await wdb.addTX(spendAll.toTX()); + + // We still have the coin, even thought it is flagged: .spent = true + const credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + const credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + const both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert(credit.coin.height > currentBlock.height); + assert.strictEqual(credit.spent, true); + } + }); + + it('should index insert (block) tx output', async () => { + const currentBlock = curBlock(wdb); + await fundWallet(wallet, TX_OPTIONS, { blockPerTX: true }); + + const credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + const credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + const both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert(credit.coin.height > currentBlock.height); + assert.strictEqual(credit.spent, false); + } + }); + + it('should index insert (block) tx input', async () => { + await fundWallet(wallet, TX_OPTIONS, false); + const currentBlock = curBlock(wdb); + + const spendAll = await wallet.createTX({ + hardFee: 0, + outputs: [{ value: TOTAL_FUNDS, address: randomP2PKAddress() }] + }); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert.strictEqual(credit.coin.height, currentBlock.height); + assert.strictEqual(credit.spent, false); + } + + await wdb.addBlock(nextBlock(wdb), [spendAll.toTX()]); + + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, 0); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, 0); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, 0); + }); + + it('should index confirm tx output', async () => { + const txs = await createInboundTXs(wallet, TX_OPTIONS); + for (const tx of txs) + await wdb.addTX(tx); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert.strictEqual(credit.coin.height, -1); + assert.strictEqual(credit.spent, false); + } + + await wdb.addBlock(nextBlock(wdb), txs); + const currentBlock = curBlock(wdb); + + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert.strictEqual(credit.coin.height, currentBlock.height); + assert.strictEqual(credit.spent, false); + } + }); + + it('should index confirm tx input', async () => { + const currentBlock = curBlock(wdb); + await fundWallet(wallet, TX_OPTIONS, { + blockPerTX: true + }); + + const spendAll = await wallet.createTX({ + hardFee: 0, + outputs: [{ value: TOTAL_FUNDS, address: randomP2PKAddress() }] + }); + const spendAllTX = spendAll.toTX(); + + await wdb.addTX(spendAllTX); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); -async function fundWallet(wallet, amounts) { - assert(Array.isArray(amounts)); + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); - const mtx = new MTX(); - const addr = await wallet.receiveAddress(); - for (const amt of amounts) { - mtx.addOutput(addr, amt); + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert(credit.coin.height > currentBlock.height); + assert.strictEqual(credit.spent, true); + } + + await wdb.addBlock(nextBlock(wdb), [spendAllTX]); + + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, 0); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, 0); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, 0); + }); + + it('should index disconnect tx output', async () => { + const currentBlock = curBlock(wdb); + await fundWallet(wallet, TX_OPTIONS, { + blockPerTX: true + }); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert(credit.coin.height > currentBlock.height); + assert.strictEqual(credit.spent, false); + } + + // disconnect last block. + await wdb.rollback(currentBlock.height); + + // Only thing that must change is the HEIGHT. + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert.strictEqual(credit.coin.height, -1); + assert.strictEqual(credit.spent, false); + } + }); + + it('should index disconnect tx input', async () => { + const startingHeight = curBlock(wdb).height; + await fundWallet(wallet, TX_OPTIONS, { blockPerTX: true }); + const createCoinHeight = curBlock(wdb).height; + + const spendAll = await wallet.createTX({ + hardFee: 0, + outputs: [{ value: TOTAL_FUNDS, address: randomP2PKAddress() }] + }); + + const spendAllTX = spendAll.toTX(); + await wdb.addBlock(nextBlock(wdb), [spendAllTX]); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, 0); + + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, 0); + + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, 0); + + await wdb.rollback(createCoinHeight); + + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert(credit.coin.height > startingHeight); + assert.strictEqual(credit.spent, true); + } + }); + + it('should index erase tx output', async () => { + const txs = await createInboundTXs(wallet, TX_OPTIONS); + + for (const tx of txs) + await wdb.addTX(tx); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert.strictEqual(credit.coin.height, -1); + assert.strictEqual(credit.spent, false); + } + + // double spend original txs. + const mtx = new MTX(); + for (const tx of txs) + mtx.addInput(tx.inputs[0]); + mtx.addOutput(randomP2PKAddress(), 1e6); + + await wdb.addBlock(nextBlock(wdb), [mtx.toTX()]); + + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, 0); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, 0); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, 0); + }); + + it('should index erase tx input', async () => { + const txs = await createInboundTXs(wallet, TX_OPTIONS); + for (const tx of txs) + await wdb.addTX(tx); + + const spendAll = await wallet.createTX({ + hardFee: 0, + outputs: [{ value: TOTAL_FUNDS, address: randomP2PKAddress() }] + }); + + await wdb.addTX(spendAll.toTX()); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert.strictEqual(credit.coin.height, -1); + assert.strictEqual(credit.spent, true); + } + + // double spend original tx. + const mtx = new MTX(); + for (const tx of txs) + mtx.addInput(tx.inputs[0]); + mtx.addOutput(randomP2PKAddress(), 1e6); + + await wdb.addBlock(nextBlock(wdb), [mtx.toTX()]); + + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, 0); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, 0); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, 0); + }); + + it('should index erase (block) tx output', async () => { + const txOptions = [...TX_OPTIONS]; + for (const opt of txOptions) + opt.coinbase = true; + + const startingHeight = curBlock(wdb).height; + const txs = await fundWallet(wallet, txOptions, { blockPerTX: true }); + + for (const tx of txs) + assert(tx.isCoinbase()); + + let credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, ACCT_0_COINS); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === ACCT_0_FUNDS); + + let credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, ACCT_1_COINS); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === ACCT_1_FUNDS); + + let both = await getCredits(wallet, -1); + assert.strictEqual(both.length, TOTAL_COINS); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === TOTAL_FUNDS); + + for (const credit of [...credits0, ...credits1, ...both]) { + assert(credit.coin.height > startingHeight); + assert.strictEqual(credit.spent, false); + } + + await wdb.rollback(startingHeight); + + credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, 0); + + credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, 0); + + both = await getCredits(wallet, -1); + assert.strictEqual(both.length, 0); + }); + + it('should index block and mempool', async () => { + const txOptionsConfirmed = [ + { value: 4e6 }, + { value: 7e6 }, + { value: 2e6, account: ALT_ACCOUNT }, + { value: 5e6, account: ALT_ACCOUNT } + ]; + await fundWallet(wallet, txOptionsConfirmed, false); + + const txOptionsUnconfirmed = [ + { value: 8e6 }, + { value: 3e6 }, + { value: 6e6, account: ALT_ACCOUNT }, + { value: 1e6, account: ALT_ACCOUNT } + ]; + const txs = await createInboundTXs(wallet, txOptionsUnconfirmed, { + txPerOutput: false + }); + await wdb.addTX(txs[0]); + + const sum0 = 3e6 + 4e6 + 7e6 + 8e6; + const sum1 = 1e6 + 2e6 + 5e6 + 6e6; + + const credits0 = await getCredits(wallet); + assert.strictEqual(credits0.length, 4); + assert(isSorted(credits0), 'Credits not sorted.'); + assert(sumCredits(credits0) === sum0); + + const credits1 = await getCredits(wallet, 1); + assert.strictEqual(credits1.length, 4); + assert(isSorted(credits1), 'Credits not sorted.'); + assert(sumCredits(credits1) === sum1); + + const both = await getCredits(wallet, -1); + assert.strictEqual(both.length, 8); + assert(isSorted(both), 'Credits not sorted.'); + assert(sumCredits(both) === sum0 + sum1); + }); + }); } - const dummy = dummyBlock(wallet.wdb.height); - await wallet.wdb.addBlock(dummy, [mtx.toTX()]); -} + /** @type {OutputInfo[]} */ + const PER_BLOCK_COINS = [ + // confirmed per block. + { value: 2e6 }, + { value: 2e6 }, + { value: 1e6, account: ALT_ACCOUNT }, + { value: 12e6 }, // LOCKED + { value: 8e6 }, + { value: 10e6, account: ALT_ACCOUNT }, // LOCKED + { value: 5e6, account: ALT_ACCOUNT } + ]; + + /** @type {OutputInfo[]} */ + const UNCONFIRMED_COINS = [ + // unconfirmed + { value: 3e6 }, // own + { value: 6e6 }, + { value: 11e6 }, // LOCKED + { value: 4e6, account: ALT_ACCOUNT }, // own + { value: 7e6, account: ALT_ACCOUNT }, + { value: 9e6, account: ALT_ACCOUNT } // LOCKED + ]; + + const LOCK = [9e6, 10e6, 11e6, 12e6]; + const OWN = [ + { account: DEFAULT_ACCOUNT, value: 3e6 }, + { account: ALT_ACCOUNT, value: 4e6 } + ]; + + const ACCT_0_CONFIRMED = 2e6 + 2e6 + 8e6; // 10e6 + const ACCT_0_UNCONFIRMED = 3e6 + 6e6; // 9e6 + const ACCT_0_FOREIGN = 6e6; + const ACCT_0_FUNDS = ACCT_0_CONFIRMED + ACCT_0_UNCONFIRMED; // 19e6 + + const ACCT_1_CONFIRMED = 1e6 + 5e6; // 6e6 + const ACCT_1_UNCONFIRMED = 4e6 + 7e6; // 11e6 + const ACCT_1_FOREIGN = 7e6; + const ACCT_1_FUNDS = ACCT_1_CONFIRMED + ACCT_1_UNCONFIRMED; // 17e6 + + /** + * @typedef {Object} SelectionTest + * @property {String} name + * @property {Object} options + * @property {Amount} value + * @property {Amount[]} [existingInputs] - use some coins that are resolved later. + * Use only unique value Coins. + * @property {CoinOptions[]} [existingCoins] + * @property {Amount[]} expectedOrdered + * @property {Object} [expectedSome] - Some of this must exist in mtx. + * @property {Number} expectedSome.count - Number of items that must exist. + * @property {Amount[]} expectedSome.items + */ + + /** @type {Object} */ + const SELECTION_TESTS = { + 'value': [ + // wallet by value + { + name: 'select 1 coin (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value' + }, + value: 1e6, + expectedOrdered: [8e6] + }, + { + name: 'select all confirmed coins (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_CONFIRMED + ACCT_1_CONFIRMED, + expectedOrdered: [8e6, 5e6, 2e6, 2e6, 1e6] + }, + { + name: 'select all confirmed and an unconfirmed (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_CONFIRMED + ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [8e6, 5e6, 2e6, 2e6, 1e6, 7e6] + }, + { + name: 'select all coins (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS, + expectedOrdered: [8e6, 5e6, 2e6, 2e6, 1e6, 7e6, 6e6, 4e6, 3e6] + }, + { + // test locked filters. + name: 'throw funding error (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS + 1e6, + error: { + availableFunds: ACCT_0_FUNDS + ACCT_1_FUNDS, + requiredFunds: ACCT_0_FUNDS + ACCT_1_FUNDS + 1e6, + type: 'FundingError' + } + }, -describe('Wallet Coin Selection', function () { - describe('Fees', function () { - const wdb = new WalletDB({network}); - let wallet; + // default account by value + { + name: 'select 1 coin (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: 1e6, + expectedOrdered: [8e6] + }, + { + name: 'select all confirmed coins (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_CONFIRMED, + expectedOrdered: [8e6, 2e6, 2e6] + }, + { + name: 'select all confirmed and an unconfirmed (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_CONFIRMED + 1e6, + expectedOrdered: [8e6, 2e6, 2e6, 6e6] + }, + { + name: 'select all coins (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_FUNDS, + expectedOrdered: [8e6, 2e6, 2e6, 6e6, 3e6] + }, + { + // test locked filters. + name: 'throw funding error (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_0_FUNDS + 1e6, + error: { + availableFunds: ACCT_0_FUNDS, + requiredFunds: ACCT_0_FUNDS + 1e6, + type: 'FundingError' + } + }, + // alt account by value + { + name: 'select 1 coin (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: 1e6, + expectedOrdered: [5e6] + }, + { + name: 'select all confirmed coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_1_CONFIRMED, + expectedOrdered: [5e6, 1e6] + }, + { + name: 'select all confirmed and an unconfirmed (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [5e6, 1e6, 7e6] + }, + { + name: 'select all coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_1_FUNDS, + expectedOrdered: [5e6, 1e6, 7e6, 4e6] + }, + { + // test locked filters. + name: 'throw funding error (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: ACCT_1_FUNDS + 1e6, + error: { + availableFunds: ACCT_1_FUNDS, + requiredFunds: ACCT_1_FUNDS + 1e6, + type: 'FundingError' + } + } + ], + 'value + smart': [ + // Test smart option. + // smart selection (wallet) + { + name: 'select all confirmed and an unconfirmed + smart (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_0_CONFIRMED + ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [8e6, 5e6, 2e6, 2e6, 1e6, 4e6] + }, + { + name: 'select all coins + smart (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS - ACCT_0_FOREIGN - ACCT_1_FOREIGN, + expectedOrdered: [8e6, 5e6, 2e6, 2e6, 1e6, 4e6, 3e6] + }, + { + name: 'throw funding error + smart (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS, + error: { + availableFunds: ACCT_0_FUNDS + ACCT_1_FUNDS - ACCT_0_FOREIGN - ACCT_1_FOREIGN, + requiredFunds: ACCT_0_FUNDS + ACCT_1_FUNDS, + type: 'FundingError' + } + }, + // smart selection (default) + { + name: 'select all confirmed and an unconfirmed + smart (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_0_CONFIRMED + 1e6, + expectedOrdered: [8e6, 2e6, 2e6, 3e6] + }, + { + name: 'select all coins + smart (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_0_FUNDS - ACCT_0_FOREIGN, + expectedOrdered: [8e6, 2e6, 2e6, 3e6] + }, + { + name: 'throw funding error + smart (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_0_FUNDS, + error: { + availableFunds: ACCT_0_FUNDS - ACCT_0_FOREIGN, + requiredFunds: ACCT_0_FUNDS, + type: 'FundingError' + } + }, + // smart selection (alt) + { + name: 'select all confirmed and an unconfirmed + smart (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [5e6, 1e6, 4e6] + }, + { + name: 'select all coins + smart (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_1_FUNDS - ACCT_1_FOREIGN, + expectedOrdered: [5e6, 1e6, 4e6] + }, + { + name: 'throw funding error + smart (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value', + smart: true + }, + value: ACCT_1_FUNDS, + error: { + availableFunds: ACCT_1_FUNDS - ACCT_1_FOREIGN, + requiredFunds: ACCT_1_FUNDS, + type: 'FundingError' + } + } + ], + // Existing coins = views + inputs + // Existing inputs = inputs (no view, needs extra resolving) + 'value + existing coins and inputs': [ + // existing coins (wallet) + { + name: 'select coins + existing coins (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value' + }, + value: 10e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6, 8e6, 5e6] + }, + // existing coins (default) + { + name: 'select coins + existing coins (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: 10e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6, 8e6, 2e6] + }, + // existing coins (alt) + { + name: 'select coins + existing coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: 10e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6, 5e6, 1e6], + expectedSome: { + count: 1, + items: [4e6, 7e6] + } + }, + { + name: 'select coins + existing inputs (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'value' + }, + value: 10e6, + existingInputs: [5e6], + expectedOrdered: [5e6, 8e6] + }, + // existing coins (default) + { + name: 'select coins + existing inputs (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: 10e6, + existingInputs: [3e6], + expectedOrdered: [3e6, 8e6] + }, + // existing coins (alt) + { + name: 'select coins + existing coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: 10e6, + existingInputs: [4e6], + expectedOrdered: [4e6, 5e6, 1e6] + }, + // fail existing inputs (cross account) + { + name: 'fail cross account existing inputs (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'value' + }, + value: 10e6, + existingInputs: [5e6], // this belongs to alt account + error: { + message: 'Could not resolve preferred inputs.' + } + } + ], + 'age': [ + // wallet by age + { + name: 'select 1 coin (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age' + }, + value: 1e6, + expectedOrdered: [2e6] + }, + { + name: 'select all confirmed coins (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_CONFIRMED + ACCT_1_CONFIRMED, + expectedOrdered: [2e6, 2e6, 1e6, 8e6, 5e6] + }, + { + name: 'select all confirmed and an unconfirmed (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_CONFIRMED + ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [2e6, 2e6, 1e6, 8e6, 5e6], + expectedSome: { + count: 1, + items: [3e6, 6e6, 4e6, 7e6] + } + }, + { + name: 'select all coins (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS, + expectedOrdered: [2e6, 2e6, 1e6, 8e6, 5e6], + expectedSome: { + count: 4, + items: [3e6, 6e6, 4e6, 7e6] + } + }, + { + // test locked filters. + name: 'throw funding error (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS + 1e6, + error: { + availableFunds: ACCT_0_FUNDS + ACCT_1_FUNDS, + requiredFunds: ACCT_0_FUNDS + ACCT_1_FUNDS + 1e6, + type: 'FundingError' + } + }, + + // default account by age + { + name: 'select 1 coin (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: 1e6, + expectedOrdered: [2e6] + }, + { + name: 'select all confirmed coins (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_CONFIRMED, + expectedOrdered: [2e6, 2e6, 8e6] + }, + { + name: 'select all confirmed and an unconfirmed (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_CONFIRMED + 1e6, + expectedOrdered: [2e6, 2e6, 8e6], + expectedSome: { + count: 1, + items: [3e6, 6e6] + } + }, + { + name: 'select all coins (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_FUNDS, + expectedOrdered: [2e6, 2e6, 8e6], + expectedSome: { + count: 2, + items: [3e6, 6e6] + } + }, + { + // test locked filters. + name: 'throw funding error (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_0_FUNDS + 1e6, + error: { + availableFunds: ACCT_0_FUNDS, + requiredFunds: ACCT_0_FUNDS + 1e6, + type: 'FundingError' + } + }, + + // alt account by age + { + name: 'select 1 coin (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: 1e6, + expectedOrdered: [1e6] + }, + { + name: 'select all confirmed coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_1_CONFIRMED, + expectedOrdered: [1e6, 5e6] + }, + { + name: 'select all confirmed and an unconfirmed (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [1e6, 5e6], + expectedSome: { + count: 1, + items: [4e6, 7e6] + } + }, + { + name: 'select all coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_1_FUNDS, + expectedOrdered: [1e6, 5e6], + expectedSome: { + count: 2, + items: [4e6, 7e6] + } + }, + { + // test locked filters. + name: 'throw funding error (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: ACCT_1_FUNDS + 1e6, + error: { + availableFunds: ACCT_1_FUNDS, + requiredFunds: ACCT_1_FUNDS + 1e6, + type: 'FundingError' + } + } + ], + 'age + smart': [ + // Test smart option. + // smart selection (wallet) + { + name: 'select all confirmed and an unconfirmed + smart (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_0_CONFIRMED + ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [2e6, 2e6, 1e6, 8e6, 5e6], + expectedSome: { + count: 1, + items: [3e6, 6e6, 4e6, 7e6] + } + }, + { + name: 'select all coins + smart (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS - ACCT_0_FOREIGN - ACCT_1_FOREIGN, + expectedOrdered: [2e6, 2e6, 1e6, 8e6, 5e6], + expectedSome: { + count: 2, + items: [3e6, 4e6] + } + }, + { + name: 'throw funding error + smart (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_0_FUNDS + ACCT_1_FUNDS, + error: { + availableFunds: ACCT_0_FUNDS + ACCT_1_FUNDS - ACCT_0_FOREIGN - ACCT_1_FOREIGN, + requiredFunds: ACCT_0_FUNDS + ACCT_1_FUNDS, + type: 'FundingError' + } + }, + // smart selection (default) + { + name: 'select all confirmed and an unconfirmed + smart (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_0_CONFIRMED + 1e6, + expectedOrdered: [2e6, 2e6, 8e6], + expectedSome: { + count: 1, + items: [3e6] + } + }, + { + name: 'select all coins + smart (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_0_FUNDS - ACCT_0_FOREIGN, + expectedOrdered: [2e6, 2e6, 8e6], + expectedSome: { + count: 1, + items: [3e6] + } + }, + { + name: 'throw funding error + smart (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_0_FUNDS, + error: { + availableFunds: ACCT_0_FUNDS - ACCT_0_FOREIGN, + requiredFunds: ACCT_0_FUNDS, + type: 'FundingError' + } + }, + // smart selection (alt) + { + name: 'select all confirmed and an unconfirmed + smart (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_1_CONFIRMED + 1e6, + expectedOrdered: [1e6, 5e6], + expectedSome: { + count: 1, + items: [4e6] + } + }, + { + name: 'select all coins + smart (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_1_FUNDS - ACCT_1_FOREIGN, + expectedOrdered: [1e6, 5e6], + expectedSome: { + count: 1, + items: [4e6] + } + }, + { + name: 'throw funding error + smart (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age', + smart: true + }, + value: ACCT_1_FUNDS, + error: { + availableFunds: ACCT_1_FUNDS - ACCT_1_FOREIGN, + requiredFunds: ACCT_1_FUNDS, + type: 'FundingError' + } + } + ], + // Existing coins = views + inputs + // Existing inputs = inputs (no view, needs extra resolving) + 'age + existing inputs': [ + // existing coins (wallet) + { + name: 'select coins + existing coins (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age' + }, + value: 10e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6, 2e6, 2e6, 1e6, 8e6] + }, + // existing coins (default) + { + name: 'select coins + existing coins (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: 10e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6, 2e6, 2e6, 8e6] + }, + // existing coins (alt) + { + name: 'select coins + existing coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: 10e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6, 1e6, 5e6], + expectedSome: { + count: 1, + items: [4e6, 7e6] + } + }, + { + name: 'select coins + existing inputs (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'age' + }, + value: 10e6, + existingInputs: [5e6], + expectedOrdered: [5e6, 2e6, 2e6, 1e6] + }, + // existing coins (default) + { + name: 'select coins + existing inputs (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: 10e6, + existingInputs: [3e6], + expectedOrdered: [3e6, 2e6, 2e6, 8e6] + }, + // existing coins (alt) + { + name: 'select coins + existing coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: 10e6, + existingInputs: [4e6], + expectedOrdered: [4e6, 1e6, 5e6] + }, + // fail existing inputs (cross account) + { + name: 'fail cross account existing inputs (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'age' + }, + value: 10e6, + existingInputs: [5e6], // this belongs to alt account + error: { + message: 'Could not resolve preferred inputs.' + } + } + ], + 'all': [ + // wallet by all + { + name: 'select all coins (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'all' + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 9, + items: [ + 2e6, 2e6, 1e6, 8e6, 5e6, + 3e6, 6e6, 4e6, 7e6 + ] + } + }, + { + name: 'select all coins + smart (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'all', + smart: true + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 7, + items: [ + 2e6, 2e6, 1e6, 8e6, 5e6, + 3e6, 4e6 + ] + } + }, + { + name: 'select all coins + depth = 0 (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'all', + depth: 0 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 9, + items: [ + 2e6, 2e6, 1e6, 8e6, 5e6, + 3e6, 6e6, 4e6, 7e6 + ] + } + }, + { + name: 'select all coins + depth = 1 (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'all', + depth: 1 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 5, + items: [ + 2e6, 2e6, 1e6, 8e6, 5e6 + ] + } + }, + { + name: 'select all coins + depth = 3 (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'all', + depth: 3 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 4, + items: [ + 2e6, 2e6, 1e6, 8e6 + ] + } + }, + + // wallet by default + { + name: 'select all coins (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all' + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 5, + items: [ + 2e6, 2e6, 8e6, + 3e6, 6e6 + ] + } + }, + { + name: 'select all coins + smart (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all', + smart: true + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 4, + items: [ + 2e6, 2e6, 8e6, + 3e6 + ] + } + }, + { + name: 'select all coins + depth = 0 (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all', + depth: 0 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 5, + items: [ + 2e6, 2e6, 8e6, + 3e6, 6e6 + ] + } + }, + { + name: 'select all coins + depth = 1 (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all', + depth: 1 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 3, + items: [ + 2e6, 2e6, 8e6 + ] + } + }, + { + name: 'select all coins + depth = 4 (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all', + depth: 4 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 2, + items: [ + 2e6, 2e6 + ] + } + }, + + // wallet by alt + { + name: 'select all coins (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'all' + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 4, + items: [ + 1e6, 5e6, + 4e6, 7e6 + ] + } + }, + { + name: 'select all coins + smart (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'all', + smart: true + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 3, + items: [ + 1e6, 5e6, + 4e6 + ] + } + }, + { + name: 'select all coins + depth = 0 (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'all', + depth: 0 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 4, + items: [ + 1e6, 5e6, + 4e6, 7e6 + ] + } + }, + { + name: 'select all coins + depth = 1 (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'all', + depth: 1 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 2, + items: [ + 1e6, 5e6 + ] + } + }, + { + name: 'select all coins + depth = 4 (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'all', + depth: 4 + }, + value: 1e6, // should select all regardless. + expectedOrdered: [], + expectedSome: { + count: 1, + items: [ + 1e6 + ] + } + } + ], + // Existing coins = views + inputs + // Existing inputs = inputs (no view, needs extra resolving) + 'all + existing inputs': [ + { + name: 'select all + existing coin (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'all' + }, + value: 2e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6], + expectedSome: { + count: 9, + items: [ + 2e6, 2e6, 1e6, 8e6, 5e6, + 3e6, 6e6, 4e6, 7e6 + ] + } + }, + { + name: 'select all + existing coin (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all' + }, + value: 2e6, + existingCoins: [ + { + height: -1, + value: 1e6 + } + ], + expectedOrdered: [1e6], + expectedSome: { + count: 5, + items: [ + 2e6, 2e6, 8e6, + 3e6, 6e6 + ] + } + }, + { + name: 'select all + existing coin (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'all' + }, + value: 2e6, + existingCoins: [ + { + height: -1, + value: 3e6 + } + ], + expectedOrdered: [3e6], + expectedSome: { + count: 4, + items: [ + 1e6, 5e6, + 4e6, 7e6 + ] + } + }, + { + name: 'select all + existing input (wallet)', + options: { + account: -1, + hardFee: 0, + selection: 'all' + }, + value: 2e6, + existingInputs: [8e6], + expectedOrdered: [8e6], + expectedSome: { + count: 8, + items: [ + 2e6, 2e6, 1e6, 5e6, + 3e6, 6e6, 4e6, 7e6 + ] + } + }, + { + name: 'select all + existing input (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all' + }, + value: 2e6, + existingInputs: [8e6], + expectedOrdered: [8e6], + expectedSome: { + count: 4, + items: [ + 2e6, 2e6, + 3e6, 6e6 + ] + } + }, + { + name: 'select all + existing input (alt)', + options: { + account: ALT_ACCOUNT, + hardFee: 0, + selection: 'all' + }, + value: 2e6, + existingInputs: [5e6], + expectedOrdered: [5e6], + expectedSome: { + count: 3, + items: [1e6, 4e6, 7e6] + } + }, + { + name: 'select all + existing input + estimate (wallet)', + options: { + account: -1, + selection: 'all', + rate: 5e7 + }, + value: 2e6, + existingInputs: [8e6], + expectedOrdered: [8e6], + expectedSome: { + count: 8, + items: [ + 2e6, 2e6, 1e6, 5e6, + 3e6, 6e6, 4e6, 7e6 + ] + } + }, + { + name: 'fail cross account existing inputs (default)', + options: { + account: DEFAULT_ACCOUNT, + hardFee: 0, + selection: 'all' + }, + value: 2e6, + existingInputs: [5e6], // this belongs to alt account + error: { + message: 'Could not resolve preferred inputs.' + } + } + ] + }; + + 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. + const valueByCoin = new BufferMap(); + // This is used for OWN and LOCK descriptions. + // The values must be unique in the UTXO set. + const coinByValue = new Map(); + + /** + * Fund the same coin in multiple different ways. + * @param {OutputInfo} output + * @returns {OutputInfo[]} + */ + + const fundCoinOptions = (output) => { + const spendables = [ + Covenant.types.NONE, + Covenant.types.OPEN, + Covenant.types.REDEEM + ]; + + const nonSpendables = [ + Covenant.types.BID, + Covenant.types.REVEAL, + Covenant.types.REGISTER, + Covenant.types.UPDATE, + Covenant.types.RENEW, + Covenant.types.TRANSFER, + Covenant.types.FINALIZE, + Covenant.types.REVOKE + ]; + + const account = output.account || 0; + const value = output.value; + const oneSpendable = spendables[Math.floor(Math.random() * spendables.length)]; + + return [{ value, account, covenant: { type: oneSpendable }}] + .concat(nonSpendables.map(t => ({ value, account, covenant: { type: t }}))); + }; + + // NOTE: tests themselves don't modify the wallet state, so before instead + // of beforeEach should be fine. before(async () => { - await wdb.open(); - wdb.height = network.txStart + 1; - wdb.state.height = wdb.height; + await beforeFn(); + + valueByCoin.clear(); + coinByValue.clear(); + + for (const coinOptions of PER_BLOCK_COINS) { + const outputInfos = fundCoinOptions(coinOptions); + const txs = await fundWallet(wallet, outputInfos, { + txPerOutput: true + }); + + for (const [i, tx] of txs.entries()) { + if (tx.outputs.length !== 1) + continue; + + if (tx.output(0).isUnspendable() || tx.output(0).covenant.isNonspendable()) + continue; + + const coin = Coin.fromTX(tx, 0, i + 1); + valueByCoin.set(coin.toKey(), tx.output(0).value); + coinByValue.set(tx.output(0).value, coin); + } + } + + for (const coinOptions of UNCONFIRMED_COINS) { + const options = fundCoinOptions(coinOptions); + const txs = await createInboundTXs(wallet, options); + + for (const tx of txs) { + await wallet.wdb.addTX(tx); + + if (tx.outputs.length !== 1) + continue; + + if (tx.output(0).isUnspendable() || tx.output(0).covenant.isNonspendable()) + continue; + + const coin = Coin.fromTX(tx, 0, -1); + valueByCoin.set(coin.toKey(), tx.output(0).value); + coinByValue.set(tx.output(0).value, coin); + } + } - const dummy = dummyBlock(network.txStart + 1); - const record = BlockMeta.fromEntry(dummy); - await wdb.setTip(record); - wallet = wdb.primary; + for (const value of LOCK) { + const coin = coinByValue.get(value); + wallet.lockCoin(coin); + } + + for (const {account, value} of OWN) { + const coin = coinByValue.get(value); + const mtx = new MTX(); + mtx.addOutput(await wallet.receiveAddress(account), value); + mtx.addCoin(coin); + await wallet.finalize(mtx); + await wallet.sign(mtx); + const tx = mtx.toTX(); + await wdb.addTX(tx); + + valueByCoin.delete(coin.toKey()); + coinByValue.delete(coin.value); + + const ownedCoin = Coin.fromTX(mtx, 0, -1); + valueByCoin.set(ownedCoin.toKey(), mtx.output(0).value); + coinByValue.set(mtx.output(0).value, ownedCoin); + } }); - after(async () => { - await wdb.close(); + after(afterFn); + + for (const fundingTest of testCase) { + it(`should ${fundingTest.name}`, async () => { + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), fundingTest.value); + + if (fundingTest.existingInputs) { + for (const inputVal of fundingTest.existingInputs) { + const coin = coinByValue.get(inputVal); + assert(coin, `Coin not found for value ${inputVal}.`); + + const input = Input.fromCoin(coin); + mtx.addInput(input); + } + } + + if (fundingTest.existingCoins) { + for (const coinOptions of fundingTest.existingCoins) { + const coin = primutils.makeCoin(coinOptions); + valueByCoin.set(coin.toKey(), coin.value); + mtx.addCoin(coin); + } + } + + let err; + + try { + await wallet.fund(mtx, fundingTest.options); + } catch (e) { + err = e; + } + + if (fundingTest.error) { + assert(err); + assert.strictEqual(err.type, fundingTest.error.type); + assert.strictEqual(err.availableFunds, fundingTest.error.availableFunds); + assert.strictEqual(err.requiredFunds, fundingTest.error.requiredFunds); + + if (fundingTest.error.message) + assert.strictEqual(err.message, fundingTest.error.message); + return; + } + + assert(!err, err); + + const inputVals = mtx.inputs.map(({prevout}) => valueByCoin.get(prevout.toKey())); + + assert(inputVals.length >= fundingTest.expectedOrdered.length, + 'Not enough inputs selected.'); + + assert.deepStrictEqual( + inputVals.slice(0, fundingTest.expectedOrdered.length), + fundingTest.expectedOrdered); + + const left = inputVals.slice(fundingTest.expectedOrdered.length); + + if (!fundingTest.expectedSome) { + assert(left.length === 0, 'Extra inputs selected.'); + return; + } + + let count = fundingTest.expectedSome.count; + const items = fundingTest.expectedSome.items.slice(); + + for (const value of left) { + assert(items.includes(value), `Value ${value} not in expected.`); + assert(count > 0, 'Too many inputs selected.'); + + const idx = items.indexOf(value); + items.splice(idx, 1); + count--; + } + + assert(count === 0, 'Not enough inputs selected.'); + }); + } + }); + } + + describe('Selection types', function() { + beforeEach(beforeFn); + afterEach(afterFn); + + it('should select all spendable coins', async () => { + const spendableCovs = [ + Covenant.types.NONE, + Covenant.types.OPEN, + Covenant.types.REDEEM + ]; + + const nonSpendableCovs = [ + Covenant.types.BID, + Covenant.types.REVEAL, + Covenant.types.REGISTER, + Covenant.types.UPDATE, + Covenant.types.RENEW, + Covenant.types.TRANSFER, + Covenant.types.FINALIZE, + Covenant.types.REVOKE + ]; + + const mkopt = type => ({ value: 1e6, covenant: { type }}); + await fundWallet(wallet, [...nonSpendableCovs, ...spendableCovs].map(mkopt)); + + const coins = await wallet.getCoins(); + assert.strictEqual(coins.length, spendableCovs.length + nonSpendableCovs.length); + + const spendables = await collectIter(wallet.getAccountCreditIterByValue(0)); + assert.strictEqual(spendables.length, spendableCovs.length); + + const mtx = new MTX(); + await wallet.fund(mtx, { + selection: 'all' + }); + + assert.strictEqual(mtx.inputs.length, spendableCovs.length); + }); + + it('should select coin by descending value', async () => { + const values = [1e6, 4e6, 3e6, 5e6, 2e6]; + await fundWallet(wallet, values.map(value => ({ value }))); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 9e6); + + await wallet.fund(mtx, { + selection: 'value', + hardFee: 0 + }); + + // 4 + 5 + assert.strictEqual(mtx.inputs.length, 2); + assert.strictEqual(mtx.outputs.length, 1); + assert.strictEqual(mtx.outputs[0].value, 9e6); }); + it('should select coins by descending age', async () => { + const values = [1e6, 2e6, 3e6, 4e6, 5e6]; + + for (const value of values) + await fundWallet(wallet, [{ value }]); + + const mtx = new MTX(); + mtx.addOutput(randomP2PKAddress(), 9e6); + await wallet.fund(mtx, { + selection: 'age', + hardFee: 0 + }); + + // 1 + 2 + 3 + 4 = 10 + assert.strictEqual(mtx.inputs.length, 4); + assert.strictEqual(mtx.outputs.length, 2); + assert.strictEqual(mtx.outputs[0].value, 9e6); + assert.strictEqual(mtx.outputs[1].value, 1e6); + }); + }); + + describe('Fees', function() { + before(beforeFn); + after(afterFn); + it('should fund wallet', async () => { - await fundWallet(wallet, [100e6, 10e6, 1e6, 100000, 10000]); + const vals = [100e6, 10e6, 1e6, 0.1e6, 0.01e6]; + await fundWallet(wallet, vals.map(value => ({ value }))); const bal = await wallet.getBalance(); - assert.strictEqual(bal.confirmed, 111110000); + assert.strictEqual(bal.confirmed, 111.11e6); }); it('should pay default fee rate for small tx', async () => { @@ -121,16 +2321,22 @@ describe('Wallet Coin Selection', function () { it('should fail to pay absurd fee rate for small tx', async () => { const address = await wallet.receiveAddress(); - await assert.rejects( - wallet.send({ + let err; + + try { + await wallet.send({ outputs: [{ address, value: 5e6 }], rate: (policy.ABSURD_FEE_FACTOR + 1) * network.minRelay - }), - {message: 'Fee exceeds absurd limit.'} - ); + }); + } catch (e) { + err = e; + } + + assert(err, 'Error not thrown.'); + assert.strictEqual(err.message, 'Fee exceeds absurd limit.'); }); it('should pay fee just under the absurd limit', async () => { @@ -177,3 +2383,111 @@ describe('Wallet Coin Selection', function () { }); }); }); + +/** + * Collect iterator items. + * @template T + * @param {AsyncGenerator} iter + * @returns {Promise} + */ + +async function collectIter(iter) { + const items = []; + + for await (const item of iter) + items.push(item); + + return items; +} + +/** + * @param {Credit[]} credits + * @returns {Boolean} + */ + +function isSortedByValueAsc(credits) { + for (let i = 1; i < credits.length; i++) { + const prev = credits[i - 1].coin; + const cur = credits[i].coin; + + if (prev.height === -1 && cur.height !== -1) + return false; + + if (prev.height !== -1 && cur.height === -1) + continue; + + if (prev.value > cur.value) + return false; + } + + return true; +} + +/** + * @param {Credit[]} credits + * @returns {Boolean} + */ + +function isSortedByValueDesc(credits) { + for (let i = 1; i < credits.length; i++) { + const prev = credits[i - 1].coin; + const cur = credits[i].coin; + + if (prev.height === -1 && cur.height !== -1) + return false; + + if (prev.height !== -1 && cur.height === -1) + continue; + + if (prev.value < cur.value) + return false; + } + + return true; +} + +/** + * @param {Credit[]} credits + * @returns {Boolean} + */ + +function isSortedByHeightAsc(credits) { + for (let i = 1; i < credits.length; i++) { + let prevHeight = credits[i - 1].coin.height; + let curHeight = credits[i].coin.height; + + if (prevHeight === -1) + prevHeight = UNCONFIRMED_HEIGHT; + + if (curHeight === -1) + curHeight = UNCONFIRMED_HEIGHT; + + if (prevHeight > curHeight) + return false; + } + + return true; +} + +/** + * @param {Credit[]} credits + * @returns {Boolean} + */ + +function isSortedByHeightDesc(credits) { + for (let i = 1; i < credits.length; i++) { + let prevHeight = credits[i - 1].coin.height; + let curHeight = credits[i].coin.height; + + if (prevHeight === -1) + prevHeight = UNCONFIRMED_HEIGHT; + + if (curHeight === -1) + curHeight = UNCONFIRMED_HEIGHT; + + if (prevHeight < curHeight) + return false; + } + + return true; +} diff --git a/test/wallet-pagination-test.js b/test/wallet-pagination-test.js index c2898717b..2e5770867 100644 --- a/test/wallet-pagination-test.js +++ b/test/wallet-pagination-test.js @@ -7,10 +7,8 @@ const WalletDB = require('../lib/wallet/walletdb'); const consensus = require('../lib/protocol/consensus'); const util = require('../lib/utils/util'); const wutils = require('./util/wallet'); -const { - dummyInput, - nextEntry -} = wutils; +const {nextEntry} = wutils; +const {dummyInput} = require('./util/primitives'); /** @typedef {import('../lib/wallet/wallet')} Wallet */ diff --git a/test/wallet-test.js b/test/wallet-test.js index e1dab5944..82ecb51a3 100644 --- a/test/wallet-test.js +++ b/test/wallet-test.js @@ -28,12 +28,12 @@ const wutils = require('./util/wallet'); const {ownership} = require('../lib/covenants/ownership'); const {CachedStubResolver, STUB_SERVERS} = require('./util/stub'); const { - dummyInput, curBlock, nextBlock, curEntry, nextEntry } = wutils; +const {dummyInput} = require('./util/primitives'); const KEY1 = 'xprv9s21ZrQH143K3Aj6xQBymM31Zb4BVc7wxqfUhMZrzewdDVCt' + 'qUP9iWfcHgJofs25xbaUpCps9GDXj83NiWvQCAkWQhVj5J4CorfnpKX94AZ'; @@ -401,7 +401,7 @@ describe('Wallet', function() { assert.strictEqual(balanceBefore.tx, 2); assert.strictEqual(balanceBefore.coin, 2); - await wdb.removeBlock(block, [cbTX.toTX(), normalTX.toTX()]); + await wdb.removeBlock(block); const pending = await wallet.getPending(); assert.strictEqual(pending.length, 1); @@ -2224,23 +2224,16 @@ describe('Wallet', function() { // Store balance data before rescan to ensure rescan was complete let recipBalBefore, senderBalBefore; - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - before(async () => { await wdb.open(); await wdb.connect(); wallet = await wdb.create(); recip = await wdb.create(); - // rollout all names - wdb.height = 52 * 144 * 7; + network.names.noRollout = true; }); after(async () => { + network.names.noRollout = false; await wdb.disconnect(); await wdb.close(); }); @@ -2610,21 +2603,15 @@ describe('Wallet', function() { let start; let wallet; - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - before(async () => { await wdb.open(); wallet = await wdb.create(); // rollout all names - wdb.height = 52 * 144 * 7; + network.names.noRollout = true; }); after(async () => { + network.names.noRollout = false; await wdb.close(); }); @@ -3290,24 +3277,19 @@ 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; - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - before(async () => { await wdb.open(); wallet = await wdb.create(); // rollout all names - wdb.height = 52 * 144 * 7; + network.names.noRollout = true; }); after(async () => { + network.names.noRollout = false; await wdb.close(); }); @@ -3748,13 +3730,6 @@ describe('Wallet', function() { const network = Network.get('regtest'); const wdb = new WalletDB({ network }); - // Hack required to focus test on txdb mechanics. - // We don't otherwise need WalletDB or Blockchain - // TODO: Remove this after #888 is merged. - wdb.getRenewalBlock = () => { - return network.genesis.hash; - }; - const mineBlocks = async (count) => { for (let i = 0; i < count; i++) { await wdb.addBlock(nextEntry(wdb), []); diff --git a/test/wallet-unit-test.js b/test/wallet-unit-test.js index 1e6a55302..8bb9c2a51 100644 --- a/test/wallet-unit-test.js +++ b/test/wallet-unit-test.js @@ -13,7 +13,8 @@ const WalletDB = require('../lib/wallet/walletdb'); const Wallet = require('../lib/wallet/wallet'); const Account = require('../lib/wallet/account'); const wutils = require('./util/wallet'); -const {nextEntry, fakeEntry} = require('./util/wallet'); +const {nextEntry, fakeEntry} = wutils; +const {dummyInput} = require('./util/primitives'); const MemWallet = require('./util/memwallet'); /** @typedef {import('../lib/primitives/tx')} TX */ @@ -541,7 +542,7 @@ describe('Wallet Unit Tests', () => { function fakeTX(addr) { const tx = new MTX(); - tx.addInput(wutils.dummyInput()); + tx.addInput(dummyInput()); tx.addOutput({ address: addr, value: 5460