diff --git a/modules/ecash-coinselect/README.md b/modules/ecash-coinselect/README.md --- a/modules/ecash-coinselect/README.md +++ b/modules/ecash-coinselect/README.md @@ -49,4 +49,8 @@ - Support input param `tokenInputs` to create txs with user-specified inputs [diff](https://reviews.bitcoinabc.org/D15520) +2.1.0 + +- Export new function `getMaxSendAmountSatoshis` [diff](https://reviews.bitcoinabc.org/D15555) + ## License [MIT](LICENSE) diff --git a/modules/ecash-coinselect/index.js b/modules/ecash-coinselect/index.js --- a/modules/ecash-coinselect/index.js +++ b/modules/ecash-coinselect/index.js @@ -3,5 +3,5 @@ // Distributed under the MIT software license, see the accompanying // file LICENSE or http://www.opensource.org/licenses/mit-license.php. 'use strict'; -const { coinSelect } = require('./src/coinSelect'); -module.exports = { coinSelect }; +const { coinSelect, getMaxSendAmountSatoshis } = require('./src/coinSelect'); +module.exports = { coinSelect, getMaxSendAmountSatoshis }; diff --git a/modules/ecash-coinselect/package.json b/modules/ecash-coinselect/package.json --- a/modules/ecash-coinselect/package.json +++ b/modules/ecash-coinselect/package.json @@ -1,6 +1,6 @@ { "name": "ecash-coinselect", - "version": "2.1.0", + "version": "2.2.0", "description": "An unspent transaction output (UTXO) selection module for eCash.", "main": "src/utxo.js", "scripts": { diff --git a/modules/ecash-coinselect/src/coinSelect.js b/modules/ecash-coinselect/src/coinSelect.js --- a/modules/ecash-coinselect/src/coinSelect.js +++ b/modules/ecash-coinselect/src/coinSelect.js @@ -94,4 +94,46 @@ throw new Error('Insufficient funds'); } -module.exports = { coinSelect }; +/** + * Get the send amount for a 'send all' tx based on the utxos in a wallet + * Note: this function ignores token utxos from NNG chronik-client and in-node chronik-client + * + * @param {array} utxos [...{value: }] + * @param {number} txFee fee in satoshis per byte (may have decimals e.g. 1.01) + * @param {array} otherTargetOutputs [...{address:
, value: { + // Ignore token utxos + const nonTokenUtxos = utxos.filter(utxo => !isToken(utxo)); + + // Get total send qty of all non-token + let totalSatsInWallet = nonTokenUtxos.reduce( + (previousBalance, utxo) => previousBalance + parseInt(utxo.value), + 0, + ); + + // Get the bytecount of a tx that spends all the non-token utxos + const byteCount = transactionBytes( + nonTokenUtxos, + [{ value: totalSatsInWallet }].concat(otherTargetOutputs), + ); + + // Your final send amount should be totalSatsInWallet - (txFee in sats/byte)*(bytes of tx) + // We apply Math.ceil to (txFee * byteCount) so we get an integer. + const maxSendAmountSatoshis = + totalSatsInWallet - Math.ceil(txFee * byteCount); + + // Test if this is a sendable output + if (maxSendAmountSatoshis < DUST_SATOSHIS) { + throw new Error( + `Insufficient funds to send any satoshis from this wallet at fee rate of ${txFee} satoshis per byte`, + ); + } + return maxSendAmountSatoshis; +}; + +module.exports = { coinSelect, getMaxSendAmountSatoshis }; diff --git a/modules/ecash-coinselect/test/coinSelect.test.js b/modules/ecash-coinselect/test/coinSelect.test.js --- a/modules/ecash-coinselect/test/coinSelect.test.js +++ b/modules/ecash-coinselect/test/coinSelect.test.js @@ -4,7 +4,7 @@ // file LICENSE or http://www.opensource.org/licenses/mit-license.php. 'use strict'; const assert = require('assert'); -const { coinSelect } = require('../src/coinSelect'); +const { coinSelect, getMaxSendAmountSatoshis } = require('../src/coinSelect'); const MOCK_TOKEN_UTXO = { value: '546' }; const MOCK_TOKEN_SEND_OUTPUT = { @@ -14,6 +14,8 @@ 'hex', ), }; +const OP_RETURN_MAX_SIZE = + '6a04007461624cd75f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f323135'; describe('coinSelect() accumulative algorithm for utxo selection in coinselect.js', async function () { it('adds a change output if change > dust', function () { @@ -407,4 +409,78 @@ coinSelect(stubChronikUtxos, [{ value: 900 }], 1); }, Error('Insufficient funds')); }); + it('getMaxSendAmountSatoshis gets max send amount for a wallet with multiple utxos', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.equal(getMaxSendAmountSatoshis(stubChronikUtxos, 1), 5512); + }); + it('getMaxSendAmountSatoshis gets the same result even if the wallet has token utxos with spendable amounts', function () { + const stubChronikUtxos = [ + { value: '10000', token: {} }, // in-node chronik + { value: '10000', slpToken: {} }, // NNG chronik + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.equal(getMaxSendAmountSatoshis(stubChronikUtxos, 1), 5512); + }); + it('getMaxSendAmountSatoshis gets max send amount for a wallet with one utxo', function () { + const stubChronikUtxos = [{ value: '1000' }]; + assert.equal(getMaxSendAmountSatoshis(stubChronikUtxos, 1), 808); + }); + it('getMaxSendAmountSatoshis gets max send amount for a wallet with multiple utxos if the user specifies an OP_RETURN output', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.equal( + getMaxSendAmountSatoshis(stubChronikUtxos, 1, [ + { + value: 0, + script: Buffer.from(OP_RETURN_MAX_SIZE, 'hex'), + }, + ]), + 5280, + ); + }); + it('getMaxSendAmountSatoshis gets max send amount for a wallet with multiple utxos at an arbitrary fee', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.equal(getMaxSendAmountSatoshis(stubChronikUtxos, 5.01), 3555); + }); + it('getMaxSendAmountSatoshis throws error if wallet has insufficient funds to send a tx with the requested inputs and outputs', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.throws(() => { + getMaxSendAmountSatoshis(stubChronikUtxos, 17.01); + }, Error('Insufficient funds to send any satoshis from this wallet at fee rate of 17.01 satoshis per byte')); + }); + it('getMaxSendAmountSatoshis will return dust amount if that is the max sendable amount', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.equal(getMaxSendAmountSatoshis(stubChronikUtxos, 11.176), 546); + }); + it('getMaxSendAmountSatoshis will throw error if amount is 1 sat lower than dust', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.throws(() => { + getMaxSendAmountSatoshis(stubChronikUtxos, 11.177); + }, Error('Insufficient funds to send any satoshis from this wallet at fee rate of 11.177 satoshis per byte')); + }); });