Merge pull request #83 from bitcoinjs/wallet

Wallet cleanup & rewrite
This commit is contained in:
Wei Lu 2014-03-26 08:46:28 +08:00
commit 7820ea7ea0
10 changed files with 788 additions and 120 deletions

View file

@ -18,7 +18,8 @@
"mocha": "1.18.2",
"istanbul": "0.1.30",
"uglify-js": "2.4.13",
"node-browserify": "https://github.com/substack/node-browserify/tarball/master"
"node-browserify": "https://github.com/substack/node-browserify/tarball/master",
"sinon": "1.9.0"
},
"testling": {
"browsers": [

View file

@ -158,6 +158,10 @@ function wordArrayToBytes(wordArray) {
return wordsToBytes(wordArray.words)
}
function reverseEndian (hex) {
return bytesToHex(hexToBytes(hex).reverse())
}
module.exports = {
lpad: lpad,
bytesToHex: bytesToHex,
@ -175,5 +179,6 @@ module.exports = {
bytesToWords: bytesToWords,
wordsToBytes: wordsToBytes,
bytesToWordArray: bytesToWordArray,
wordArrayToBytes: wordArrayToBytes
wordArrayToBytes: wordArrayToBytes,
reverseEndian: reverseEndian
}

View file

@ -150,7 +150,8 @@ Script.prototype.toScriptHash = function() {
return util.sha256ripe160(this.buffer)
}
Script.prototype.toAddress = function() {
//TODO: support testnet
Script.prototype.getToAddress = function() {
var outType = this.getOutType();
if (outType == 'Pubkey') {
@ -164,6 +165,11 @@ Script.prototype.toAddress = function() {
return new Address(this.chunks[1], 5)
}
//TODO: support testnet
Script.prototype.getFromAddress = function(){
return new Address(this.simpleInHash());
}
/**
* Compare the script to known templates of scriptSig.
*

View file

@ -377,6 +377,18 @@ Transaction.prototype.validateSig = function(index, script, sig, pub) {
convert.coerceToBytes(pub));
}
Transaction.feePerKb = 20000
Transaction.prototype.estimateFee = function(feePerKb){
var uncompressedInSize = 180
var outSize = 34
var fixedPadding = 34
if(feePerKb == undefined) feePerKb = Transaction.feePerKb
var size = this.ins.length * uncompressedInSize + this.outs.length * outSize + fixedPadding
return feePerKb * Math.ceil(size / 1000)
}
var TransactionIn = function (data) {
if (typeof data == "string")
this.outpoint = { hash: data.split(':')[0], index: data.split(':')[1] }
@ -415,7 +427,7 @@ var TransactionOut = function (data) {
: data.address ? Script.createOutputScript(data.address)
: new Script();
if (this.script.buffer.length > 0) this.address = this.script.toAddress();
if (this.script.buffer.length > 0) this.address = this.script.getToAddress();
this.value =
Array.isArray(data.value) ? convert.bytesToNum(data.value)
@ -424,8 +436,7 @@ var TransactionOut = function (data) {
: data.value;
};
TransactionOut.prototype.clone = function ()
{
TransactionOut.prototype.clone = function() {
var newTxout = new TransactionOut({
script: this.script.clone(),
value: this.value
@ -433,6 +444,10 @@ TransactionOut.prototype.clone = function ()
return newTxout;
};
TransactionOut.prototype.scriptPubKey = function() {
return convert.bytesToHex(this.script.buffer)
}
module.exports = {
Transaction: Transaction,
TransactionIn: TransactionIn,

View file

@ -1,11 +1,5 @@
var Script = require('./script');
var ECKey = require('./eckey').ECKey;
var convert = require('./convert');
var assert = require('assert');
var BigInteger = require('./jsbn/jsbn');
var Transaction = require('./transaction').Transaction;
var TransactionIn = require('./transaction').TransactionIn;
var TransactionOut = require('./transaction').TransactionOut;
var HDNode = require('./hdwallet.js')
var rng = require('secure-random');
@ -60,111 +54,209 @@ var Wallet = function (seed, options) {
return this.changeAddresses[this.changeAddresses.length - 1]
}
// Processes a transaction object
// If "verified" is true, then we trust the transaction as "final"
this.processTx = function(tx, verified) {
this.getBalance = function() {
return this.getUnspentOutputs().reduce(function(memo, output){
return memo + output.value
}, 0)
}
this.getUnspentOutputs = function() {
var utxo = []
for(var key in this.outputs){
var output = this.outputs[key]
if(!output.spend) utxo.push(outputToUnspentOutput(output))
}
return utxo
}
this.setUnspentOutputs = function(utxo) {
var outputs = {}
utxo.forEach(function(uo){
validateUnspentOutput(uo)
var o = unspentOutputToOutput(uo)
outputs[o.receive] = o
})
this.outputs = outputs
}
this.setUnspentOutputsAsync = function(utxo, callback) {
try {
this.setUnspentOutputs(utxo)
} catch(err) {
return callback(err)
}
return callback()
}
function outputToUnspentOutput(output){
var hashAndIndex = output.receive.split(":")
return {
hash: hashAndIndex[0],
hashLittleEndian: convert.reverseEndian(hashAndIndex[0]),
outputIndex: parseInt(hashAndIndex[1]),
address: output.address,
value: output.value
}
}
function unspentOutputToOutput(o) {
var hash = o.hash || convert.reverseEndian(o.hashLittleEndian)
var key = hash + ":" + o.outputIndex
return {
receive: key,
address: o.address,
value: o.value
}
}
function validateUnspentOutput(uo) {
var missingField;
if(isNullOrUndefined(uo.hash) && isNullOrUndefined(uo.hashLittleEndian)){
missingField = "hash(or hashLittleEndian)"
}
var requiredKeys = ['outputIndex', 'address', 'value']
requiredKeys.forEach(function(key){
if(isNullOrUndefined(uo[key])){
missingField = key
}
})
if(missingField) {
var message = [
'Invalid unspent output: key', field, 'is missing.',
'A valid unspent output must contain'
]
message.push(requiredKeys.join(', '))
message.push("and hash(or hashLittleEndian)")
throw new Error(message.join(' '))
}
}
function isNullOrUndefined(value){
return value == undefined
}
this.processTx = function(tx) {
var txhash = convert.bytesToHex(tx.getHash())
for (var i = 0; i < tx.outs.length; i++) {
if (this.addresses.indexOf(tx.outs[i].address.toString()) >= 0) {
me.outputs[txhash+':'+i] = {
output: txhash+':'+i,
value: tx.outs[i].value,
address: tx.outs[i].address.toString(),
timestamp: new Date().getTime() / 1000,
pending: true
tx.outs.forEach(function(txOut, i){
var address = txOut.address.toString()
if (isMyAddress(address)) {
var output = txhash+':'+i
me.outputs[output] = {
receive: output,
value: txOut.value,
address: address,
}
}
}
for (var i = 0; i < tx.ins.length; i++) {
var op = tx.ins[i].outpoint
})
tx.ins.forEach(function(txIn, i){
var op = txIn.outpoint
var o = me.outputs[op.hash+':'+op.index]
if (o) {
o.spend = txhash+':'+i
o.spendpending = true
o.timestamp = new Date().getTime() / 1000
}
}
}
// Processes an output from an external source of the form
// { output: txhash:index, value: integer, address: address }
// Excellent compatibility with SX and pybitcointools
this.processOutput = function(o) {
if (!this.outputs[o.output] || this.outputs[o.output].pending)
this.outputs[o.output] = o;
}
this.processExistingOutputs = function() {
var t = new Date().getTime() / 1000
for (var o in this.outputs) {
if (o.pending && t > o.timestamp + 1200)
delete this.outputs[o]
if (o.spendpending && t > o.timestamp + 1200) {
o.spendpending = false
o.spend = false
delete o.timestamp
}
}
}
var peoInterval = setInterval(this.processExistingOutputs, 10000)
this.getUtxoToPay = function(value) {
var h = []
for (var out in this.outputs) h.push(this.outputs[out])
var utxo = h.filter(function(x) { return !x.spend });
var valuecompare = function(a,b) { return a.value > b.value; }
var high = utxo.filter(function(o) { return o.value >= value; })
.sort(valuecompare);
if (high.length > 0) return [high[0]];
utxo.sort(valuecompare);
var totalval = 0;
for (var i = 0; i < utxo.length; i++) {
totalval += utxo[i].value;
if (totalval >= value) return utxo.slice(0,i+1);
}
throw ("Not enough money to send funds including transaction fee. Have: "
+ (totalval / 100000000) + ", needed: " + (value / 100000000));
}
this.mkSend = function(to, value, fee) {
var utxo = this.getUtxoToPay(value + fee)
var sum = utxo.reduce(function(t,o) { return t + o.value },0),
remainder = sum - value - fee
if (value < 5430) throw new Error("Amount below dust threshold!")
var unspentOuts = 0;
for (var o in this.outputs) {
if (!this.outputs[o].spend) unspentOuts += 1
if (unspentOuts >= 5) return
}
var change = this.addresses[this.addresses.length - 1]
var toOut = { address: to, value: value },
changeOut = { address: change, value: remainder }
halfChangeOut = { address: change, value: Math.floor(remainder/2) };
var outs =
remainder < 5430 ? [toOut]
: remainder < 10860 ? [toOut, changeOut]
: unspentOuts == 5 ? [toOut, changeOut]
: [toOut, halfChangeOut, halfChangeOut]
var tx = new Bitcoin.Transaction({
ins: utxo.map(function(x) { return x.output }),
outs: outs
})
}
this.createTx = function(to, value, fixedFee) {
checkDust(value)
var tx = new Transaction()
tx.addOutput(to, value)
var utxo = getCandidateOutputs(value)
var totalInValue = 0
for(var i=0; i<utxo.length; i++){
var output = utxo[i]
tx.addInput(output.receive)
totalInValue += output.value
if(totalInValue < value) continue;
var fee = fixedFee || estimateFeePadChangeOutput(tx)
if(totalInValue < value + fee) continue;
var change = totalInValue - value - fee
if(change > 0 && !isDust(change)) {
tx.addOutput(getChangeAddress(), change)
}
break;
}
checkInsufficientFund(totalInValue, value, fee)
this.sign(tx)
return tx
}
this.mkSendToOutputs = function(outputs, changeIndex, fee) {
var value = outputs.reduce(function(t,o) { return t + o.value },0),
utxo = this.getUtxoToPay(value + fee),
sum = utxo.reduce(function(t,p) { return t + o.value },0);
utxo[changeIndex].value += sum - value - fee;
var tx = new Bitcoin.Transaction({
ins: utxo.map(function(x) { return x.output }),
outs: outputs
})
this.sign(tx)
return tx
this.createTxAsync = function(to, value, fixedFee, callback){
if(fixedFee instanceof Function) {
callback = fixedFee
fixedFee = undefined
}
var tx = null
try {
tx = this.createTx(to, value, fixedFee)
} catch(err) {
return callback(err)
}
callback(null, tx)
}
this.dustThreshold = 5430
function isDust(amount) {
return amount <= me.dustThreshold
}
function checkDust(value){
if (isNullOrUndefined(value) || isDust(value)) {
throw new Error("Value must be above dust threshold")
}
}
function getCandidateOutputs(value){
var unspent = []
for (var key in me.outputs){
var output = me.outputs[key]
if(!output.spend) unspent.push(output)
}
var sortByValueDesc = unspent.sort(function(o1, o2){
return o2.value - o1.value
})
return sortByValueDesc;
}
function estimateFeePadChangeOutput(tx){
var tmpTx = tx.clone()
tmpTx.addOutput(getChangeAddress(), 0)
return tmpTx.estimateFee()
}
function getChangeAddress() {
if(me.changeAddresses.length === 0) me.generateChangeAddress()
return me.changeAddresses[me.changeAddresses.length - 1]
}
function checkInsufficientFund(totalInValue, value, fee) {
if(totalInValue < value + fee) {
throw new Error('Not enough money to send funds including transaction fee. Have: ' +
totalInValue + ', needed: ' + (value + fee))
}
}
this.sign = function(tx) {
@ -201,6 +293,18 @@ var Wallet = function (seed, options) {
throw new Error('Unknown address. Make sure the address is from the keychain and has been generated.')
}
}
function isReceiveAddress(address){
return me.addresses.indexOf(address) > -1
}
function isChangeAddress(address){
return me.changeAddresses.indexOf(address) > -1
}
function isMyAddress(address) {
return isReceiveAddress(address) || isChangeAddress(address)
}
};
module.exports = Wallet;

View file

@ -93,4 +93,13 @@ describe('convert', function() {
}
})
})
describe('reverseEndian', function() {
it('works', function() {
var bigEndian = "6a4062273ac4f9ea4ffca52d9fd102b08f6c32faa0a4d1318e3a7b2e437bb9c7"
var littleEdian = "c7b97b432e7b3a8e31d1a4a0fa326c8fb002d19f2da5fc4feaf9c43a2762406a"
assert.deepEqual(convert.reverseEndian(bigEndian), littleEdian)
assert.deepEqual(convert.reverseEndian(littleEdian), bigEndian)
})
})
})

5
test/fixtures/mainnet_tx.json vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -7,9 +7,17 @@ var sha256ripe160 = Util.sha256ripe160;
var Convert = require('../src/convert.js')
var bytesToHex = Convert.bytesToHex;
var hexToBytes = Convert.hexToBytes;
var hexToBytes = Convert.hexToBytes;
describe('Script', function() {
var p2shScriptPubKey, pubkeyScriptPubkey, addressScriptSig
beforeEach(function(){
p2shScriptPubKey = "a914e8c300c87986efa84c37c0519929019ef86eb5b487"
pubkeyScriptPubKey = "76a9145a3acbc7bbcc97c5ff16f5909c9d7d3fadb293a888ac"
addressScriptSig = "48304502206becda98cecf7a545d1a640221438ff8912d9b505ede67e0138485111099f696022100ccd616072501310acba10feb97cecc918e21c8e92760cd35144efec7622938f30141040cd2d2ce17a1e9b2b3b2cb294d40eecf305a25b7e7bfdafae6bb2639f4ee399b3637706c3d377ec4ab781355add443ae864b134c5e523001c442186ea60f0eb8"
})
describe('constructor', function() {
it('works for a byte array', function() {
assert.ok(new Script([]))
@ -23,7 +31,7 @@ describe('Script', function() {
assert.throws(function(){ new Script({}) })
})
})
describe('2-of-3 Multi-Signature', function() {
var compressedPubKeys = []
var numSigs
@ -32,7 +40,7 @@ describe('Script', function() {
compressedPubKeys = ['02ea1297665dd733d444f31ec2581020004892cdaaf3dd6c0107c615afb839785f',
'02fab2dea1458990793f56f42e4a47dbf35a12a351f26fa5d7e0cc7447eaafa21f',
'036c6802ce7e8113723dd92cdb852e492ebb157a871ca532c3cb9ed08248ff0e19']
numSigs = 2;
})
@ -42,13 +50,50 @@ describe('Script', function() {
var multisig = sha256ripe160(script.buffer)
var multiSigAddress = Address(multisig,network).toString()
var redeemScript = bytesToHex(script.buffer)
assert.ok(Address.validate(multiSigAddress))
assert.equal(Address.getVersion(multiSigAddress),'0x05')
assert.equal(multiSigAddress,'32vYjxBb7pHJJyXgNk8UoK3BdRDxBzny2v')
assert.equal(Address(sha256ripe160(hexToBytes(redeemScript)),network).toString(),
multiSigAddress)
multiSigAddress)
})
})
describe('getOutType', function() {
it('works for p2sh', function() {
var script = Script.fromHex(p2shScriptPubKey)
assert.equal(script.getOutType(), 'P2SH')
})
it('works for pubkey', function() {
var script = Script.fromHex(pubkeyScriptPubKey)
assert.equal(script.getOutType(), 'Pubkey')
})
})
describe('getInType', function() {
it('works for address', function() {
var script = Script.fromHex(addressScriptSig)
assert.equal(script.getInType(), 'Address')
})
})
describe('getToAddress', function() {
it('works for p2sh type output', function() {
var script = Script.fromHex(p2shScriptPubKey)
assert.equal(script.getToAddress().toString(), '3NukJ6fYZJ5Kk8bPjycAnruZkE5Q7UW7i8')
})
it('works for pubkey type output', function() {
var script = Script.fromHex(pubkeyScriptPubKey)
assert.equal(script.getToAddress().toString(), '19E6FV3m3kEPoJD5Jz6dGKdKwTVvjsWUvu')
})
})
describe('getFromAddress', function() {
it('works for address type input', function() {
var script = Script.fromHex(addressScriptSig)
assert.equal(script.getFromAddress().toString(), '1BBjuhF2jHxu7tPinyQGCuaNhEs6f5u59u')
})
})
})

View file

@ -1,8 +1,17 @@
var Transaction = require('../src/transaction').Transaction
var T = require('../src/transaction')
var Transaction = T.Transaction
var TransactionOut = T.TransactionOut
var convert = require('../src/convert')
var ECKey = require('../src/eckey').ECKey
var Script = require('../src/script')
var assert = require('assert')
var fixtureTxes = require('./fixtures/mainnet_tx')
var fixtureTx1Hex = fixtureTxes.prevTx
var fixtureTx2Hex = fixtureTxes.tx
var fixtureTxBigHex = fixtureTxes.bigTx
describe('Transaction', function() {
describe('deserialize', function() {
var tx, serializedTx
@ -65,7 +74,7 @@ describe('Transaction', function() {
describe('creating a transaction', function() {
var tx, prevTx
beforeEach(function() {
prevTx = Transaction.deserialize('0100000001e0214ebebb0fd3414d3fdc0dbf3b0f4b247a296cafc984558622c3041b0fcc9b010000008b48304502206becda98cecf7a545d1a640221438ff8912d9b505ede67e0138485111099f696022100ccd616072501310acba10feb97cecc918e21c8e92760cd35144efec7622938f30141040cd2d2ce17a1e9b2b3b2cb294d40eecf305a25b7e7bfdafae6bb2639f4ee399b3637706c3d377ec4ab781355add443ae864b134c5e523001c442186ea60f0eb8ffffffff03a0860100000000001976a91400ea3576c8fcb0bc8392f10e23a3425ae24efea888ac40420f00000000001976a91477890e8ec967c5fd4316c489d171fd80cf86997188acf07cd210000000001976a9146fb93c557ee62b109370fd9003e456917401cbfa88ac00000000')
prevTx = Transaction.deserialize(fixtureTx1Hex)
tx = new Transaction()
})
@ -157,7 +166,7 @@ describe('Transaction', function() {
var validTx
beforeEach(function() {
validTx = Transaction.deserialize('0100000001576bc3c3285dbdccd8c3cbd8c03e10d7f77a5c839c744f34c3eb00511059b80c000000006b483045022100a82a31607b837c1ae510ae3338d1d3c7cbd57c15e322ab6e5dc927d49bffa66302205f0db6c90f1fae3c8db4ebfa753d7da1b2343d653ce0331aa94ed375e6ba366c0121020497bfc87c3e97e801414fed6a0db4b8c2e01c46e2cf9dff59b406b52224a76bffffffff02409c0000000000001976a9143443bc45c560866cfeabf1d52f50a6ed358c69f288ac50c30000000000001976a91477890e8ec967c5fd4316c489d171fd80cf86997188ac00000000')
validTx = Transaction.deserialize(fixtureTx2Hex)
})
it('returns true for valid signature', function(){
@ -170,7 +179,40 @@ describe('Transaction', function() {
})
})
describe('estimateFee', function(){
it('works for fixture tx 1', function(){
var tx = Transaction.deserialize(fixtureTx1Hex)
assert.equal(tx.estimateFee(), 20000)
})
it('works for fixture big tx', function(){
var tx = Transaction.deserialize(fixtureTxBigHex)
assert.equal(tx.estimateFee(), 60000)
})
it('allow feePerKb to be passed in as an argument', function(){
var tx = Transaction.deserialize(fixtureTx2Hex)
assert.equal(tx.estimateFee(10000), 10000)
})
it('allow feePerKb to be set to 0', function(){
var tx = Transaction.deserialize(fixtureTx2Hex)
assert.equal(tx.estimateFee(0), 0)
})
})
})
describe('TransactionOut', function() {
describe('scriptPubKey', function() {
it('returns hex string', function() {
var txOut = new TransactionOut({
value: 50000,
script: Script.createOutputScript("1AZpKpcfCzKDUeTFBQUL4MokQai3m3HMXv")
})
assert.equal(txOut.scriptPubKey(), "76a91468edf28474ee22f68dfe7e56e76c017c1701b84f88ac")
})
})
})
})

View file

@ -1,22 +1,28 @@
var Wallet = require('../src/wallet.js')
var HDNode = require('../src/hdwallet.js')
var T = require('../src/transaction.js')
var Transaction = T.Transaction
var TransactionOut = T.TransactionOut
var Script = require('../src/script.js')
var convert = require('../src/convert.js')
var assert = require('assert')
var SHA256 = require('crypto-js/sha256')
var Crypto = require('crypto-js')
var fixtureTxes = require('./fixtures/mainnet_tx')
var fixtureTx1Hex = fixtureTxes.prevTx
var fixtureTx2Hex = fixtureTxes.tx
var sinon = require('sinon')
describe('Wallet', function() {
var seed;
var seed, wallet;
beforeEach(function(){
seed = convert.wordArrayToBytes(SHA256("don't use a string seed like this in real life"))
wallet = new Wallet(seed)
})
describe('constructor', function() {
var wallet;
beforeEach(function() {
wallet = new Wallet(seed)
})
it('defaults to Bitcoin mainnet', function() {
assert.equal(wallet.getMasterKey().network, 'mainnet')
})
@ -47,7 +53,6 @@ describe('Wallet', function() {
})
describe('constructor options', function() {
var wallet;
beforeEach(function() {
wallet = new Wallet(seed, {network: 'testnet'})
})
@ -149,6 +154,432 @@ describe('Wallet', function() {
})
})
describe('Unspent Outputs', function(){
var expectedUtxo, expectedOutputKey;
beforeEach(function(){
expectedUtxo = {
"hash":"6a4062273ac4f9ea4ffca52d9fd102b08f6c32faa0a4d1318e3a7b2e437bb9c7",
"hashLittleEndian":"c7b97b432e7b3a8e31d1a4a0fa326c8fb002d19f2da5fc4feaf9c43a2762406a",
"outputIndex": 0,
"address" : "1AZpKpcfCzKDUeTFBQUL4MokQai3m3HMXv",
"value": 20000
}
expectedOutputKey = expectedUtxo.hash + ":" + expectedUtxo.outputIndex
})
function addUtxoToOutput(utxo){
var key = utxo.hash + ":" + utxo.outputIndex
wallet.outputs[key] = {
receive: key,
address: utxo.address,
value: utxo.value
}
}
describe('getBalance', function(){
var utxo1
beforeEach(function(){
utxo1 = cloneObject(expectedUtxo)
utxo1.hash = utxo1.hash.replace('7', 'l')
})
it('sums over utxo values', function(){
addUtxoToOutput(expectedUtxo)
addUtxoToOutput(utxo1)
assert.deepEqual(wallet.getBalance(), 40000)
})
it('excludes spent outputs', function(){
addUtxoToOutput(expectedUtxo)
addUtxoToOutput(utxo1)
wallet.outputs[utxo1.hash + ':' + utxo1.outputIndex].spend = "sometxn:m"
assert.deepEqual(wallet.getBalance(), 20000)
})
})
describe('getUnspentOutputs', function(){
beforeEach(function(){
addUtxoToOutput(expectedUtxo)
})
it('parses wallet outputs to the expect format', function(){
assert.deepEqual(wallet.getUnspentOutputs(), [expectedUtxo])
})
it('excludes spent outputs', function(){
wallet.outputs[expectedOutputKey].spend = "sometxn:m"
assert.deepEqual(wallet.getUnspentOutputs(), [])
})
})
describe('setUnspentOutputs', function(){
var utxo;
beforeEach(function(){
utxo = cloneObject([expectedUtxo])
})
it('uses hashLittleEndian when hash is not present', function(){
delete utxo[0]['hash']
wallet.setUnspentOutputs(utxo)
verifyOutputs()
})
it('uses hash when hashLittleEndian is not present', function(){
delete utxo[0]['hashLittleEndian']
wallet.setUnspentOutputs(utxo)
verifyOutputs()
})
it('uses hash when both hash and hashLittleEndian are present', function(){
wallet.setUnspentOutputs(utxo)
verifyOutputs()
})
describe('required fields', function(){
it("throws an error when hash and hashLittleEndian are both missing", function(){
delete utxo[0]['hash']
delete utxo[0]['hashLittleEndian']
var errorMessage = 'Invalid unspent output: key hash(or hashLittleEndian) is missing. ' +
'A valid unspent output must contain outputIndex, address, value and hash(or hashLittleEndian)'
assert.throws(function() {
wallet.setUnspentOutputs(utxo)
}, Error, errorMessage)
});
['outputIndex', 'address', 'value'].forEach(function(field){
it("throws an error when " + field + " is missing", function(){
delete utxo[0][field]
var errorMessage = 'Invalid unspent output: key ' + field +
' is missing. A valid unspent output must contain outputIndex, address, value and hash(or hashLittleEndian)'
assert.throws(function() {
wallet.setUnspentOutputs(utxo)
}, Error, errorMessage)
})
})
})
function verifyOutputs() {
var output = wallet.outputs[expectedOutputKey]
assert(output)
assert.equal(output.value, utxo[0].value)
assert.equal(output.address, utxo[0].address)
}
})
describe('setUnspentOutputsAsync', function(){
var utxo;
beforeEach(function(){
utxo = cloneObject([expectedUtxo])
})
afterEach(function(){
wallet.setUnspentOutputs.restore()
})
it('calls setUnspentOutputs', function(){
sinon.stub(wallet, "setUnspentOutputs")
var callback = sinon.spy()
var tx = wallet.setUnspentOutputsAsync(utxo, callback)
assert(wallet.setUnspentOutputs.calledWith(utxo))
assert(callback.called)
})
it('when setUnspentOutputs throws an error, it invokes callback with error', function(){
sinon.stub(wallet, "setUnspentOutputs").throws()
var callback = sinon.spy()
var tx = wallet.setUnspentOutputsAsync(utxo, callback)
assert(callback.called)
assert(callback.args[0][0] instanceof Error)
})
})
})
describe('processTx', function(){
var tx;
beforeEach(function(){
tx = Transaction.deserialize(fixtureTx1Hex)
})
describe("when tx outs contains an address owned by the wallet, an 'output' gets added to wallet.outputs", function(){
it("works for receive address", function(){
var totalOuts = outputCount()
wallet.addresses = [tx.outs[0].address.toString()]
wallet.processTx(tx)
assert.equal(outputCount(), totalOuts + 1)
verifyOutputAdded(0)
})
it("works for change address", function(){
var totalOuts = outputCount()
wallet.changeAddresses = [tx.outs[1].address.toString()]
wallet.processTx(tx)
assert.equal(outputCount(), totalOuts + 1)
verifyOutputAdded(1)
})
function outputCount(){
return Object.keys(wallet.outputs).length
}
function verifyOutputAdded(index) {
var txOut = tx.outs[index]
var key = convert.bytesToHex(tx.getHash()) + ":" + index
var output = wallet.outputs[key]
assert.equal(output.receive, key)
assert.equal(output.value, txOut.value)
assert.equal(output.address, txOut.address)
}
})
describe("when tx ins outpoint contains a known txhash:i, the corresponding 'output' gets updated", function(){
beforeEach(function(){
wallet.addresses = [tx.outs[0].address.toString()] // the address fixtureTx2 used as input
wallet.processTx(tx)
tx = Transaction.deserialize(fixtureTx2Hex)
})
it("does not add to wallet.outputs", function(){
var outputs = wallet.outputs
wallet.processTx(tx)
assert.deepEqual(wallet.outputs, outputs)
})
it("sets spend with the transaction hash and input index", function(){
wallet.processTx(tx)
var txIn = tx.ins[0]
var key = txIn.outpoint.hash + ":" + txIn.outpoint.index
var output = wallet.outputs[key]
assert.equal(output.spend, convert.bytesToHex(tx.getHash()) + ':' + 0)
})
})
it("does nothing when none of the involved addresses belong to the wallet", function(){
var outputs = wallet.outputs
wallet.processTx(tx)
assert.deepEqual(wallet.outputs, outputs)
})
})
describe('createTx', function(){
var to, value;
var address1, address2;
beforeEach(function(){
to = '15mMHKL96tWAUtqF3tbVf99Z8arcmnJrr3'
value = 500000
// generate 2 addresses
address1 = wallet.generateAddress()
address2 = wallet.generateAddress()
// set up 3 utxo
utxo = [
{
"hash": fakeTxHash(1),
"outputIndex": 0,
"address" : address1,
"value": 400000 // not enough for value
},
{
"hash": fakeTxHash(2),
"outputIndex": 1,
"address" : address1,
"value": 500000 // enough for only value
},
{
"hash": fakeTxHash(3),
"outputIndex": 0,
"address" : address2,
"value": 520000 // enough for value and fee
}
]
wallet.setUnspentOutputs(utxo)
})
describe('choosing utxo', function(){
it('calculates fees', function(){
var tx = wallet.createTx(to, value)
assert.equal(tx.ins.length, 1)
assert.deepEqual(tx.ins[0].outpoint, { hash: fakeTxHash(3), index: 0 })
})
it('allows fee to be specified', function(){
var fee = 30000
var tx = wallet.createTx(to, value, fee)
assert.equal(tx.ins.length, 2)
assert.deepEqual(tx.ins[0].outpoint, { hash: fakeTxHash(3), index: 0 })
assert.deepEqual(tx.ins[1].outpoint, { hash: fakeTxHash(2), index: 1 })
})
it('ignores spent outputs', function(){
utxo.push(
{
"hash": fakeTxHash(4),
"outputIndex": 0,
"address" : address2,
"value": 530000 // enough but spent before createTx
}
)
wallet.setUnspentOutputs(utxo)
wallet.outputs[fakeTxHash(4) + ":" + 0].spend = fakeTxHash(5) + ":" + 0
var tx = wallet.createTx(to, value)
assert.equal(tx.ins.length, 1)
assert.deepEqual(tx.ins[0].outpoint, { hash: fakeTxHash(3), index: 0 })
})
})
describe('transaction outputs', function(){
it('includes the specified address and amount', function(){
var tx = wallet.createTx(to, value)
assert.equal(tx.outs.length, 1)
var out = tx.outs[0]
assert.equal(out.address, to)
assert.equal(out.value, value)
})
describe('change', function(){
it('uses the last change address if there is any', function(){
var fee = 5000
wallet.generateChangeAddress()
wallet.generateChangeAddress()
var tx = wallet.createTx(to, value, fee)
assert.equal(tx.outs.length, 2)
var out = tx.outs[1]
assert.equal(out.address, wallet.changeAddresses[1])
assert.equal(out.value, 15000)
})
it('generates a change address if there is not any', function(){
var fee = 5000
assert.equal(wallet.changeAddresses.length, 0)
var tx = wallet.createTx(to, value, fee)
assert.equal(wallet.changeAddresses.length, 1)
var out = tx.outs[1]
assert.equal(out.address, wallet.changeAddresses[0])
assert.equal(out.value, 15000)
})
it('skips change if it is not above dust threshold', function(){
var fee = 14570
var tx = wallet.createTx(to, value)
assert.equal(tx.outs.length, 1)
})
})
})
describe('signing', function(){
afterEach(function(){
Transaction.prototype.sign.restore()
})
it('signes the inputs with respective keys', function(){
var fee = 30000
sinon.stub(Transaction.prototype, "sign")
var tx = wallet.createTx(to, value, fee)
assert(Transaction.prototype.sign.calledWith(0, wallet.getPrivateKeyForAddress(address2)))
assert(Transaction.prototype.sign.calledWith(1, wallet.getPrivateKeyForAddress(address1)))
})
})
describe('when value is below dust threshold', function(){
it('throws an error', function(){
var value = 5430
assert.throws(function() {
wallet.createTx(to, value)
}, /Value must be above dust threshold/)
})
})
describe('when there is not enough money', function(){
it('throws an error', function(){
var value = 1400001
assert.throws(function() {
wallet.createTx(to, value)
}, Error, 'Not enough money to send funds including transaction fee. Have: 1420000, needed: 1420001')
})
})
function fakeTxHash(i) {
return "txtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtxtx" + i
}
})
describe('createTxAsync', function(){
var to, value, fee;
beforeEach(function(){
to = '15mMHKL96tWAUtqF3tbVf99Z8arcmnJrr3'
value = 500000
fee = 10000
})
afterEach(function(){
wallet.createTx.restore()
})
it('calls createTx', function(){
sinon.stub(wallet, "createTx").returns("fakeTx")
var callback = sinon.spy()
var tx = wallet.createTxAsync(to, value, callback)
assert(wallet.createTx.calledWith(to, value))
assert(callback.calledWith(null, "fakeTx"))
})
it('calls createTx correctly when fee is specified', function(){
sinon.stub(wallet, "createTx").returns("fakeTx")
var callback = sinon.spy()
var tx = wallet.createTxAsync(to, value, fee, callback)
assert(wallet.createTx.calledWith(to, value, fee))
assert(callback.calledWith(null, "fakeTx"))
})
it('when createTx throws an error, it invokes callback with error', function(){
sinon.stub(wallet, "createTx").throws()
var callback = sinon.spy()
var tx = wallet.createTxAsync(to, value, callback)
assert(callback.called)
assert(callback.args[0][0] instanceof Error)
})
})
function assertEqual(obj1, obj2){
assert.equal(obj1.toString(), obj2.toString())
}
@ -156,4 +587,9 @@ describe('Wallet', function() {
function assertNotEqual(obj1, obj2){
assert.notEqual(obj1.toString(), obj2.toString())
}
// quick and dirty: does not deal with functions on object
function cloneObject(obj){
return JSON.parse(JSON.stringify(obj))
}
})