diff --git a/modules/ecash-coinselect/.eslintrc.js b/modules/ecash-coinselect/.eslintrc.js --- a/modules/ecash-coinselect/.eslintrc.js +++ b/modules/ecash-coinselect/.eslintrc.js @@ -1,6 +1,7 @@ -// Copyright (c) 2023 The Bitcoin developers +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC // Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. 'use strict'; module.exports = { env: { @@ -21,9 +22,10 @@ 2, 'line', [ - ' Copyright (c) 2023 The Bitcoin developers', + ' Copyright (c) 2018 Daniel Cousens', + ' Copyright (c) 2023 Bitcoin ABC', ' Distributed under the MIT software license, see the accompanying', - ' file COPYING or http://www.opensource.org/licenses/mit-license.php.', + ' file LICENSE or http://www.opensource.org/licenses/mit-license.php.', ], 1, ], diff --git a/modules/ecash-coinselect/.mocharc.js b/modules/ecash-coinselect/.mocharc.js --- a/modules/ecash-coinselect/.mocharc.js +++ b/modules/ecash-coinselect/.mocharc.js @@ -1,6 +1,7 @@ -// Copyright (c) 2023 The Bitcoin developers +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC // Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. 'use strict'; diff --git a/modules/ecash-coinselect/LICENSE b/modules/ecash-coinselect/LICENSE --- a/modules/ecash-coinselect/LICENSE +++ b/modules/ecash-coinselect/LICENSE @@ -1,5 +1,6 @@ MIT License +Copyright (c) 2018 Daniel Cousens Copyright (c) 2023 Bitcoin ABC Permission is hereby granted, free of charge, to any person obtaining a copy 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 @@ -1,111 +1,40 @@ -# ecash-coinselect: An eCash utxo selection utility. +# ecash-coinselect -JavaScript implementation of an unspent transaction output (UTXO) selection module for eCash and compatible with the Chronik indexer. +An unspent transaction output (UTXO) selection module for eCash (XEC). -There are a number of possible approaches for selecting which utxos to use to build a transaction. +**WARNING:** `value` units are in satoshis. -These approaches include: +### Installation -1. Indiscriminately collecting enough utxos to cover the sending amount plus fees -2. Collecting the biggest utxos first -3. Consolidating dust by using the smallest utxos first -4. Using utxos most appropriately sized for the given tx +```bsh +$ npm i ecash-coinselect +``` -This library is currently utilizing the first approach which collects utxos until the aggregate value can cover the send amount plus the tx fee. Subsequent updates will cater for the other alternate coin selection approaches. +### Usage -## Installation +See `test/` for usage. -### Using NPM +#### Changelog -```bsh -$ npm install --save ecash-coinselect -``` +1.0.0 -## Usage - -- Get enough XEC utxos to cover a send amount plus fees based on a utxo set for a single p2pkh address. - -```javascript -const coinselect = require('ecash-coinselect'); -// Retrieve utxos from Chronik -// const chronikUtxos = await chronik.script("p2pkh", hash160).utxos(); - -// `outputArray` should consist of an array of intended outputs for the transaction -// containing an address and value properties. The value is in satoshis. -const outputArray = [ - { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 900, // 9 XEC - }, -]; -const collectedXecUtxos = coinselect.getInputUtxos(chronikUtxos, outputArray); -console.log(collectedXecUtxos); -// "inputs": [ -// { -// "outpoint": { -// "txid": "1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994", -// "outIdx": 0 -// }, -// "blockHeight": 799480, -// "isCoinbase": false, -// "value": "600", -// "network": "XEC" -// }, -// { -// "outpoint": { -// "txid": "1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994", -// "outIdx": 1 -// }, -// "blockHeight": 799480, -// "isCoinbase": false, -// "value": "38052", -// "network": "XEC" -// } -// ], -// "changeAmount": 500, -// "txFee": 374, -// }; -``` +- Support collection of eCash XEC utxos for one to one p2pkh transactions. -- Parse a utxo set returned from Chronik's [.script().utxo()](https://www.npmjs.com/package/chronik-client?activeTab=readme) API and separate the XEC utxos from the SLP utxos. - -```javascript -const coinselect = require('ecash-coinselect'); -// Retrieve utxos from Chronik -// const chronikUtxos = await chronik.script("p2pkh", hash160).utxos(); -const parsedUtxos = coinselect.parseChronikUtxos(parseChronikUtxos); -console.log(parsedUtxos); -// output below for a wallet with only XEC utxos and no SLP utxos -//{ -// "xecUtxos": [{ -// "outpoint": { -// "txid": "1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994", -// "outIdx": 1 -// }, -// "blockHeight": 799480, -// "isCoinbase": false, -// "value": "38052", -// "network": "XEC" -// }], -// "slpUtxos": [] -//} -``` +1.0.1 -- Calculate a p2pkh transaction byte count based on inputs and outputs. +- Fixed p2pkh byte count calculations and renamed `calcByteCount` to `calcP2pkhByteCount`. -```javascript -const coinselect = require('ecash-coinselect'); -const byteCount = coinselect.calcP2pkhByteCount( - 5, // inputs - 2, // outputs -); -console.log(byteCount); // 818 -``` +1.0.2 + +- Updated `getInputUtxos` to take in an outputArray. + +2.0.0 -#### Change Log +- Deprecate `getInputUtxos`, `parseChronikUtxos`, and `calcP2pkhByteCount` +- Implement `coinSelect` function for eCash based on the accumulative algorithm of the [coinselect](https://github.com/bitcoinjs/coinselect) library from [bitcoinjs](https://github.com/bitcoinjs) -1.0.2 - Updated `getInputUtxos` to take in an outputArray. +2.0.1 -1.0.1 - Fixed p2pkh byte count calculations and renamed `calcByteCount` to `calcP2pkhByteCount`. +- Improvements from [diff review](https://reviews.bitcoinabc.org/D14526) -1.0.0 - Support collection of eCash XEC utxos for one to one p2pkh transactions. +## License [MIT](LICENSE) diff --git a/modules/ecash-coinselect/index.js b/modules/ecash-coinselect/index.js new file mode 100644 --- /dev/null +++ b/modules/ecash-coinselect/index.js @@ -0,0 +1,7 @@ +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC +// 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 }; diff --git a/modules/ecash-coinselect/package-lock.json b/modules/ecash-coinselect/package-lock.json --- a/modules/ecash-coinselect/package-lock.json +++ b/modules/ecash-coinselect/package-lock.json @@ -9,7 +9,6 @@ "version": "1.0.2", "license": "MIT", "dependencies": { - "ecashaddrjs": "^1.5.2", "eslint-plugin-header": "^3.1.1", "mocha-suppress-logs": "^0.3.1" }, @@ -711,17 +710,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -873,19 +861,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/base-x": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", - "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" - }, - "node_modules/big-integer": { - "version": "1.6.36", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.36.tgz", - "integrity": "sha512-t70bfa7HYEA1D9idDbmuv7YbsbVkQ+Hp+8KFSul4aE5e/i1bjCNIRYJZlA8Q8p0r9T8cF/RVvwUgRA//FydEyg==", - "engines": { - "node": ">=0.6" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -954,23 +929,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bs58": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", - "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", - "dependencies": { - "base-x": "^4.0.0" - } - }, - "node_modules/bs58check": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", - "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", - "dependencies": { - "@noble/hashes": "^1.2.0", - "bs58": "^5.0.0" - } - }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -1230,15 +1188,6 @@ "node": ">=6.0.0" } }, - "node_modules/ecashaddrjs": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/ecashaddrjs/-/ecashaddrjs-1.5.2.tgz", - "integrity": "sha512-zzpDxdVR6SmIym/s1OUo5gEzkH86ztHKbdzuFGet8vUpV0nIejOcW8JiTiq74PHAcIKVb9bQ7ZU7F1Ryw6F6xA==", - "dependencies": { - "big-integer": "1.6.36", - "bs58check": "^3.0.1" - } - }, "node_modules/electron-to-chromium": { "version": "1.4.452", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.452.tgz", @@ -3837,11 +3786,6 @@ "integrity": "sha512-3Yc1fUTs69MG/uZbJlLSI3JISMn2UV2rg+1D/vROUqZyh3l6iYHCs7GMp+M40ZD7yOdDbYjJcU1oTJhrc+dGKg==", "dev": true }, - "@noble/hashes": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", - "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==" - }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3951,16 +3895,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "base-x": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", - "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" - }, - "big-integer": { - "version": "1.6.36", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.36.tgz", - "integrity": "sha512-t70bfa7HYEA1D9idDbmuv7YbsbVkQ+Hp+8KFSul4aE5e/i1bjCNIRYJZlA8Q8p0r9T8cF/RVvwUgRA//FydEyg==" - }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -4003,23 +3937,6 @@ "update-browserslist-db": "^1.0.11" } }, - "bs58": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", - "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", - "requires": { - "base-x": "^4.0.0" - } - }, - "bs58check": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", - "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", - "requires": { - "@noble/hashes": "^1.2.0", - "bs58": "^5.0.0" - } - }, "caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -4202,15 +4119,6 @@ "esutils": "^2.0.2" } }, - "ecashaddrjs": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/ecashaddrjs/-/ecashaddrjs-1.5.2.tgz", - "integrity": "sha512-zzpDxdVR6SmIym/s1OUo5gEzkH86ztHKbdzuFGet8vUpV0nIejOcW8JiTiq74PHAcIKVb9bQ7ZU7F1Ryw6F6xA==", - "requires": { - "big-integer": "1.6.36", - "bs58check": "^3.0.1" - } - }, "electron-to-chromium": { "version": "1.4.452", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.452.tgz", 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": "1.0.2", + "version": "2.0.1", "description": "An unspent transaction output (UTXO) selection module for eCash.", "main": "src/utxo.js", "scripts": { @@ -36,7 +36,6 @@ "nyc": "^15.1.0" }, "dependencies": { - "ecashaddrjs": "^1.5.2", "eslint-plugin-header": "^3.1.1", "mocha-suppress-logs": "^0.3.1" } diff --git a/modules/ecash-coinselect/src/byteCount.js b/modules/ecash-coinselect/src/byteCount.js new file mode 100644 --- /dev/null +++ b/modules/ecash-coinselect/src/byteCount.js @@ -0,0 +1,101 @@ +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC +// Distributed under the MIT software license, see the accompanying +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. +'use strict'; +const { isHexString } = require('./utils'); + +// NB -- "baseline" here is from bitcoinjs coinselect, https://github.com/bitcoinjs/coinselect/blob/master/utils.js +// baseline estimates, used to improve performance +const TX_EMPTY_SIZE = 4 + 1 + 1 + 4; +const TX_INPUT_BASE = 32 + 4 + 1 + 4; +const TX_INPUT_PUBKEYHASH = 107; +const TX_OUTPUT_BASE = 8 + 1; +const TX_OUTPUT_PUBKEYHASH = 25; + +/** + * Estimate byteCount of input + * @param {object} input utxo object, at minimum must have {value: } key + * input may have script key. script may be a Buffer or a hex string. + * @returns {number} estimated size in bytes of a transaction input of this utxo + * @throws {error} if script is specified but is not a Buffer or a hexadecimal string + */ +function inputBytes(input) { + const { script } = input; + switch (typeof script) { + case 'undefined': + // No script key in this utxo, i.e. it will be p2pkh or p2sh + return TX_INPUT_BASE + TX_INPUT_PUBKEYHASH; + case 'string': + // Parse string script as hex bytes + if (isHexString(script)) { + return TX_INPUT_BASE + script.length / 2; + } + // falls through + case 'object': + if (ArrayBuffer.isView(script)) { + // Note: ArrayBuffer.isView(script) returns true for a Buffer + // Some transaction building libraries require buffer type + return TX_INPUT_BASE + script.length; + } + // falls through + default: + throw new Error('Unrecognized script type'); + } +} + +/** + * Estimate byteCount of output + * @param {object} output output object, at minimum must have {value: } key + * output may have script key. script may be a Buffer or a hex string. + * @returns {number} estimated size in bytes of this transaction output + * @throws {error} if script is specified but is not a Buffer or a hexadecimal string + */ +function outputBytes(output) { + const { script } = output; + switch (typeof script) { + case 'undefined': + // No script key in this utxo, i.e. it will be p2pkh or p2sh + return TX_OUTPUT_BASE + TX_OUTPUT_PUBKEYHASH; + case 'string': + // Parse string script as hex bytes + if (isHexString(script)) { + return TX_OUTPUT_BASE + script.length / 2; + } + // falls through + case 'object': + if (ArrayBuffer.isView(script)) { + // Note: ArrayBuffer.isView(script) returns true for a Buffer + // Some transaction building libraries require buffer type + return TX_OUTPUT_BASE + script.length; + } + // falls through + default: + throw new Error('Unrecognized script type'); + } +} + +/** + * Estimate byteCount of a transaction given its inputs and outputs + * @param {array} inputs + * @param {array} outputs + * @returns {number} + * @throws {error} if inputs or outputs contain unrecognized script key + */ +function transactionBytes(inputs, outputs) { + return ( + TX_EMPTY_SIZE + + inputs.reduce(function (a, x) { + return a + inputBytes(x); + }, 0) + + outputs.reduce(function (a, x) { + return a + outputBytes(x); + }, 0) + ); +} + +module.exports = { + inputBytes, + outputBytes, + transactionBytes, +}; diff --git a/modules/ecash-coinselect/src/coinSelect.js b/modules/ecash-coinselect/src/coinSelect.js new file mode 100644 --- /dev/null +++ b/modules/ecash-coinselect/src/coinSelect.js @@ -0,0 +1,87 @@ +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC +// Distributed under the MIT software license, see the accompanying +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. +'use strict'; +const { transactionBytes, inputBytes, outputBytes } = require('./byteCount'); +const { isToken, sumValues } = require('./utils'); + +const BLANK_OUTPUT = outputBytes({}); +const DUST_SATOSHIS = 546; + +function finalize(inputs, outputs, feeRate) { + let bytesAccum = transactionBytes(inputs, outputs); + let feeAfterExtraOutput = feeRate * (bytesAccum + BLANK_OUTPUT); + let remainderAfterExtraOutput = + sumValues(inputs) - (sumValues(outputs) + feeAfterExtraOutput); + if (remainderAfterExtraOutput > DUST_SATOSHIS) { + outputs = outputs.concat({ value: remainderAfterExtraOutput }); + } + + let fee = sumValues(inputs) - sumValues(outputs); + return { + inputs: inputs, + outputs: outputs, + fee: fee, + }; +} + +/** + * Select input utxos using accumulative algorithm + * Convert input utxo 'value' key from string to number, the format required to construct transactions + * Add a change output if necessary + * @param {array} utxos [...{value: }] + * @param {array} targetOutputs [...{address:
, value: = 1'); + } + + // Initialize tx bytecount with bytecount of the target outputs + let bytesAccum = transactionBytes([], targetOutputs); + + let outAccum = sumValues(targetOutputs); + + if (outAccum < DUST_SATOSHIS) { + throw new Error( + `Transaction must send more than dust threshold of ${DUST_SATOSHIS} satoshis`, + ); + } + + let inAccum = 0; + let inputs = []; + for (let utxo of utxos) { + // Do not use any slp utxos + if (isToken(utxo)) { + continue; + } + + let utxoBytes = inputBytes(utxo); + + // utxo may be stored as a string + let utxoValue = parseInt(utxo.value); + + // returned tx input stores value as a number + utxo.value = utxoValue; + + bytesAccum += utxoBytes; + inAccum += utxoValue; + + inputs.push(utxo); + + let fee = feeRate * bytesAccum; + + // Add another utxo? + if (inAccum < outAccum + fee) { + continue; + } + + return finalize(inputs, targetOutputs, feeRate); + } + throw new Error('Insufficient funds'); +} + +module.exports = { coinSelect }; diff --git a/modules/ecash-coinselect/src/utils.js b/modules/ecash-coinselect/src/utils.js new file mode 100644 --- /dev/null +++ b/modules/ecash-coinselect/src/utils.js @@ -0,0 +1,41 @@ +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC +// Distributed under the MIT software license, see the accompanying +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. +'use strict'; +const assert = require('assert'); + +/** + * Is this given utxo for an slp token + * @param {object} utxo utxo object, must have at least a value key, e.g. {value: } + * @returns {bool} true if this utxo is a valid SLP utxo + */ +function isToken(utxo) { + return typeof utxo.slpToken !== 'undefined'; +} + +/** + * Sum total values of + * @param {array} utxos [...{value: }] + * @returns {number} + */ +function sumValues(utxos) { + return utxos.reduce((accumulator, currentValue) => { + const valueAsInteger = parseInt(currentValue.value); + assert( + !isNaN(valueAsInteger), + `Input must be an object with 'value' as a key and an integer representing the amount in satoshis as a value`, + ); + return accumulator + valueAsInteger; + }, 0); +} + +/** + * @param {string} hexString + * @returns {bool} true if string contains only characters 0-9 or a-f, case insensitive + */ +function isHexString(hexString) { + return /^[\da-f]+$/i.test(hexString); +} + +module.exports = { isToken, sumValues, isHexString }; diff --git a/modules/ecash-coinselect/src/utxo.js b/modules/ecash-coinselect/src/utxo.js deleted file mode 100644 --- a/modules/ecash-coinselect/src/utxo.js +++ /dev/null @@ -1,259 +0,0 @@ -// Copyright (c) 2023 The Bitcoin developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. -'use strict'; -const ecashaddr = require('ecashaddrjs'); - -const DUST_AMOUNT_SATOSHIS = 546; -// The minimum fee is 1 sat/byte however to use this you need to -// make sure your app has accurately determined the tx bytecount. -// See calcP2pkhByteCount() for the underlying calculations. -const SATOSHIS_PER_BYTE = 1; - -/** - * For legacy reasons, the term "SLP" is still sometimes used to describe an eToken, - * however SLP utxos is the same as eToken utxos, as it's just a semantics difference. - */ - -/** - * Collects enough XEC utxos to cover an XEC tx sending to n recipients. - * - * @param {array} chronikUtxos array response from a chronik.script().utxo() call - * @param {array} outputArray an array of output objects consisting of address and value props - * @returns {object} an object containing: - * - inputs: an array of collected XEC input utxos - * - changeAmount: change amount in sats - * - txFee: total transaction fee for this tx - * @throws {error} on utxo parsing error - */ -function getInputUtxos(chronikUtxos, outputArray) { - const inputUtxos = []; // keeps track of the utxos to be collected (to be signed later) - let txFee = 0; - let totalInputUtxoValue = 0; // aggregate value of all collected input utxos - let outputCount; - let remainder = 0; - let totalSendAmountInSats = 0; - - try { - if (!Array.isArray(outputArray) || outputArray.length === 0) { - throw new Error('Invalid output supplied'); - } - - outputCount = outputArray.length; - - // Validate each script type in outputArray and tally up the total send value - for (const output of outputArray) { - const { type } = ecashaddr.decode(output.address, true); - if (type !== 'p2pkh') { - throw new Error( - `${output.address} is not a p2pkh address (only supported type for now)`, - ); - } - totalSendAmountInSats += output.value; - } - - // Extract the XEC utxos only - const xecUtxos = parseChronikUtxos(chronikUtxos).xecUtxos; - - // Collect enough XEC utxos to cover total send amount and fee - for (let i = 0; i < xecUtxos.length; i += 1) { - const thisUtxo = xecUtxos[i]; - totalInputUtxoValue = totalInputUtxoValue + Number(thisUtxo.value); // Chronik's output for utxo.value is a string hence the need to convert to number - - // Add this utxo to the input utxo array - inputUtxos.push(thisUtxo); - - // Update byte count for this tx passing in the number of utxos - // traversed thus far in this loop and tx outputs. - let byteCount = calcP2pkhByteCount(inputUtxos.length, outputCount); - - // Update tx fee based on byteCount and satoshis per byte - txFee = Math.ceil(SATOSHIS_PER_BYTE * byteCount); - - remainder = totalInputUtxoValue - totalSendAmountInSats - txFee; - // If enough XEC utxos have been collected, exit loop - if (remainder >= 0) { - if (remainder < DUST_AMOUNT_SATOSHIS) { - // You cannot create a valid transaction with a change output less than dust - // Reset remainder to 0 so that you return '0' for change amount - // Note that the actual tx fee will be higher than the optimum fee calculated here, because - // the remainder amount that is too small for change becomes part of the implied tx fee - remainder = 0; - } else { - // Recalculate byte count with an additional output for change - byteCount = calcP2pkhByteCount( - inputUtxos.length, - outputCount + 1, - ); - txFee = Math.ceil(SATOSHIS_PER_BYTE * byteCount); - remainder = - totalInputUtxoValue - totalSendAmountInSats - txFee; - } - break; - } - } - - // if final utxo total is less than send amount plus tx fee, throw error - if (totalInputUtxoValue < totalSendAmountInSats + txFee) { - throw new Error('Insufficient balance'); - } - } catch (err) { - console.log(`getInputUtxos(): Error collecting XEC utxos`); - throw err; - } - - return { - inputs: inputUtxos, - changeAmount: remainder, - txFee: txFee, - }; -} - -/** - * Parse through a wallet's utxos to separate XEC utxos from SLP utxos. - * - * When using the Chronik indexer to interact with the XEC chain, SLP utxos can - * be differentiated by whether they contain the `slpToken` key. Please note this - * is not the case with other indexers like SlpDB...etc - * - * @param {object} chronikUtxos an array response from a chronik.script().utxo() call - * @returns {object} an object containing the XEC and SLP utxo arrays - * @throws {error} on utxo parsing error - */ -function parseChronikUtxos(chronikUtxos) { - const xecUtxos = []; // to store the XEC utxos - const slpUtxos = []; // to store the SLP utxos - - // Chronik returns an array containing a single object if an address has utxos - // e.g. [{ - // outputScript: ..., - // utxos: [{...}, {...}}], - // }] - // hence the need to extract the embedded `utxos` array within. - - // If the wallet has no utxos, return in structured format - if (!chronikUtxos || chronikUtxos.length === 0) { - return { - xecUtxos: [], - slpUtxos: [], - }; - } - const chronikUtxosTrimmed = chronikUtxos[0].utxos; - - try { - // Separate SLP utoxs from XEC utxos - for (let i = 0; i < chronikUtxosTrimmed.length; i += 1) { - const thisUtxo = chronikUtxosTrimmed[i]; - if (thisUtxo.slpToken) { - slpUtxos.push(thisUtxo); - } else { - xecUtxos.push(thisUtxo); - } - } - } catch (err) { - console.log(`parseChronikUtxos(): Error parsing chronik utxos.`); - throw err; - } - - return { - xecUtxos: xecUtxos, - slpUtxos: slpUtxos, - }; -} - -/** - * Calculates tx byte count for p2pkh - * Assumes compressed pubkey and p2pkh - * - * @param {number} inputCount the quantity of p2pkh input utxos consumed in this tx - * @param {number} outputCount the quantity of p2pkh outputs created by this tx - * @returns {number} byteCount the byte count based on input and output weightings - */ -function calcP2pkhByteCount(inputCount, outputCount) { - // Based on https://en.bitcoin.it/wiki/Protocol_documentation#Variable_length_integer - // the amount of inputs and ouputs will influence how byte count is calculated. - - // ** Key Parameters ** - - // - The max outputs per tx is based on 1MB limit divided by 34 output bytes = ~30k - // outputs (rounded up from 29,411) - // - The varint encoding for ~30k outputs is 3 bytes as it is greater than uint8 but - // safe under uint16 - - // - The max inputs per tx is based on 1BM limit divided by 140 input bytes which assumes - // a VERY unlikely 64 bytes DER sig encoding with a compressed pubkey = ~8k inputs (rounded up from 7,142) - // - The varint encoding for ~8k inputs is 3 bytes as it is greater than uint8 but - // safe under uint16 - - // - An eCash signature using the ECDSA signature scheme is typically between 71-73 bytes long. - // This is based on the following breakdown: - // * DER encoding overhead (6 bytes) - // * r-value (up to 32 bytes) - // * r-value signedness (1 byte) - // * S-value (up to 32 bytes) - // * S-value signedness (1 byte) - // * Signature hash (1 byte) - // DER encoded signatures have no r-value/s-value padding (i.e. 71 bytes), whilst - // 72 byte signatures have padding either for the r-value or s-value, with 73 byte - // signatures having padding for both values. (ECDSA requires the values to be unsigned integers) - // Since low S is enforced as a standardness rule, this function uses 72 bytes as an upper limit including sighash. - // The smallest scriptSig, however very unlikely, would be a DER encoded signature with - // a compressed pubkey, at a total length of 100 bytes. - // See https://en.bitcoin.it/wiki/Protocol_documentation#Signatures for more info. - - // - Compressed public keys are 33 bytes whilst older uncompressed keys are 65 bytes. For - // the purposes of this function, it is assumed all public keys are compressed. - - // p2pkh tx byte size formula = - // ( Inputs * Input Size ) + - // ( Outputs * Output Size ) + - // Fixed fee required for the framework of the transaction - - // p2pkh input size = PREVOUT + SCRIPTSIG + sequence - // whereby: - const PREVOUT = 32 /* hash */ + 4; /* index */ - const SCRIPTSIG = - 1 /* length */ + - 1 /* push opcode */ + - 72 /* signature */ + - 1 /* push opcode */ + - 33; /* compressed pubkey */ - const SEQUENCE = 4; - const P2PKH_IN_SIZE = PREVOUT + SCRIPTSIG + SEQUENCE; - - // p2pkh output size = - // value encoding (8 bytes) - // locking script length (1 byte) - // locking script (25 bytes) - const VALUE_ENCODING = 8; - // Since PK_SCRIPT is set to 25 bytes which is safe under uint8, PK_SCRIPT_LENGTH is set to 1 byte. - const PK_SCRIPT_LENGTH = 1; - const PK_SCRIPT = 25; - const P2PKH_OUT_SIZE = VALUE_ENCODING + PK_SCRIPT_LENGTH + PK_SCRIPT; - - // the extra bytes of framework data = - // version number (4 bytes) + - // quantity of inputs (varies based on inputs) + - // quantity of outputs (varies based on outputs) + - // locktime (4 bytes) - const VERSION_NUMBER = 4; - // 1 byte if inputs < 253, otherwise 3 bytes for up to 8k inputs - const INPUT_SIZE = inputCount < 253 ? 1 : 3; - // 1 byte if outputs < 253, otherwise 3 bytes for up to 30k ouputs - const OUTPUT_SIZE = outputCount < 253 ? 1 : 3; - const LOCKTIME = 4; - const FRAMEWORK_BYTES = - VERSION_NUMBER + INPUT_SIZE + OUTPUT_SIZE + LOCKTIME; - - return ( - inputCount * P2PKH_IN_SIZE + - outputCount * P2PKH_OUT_SIZE + - FRAMEWORK_BYTES - ); -} - -module.exports = { - getInputUtxos: getInputUtxos, - parseChronikUtxos: parseChronikUtxos, - calcP2pkhByteCount: calcP2pkhByteCount, -}; diff --git a/modules/ecash-coinselect/test/byteCount.test.js b/modules/ecash-coinselect/test/byteCount.test.js new file mode 100644 --- /dev/null +++ b/modules/ecash-coinselect/test/byteCount.test.js @@ -0,0 +1,140 @@ +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC +// Distributed under the MIT software license, see the accompanying +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. +'use strict'; +const assert = require('assert'); +const { inputBytes, outputBytes } = require('../src/byteCount'); + +// Input constants from byteCount.js +const TX_INPUT_BASE = 32 + 4 + 1 + 4; +const TX_INPUT_PUBKEYHASH = 107; +// Sample input script +const P2PKH_INPUT_SCRIPT = + '483045022100aac70814db602323543d8b5812d055d33df510f8ad580b4fbf4ed699f7a61da902204308e84d596ddc42f3eadef6fad1ec16c05aefd90655a0659351d20d12e67f0f412103771805b54969a9bea4e3eb14a82851c67592156ddb5e52d3d53677d14a40fba6'; + +const TX_OUTPUT_BASE = 8 + 1; +const TX_OUTPUT_PUBKEYHASH = 25; +// Sample output script +const OP_RETURN_MAX_SIZE = + '6a04007461624cd75f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f323135'; + +describe('ecash-coinselect byteCount.js functions', async function () { + it('inputBytes() returns expected estimate for a utxo without script defined', function () { + const utxo = { value: 1000 }; + assert.strictEqual( + inputBytes(utxo), + TX_INPUT_BASE + TX_INPUT_PUBKEYHASH, + ); + }); + it('inputBytes() returns expected estimate for a utxo with script specified as a hex string', function () { + const utxo = { value: 1000, script: P2PKH_INPUT_SCRIPT }; + assert.strictEqual( + inputBytes(utxo), + TX_INPUT_BASE + P2PKH_INPUT_SCRIPT.length / 2, + ); + }); + it('inputBytes() returns expected estimate for a utxo with script specified as a buffer', function () { + const scriptBuffer = Buffer.from(P2PKH_INPUT_SCRIPT, 'hex'); + const utxo = { + value: 1000, + script: scriptBuffer, + }; + assert.strictEqual( + inputBytes(utxo), + TX_INPUT_BASE + scriptBuffer.length, + ); + }); + it('inputBytes() throws expected error if script is a string that is not a valid hexadecimal string', function () { + const unrecognizedScriptType = + 'here is a string that is not hexadecimal'; + const utxo = { + value: 1000, + script: unrecognizedScriptType, + }; + + assert.throws(() => { + inputBytes(utxo); + }, Error('Unrecognized script type')); + }); + it('inputBytes() throws expected error if script is specified as an unknown type', function () { + const unrecognizedScriptType = ['one', 'two', 'three']; + const utxo = { + value: 1000, + script: unrecognizedScriptType, + }; + + assert.throws(() => { + inputBytes(utxo); + }, Error('Unrecognized script type')); + }); + it('outputBytes() returns expected estimate for an output without script defined', function () { + const output = { value: 1000 }; + assert.strictEqual( + outputBytes(output), + TX_OUTPUT_BASE + TX_OUTPUT_PUBKEYHASH, + ); + }); + it('outputBytes() returns expected estimate for an output with script specified as a hex string', function () { + const output = { value: 1000, script: OP_RETURN_MAX_SIZE }; + assert.strictEqual( + outputBytes(output), + TX_OUTPUT_BASE + OP_RETURN_MAX_SIZE.length / 2, + ); + }); + it('outputBytes() returns expected estimate for an output with script specified as a buffer', function () { + // Note: While this library uses ArrayBuffer for type checking, it's important to support Buffer inputs + // as existing node and React apps / libraries use this type + const scriptBuffer = Buffer.from(OP_RETURN_MAX_SIZE, 'hex'); + const output = { + value: 1000, + script: scriptBuffer, + }; + assert.strictEqual( + outputBytes(output), + TX_OUTPUT_BASE + scriptBuffer.length, + ); + }); + it('outputBytes() returns expected estimate for an output with script specified as a uint8array', function () { + const scriptBuffer = Buffer.from(OP_RETURN_MAX_SIZE, 'hex'); + + // Convert to uint8array + const scriptArrayBuffer = new ArrayBuffer(scriptBuffer.length); + const scriptUint8Array = new Uint8Array(scriptArrayBuffer); + for (let i = 0; i < scriptUint8Array.length; i += 1) { + scriptUint8Array[i] = scriptBuffer[i]; + } + + const output = { + value: 1000, + script: scriptUint8Array, + }; + assert.strictEqual( + outputBytes(output), + TX_OUTPUT_BASE + scriptUint8Array.length, + ); + }); + it('outputBytes() throws expected error if script is a string that is not a valid hexadecimal string', function () { + const unrecognizedScriptType = + 'here is a string that is not hexadecimal'; + const utxo = { + value: 1000, + script: unrecognizedScriptType, + }; + + assert.throws(() => { + outputBytes(utxo); + }, Error('Unrecognized script type')); + }); + it('outputBytes() throws expected error if script is specified as an unknown type', function () { + const unrecognizedScriptType = ['one', 'two', 'three']; + const utxo = { + value: 1000, + script: unrecognizedScriptType, + }; + + assert.throws(() => { + outputBytes(utxo); + }, Error('Unrecognized script type')); + }); +}); diff --git a/modules/ecash-coinselect/test/coinSelect.test.js b/modules/ecash-coinselect/test/coinSelect.test.js new file mode 100644 --- /dev/null +++ b/modules/ecash-coinselect/test/coinSelect.test.js @@ -0,0 +1,163 @@ +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC +// Distributed under the MIT software license, see the accompanying +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. +'use strict'; +const assert = require('assert'); +const { coinSelect } = require('../src/coinSelect'); + +describe('coinSelect() accumulative algorithm for utxo selection in coinselect.js', async function () { + it('adds a change output if change > dust', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.deepEqual(coinSelect(stubChronikUtxos, [{ value: 1000 }], 1), { + inputs: [{ value: 1000 }, { value: 2000 }], + outputs: [{ value: 1000 }, { value: 1626 }], + fee: 374, + }); + }); + it('does not add a change output if change < dust', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.deepEqual(coinSelect(stubChronikUtxos, [{ value: 550 }], 1), { + inputs: [{ value: 1000 }], + outputs: [{ value: 550 }], + fee: 450, + }); + }); + it('handles a one-input tx with change and no OP_RETURN', function () { + assert.deepEqual( + coinSelect([{ value: '100000' }], [{ value: 10000 }], 1), + { + inputs: [{ value: 100000 }], + outputs: [{ value: 10000 }, { value: 89774 }], + fee: 226, + }, + ); + }); + it('handles eCash max length OP_RETURN in output script', function () { + const OP_RETURN_MAX_SIZE = + '6a04007461624cd75f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f5f5f5f5f5f5f31305f5f323135'; + + const FEE_OF_SAME_TX_WITHOUT_OP_RETURN_OUTPUT_SEE_TEST_ABOVE = 226; + + const TX_OUTPUT_BASE = 8 + 1; + + const expectedFee = + FEE_OF_SAME_TX_WITHOUT_OP_RETURN_OUTPUT_SEE_TEST_ABOVE + + TX_OUTPUT_BASE + + OP_RETURN_MAX_SIZE.length / 2; + + assert.deepEqual( + coinSelect( + [{ value: '100000' }], + [ + { + value: 0, + script: Buffer.from(OP_RETURN_MAX_SIZE, 'hex'), + }, + { value: 10000 }, + ], + 1, + ), + { + inputs: [{ value: 100000 }], + outputs: [ + { + value: 0, + script: Buffer.from(OP_RETURN_MAX_SIZE, 'hex'), + }, + { value: 10000 }, + { value: 89542 }, + ], + fee: expectedFee, + }, + ); + // Also works if script output is a hex string and not a Buffer + assert.deepEqual( + coinSelect( + [{ value: '100000' }], + [ + { + value: 0, + script: OP_RETURN_MAX_SIZE, + }, + { value: 10000 }, + ], + 1, + ), + { + inputs: [{ value: 100000 }], + outputs: [ + { + value: 0, + script: OP_RETURN_MAX_SIZE, + }, + { value: 10000 }, + { value: 89542 }, + ], + fee: expectedFee, + }, + ); + }); + it('adds a change output if change > dust', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.deepEqual(coinSelect(stubChronikUtxos, [{ value: 1000 }], 1), { + inputs: [{ value: 1000 }, { value: 2000 }], + outputs: [{ value: 1000 }, { value: 1626 }], + fee: 374, + }); + }); + it('throws expected error if called with feeRate < 1', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.throws(() => { + coinSelect(stubChronikUtxos, [{ value: 500 }], 0.99); + }, Error('feeRate must be a number >= 1')); + }); + it('throws expected error if targetOutputs sum to dust', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.throws(() => { + coinSelect(stubChronikUtxos, [{ value: 500 }], 1); + }, Error('Transaction must send more than dust threshold of 546 satoshis')); + }); + it('throws expected error if sum(targetOutputs) < sum(inputs) + fee', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + assert.throws(() => { + coinSelect(stubChronikUtxos, [{ value: 5600 }], 1); + }, Error('Insufficient funds')); + }); + it('ignores slp utxos', function () { + // Make all utxos slp utxos + const stubChronikUtxos = [ + { value: '1000', slpToken: {} }, + { value: '2000', slpToken: {} }, + { value: '3000', slpToken: {} }, + ]; + // The wallet now has insufficient funds to send an eCash tx, as slp utxos will be ignored + assert.throws(() => { + coinSelect(stubChronikUtxos, [{ value: 900 }], 1); + }, Error('Insufficient funds')); + }); +}); diff --git a/modules/ecash-coinselect/test/utils.test.js b/modules/ecash-coinselect/test/utils.test.js new file mode 100644 --- /dev/null +++ b/modules/ecash-coinselect/test/utils.test.js @@ -0,0 +1,47 @@ +// Copyright (c) 2018 Daniel Cousens +// Copyright (c) 2023 Bitcoin ABC +// Distributed under the MIT software license, see the accompanying +// file LICENSE or http://www.opensource.org/licenses/mit-license.php. +'use strict'; +const assert = require('assert'); +const { sumValues, isToken } = require('../src/utils'); + +describe('ecash-coinselect utils.js functions', async function () { + it('sumValues() returns total value of an array of stub utxos with value as string type as a number', function () { + const stubUtxos = [ + { value: '100' }, + { value: '200' }, + { value: '300' }, + ]; + assert.strictEqual(sumValues(stubUtxos), 600); + }); + it('sumValues() returns total value of an array of stub utxos with value as number type as a number', function () { + const stubUtxos = [{ value: 100 }, { value: 200 }, { value: 300 }]; + assert.strictEqual(sumValues(stubUtxos), 600); + }); + it('sumValues() throws an error if provided with unsupported input objects', function () { + // no 'value' key + const badKeyUtxos = [{ valu: '100' }, { valu: '200' }, { valu: '300' }]; + assert.throws(() => { + sumValues(badKeyUtxos); + }, new assert.AssertionError({ message: `Input must be an object with 'value' as a key and an integer representing the amount in satoshis as a value`, actual: false, expected: true, operator: '==' })); + + // value is infinity (number) + const badValueUtxosNumber = [{ value: Number.POSITIVE_INFINITY }]; + assert.throws(() => { + sumValues(badValueUtxosNumber); + }, new assert.AssertionError({ message: `Input must be an object with 'value' as a key and an integer representing the amount in satoshis as a value`, actual: false, expected: true, operator: '==' })); + + // value is infinity (string) + const badValueUtxosString = [{ value: 'infinity' }]; + assert.throws(() => { + sumValues(badValueUtxosString); + }, new assert.AssertionError({ message: `Input must be an object with 'value' as a key and an integer representing the amount in satoshis as a value`, actual: false, expected: true, operator: '==' })); + }); + it(`isToken() returns true for a stub SLP utxo, i.e. an object with key 'slpToken'`, function () { + assert.strictEqual(isToken({ slpToken: {} }), true); + }); + it(`isToken() returns false for a stub non-SLP utxo, i.e. an object without key 'slpToken'`, function () { + assert.strictEqual(isToken({}), false); + }); +}); diff --git a/modules/ecash-coinselect/test/utxo.test.js b/modules/ecash-coinselect/test/utxo.test.js deleted file mode 100644 --- a/modules/ecash-coinselect/test/utxo.test.js +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright (c) 2023 The Bitcoin developers -// Distributed under the MIT software license, see the accompanying -// file COPYING or http://www.opensource.org/licenses/mit-license.php. -'use strict'; -const assert = require('assert'); -const { - getInputUtxos, - parseChronikUtxos, - calcP2pkhByteCount, -} = require('../src/utxo'); -const { - chronikUtxos, - parsedChronikUtxos, - chronikUtxosSlpOnly, - chronikUtxosXecOnly, - parsedChronikUtxosSlpOnly, - parsedChronikUtxosXecOnly, -} = require('../mocks/mockChronikUtxos'); - -/** - * Validates whether rawTxHex's byte size is within the acceptable range of byteCount - * - * `calcP2pkhByteCount` uses the max possible size approach. In this logic the signature - * is set to 72 bytes in the input size. Since it is highly unlikely to get a signature - * that is not 71 or 72 bytes, combined with the fact the sighash byte is separately - * accounted for, then the actual byte size can only be over-evaluated by 1 byte per input. - * Therefore the range used below caters for the maximum delta based on inputCount * 1 byte. - */ -function isWithinByteCountRange(rawTxHex, byteCount, inputCount) { - const rawTxHexBytes = rawTxHex.length / 2; - const isWithinValidRange = - // Validate rawTxHexBytes is within the maximum range from byteCount - rawTxHexBytes >= byteCount - inputCount && - // Validate the byteCount is always the maximum size - rawTxHexBytes <= byteCount; - - // Note: can't return on the above boolean evaluations directly otherwise - // the '&&' operator will cause JS to return the inherent undefined value - return isWithinValidRange; -} - -it('parseChronikUtxos() correctly returns an empty parsed object for an empty utxo set', function () { - const result = parseChronikUtxos([]); - assert.deepEqual(result, { - xecUtxos: [], - slpUtxos: [], - }); -}); - -it('parseChronikUtxos() correctly returns a parsed object for a mixed XEC/SLP utxo set', function () { - const result = parseChronikUtxos(chronikUtxos); - assert.deepEqual(result, parsedChronikUtxos); -}); - -it('parseChronikUtxos() correctly returns a parsed object for an XEC only utxo set', function () { - const result = parseChronikUtxos(chronikUtxosXecOnly); - assert.deepEqual(result, parsedChronikUtxosXecOnly); -}); - -it('parseChronikUtxos() correctly returns a parsed object for an SLP only utxo set', function () { - const result = parseChronikUtxos(chronikUtxosSlpOnly); - assert.deepEqual(result, parsedChronikUtxosSlpOnly); -}); - -it('calcP2pkhByteCount() returns a correct byte count for a p2pkh tx with 1 input and 2 outputs', function () { - const inputCount = 1; - const outputCount = 2; - const rawTxHex = - '0200000001076682ecbf1a38e08cd773d3da3c87cbfa30c296bf1a85edd791720f895ed2f3010000006b483045022100a4b774d4734df0909f73470dadc2f3c7edd179ee35ed1f42dcd05b60cf76efcb022042011b283795ebb2e8b27855404e6ef5ba0bdcc00ef598c0536357a92ade86a44121027388cc87347171e7dbd714ce6a06e74235b181a7e4e0700042cf0546d7717d7effffffff0288130000000000001976a9148dcf6103a371e2c7216cff3b0243c13f5cf63a5a88ac7327c905000000001976a914a46b94c091f0569a61a00a48e16beafbd4084b8f88ac00000000'; - const byteCount = calcP2pkhByteCount(inputCount, outputCount); - assert.equal(isWithinByteCountRange(rawTxHex, byteCount, inputCount), true); -}); - -it('calcP2pkhByteCount() returns a correct byte count for a p2pkh tx with 20 inputs and 1 output', function () { - const inputCount = 20; - const outputCount = 1; - const rawTxHex = - '0100000014326e656ce70db23365cfdb2515ea07cf9aed43db796a701ed3395f3bfc33d11a320000006a47304402201f6c45459ae029db63cb9758652179771662a9a03e6e271153f016396e35c5e402205cc5f5f8d48a74a008ed73697f262ae864a1365f251aca4d3915196d6d0b51ae4121027d13e055c8310b474d11857ac598333422e2615e0520801757f252b1927abf73ffffffff1405e211865256970b175182faa7bb4720f04e8257f19bc1870af53cc8fba0a9000000006b4830450221008a3d4d1ac9c6b493e79153fb1b36d776a605922b3d331eb51a1eb0f3c0c44203022045afe2b894943ef78fd77021e97dd40e00cf70257072cfd86f9a64af19c4e0664121031ca581d5f01efbd1126ab5ee190b3f01a6f93516e8a0b3371afe3ef1e56405f0ffffffff9299b5b68a3bf0745812a295e864bbc0a93e9148e122204eb4341fec49d60b36000000006b483045022100fd42a70e9eccc35412aa036796e8693d5a760228090e6c9f279cb7341010f2c902206dbd4ade763a4c9c44597e7b440460f1c6903e0eae941c51b6bb1df6d75a05e64121031ca581d5f01efbd1126ab5ee190b3f01a6f93516e8a0b3371afe3ef1e56405f0ffffffffbdc12910d02670f84da5396371ec9c5fd00901022faaba2faeccc0e28609298d000000006b483045022100f34b5b428d0a127c37d9c8ddd0969c43e96727d799623614b1262a9bcc113a53022006b6ab607fbe8ec347eb1355cc94c994212122b138a60fb513fc1aeb6f0899b54121031ca581d5f01efbd1126ab5ee190b3f01a6f93516e8a0b3371afe3ef1e56405f0ffffffff1ec3abe18b5c150e301071768cfbabc07417305f6db4865a1cab85a3ddd42324010000006b483045022100c2c28a52bc5981d15b6e658f9d61bd0da2dd5a557df19494541d6e6e33e144e102202e42c362895e3896bb2cb4983cc81a5bb401bd123a092c2706aa133a256cb6984121031ca581d5f01efbd1126ab5ee190b3f01a6f93516e8a0b3371afe3ef1e56405f0ffffffff9f8ff404da7782a19bc4ac8c352a97046242ee0e4ab0f09e32559297c17d948d000000006a47304402203e613dc7589cca4c24c9ec057aed0c09de9aa67af219f076f88c03e4bf466bbd022000ebfca4217db06a86b38c48bd9435dedabb327ff2460e9ea0fbba2661bfe55f4121039ecd017789c3e82e0c3c547a2a500b16a7d59585b7ba7f8b5b4dfc49a43592d7ffffffff93cb6cc69eb8f542b7b9ef8bbb6f8b588c5426a28d4b541a144c0a19974c1aee080000006a473044022079094e7ab38d8ee3a47ad15b6f59c4c0d7b04bb69ff646c3b0fb45e9a12a4cfd02204196a68d5678f99fd2be77fb6bbde5f7ab7315a3b09cb7b344897370556d6d4f4121026c1885f2822f1fd58d9c58ecbc504531af72c62944f7d34a582660da38e45837ffffffff326e656ce70db23365cfdb2515ea07cf9aed43db796a701ed3395f3bfc33d11a390000006b483045022100cc64789815bc7258c44de15e1a6e0d827a941e87e4caed9f132747936800a1a802202f340793570627ae1201d9f26802c84f933a954e7e0ea95b609ee8ca1fbc88b2412103d145942fe060c1b9d6a6be86cf47bf0256d690bfcd85305632b9798eb776ce14ffffffff326e656ce70db23365cfdb2515ea07cf9aed43db796a701ed3395f3bfc33d11a1c0000006a4730440220365423e46f7910230aaa3351b17d8e551588fa1c4a707c9669b868be328cfd9802205eb1eb8b66d9280fcb0821aa286741d2c9d5aee9a928eeffb4890f860771ff90412102e2e40b93c683d6d3f66e991d25836d6e5fbfaacd94b43354ef8cb2a00a1f09beffffffff6282ec04a480da2b90edaa36af8926a9a4cec690dd862afe42fafcc0aa27bc70000000006a47304402202ef95b58e68ca9fc47a0ca1ded4bfe6412f9053f7ba5d0e0ed9cb6bb7602c60902207db5c583177658082e2d4ebd1839a23e55e396a1fd9f4e9e256df1f42fe9b415412103b661e06fb535c47b489118aeb0b7b72238a8c70fb75635e1491c3e314798ec4dffffffffd9d6a44b288ecdb4474038b89818ae6dd640956a3c014209acff2d4ea086ce88000000006b483045022100895ae20735035b97b27d1dacce207ed067a755fa7d7cdc870aefdf0736f84fc3022021235b27682a59d2b5a791482934f7a14f701aa5449097793c57dec9b10d40ea412103b661e06fb535c47b489118aeb0b7b72238a8c70fb75635e1491c3e314798ec4dffffffff3a50b453c5bff1ffc113a0ed55888ed467232ac2ac93116f5a1d507ea5309fed000000006a4730440220461cd20f8b68cbc54d88c5207a04bf92cd6e553a86ee1adde3668dbae33f4ab402200703cba4134b7dda4f61c818426dafb0172d70b6d480f9a7d3a3bd6c862e914c412103b661e06fb535c47b489118aeb0b7b72238a8c70fb75635e1491c3e314798ec4dffffffff326e656ce70db23365cfdb2515ea07cf9aed43db796a701ed3395f3bfc33d11a210000006a47304402205215adfdccb38ea7de504e9c44d6e838c3b72b5fcf0e11ea1e9b714740775fac02200f97bbd3941fe7faf5c4b4b2048d59cc10a1043622de4c9bcdcbc9ea3cfbcfac412102dc202f4cbc6a40d6f82ae40d2839fcda280196d1e8f01aa7b2bd1ed9f8bad076ffffffffd71b8e35db2e41454347b3a725650b4dd1704900a8cfb43e6fe3ae5dca95edba000000006b483045022100913acf757b3cd919c114c2a59586c7900267dbd018f38edc9b15a851ff010407022027a4a83fa55638d8027859e7102a4ab9065388f1f928e66e7aef44a00f821d274121037e7e1d2236bb20e2b34750e9702e886828a90c9b48503c74ac28db78b8b0ac3fffffffffa39b8668bf5492763b75c5fe769844b9f42e5427d9fe3d02f9ce2d8ef71263a5000000006a47304402206dba5a127c3cba2ed6ba390dbeef1852758319ea22ef7cb1017db054e84aaada02206e1ecbb1bee0708a3e48863c7bf1060230c9d6d746dbf63aea18def4bee85f864121037e7e1d2236bb20e2b34750e9702e886828a90c9b48503c74ac28db78b8b0ac3fffffffff326e656ce70db23365cfdb2515ea07cf9aed43db796a701ed3395f3bfc33d11a0e0000006a473044022040c86dc497409763cba953578fa45e4e87e1ed89e93e5667cfba83d6372c0b2d022042d19cb9749619594469182901bf295a538de9db959696ed774e4d9264b3ed40412103bab7775c3dd312457431be187763f285fadd6f87914e1158a203017f8523cca0ffffffff326e656ce70db23365cfdb2515ea07cf9aed43db796a701ed3395f3bfc33d11a000000006a47304402204fb28c73ab3f5b42384de91344e6fcad273190bb3f089793d8db1fafe35d393402201a1cae1a8ae628518273d0f45d05a78d27bd4111c483aff8ef1b7418106b311f4121037c065074b60e5f4bab2716ee51efe29aabea3aa835812247706a5cdd3607ee3affffffff326e656ce70db23365cfdb2515ea07cf9aed43db796a701ed3395f3bfc33d11a2d0000006b483045022100e24fe798f65adcd6a55abfffdcab403830d19ad4d327a9667738986bd1a1ee1c02201d64d19dde5b1149b25032e949517df26ae4fe330cbf9d8a713424b0c8109c86412102cf089e85b13e39fad2d1c809a310fc35c7e0aefa33b7280039d14beb4983d683ffffffffe851840960a106acd053ba8690dfad8fc3c4466114fb79fe52ee06816cf866bc000000006b483045022100b21821646ccb57dbb012a826639169ff02ee9eca5bc8402d697a49721aec7b5a0220079fd92a06823592e4b9d84f2133f58f12bafb0a65e13e2e589553b990b7144e412102ae4ec8fd5df90d3dee60572916801d51d9ba440dcbd51ac099222fedc05cfbf1ffffffff93cb6cc69eb8f542b7b9ef8bbb6f8b588c5426a28d4b541a144c0a19974c1aee040000006a4730440220214e1d1164c78bd3076efba801c58c4ca42de47bb0496f38c507eb145353262f022076560537dd0775fda0ff74d3a0fe1cae796569eb630dabb75797951625f6384b412103ed5fabeb20086066fd91917bdfb2c1f348051b4f3e22f63d5fbe442e73b065d7ffffffff01e4392c82120000001976a914231f7087937684790d1049294f3aef9cfb7b05dd88ac00000000'; - const rawTxHexBytes = rawTxHex.length / 2; - const byteCount = calcP2pkhByteCount(inputCount, outputCount); - assert.equal(isWithinByteCountRange(rawTxHex, byteCount, inputCount), true); -}); - -it('calcP2pkhByteCount() returns a correct byte count for a p2pkh tx with 6 inputs and 4 outputs', function () { - const inputCount = 6; - const outputCount = 4; - const rawTxHex = - '010000000609bd22e3fae25a049a374bb6d1888bb2ea79e2ee4ebac511922d1d7513cc6158020000006441c5b9040987521876f381d2897598de8002a444502b1a47da913f6b06e840b0d3a1ae1d67b1d7d447508afa53bd083cceb5d9e1d2b336c31f4a10dc649e879a8cc121023eaeda390915c1c68c5c372b84ae6ebfd2b13235a020b4bfbf200614d5ad1a4dffffffff719522dbe8786c20379a798be10289af848b281ee2b2c5ec0221ce26eaa8c7ac010000006441aaff0d67934ce2c462971b35acbdf5a03b40eeaded7a559cd76fe3520deaf58707f43a5f8ffd356da3aba7f235f6ce89b82eeb3e125e827259220ccd125db30dc121023eaeda390915c1c68c5c372b84ae6ebfd2b13235a020b4bfbf200614d5ad1a4dffffffff6bba80d653823b255a70e7564c4c49a2463748eb47dcc2ac4af441590d8f49211d00000064414f88a0fe1493c3a604f7411a34518dfaa7b862aa954b00049ac41eeac9ca76cf27f35403cae8de335e9ca99a2a7648af0f6d449570b3d8da8a4a1b3f38889fad412102f49a7fd4e0c6cea6401aed57b76b2fb358e1ebbb65fc5782e3c2165c9e850b31ffffffff6bba80d653823b255a70e7564c4c49a2463748eb47dcc2ac4af441590d8f49211e00000064416c0dcb69746bf4b7d987bea83b0def0cb780a5c4d6cf54e8a842fc0d2f764694d7f5ba659cfa42853a4da42d28ad9448cac4c303cddd9b30e5856f15837a4ed7412102f49a7fd4e0c6cea6401aed57b76b2fb358e1ebbb65fc5782e3c2165c9e850b31ffffffff6bba80d653823b255a70e7564c4c49a2463748eb47dcc2ac4af441590d8f49211f000000644190fb8d19235d4ad4ec222a86d69512e4c4a085e5a4e9fafb5cd2bf15c88e09204bb8a7d88caf7b961c77ec9d81ae97e5ab134359fef3fc5302dee6ff76c5f3c7412102f49a7fd4e0c6cea6401aed57b76b2fb358e1ebbb65fc5782e3c2165c9e850b31ffffffff6bba80d653823b255a70e7564c4c49a2463748eb47dcc2ac4af441590d8f492120000000644148c7171db8d7e8a615b2ee298d31cc87565286d14a1ce5f79ae544a7e2c34fd5848a3ad349976b1bbabfa112199ea93c01b66f2c58d66b35c2a8cc60935d2c4e412102f49a7fd4e0c6cea6401aed57b76b2fb358e1ebbb65fc5782e3c2165c9e850b31ffffffff040000000000000000406a503d534c5032000453454e4445e1f25de444e399b6d46fa66e3424c04549a85a14b12bc9a4ddc9cdcdcdcdcd03400600000000e0470000000000000000000022020000000000001976a914dee50f576362377dd2f031453c0bb09009acaf8188aca80700000000000017a914598df6ad1783bf1bb16bd16ca2d4c899ab637b408722020000000000001976a91436ca3d0fe6bba7b7deae86967546ec1b745aace388ac00000000'; - const rawTxHexBytes = rawTxHex.length / 2; - const byteCount = calcP2pkhByteCount(inputCount, outputCount); - assert.equal(isWithinByteCountRange(rawTxHex, byteCount, inputCount), true); -}); - -it('getInputUtxos() correctly collects enough XEC utxos for a single element outputArray', function () { - const outputArray = [ - { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 900, // 9 XEC - }, - ]; - const result = getInputUtxos(chronikUtxos, outputArray); - - // The first XEC utxo in `chronikUtxos` is 900 sats, hence result should contain this 900 sat - // utxo plus one other XEC utxo to cover fees - const expectedResult = { - inputs: [ - { - outpoint: { - txid: '1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994', - outIdx: 0, - }, - blockHeight: 799480, - isCoinbase: false, - value: '900', - network: 'XEC', - }, - { - outpoint: { - txid: '1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994', - outIdx: 1, - }, - blockHeight: 799480, - isCoinbase: false, - value: '38052', - network: 'XEC', - }, - ], - changeAmount: 37678, - txFee: 374, - }; - assert.deepEqual(result, expectedResult); -}); - -it('getInputUtxos() correctly collects enough XEC utxos where a change output is not required', function () { - const outputArray = [ - { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 590, // 5.9 XEC - }, - ]; - const result = getInputUtxos(chronikUtxos, outputArray); - - // The first XEC utxo in `chronikUtxos` is 900 sats, which covers the send amount and tx fee without needing a change output - const expectedResult = { - inputs: [ - { - outpoint: { - txid: '1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994', - outIdx: 0, - }, - blockHeight: 799480, - isCoinbase: false, - value: '900', - network: 'XEC', - }, - ], - changeAmount: 0, - txFee: 192, - }; - assert.deepEqual(result, expectedResult); -}); - -it('getInputUtxos() correctly collects enough XEC utxos for a multi-element outputArray', function () { - const outputArray = [ - { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 900, // 9 XEC - }, - { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 9000, // 90 XEC - }, - { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 5000, // 50 XEC - }, - ]; - const result = getInputUtxos(chronikUtxos, outputArray); - - // The first two XEC utxo (900+38052) in `chronikUtxos` will cover the total 14900 sats send value and fee - const expectedResult = { - inputs: [ - { - outpoint: { - txid: '1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994', - outIdx: 0, - }, - blockHeight: 799480, - isCoinbase: false, - value: '900', - network: 'XEC', - }, - { - outpoint: { - txid: '1b59feeb756e59c8df26af0f636dfb7c6fd466743539617cee49d60ffda02994', - outIdx: 1, - }, - blockHeight: 799480, - isCoinbase: false, - value: '38052', - network: 'XEC', - }, - ], - changeAmount: 23610, - txFee: 442, - }; - assert.deepEqual(result, expectedResult); -}); - -it('getInputUtxos() correctly throws an error if there are not enough XEC utxos for a given send amount in satoshis', function () { - const outputArray = [ - { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 50000000000, // 500m XEC - }, - ]; - assert.rejects( - () => { - getInputUtxos(chronikUtxos, outputArray); - }, - { - name: 'Error', - message: 'Insufficient balance', - }, - ); -}); - -it('getInputUtxos() correctly throws an error if a non array outputArray is supplied', function () { - const outputArray = { - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 50000000000, // 500m XEC - }; - assert.rejects( - () => { - getInputUtxos(chronikUtxos, outputArray); - }, - { - name: 'Error', - message: 'Invalid output supplied', - }, - ); -}); - -it('getInputUtxos() correctly throws an error if a non-p2pkh address is in outputArray', function () { - const outputArray = [ - { - // p2pkh address - address: 'ecash:qq9h6d0a5q65fgywv4ry64x04ep906mdku8f0gxfgx', - value: 900, // 9 XEC - }, - { - // p2sh address - address: 'ecash:pzwgdlqrf0g45yy20exkeyevuhjp9hjvksa4n0wakr', - value: 9000, // 90 XEC - }, - ]; - assert.rejects( - () => { - getInputUtxos(chronikUtxos, outputArray); - }, - { - name: 'Error', - message: `${outputArray[1].address} is not a p2pkh address (only supported type for now)`, - }, - ); -});