690 lines
16 KiB
JavaScript
690 lines
16 KiB
JavaScript
/*!
|
|
* 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 {
|
|
hasNext() {
|
|
throw new Error('Abstract method.');
|
|
}
|
|
|
|
async init() {
|
|
throw new Error('Abstract method.');
|
|
}
|
|
|
|
/**
|
|
* @param {BufferMap<Number>} inputs
|
|
* @param {Coin[]} coins
|
|
* @returns {Promise<void>}
|
|
*/
|
|
|
|
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 {
|
|
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<Coin?>}
|
|
*/
|
|
|
|
async next() {
|
|
if (!this.hasNext())
|
|
return null;
|
|
|
|
return this.coins[this.index++];
|
|
}
|
|
|
|
/**
|
|
* @param {BufferMap<Number>} inputs
|
|
* @param {Coin[]} coins
|
|
* @returns {Promise<void>}
|
|
*/
|
|
|
|
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} tx
|
|
* @param {InMemoryCoinSource} source
|
|
* @param {CoinSelectorOptions?} [options]
|
|
*/
|
|
|
|
constructor(tx, source, options = {}) {
|
|
this.original = tx;
|
|
/** @type {MTX} */
|
|
this.tx = tx.clone();
|
|
/** @type {CoinView} */
|
|
this.view = tx.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<Number>} */
|
|
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<void>}
|
|
*/
|
|
|
|
async fund() {
|
|
// Ensure all preferred inputs first.
|
|
await this.resolveInputCoins();
|
|
|
|
if (this.isFull())
|
|
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<void>}
|
|
*/
|
|
|
|
async selectHard() {
|
|
this.fee = this.hardFee;
|
|
await this.fund();
|
|
}
|
|
|
|
/**
|
|
* Fill the transaction with inputs.
|
|
* @returns {Promise<this>}
|
|
*/
|
|
|
|
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;
|