diff --git a/karma.conf.js b/karma.conf.js index 88151995f..c13c64724 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -8,6 +8,11 @@ module.exports = (config) => { './index.js': ['webpack'], './test.spec.js': ['webpack'], }, + client: { + mocha: { + timeout: 60000 + } + }, webpack: { resolve: { fallback: { diff --git a/lib/transaction/transaction.js b/lib/transaction/transaction.js index 231af0ef4..cb204913b 100644 --- a/lib/transaction/transaction.js +++ b/lib/transaction/transaction.js @@ -102,6 +102,8 @@ Transaction.FEE_PER_KB = 1000; Transaction.CHANGE_OUTPUT_MAX_SIZE = 20 + 4 + 34 + 4; Transaction.MAXIMUM_EXTRA_SIZE = 4 + 9 + 9 + 4; +Transaction.MINIMUM_FEE = 255; + Transaction.TYPES = registeredTransactionTypes; Transaction.CURRENT_VERSION = CURRENT_VERSION; @@ -832,6 +834,21 @@ Transaction.prototype.feePerKb = function (amount) { return this; }; +/** + * Manually set the minimum fee's for this transaction. Beware that this resets all the signatures + * for inputs (in further versions, SIGHASH_SINGLE or SIGHASH_NONE signatures will not + * be reset). + * + * @param {number} amount satoshis per KB to be sent + * @return {Transaction} this, for chaining + */ +Transaction.prototype.minimumFee = function (amount) { + $.checkArgument(_.isNumber(amount), 'amount must be a number'); + this._minimumFee = amount; + this._updateChangeOutput(); + return this; +}; + /* Output management */ /** @@ -1046,7 +1063,7 @@ Transaction.prototype.getFee = function () { Transaction.prototype._estimateFee = function () { var estimatedSize = this._estimateSize(); var available = this._getUnspentValue(); - return Transaction._estimateFee(estimatedSize, available, this._feePerKb); + return Transaction._estimateFee(estimatedSize, available, this._feePerKb, this._minimumFee); }; Transaction.prototype._getUnspentValue = function () { @@ -1059,23 +1076,30 @@ Transaction.prototype._clearSignatures = function () { }); }; -Transaction._estimateFee = function (size, amountAvailable, feePerKb) { - var fee = Math.ceil(size / 1000) * (feePerKb || Transaction.FEE_PER_KB); - if (amountAvailable > fee) { - size += Transaction.CHANGE_OUTPUT_MAX_SIZE; +Transaction._estimateFee = function (size, amountAvailable, feePerKb, minimumFee) { + var feePerByte = (feePerKb || Transaction.FEE_PER_KB) / 1000; + var fee = size * feePerByte; + if(fee<(minimumFee || Transaction.MINIMUM_FEE)){ + return (minimumFee || Transaction.MINIMUM_FEE) + } + if (amountAvailable > fee + minimumFee) { + fee += (minimumFee || Transaction.MINIMUM_FEE); } - return Math.ceil(size / 1000) * (feePerKb || Transaction.FEE_PER_KB); + return fee; }; Transaction.prototype._estimateSize = function () { - var result = Transaction.MAXIMUM_EXTRA_SIZE; - _.each(this.inputs, function (input) { - result += input._estimateSize(); - }); - _.each(this.outputs, function (output) { - result += output.script.toBuffer().length + 9; - }); - return result; + // string format HEX -> 2 symbols == 1 byte + var serializedSize = this.toString().length / 2; + if ( !this.isFullySigned() ) { + // every signed input contains some additional bytes + serializedSize += this.inputs.length * PublicKeyHashInput.SCRIPT_MAX_SIZE; + } + if ( this._missingChange() ) { + serializedSize += Transaction.CHANGE_OUTPUT_MAX_SIZE; + } + + return serializedSize; }; Transaction.prototype._removeOutput = function (index) { diff --git a/test/data/tx_creation.json b/test/data/tx_creation.json index 3f34cceb3..8ba548ec6 100644 --- a/test/data/tx_creation.json +++ b/test/data/tx_creation.json @@ -182,6 +182,6 @@ "sign", ["XJoAwsbx9EqCp7PTMAWbhQsjFHDuQEaEJwcNS96F3ZKuPM4y8ntu"], "serialize", - "010000000220c24f763536edb05ce8df2a4816d971be4f20b58451d71589db434aca98bfaf00000000fdfd000048304502210088ddd40f1b58603b4ee75d21226528426ee4d9c9e88b960bf80081dcdf1a9d5a02202f368ce8bd9c66b9ec17f92569ae5dca2ef541e34bf632f8487009a58a1d1925014730440220704d7b99bb5333dd6a3e8b09fc5fa4fb9c891ce03cab47e902dfce5aeaf8724a0220402e08c44d088e4e5448574c2c10d7bc36d7ba1eb5de61dccb08786628738a19014c695221020483ebb834d91d494a3b649cf0e8f5c9c4fcec5f194ab94341cc99bb440007f2210271ebaeef1c2bf0c1a4772d1391eab03e4d96a6e9b48551ab4e4b0d2983eb452b2103a659828aabe443e2dedabb1db5a22335c5ace5b5b7126998a288d63c99516dd853aeffffffffa0644cd1606e081c59eb65fe69d4a83a3a822da423bc392c91712fb77a192edc00000000fdfd00004830450221008610924a2e48fd0f0be9b8dd5017f1d478be6101ee8ea69e0cdca6bcfbfd4c21022012da689506940bb20fcf9fe590d7687180b49904b82525c331bfe1965965c2f601473044022062b1f55d1afaaa94c2f27eec3bd5ea20f8a19afe09387655b7c9f7e03749f690022077904d8490ef149b8a097aa23ebc384cabe7c9b4f6b137e672793e176a1cf684014c695221020483ebb834d91d494a3b649cf0e8f5c9c4fcec5f194ab94341cc99bb440007f2210271ebaeef1c2bf0c1a4772d1391eab03e4d96a6e9b48551ab4e4b0d2983eb452b2103a659828aabe443e2dedabb1db5a22335c5ace5b5b7126998a288d63c99516dd853aeffffffff03f04902000000000017a9144de752833233fe69a20064f29b2ca0f6399c8af387007102000000000017a9144de752833233fe69a20064f29b2ca0f6399c8af38763b204000000000017a9146c8d8b04c6a1e664b1ec20ec932760760c97688e8700000000" + "010000000220c24f763536edb05ce8df2a4816d971be4f20b58451d71589db434aca98bfaf00000000fdfe0000483045022100cf4007c6c85f5dc78afa0a1433267df95230745c055bdf727e3e5d96b1b97c070220449391b5522cafc9765286069e1c7b586ead5cc885cba46cf04a7ee1a87a886e01483045022100dc711e292f80a26f1836d50ace019d2b82841aa9209c31a0c7d7c78e7fce9b6502206dfd33a16f8566ebe54f3fbc467135a43ecaf968668f9c83cce57ec12312f2d9014c695221020483ebb834d91d494a3b649cf0e8f5c9c4fcec5f194ab94341cc99bb440007f2210271ebaeef1c2bf0c1a4772d1391eab03e4d96a6e9b48551ab4e4b0d2983eb452b2103a659828aabe443e2dedabb1db5a22335c5ace5b5b7126998a288d63c99516dd853aeffffffffa0644cd1606e081c59eb65fe69d4a83a3a822da423bc392c91712fb77a192edc00000000fc0047304402201ea8024482d974efa29f8b56b62471714d5b73e4fce9da3353ecbeb338091f0c022070fc267ec8ebf62b8b2cd2fdf763888c062744c586ed7ea1b79314727d7abc120147304402200b0e08d20084090fff0749311e6a9a4a66735a22b6eefebff0060140f60ad7c50220181d8dcfef0c1508cfb08eb36223ca9249927442d9d956039f3330aa6b543fc6014c695221020483ebb834d91d494a3b649cf0e8f5c9c4fcec5f194ab94341cc99bb440007f2210271ebaeef1c2bf0c1a4772d1391eab03e4d96a6e9b48551ab4e4b0d2983eb452b2103a659828aabe443e2dedabb1db5a22335c5ace5b5b7126998a288d63c99516dd853aeffffffff03f04902000000000017a9144de752833233fe69a20064f29b2ca0f6399c8af387007102000000000017a9144de752833233fe69a20064f29b2ca0f6399c8af38701b404000000000017a9146c8d8b04c6a1e664b1ec20ec932760760c97688e8700000000" ] ] diff --git a/test/transaction/transaction.js b/test/transaction/transaction.js index 57d8f5072..99c34e638 100644 --- a/test/transaction/transaction.js +++ b/test/transaction/transaction.js @@ -235,6 +235,113 @@ describe('Transaction', function () { ); }); + + describe('transaction fee calculation test', function () { + var minimalFee = 255; + + var minimalUTXO = { + "satoshis": 5460 + 255, + "script": Script.buildPublicKeyHashOut('yfGjFr9Cu8AZYYrdeiRRNtLktWcJetxKv4').toString(), + "txid": '88d78d6afaa06bbe5943152757305338eba27cc1f3e84acb1a31ab17f26c038d', + outputIndex: 0, + address: "yfGjFr9Cu8AZYYrdeiRRNtLktWcJetxKv4" + } + + var simpleUTXO = { + ...minimalUTXO, + "satoshis": 1000000000, + } + + var privateKey = PrivateKey.fromWIF( + 'cTtLHt4mv6zuJytSnM7Vd6NLxyNauYLMxD818sBC8PJ1UPiVTRSs' + ) + + it('should return correct minimal fee amount with 1 input and 1 output', () => { + var tx = new Transaction() + .from(minimalUTXO) + .to('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm', 5460) + .change('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm') + + tx.getFee().should.equal(minimalFee); + + tx.sign(privateKey); + + tx.isFullySigned().should.equal(true); + + tx.getFee().should.equal(minimalFee); + + should.equal(tx.getChangeOutput(), null); + }); + + it('should return correct minimal fee amount with 30 input and 1 output', () => { + var tx = new Transaction() + + const privateKeys = [] + + for (let i=0; i < 30; i++){ + tx.from(simpleUTXO) + privateKeys.push(privateKey) + } + + tx + .to('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm', 5460) + .change('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm') + .sign(privateKeys); + + tx.isFullySigned().should.equal(true); + + tx.getFee().should.gte(tx.toString().length/2); + }); + + it('should return correct minimal fee amount with 30 input and 30 outputs', () => { + var tx = new Transaction() + + const privateKeys = [] + + for (let i=0; i < 30; i++){ + tx.from(simpleUTXO) + privateKeys.push(privateKey) + } + + for (let i=0; i < 30; i++){ + tx.to('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm', 5460) + } + + tx + .change('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm') + .sign(privateKeys); + + tx.isFullySigned().should.equal(true); + + tx.getFee().should.gte(tx.toString().length/2); + }); + + it('should return correct minimal fee amount with 30 input and 30 outputs with custom fee per KB', () => { + var tx = new Transaction() + .feePerKb(2000) + + const privateKeys = [] + + for (let i=0; i < 30; i++){ + tx.from(simpleUTXO) + privateKeys.push(privateKey) + } + + for (let i=0; i < 30; i++){ + tx.to('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm', 5460) + } + + tx + .change('yeB49jSYYp3786GQr7eKFwLNdEqonBf6hm') + .sign(privateKeys); + + tx.isFullySigned().should.equal(true); + + tx.getFee().should.gte(tx.toString().length); + }); + }) + + describe('transaction creation test vector', function () { this.timeout(5000); var index = 0; @@ -466,7 +573,7 @@ describe('Transaction', function () { .change(changeAddress) .sign(privateKey); transaction.outputs.length.should.equal(2); - transaction.outputs[1].satoshis.should.equal(49000); + transaction.outputs[1].satoshis.should.equal(49745); transaction.outputs[1].script .toString() .should.equal(Script.fromAddress(changeAddress).toString()); @@ -540,7 +647,7 @@ describe('Transaction', function () { .sign(privateKey); transaction._estimateSize().should.be.within(1000, 1999); transaction.outputs.length.should.equal(2); - transaction.outputs[1].satoshis.should.equal(34000); + transaction.outputs[1].satoshis.should.equal(37808); }); it('if satoshis are invalid', function () { var transaction = new Transaction() @@ -1126,7 +1233,7 @@ describe('Transaction', function () { .change(changeAddress) .to(toAddress, 10000); transaction.inputAmount.should.equal(100000000); - transaction.outputAmount.should.equal(99999000); + transaction.outputAmount.should.equal(99999745); }); it('returns correct values for coinjoin transaction', function () { // see livenet tx c16467eea05f1f30d50ed6dbc06a38539d9bb15110e4b7dc6653046a3678a718 @@ -1216,7 +1323,7 @@ describe('Transaction', function () { tx.outputs.length.should.equal(2); tx.outputs[0].satoshis.should.equal(10000000); tx.outputs[0].script.toAddress().toString().should.equal(toAddress); - tx.outputs[1].satoshis.should.equal(89999000); + tx.outputs[1].satoshis.should.equal(89999745); tx.outputs[1].script.toAddress().toString().should.equal(changeAddress); }); });