Merge pull request #1048 from maraoz/nlocktime/utils

Add some useful Transaction methods to handle nLockTime
This commit is contained in:
Esteban Ordano 2015-02-11 12:31:14 -03:00
commit 1c21f6e118
5 changed files with 296 additions and 73 deletions

View File

@ -112,11 +112,11 @@ When outputs' value don't sum up to the same amount that inputs, the difference
For this reason, some methods in the Transaction class are provided:
* `change(address)`: Set up the change address. This will set an internal `_change` property that will store the change address.
* `change(address)`: Set up the change address. This will set an internal `_changeScript` property that will store the change script associated with that address.
* `fee(amount)`: Sets up the exact amount of fee to pay. If no change address is provided, this will raise an exception.
* `getFee()`: returns the estimated fee amount to be paid, based on the size of the transaction, but disregarding the priority of the outputs.
Internally, a `_changeOutput` property stores the index of the change output (so it can get updated when a new input or output is added).
Internally, a `_changeIndex` property stores the index of the change output (so it can get updated when a new input or output is added).
## Multisig Transactions
@ -140,6 +140,26 @@ var multiSigTx = new Transaction(serialized)
assert(multiSigTx.isFullySigned());
```
## Time-Locking transaction
All bitcoin transactions contain a locktime field.
The locktime indicates the earliest time a transaction can be added to the blockchain.
Locktime allows signers to create time-locked transactions which will only become valid in the future, giving the signers a chance to change their minds.
Locktime can be set in the form of a bitcoin block height (the transaction can only be included in a block with a higher height than specified) or a linux timestamp (transaction can only be confirmed after that time).
For more information see [bitcoin's development guide section on locktime](https://bitcoin.org/en/developer-guide#locktime-and-sequence-number).
In bitcore, you can set a `Transaction`'s locktime by using the methods `Transaction#lockUntilDate` and `Transaction#lockUntilBlockHeight`. You can also get a friendly version of the locktime field via `Transaction#getLockTime`;
For example:
```javascript
var future = new Date(2025,10,30); // Sun Nov 30 2025
var transaction = new Transaction()
.lockUntilDate(future);
console.log(transaction.getLockTime());
// output similar to: Sun Nov 30 2025 00:00:00 GMT-0300 (ART)
```
## Upcoming changes
We're debating an API for Merge Avoidance, CoinJoin, Smart contracts, CoinSwap, and Stealth Addresses. We're expecting to have all of them by some time in 2015. Payment channel creation is avaliable in the [bitcore-channel](https://github.com/bitpay/bitcore-channel) module.

View File

@ -24,7 +24,7 @@ module.exports = [{
name: 'InvalidArgument',
message: function() {
return 'Invalid Argument' + (arguments[0] ? (': ' + arguments[0]) : '') +
(arguments[1] ? (' Documentation: ' + docsURL + arguments[1]): '');
(arguments[1] ? (' Documentation: ' + docsURL + arguments[1]) : '');
}
}, {
name: 'AbstractMethodInvoked',
@ -53,6 +53,9 @@ module.exports = [{
errors: [{
name: 'MissingScript',
message: 'Need a script to create an input'
}, {
name: 'UnsupportedScript',
message: 'Unsupported input script type: {0}'
}]
}, {
name: 'NeedMoreInfo',
@ -72,6 +75,15 @@ module.exports = [{
}, {
name: 'ChangeAddressMissing',
message: 'Change address is missing'
}, {
name: 'BlockHeightTooHigh',
message: 'Block Height can be at most 2^32 -1'
}, {
name: 'NLockTimeOutOfRange',
message: 'Block Height can only be between 0 and 499 999 999'
}, {
name: 'LockTimeTooEarly',
message: 'Lock Time can\'t be earlier than UNIX date 500 000 000'
}]
}, {
name: 'Script',

View File

@ -36,15 +36,14 @@ Input.prototype._fromObject = function(params) {
params.prevTxId = new buffer.Buffer(params.prevTxId, 'hex');
}
this.output = params.output ?
(params.output instanceof Output ? params.output : new Output(params.output)) : undefined;
(params.output instanceof Output ? params.output : new Output(params.output)) : undefined;
this.prevTxId = params.prevTxId;
this.outputIndex = params.outputIndex;
this.sequenceNumber = params.sequenceNumber;
if (!_.isUndefined(params.script) || !_.isUndefined(params.scriptBuffer)) {
this.setScript(_.isUndefined(params.script) ? params.scriptBuffer : params.script);
} else {
if (_.isUndefined(params.script) && _.isUndefined(params.scriptBuffer)) {
throw new errors.Transaction.Input.MissingScript();
}
this.setScript(params.scriptBuffer || params.script);
return this;
};

View File

@ -66,6 +66,12 @@ function Transaction(serialized) {
// max amount of satoshis in circulation
Transaction.MAX_MONEY = 21000000 * 1e8;
// nlocktime limit to be considered block height rather than a timestamp
Transaction.NLOCKTIME_BLOCKHEIGHT_LIMIT = 5e8;
// Max value for an unsigned 32 bit value
Transaction.NLOCKTIME_MAX_VALUE = 4294967295;
/* Constructors and Serialization */
/**
@ -120,13 +126,12 @@ Transaction.prototype.uncheckedSerialize = function() {
Transaction.prototype.checkedSerialize = Transaction.prototype.toString = function() {
var feeError = this._validateFees();
if (feeError) {
var changeError = this._validateChange();
if (changeError) {
throw new errors.Transaction.ChangeAddressMissing();
} else {
throw new errors.Transaction.FeeError(feeError);
}
var missingChange = this._missingChange();
if (feeError && missingChange) {
throw new errors.Transaction.ChangeAddressMissing();
}
if (feeError && !missingChange) {
throw new errors.Transaction.FeeError(feeError);
}
if (this._hasDustOutputs()) {
throw new errors.Transaction.DustOutputs();
@ -142,10 +147,8 @@ Transaction.prototype._validateFees = function() {
}
};
Transaction.prototype._validateChange = function() {
if (!this._change) {
return 'Missing change address';
}
Transaction.prototype._missingChange = function() {
return !this._changeScript;
};
Transaction.DUST_AMOUNT = 5460;
@ -211,23 +214,7 @@ Transaction.prototype.fromJSON = function(json) {
if (JSUtil.isValidJSON(json)) {
json = JSON.parse(json);
}
var self = this;
this.inputs = [];
var inputs = json.inputs || json.txins;
inputs.forEach(function(input) {
self.inputs.push(Input.fromJSON(input));
});
this.outputs = [];
var outputs = json.outputs || json.txouts;
outputs.forEach(function(output) {
self.outputs.push(Output.fromJSON(output));
});
if (json.change) {
this.change(json.change);
}
this.version = json.version;
this.nLockTime = json.nLockTime;
return this;
return this.fromObject(json);
};
Transaction.prototype.toObject = function toObject() {
@ -239,44 +226,126 @@ Transaction.prototype.toObject = function toObject() {
this.outputs.forEach(function(output) {
outputs.push(output.toObject());
});
return {
change: this._change ? this._change.toString() : undefined,
fee: this._fee ? this._fee : undefined,
var obj = {
version: this.version,
inputs: inputs,
outputs: outputs,
nLockTime: this.nLockTime
};
if (this._changeScript) {
obj.changeScript = this._changeScript.toString();
}
if (!_.isUndefined(this._changeIndex)) {
obj.changeIndex = this._changeIndex;
}
if (!_.isUndefined(this._fee)) {
obj.fee = this._fee;
}
return obj;
};
Transaction.prototype.fromObject = function(transaction) {
var self = this;
_.each(transaction.inputs, function(input) {
if (input.output && input.output.script) {
input.output.script = new Script(input.output.script);
if (input.output.script.isPublicKeyHashOut()) {
self.addInput(new Input.PublicKeyHash(input));
return;
} else if (input.output.script.isScriptHashOut() && input.publicKeys && input.threshold) {
self.addInput(new Input.MultiSigScriptHash(
input, input.publicKeys, input.threshold, input.signatures
));
return;
}
if (!input.output || !input.output.script) {
self.uncheckedAddInput(new Input(input));
return;
}
self.uncheckedAddInput(new Input(input));
input.output.script = new Script(input.output.script);
var txin;
if (input.output.script.isPublicKeyHashOut()) {
console.log(input.output.script);
txin = new Input.PublicKeyHash(input);
} else if (input.output.script.isScriptHashOut() && input.publicKeys && input.threshold) {
txin = new Input.MultiSigScriptHash(
input, input.publicKeys, input.threshold, input.signatures
);
} else {
throw new errors.Transaction.Input.UnsupportedScript(input.output.script);
}
self.addInput(txin);
});
_.each(transaction.outputs, function(output) {
self.addOutput(new Output(output));
});
if (transaction.change) {
this.change(transaction.change);
if (transaction.changeIndex) {
this._changeIndex = transaction.changeIndex;
}
if (transaction.changeScript) {
this._changeScript = new Script(transaction.changeScript);
}
if (transaction.fee) {
this.fee(transaction.fee);
}
this.nLockTime = transaction.nLockTime;
this.version = transaction.version;
this._checkConsistency();
return this;
};
Transaction.prototype._checkConsistency = function() {
if (!_.isUndefined(this._changeIndex)) {
$.checkState(this._changeScript);
$.checkState(this.outputs[this._changeIndex]);
$.checkState(this.outputs[this._changeIndex].script.toString() ===
this._changeScript.toString());
}
// TODO: add other checks
};
/**
* Sets nLockTime so that transaction is not valid until the desired date(a
* timestamp in seconds since UNIX epoch is also accepted)
*
* @param {Date | Number} time
* @return {Transaction} this
*/
Transaction.prototype.lockUntilDate = function(time) {
$.checkArgument(time);
if (_.isNumber(time) && time < Transaction.NLOCKTIME_BLOCKHEIGHT_LIMIT) {
throw new errors.Transaction.LockTimeTooEarly();
}
if (_.isDate(time)) {
time = time.getTime() / 1000;
}
this.nLockTime = time;
return this;
};
/**
* Sets nLockTime so that transaction is not valid until the desired block
* height.
*
* @param {Number} height
* @return {Transaction} this
*/
Transaction.prototype.lockUntilBlockHeight = function(height) {
$.checkArgument(_.isNumber(height));
if (height >= Transaction.NLOCKTIME_BLOCKHEIGHT_LIMIT) {
throw new errors.Transaction.BlockHeightTooHigh();
}
if (height < 0) {
throw new errors.Transaction.NLockTimeOutOfRange();
}
this.nLockTime = height;
return this;
};
/**
* Returns a semantic version of the transaction's nLockTime.
* @return {Number|Date}
* If nLockTime is 0, it returns null,
* if it is < 500000000, it returns a block height (number)
* else it returns a Date object.
*/
Transaction.prototype.getLockTime = function() {
if (!this.nLockTime) {
return null;
}
if (this.nLockTime < Transaction.NLOCKTIME_BLOCKHEIGHT_LIMIT) {
return this.nLockTime;
}
return new Date(1000 * this.nLockTime);
};
Transaction.prototype.toJSON = function toJSON() {
@ -472,11 +541,22 @@ Transaction.prototype.fee = function(amount) {
* @return {Transaction} this, for chaining
*/
Transaction.prototype.change = function(address) {
this._change = new Address(address);
this._changeScript = Script.fromAddress(address);
this._updateChangeOutput();
return this;
};
/**
* @return {Output} change output, if it exists
*/
Transaction.prototype.getChangeOutput = function() {
if (!_.isUndefined(this._changeIndex)) {
return this.outputs[this._changeIndex];
}
return null;
};
/**
* Add an output to the transaction.
*
@ -525,33 +605,46 @@ Transaction.prototype._addOutput = function(output) {
};
Transaction.prototype._updateChangeOutput = function() {
if (!this._change) {
if (!this._changeScript) {
return;
}
this._clearSignatures();
if (!_.isUndefined(this._changeOutput)) {
this._removeOutput(this._changeOutput);
if (!_.isUndefined(this._changeIndex)) {
this._removeOutput(this._changeIndex);
}
var available = this._getUnspentValue();
var fee = this.getFee();
if (available - fee > 0) {
this._changeOutput = this.outputs.length;
var changeAmount = available - fee;
if (changeAmount > 0) {
this._changeIndex = this.outputs.length;
this._addOutput(new Output({
script: Script.fromAddress(this._change),
satoshis: available - fee
script: this._changeScript,
satoshis: changeAmount
}));
} else {
this._changeOutput = undefined;
this._changeIndex = undefined;
}
};
/**
* Calculates the fees for the transaction.
*
* If there is no change output set, the fee will be the
* output amount minus the input amount.
* If there's a fixed fee set, return that
* If there's no fee set, estimate it based on size
* @return {Number} miner fee for this transaction in satoshis
*/
Transaction.prototype.getFee = function() {
if (!this._change) {
// if no change output is set, fees should equal all the unspent amount
if (!this._changeScript) {
return this._getUnspentValue();
}
return this._fee || this._estimateFee();
return _.isUndefined(this._fee) ? this._estimateFee() : this._fee;
};
/**
* Estimates fee from serialized transaction size in bytes.
*/
Transaction.prototype._estimateFee = function() {
var estimatedSize = this._estimateSize();
var available = this._getUnspentValue();
@ -569,12 +662,12 @@ Transaction.prototype._clearSignatures = function() {
};
Transaction.FEE_PER_KB = 10000;
// Safe upper bound for change address script
Transaction.CHANGE_OUTPUT_MAX_SIZE = 20 + 4 + 34 + 4;
Transaction._estimateFee = function(size, amountAvailable) {
var fee = Math.ceil(size / Transaction.FEE_PER_KB);
if (amountAvailable > fee) {
// Safe upper bound for change address script
size += Transaction.CHANGE_OUTPUT_MAX_SIZE;
}
return Math.ceil(size / 1000) * Transaction.FEE_PER_KB;

View File

@ -120,6 +120,8 @@ describe('Transaction', function() {
script: Script.buildPublicKeyHashOut(fromAddress).toString(),
satoshis: 100000
};
var anyoneCanSpendUTXO = JSON.parse(JSON.stringify(simpleUtxoWith100000Satoshis));
anyoneCanSpendUTXO.script = new Script().add('OP_TRUE');
var toAddress = 'mrU9pEmAx26HcbKVrABvgL7AwA5fjNFoDc';
var changeAddress = 'mgBCJAsvzgT2qNNeXsoECg2uPKrUsZ76up';
var changeAddressP2SH = '2N7T3TAetJrSCruQ39aNrJvYLhG1LJosujf';
@ -135,14 +137,23 @@ describe('Transaction', function() {
describe('adding inputs', function() {
it('it only adds once one utxo', function() {
it('only adds once one utxo', function() {
var tx = new Transaction();
tx.from(simpleUtxoWith1BTC);
tx.from(simpleUtxoWith1BTC);
tx.inputs.length.should.equal(1);
});
describe('not enough information errors', function() {
describe('isFullySigned', function() {
it('works for normal p2pkh', function() {
var transaction = new Transaction()
.from(simpleUtxoWith100000Satoshis)
.to(toAddress, 50000)
.change(changeAddress)
.sign(privateKey);
transaction.isFullySigned().should.equal(true);
});
it('fails when Inputs are not subclassed and isFullySigned is called', function() {
var tx = new Transaction(tx_1_hex);
expect(function() {
@ -172,6 +183,7 @@ describe('Transaction', function() {
transaction.outputs[1].satoshis.should.equal(40000);
transaction.outputs[1].script.toString()
.should.equal(Script.fromAddress(changeAddress).toString());
transaction.getChangeOutput().script.should.deep.equal(Script.fromAddress(changeAddress));
});
it('accepts a P2SH address for change', function() {
var transaction = new Transaction()
@ -187,11 +199,17 @@ describe('Transaction', function() {
.from(simpleUtxoWith100000Satoshis)
.to(toAddress, 50000)
.change(changeAddress)
.sign(privateKey)
.fee(0)
.sign(privateKey);
transaction.getChangeOutput().satoshis.should.equal(50000);
transaction = transaction
.to(toAddress, 20000)
.sign(privateKey);
transaction.outputs.length.should.equal(3);
transaction.outputs[2].satoshis.should.equal(20000);
transaction.outputs[2].satoshis.should.equal(30000);
transaction.outputs[2].script.toString()
.should.equal(Script.fromAddress(changeAddress).toString());
});
@ -243,7 +261,8 @@ describe('Transaction', function() {
.change(changeAddress)
.toObject();
var deserialized = new Transaction(serialized);
expect(deserialized._change.toString()).to.equal(changeAddress);
expect(deserialized._changeScript.toString()).to.equal(Script.fromAddress(changeAddress).toString());
expect(deserialized.getChangeOutput()).to.equal(null);
});
it('can avoid checked serialize', function() {
var transaction = new Transaction()
@ -309,13 +328,23 @@ describe('Transaction', function() {
describe('to and from JSON', function() {
it('takes a string that is a valid JSON and deserializes from it', function() {
var transaction = new Transaction();
expect(new Transaction(transaction.toJSON()).serialize()).to.equal(transaction.serialize());
var simple = new Transaction();
expect(new Transaction(simple.toJSON()).serialize()).to.equal(simple.serialize());
var complex = new Transaction()
.from(simpleUtxoWith100000Satoshis)
.to(toAddress, 50000)
.change(changeAddress)
.sign(privateKey);
var cj = complex.toJSON();
var ctx = new Transaction(cj);
expect(ctx.serialize()).to.equal(complex.serialize());
});
it('serializes the `change` information', function() {
var transaction = new Transaction();
transaction.change(changeAddress);
expect(JSON.parse(transaction.toJSON()).change).to.equal(changeAddress.toString());
expect(JSON.parse(transaction.toJSON()).changeScript).to.equal(Script.fromAddress(changeAddress).toString());
expect(new Transaction(transaction.toJSON()).serialize()).to.equal(transaction.serialize());
});
it('serializes correctly p2sh multisig signed tx', function() {
var t = new Transaction(tx2hex);
@ -407,6 +436,74 @@ describe('Transaction', function() {
transaction.outputs.length.should.equal(1);
});
});
describe('handling the nLockTime', function() {
var MILLIS_IN_SECOND = 1000;
var timestamp = 1423504946;
var blockHeight = 342734;
var date = new Date(timestamp * MILLIS_IN_SECOND);
it('handles a null locktime', function() {
var transaction = new Transaction();
expect(transaction.getLockTime()).to.equal(null);
});
it('handles a simple example', function() {
var future = new Date(2025, 10, 30); // Sun Nov 30 2025
var transaction = new Transaction()
.lockUntilDate(future);
transaction.nLockTime.should.equal(future.getTime() / 1000);
transaction.getLockTime().should.deep.equal(future);
});
it('accepts a date instance', function() {
var transaction = new Transaction()
.lockUntilDate(date);
transaction.nLockTime.should.equal(timestamp);
transaction.getLockTime().should.deep.equal(date);
});
it('accepts a number instance with a timestamp', function() {
var transaction = new Transaction()
.lockUntilDate(timestamp);
transaction.nLockTime.should.equal(timestamp);
transaction.getLockTime().should.deep.equal(new Date(timestamp * 1000));
});
it('accepts a block height', function() {
var transaction = new Transaction()
.lockUntilBlockHeight(blockHeight);
transaction.nLockTime.should.equal(blockHeight);
transaction.getLockTime().should.deep.equal(blockHeight);
});
it('fails if the block height is too high', function() {
expect(function() {
return new Transaction().lockUntilBlockHeight(5e8);
}).to.throw(errors.Transaction.BlockHeightTooHigh);
});
it('fails if the date is too early', function() {
expect(function() {
return new Transaction().lockUntilDate(1);
}).to.throw(errors.Transaction.LockTimeTooEarly);
expect(function() {
return new Transaction().lockUntilDate(499999999);
}).to.throw(errors.Transaction.LockTimeTooEarly);
});
it('fails if the block height is negative', function() {
expect(function() {
return new Transaction().lockUntilBlockHeight(-1);
}).to.throw(errors.Transaction.NLockTimeOutOfRange);
});
});
it('handles anyone-can-spend utxo', function() {
var transaction = new Transaction()
.from(anyoneCanSpendUTXO)
.to(toAddress, 50000);
should.exist(transaction);
});
it('handles unsupported utxo in tx object', function() {
var transaction = new Transaction();
transaction.fromJSON.bind(transaction, unsupportedTxObj)
.should.throw('Unsupported input script type: OP_1 OP_ADD OP_2 OP_EQUAL');
});
});
var tx_empty_hex = '01000000000000000000';
@ -417,3 +514,5 @@ var tx_1_id = '779a3e5b3c2c452c85333d8521f804c1a52800e60f4b7c3bbe36f4bab350b72c'
var tx2hex = '0100000001e07d8090f4d4e6fcba6a2819e805805517eb19e669e9d2f856b41d4277953d640000000091004730440220248bc60bb309dd0215fbde830b6371e3fdc55685d11daa9a3c43828892e26ce202205f10cd4011f3a43657260a211f6c4d1fa81b6b6bdd6577263ed097cc22f4e5b50147522102fa38420cec94843ba963684b771ba3ca7ce1728dc2c7e7cade0bf298324d6b942103f948a83c20b2e7228ca9f3b71a96c2f079d9c32164cd07f08fbfdb483427d2ee52aeffffffff01180fe200000000001976a914ccee7ce8e8b91ec0bc23e1cfb6324461429e6b0488ac00000000';
var unsupportedTxObj = '{"version":1,"inputs":[{"prevTxId":"a477af6b2667c29670467e4e0728b685ee07b240235771862318e29ddbe58458","outputIndex":0,"sequenceNumber":4294967295,"script":"OP_1","output":{"satoshis":1020000,"script":"OP_1 OP_ADD OP_2 OP_EQUAL"}}],"outputs":[{"satoshis":1010000,"script":"OP_DUP OP_HASH160 20 0x7821c0a3768aa9d1a37e16cf76002aef5373f1a8 OP_EQUALVERIFY OP_CHECKSIG"}],"nLockTime":0}';