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 @@ -45,4 +45,8 @@ - Add support for utxo format from in-node chronik-client [diff](https://reviews.bitcoinabc.org/D15518) +2.1.0 + +- Support input param `tokenInputs` to create txs with user-specified inputs [diff](https://reviews.bitcoinabc.org/D15520) + ## License [MIT](LICENSE) 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 @@ -1,12 +1,12 @@ { "name": "ecash-coinselect", - "version": "2.0.4", + "version": "2.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ecash-coinselect", - "version": "2.0.4", + "version": "2.1.0", "license": "MIT", "dependencies": { "eslint-plugin-header": "^3.1.1", 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.0.4", + "version": "2.1.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 @@ -33,15 +33,16 @@ * @param {array} utxos [...{value: }] * @param {array} targetOutputs [...{address:
, value: = 1'); } // Initialize tx bytecount with bytecount of the target outputs - let bytesAccum = transactionBytes([], targetOutputs); + let bytesAccum = transactionBytes(tokenInputs, targetOutputs); let outAccum = sumValues(targetOutputs); @@ -51,10 +52,19 @@ ); } - let inAccum = 0; let inputs = []; + // Note for SLP v1, the token inputs can be at any index + // https://github.com/simpleledger/slp-specifications/blob/master/slp-token-type-1.md#send---spend-transaction + inputs = inputs.concat(tokenInputs); + // Make sure all value keys are number and not string + inputs.forEach(input => { + input.value = parseInt(input.value); + }); + let inAccum = inputs + .map(tokenUtxo => parseInt(tokenUtxo.value)) + .reduce((prev, curr) => prev + curr, 0); for (let utxo of utxos) { - // Do not use any slp utxos + // Do not use any slp utxos not specified by tokenUtxos input param if (isToken(utxo)) { continue; } 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 @@ -6,6 +6,15 @@ const assert = require('assert'); const { coinSelect } = require('../src/coinSelect'); +const MOCK_TOKEN_UTXO = { value: '546' }; +const MOCK_TOKEN_SEND_OUTPUT = { + value: 0, + script: Buffer.from( + '6a04534c500001010453454e4420111111111111111111111111111111111111111111111111111111111111111108000000000bebc200080000000002faf080', + 'hex', + ), +}; + describe('coinSelect() accumulative algorithm for utxo selection in coinselect.js', async function () { it('adds a change output if change > dust', function () { const stubChronikUtxos = [ @@ -13,11 +22,174 @@ { value: '2000' }, { value: '3000' }, ]; - assert.deepEqual(coinSelect(stubChronikUtxos, [{ value: 1000 }], 1), { + + const result = coinSelect(stubChronikUtxos, [{ value: 1000 }], 1); + assert.deepEqual(result, { inputs: [{ value: 1000 }, { value: 2000 }], outputs: [{ value: 1000 }, { value: 1626 }], fee: 374, }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } + }); + it('Handles an slpv1 token send tx with token change and eCash change', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + + // Assume we have 3 input utxos + const tokenInputs = [MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO]; + + // Target outputs for a typical slpv1 send with change + // These would be calculated by an app dev since the script depends on token change + const targetOutputs = [ + MOCK_TOKEN_SEND_OUTPUT, + MOCK_TOKEN_UTXO, + MOCK_TOKEN_UTXO, + { value: 1000 }, + ]; + const result = coinSelect( + stubChronikUtxos, + targetOutputs, + 1, + tokenInputs, + ); + assert.deepEqual(result, { + inputs: tokenInputs.concat([{ value: 1000 }, { value: 2000 }]), + outputs: targetOutputs.concat([{ value: 1587 }]), + fee: 959, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } + }); + it('Handles an slpv1 token send tx with token change and no eCash change', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + + // Assume we have 3 input utxos + const tokenInputs = [MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO]; + + // Target outputs for a typical slpv1 send with change + // These would be calculated by an app dev since the script depends on token change + const targetOutputs = [ + MOCK_TOKEN_SEND_OUTPUT, + MOCK_TOKEN_UTXO, + MOCK_TOKEN_UTXO, + { value: 5473 }, + ]; + + const result = coinSelect( + stubChronikUtxos, + targetOutputs, + 1, + tokenInputs, + ); + assert.deepEqual(result, { + inputs: tokenInputs.concat([ + { value: 1000 }, + { value: 2000 }, + { value: 3000 }, + ]), + outputs: targetOutputs, + fee: 1073, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } + }); + it('Handles an slpv1 token send tx with no token change and eCash change', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + + // Assume we have 3 input utxos + const tokenInputs = [MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO]; + + // Target outputs for a typical slpv1 send with no change, i.e. only 1 output + // These would be calculated by an app dev since the script depends on token change + const targetOutputs = [ + MOCK_TOKEN_SEND_OUTPUT, + MOCK_TOKEN_UTXO, + { value: 1500 }, + ]; + + const result = coinSelect( + stubChronikUtxos, + targetOutputs, + 1, + tokenInputs, + ); + + assert.deepEqual(result, { + inputs: tokenInputs.concat([{ value: 1000 }, { value: 2000 }]), + outputs: targetOutputs.concat([{ value: 1667 }]), + fee: 925, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } + }); + it('Handles an slpv1 token send tx with no token change and no eCash change', function () { + const stubChronikUtxos = [ + { value: '1000' }, + { value: '2000' }, + { value: '3000' }, + ]; + + // Assume we have 3 input utxos + const tokenInputs = [MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO, MOCK_TOKEN_UTXO]; + + // Target outputs for a typical slpv1 send with no change, i.e. only 1 output + // These would be calculated by an app dev since the script depends on token change + const targetOutputs = [ + MOCK_TOKEN_SEND_OUTPUT, + MOCK_TOKEN_UTXO, + { value: 6053 }, + ]; + + const result = coinSelect( + stubChronikUtxos, + targetOutputs, + 1, + tokenInputs, + ); + + assert.deepEqual(result, { + inputs: tokenInputs.concat([ + { value: 1000 }, + { value: 2000 }, + { value: 3000 }, + ]), + outputs: targetOutputs, + fee: 1039, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } }); it('Does not include decimals in a change output', function () { const stubChronikUtxos = [ @@ -26,18 +198,28 @@ }, ]; const mockFeeRate = 2.01; - assert.deepEqual( - coinSelect(stubChronikUtxos, [{ value: 11000 }], mockFeeRate), - { - inputs: [ - { - value: 206191611, - }, - ], - outputs: [{ value: 11000 }, { value: 206180156 }], - fee: 455, - }, + + const result = coinSelect( + stubChronikUtxos, + [{ value: 11000 }], + mockFeeRate, ); + + assert.deepEqual(result, { + inputs: [ + { + value: 206191611, + }, + ], + outputs: [{ value: 11000 }, { value: 206180156 }], + fee: 455, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } }); it('does not add a change output if change < dust', function () { const stubChronikUtxos = [ @@ -45,21 +227,35 @@ { value: '2000' }, { value: '3000' }, ]; - assert.deepEqual(coinSelect(stubChronikUtxos, [{ value: 550 }], 1), { + + const result = coinSelect(stubChronikUtxos, [{ value: 550 }], 1); + + assert.deepEqual(result, { inputs: [{ value: 1000 }], outputs: [{ value: 550 }], fee: 450, }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } }); 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, - }, - ); + const result = coinSelect([{ value: '100000' }], [{ value: 10000 }], 1); + + assert.deepEqual(result, { + inputs: [{ value: 100000 }], + outputs: [{ value: 10000 }, { value: 89774 }], + fee: 226, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } }); it('handles eCash max length OP_RETURN in output script', function () { const OP_RETURN_MAX_SIZE = @@ -74,57 +270,67 @@ 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, - }, + const result = coinSelect( + [{ value: '100000' }], + [ + { + value: 0, + script: Buffer.from(OP_RETURN_MAX_SIZE, 'hex'), + }, + { value: 10000 }, + ], + 1, ); - // 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, - }, + + assert.deepEqual(result, { + inputs: [{ value: 100000 }], + outputs: [ + { + value: 0, + script: Buffer.from(OP_RETURN_MAX_SIZE, 'hex'), + }, + { value: 10000 }, + { value: 89542 }, + ], + fee: expectedFee, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } + + const resultHex = coinSelect( + [{ value: '100000' }], + [ + { + value: 0, + script: OP_RETURN_MAX_SIZE, + }, + { value: 10000 }, + ], + 1, ); + // Also works if script output is a hex string and not a Buffer + assert.deepEqual(resultHex, { + inputs: [{ value: 100000 }], + outputs: [ + { + value: 0, + script: OP_RETURN_MAX_SIZE, + }, + { value: 10000 }, + { value: 89542 }, + ], + fee: expectedFee, + }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of resultHex.inputs) { + assert.equal(typeof input.value === 'number', true); + } }); it('adds a change output if change > dust', function () { const stubChronikUtxos = [ @@ -132,11 +338,20 @@ { value: '2000' }, { value: '3000' }, ]; - assert.deepEqual(coinSelect(stubChronikUtxos, [{ value: 1000 }], 1), { + + const result = coinSelect(stubChronikUtxos, [{ value: 1000 }], 1); + + assert.deepEqual(result, { inputs: [{ value: 1000 }, { value: 2000 }], outputs: [{ value: 1000 }, { value: 1626 }], fee: 374, }); + + // Inputs all have a 'number' at value key + // TxBuilder can only sign if this is the case + for (const input of result.inputs) { + assert.equal(typeof input.value === 'number', true); + } }); it('throws expected error if called with feeRate < 1', function () { const stubChronikUtxos = [