Initial commit

This commit is contained in:
Ivan Socolsky 2015-01-27 10:18:45 -03:00
commit 3e6f1cfebe
9 changed files with 837 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# Commenting this out is preferred by some people, see
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-
node_modules
# Users Environment Variables
.lock-wscript
*.swp

33
lib/lock.js Normal file
View File

@ -0,0 +1,33 @@
var _ = require('lodash');
var locks = {};
var Lock = function () {
this.taken = false;
this.queue = [];
};
Lock.prototype.free = function () {
if (this.queue.length > 0) {
var f = this.queue.shift();
f(this);
} else {
this.taken = false;
}
};
Lock.get = function (key, callback) {
if (_.isUndefined(locks[key])) {
locks[key] = new Lock();
}
var lock = locks[key];
if (lock.taken) {
lock.queue.push(callback);
} else {
lock.taken = true;
callback(lock);
}
};
module.exports = Lock;

18
lib/model/address.js Normal file
View File

@ -0,0 +1,18 @@
'use strict';
function Address(opts) {
opts = opts || {};
this.address = opts.address;
this.path = opts.path;
};
Address.fromObj = function (obj) {
var x = new Address();
x.address = obj.address;
x.path = obj.path;
return x;
};
module.exports = Address;

27
lib/model/copayer.js Normal file
View File

@ -0,0 +1,27 @@
'use strict';
var _ = require('lodash');
function Copayer(opts) {
opts = opts || {};
this.walletId = opts.walletId;
this.id = opts.id;
this.name = opts.name;
this.xPubKey = opts.xPubKey;
this.xPubKeySignature = opts.xPubKeySignature;
};
Copayer.fromObj = function (obj) {
var x = new Copayer();
x.walletId = obj.walletId;
x.id = obj.id;
x.name = obj.name;
x.xPubKey = obj.xPubKey;
x.xPubKeySignature = obj.xPubKeySignature;
return x;
};
module.exports = Copayer;

28
lib/model/wallet.js Normal file
View File

@ -0,0 +1,28 @@
'use strict';
var _ = require('lodash');
function Wallet(opts) {
opts = opts || {};
this.id = opts.id;
this.name = opts.name;
this.m = opts.m;
this.n = opts.n;
this.status = 'pending';
this.publicKeyRing = [];
};
Wallet.fromObj = function (obj) {
var x = new Wallet();
x.id = obj.id;
x.name = obj.name;
x.m = obj.m;
x.n = obj.n;
x.status = obj.status;
x.publicKeyRing = obj.publicKeyRing;
return x;
};
module.exports = Wallet;

235
lib/server.js Normal file
View File

@ -0,0 +1,235 @@
'use strict';
var _ = require('lodash');
var $ = require('preconditions').singleton();
var async = require('async');
var log = require('npmlog');
log.debug = log.verbose;
var Lock = require('./lock');
var Storage = require('./storage');
var Wallet = require('./model/wallet');
var Copayer = require('./model/copayer');
function CopayServer(opts) {
opts = opts || {};
this.storage = new Storage(opts);
};
CopayServer.prototype.createWallet = function (opts, cb) {
var self = this;
self.getWallet({ id: opts.id }, function (err, wallet) {
if (err) return cb(err);
if (wallet) return cb('Wallet already exists');
var wallet = new Wallet({
id: opts.id,
name: opts.name,
m: opts.m,
n: opts.n,
network: opts.network || 'livenet',
pubKey: opts.pubKey,
});
self.storage.storeWallet(wallet, cb);
});
};
CopayServer.prototype.getWallet = function (opts, cb) {
var self = this;
self.storage.fetchWallet(opts.id, function (err, wallet) {
if (err || !wallet) return cb(err);
if (opts.includeCopayers) {
self.storage.fetchCopayers(wallet.id, function (err, copayers) {
if (err) return cb(err);
wallet.copayers = copayers || [];
return cb(null, wallet);
});
} else {
return cb(null, wallet);
}
});
};
CopayServer.prototype.joinWallet = function (opts, cb) {
var self = this;
Lock.get(opts.walletId, function (lock) {
var _cb = function (err, res) {
cb(err, res);
lock.free();
};
self.getWallet({ id: opts.walletId, includeCopayers: true }, function (err, wallet) {
if (err) return _cb(err);
if (!wallet) return _cb('Wallet not found');
if (_.find(wallet.copayers, { xPubKey: opts.xPubKey })) return _cb('Copayer already in wallet');
if (wallet.copayers.length == wallet.n) return _cb('Wallet full');
// TODO: validate copayer's extended public key using the public key from this wallet
// Note: use Bitcore.crypto.ecdsa .verify()
var copayer = new Copayer({
walletId: wallet.id,
id: opts.id,
name: opts.name,
xPubKey: opts.xPubKey,
xPubKeySignature: opts.xPubKeySignature,
});
self.storage.storeCopayer(copayer, function (err) {
if (err) return _cb(err);
if ((wallet.copayers.length + 1) < wallet.n) return _cb();
wallet.status = 'complete';
wallet.publicKeyRing = _.pluck(wallet.copayers, 'xPubKey');
wallet.publicKeyRing.push(copayer.xPubKey);
self.storage.storeWallet(wallet, _cb);
});
});
});
};
CopayServer.prototype._doCreateAddress = function (pkr, isChange) {
throw 'not implemented';
};
// opts = {
// walletId,
// isChange,
// };
CopayServer.prototype.createAddress = function (opts, cb) {
var self = this;
self.getWallet({ id: opts.walletId }, function (err, wallet) {
if (err) return cb(err);
if (!wallet) return cb('Wallet not found');
var address = self._doCreateAddress(wallet.publicKeyRing, opts.isChange);
return cb(null, address);
});
};
CopayServer.prototype._verifyMessageSignature = function (copayerId, message, signature) {
throw 'not implemented';
};
CopayServer.prototype._getBlockExplorer = function (provider, network) {
var url;
switch (provider) {
default:
case 'insight':
switch (network) {
default:
case 'livenet':
url = 'https://insight.bitpay.com:443';
break;
case 'testnet':
url = 'https://test-insight.bitpay.com:443'
break;
}
return new Bitcore.Insight(url, network);
break;
}
};
CopayServer.prototype._getUtxos = function (opts, cb) {
var self = this;
// Get addresses for this wallet
self.storage.getAddresses(opts.walletId, function (err, addresses) {
if (err) return cb(err);
if (addresses.length == 0) return cb('The wallet has no addresses');
var addresses = _.pluck(addresses, 'address');
var bc = _getBlockExplorer('insight', opts.network);
bc.getUnspentUtxos(addresses, function (err, utxos) {
if (err) return cb(err);
// TODO: filter 'locked' utxos
return cb(null, utxos);
});
});
};
CopayServer.prototype._doCreateTx = function (opts, cb) {
var tx = new Bitcore.Transaction()
.from(opts.utxos)
.to(opts.toAddress, opts.amount)
.change(opts.changeAddress);
return tx;
};
// opts = {
// copayerId,
// walletId,
// toAddress,
// amount, // in Satoshi
// message,
// otToken, // one time random token generated by the client and signed to avoid replay attacks
// utxos: [], // optional (not yet implemented)
// requestSignature, // S(toAddress + amount + otToken) using this copayers privKey
// // using this signature, the server can
// };
// result = {
// ntxid,
// rawTx,
// };
CopayServer.prototype.createTx = function (opts, cb) {
// Client generates a unique token and signs toAddress + amount + token.
// This way we authenticate + avoid replay attacks.
var self = this;
self.getWallet({ id: opts.walletId }, function (err, wallet) {
if (err) return cb(err);
if (!wallet) return cb('Wallet not found');
var msg = '' + opts.toAddress + opts.amount + opts.otToken;
if (!self._verifyMessageSignature(opts.copayerId, msg, opts.requestSignature)) return cb('Invalid request');
var txArgs = {
toAddress: opts.toAddress,
amount: opts.amount,
changeAddress: opts.changeAddress,
};
self._getUtxos({ walletId: wallet.id }, function (err, utxos) {
if (err) return cb('Could not retrieve UTXOs');
txArgs.utxos = utxos;
self._doCreateTx(txArgs, function (err, tx) {
if (err) return cb('Could not create transaction');
self.storage.storeTx(tx, function (err) {
if (err) return cb(err);
return cb(null, {
ntxid: tx.ntxid,
rawTx: tx.raw,
});
});
});
});
});
};
CopayServer.prototype.getPendingTxs = function (opts, cb) {
var self = this;
//self.storage.get
};
module.exports = CopayServer;

77
lib/storage.js Normal file
View File

@ -0,0 +1,77 @@
'use strict';
var _ = require('lodash');
var levelup = require('levelup');
var $ = require('preconditions').singleton();
var async = require('async');
var log = require('npmlog');
log.debug = log.verbose;
var Wallet = require('./model/wallet');
var Copayer = require('./model/copayer');
var Address = require('./model/address');
var Storage = function (opts) {
opts = opts || {};
this.db = opts.db || levelup(opts.dbPath || './db/copay.db', { valueEncoding: 'json' });
};
Storage.prototype.fetchWallet = function (id, cb) {
this.db.get('wallet-' + id, function (err, data) {
if (err) {
if (err.notFound) return cb();
return cb(err);
}
return cb(null, Wallet.fromObj(data));
});
};
Storage.prototype.fetchCopayers = function (walletId, cb) {
var copayers = [];
var key = 'wallet-' + walletId + '-copayer-';
this.db.createReadStream({ gte: key, lt: key + '~' })
.on('data', function (data) {
copayers.push(Copayer.fromObj(data.value));
})
.on('error', function (err) {
if (err.notFound) return cb();
return cb(err);
})
.on('end', function () {
return cb(null, copayers);
});
};
Storage.prototype.storeWallet = function (wallet, cb) {
this.db.put('wallet-' + wallet.id, wallet, cb);
};
Storage.prototype.storeCopayer = function (copayer, cb) {
this.db.put('wallet-' + copayer.walletId + '-copayer-' + copayer.id, copayer, cb);
};
Storage.prototype.getAddresses = function (walletId, cb) {
var addresses = [];
var key = 'wallet-' + walletId + '-address-';
this.db.createReadStream({ gte: key, lt: key + '~' })
.on('data', function (data) {
addresses.push(Address.fromObj(data.value));
})
.on('error', function (err) {
if (err.notFound) return cb();
return cb(err);
})
.on('end', function () {
return cb(null, addresses);
});
};
Storage.prototype._dump = function (cb) {
this.db.readStream()
.on('data', console.log)
.on('end', function () { if (cb) return cb(); });
};
module.exports = Storage;

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "copay-server",
"description": "Copay server",
"author": "isocolsky",
"version": "0.0.1",
"keywords": [
"bitcoin",
"copay",
"multisig",
"wallet"
],
"repository": {
"url": "git@github.com:isocolsky/copay-lib.git",
"type": "git"
},
"bugs": {
"url": "https://github.com/isocolsky/copay-lib/issues"
},
"dependencies": {
"bitcore": "^0.8.6",
"async": "^0.9.0",
"lodash": "^2.4.1",
"preconditions": "^1.0.7",
"express": "^4.10.0",
"leveldown": "^0.10.0",
"levelup": "^0.19.0",
"npmlog": "^0.1.1"
},
"devDependencies": {
"chai": "^1.9.1",
"mocha": "^1.18.2",
"sinon": "^1.10.3",
"memdown": "^1.0.0"
},
"scripts": {
"start": "node server.js"
}
}

351
test/integration.js Normal file
View File

@ -0,0 +1,351 @@
'use strict';
var _ = require('lodash');
var async = require('async');
var chai = require('chai');
var sinon = require('sinon');
var should = chai.should();
var levelup = require('levelup');
var memdown = require('memdown');
var Wallet = require('../lib/model/wallet');
var Copayer = require('../lib/model/copayer');
var CopayServer = require('../lib/server');
var db;
var server;
describe('Copay server', function() {
beforeEach(function() {
db = levelup(memdown, { valueEncoding: 'json' });
});
describe('#getWallet', function() {
beforeEach(function() {
server = new CopayServer({
db: db,
});
});
it('should get existing wallet', function (done) {
var w1 = new Wallet({
id: '123',
name: 'my wallet',
m: 2,
n: 3,
pubKey: 'dummy',
});
var w2 = new Wallet({
id: '234',
name: 'my wallet 2',
m: 3,
n: 4,
pubKey: 'dummy',
});
db.batch([{
type: 'put',
key: 'wallet-123',
value: w1,
}, {
type: 'put',
key: 'wallet-234',
value: w2,
}]);
server.getWallet({ id: '123', includeCopayers: true }, function (err, wallet) {
should.not.exist(err);
wallet.id.should.equal('123');
wallet.name.should.equal('my wallet');
wallet.status.should.equal('pending');
wallet.copayers.length.should.equal(0);
done();
});
});
it('should return undefined when requesting non-existent wallet', function (done) {
var w1 = new Wallet({
id: '123',
name: 'my wallet',
m: 2,
n: 3,
pubKey: 'dummy',
});
var w2 = new Wallet({
id: '234',
name: 'my wallet 2',
m: 3,
n: 4,
pubKey: 'dummy',
});
db.batch([{
type: 'put',
key: 'wallet-123',
value: w1,
}, {
type: 'put',
key: 'wallet-234',
value: w2,
}]);
server.getWallet({ id: '345' }, function (err, wallet) {
should.not.exist(err);
should.not.exist(wallet);
done();
});
});
});
describe('#createWallet', function() {
beforeEach(function() {
server = new CopayServer({
db: db,
});
});
it('should create and store wallet', function(done) {
var opts = {
id: '123',
name: 'my wallet',
m: 2,
n: 3,
pubKey: 'dummy',
};
server.createWallet(opts, function(err) {
should.not.exist(err);
server.getWallet({ id: '123' }, function (err, wallet) {
should.not.exist(err);
wallet.id.should.equal('123');
wallet.name.should.equal('my wallet');
done();
});
});
});
it('should fail to recreate existing wallet', function(done) {
var opts = {
id: '123',
name: 'my wallet',
m: 2,
n: 3,
pubKey: 'dummy',
};
server.createWallet(opts, function(err) {
should.not.exist(err);
server.getWallet({ id: '123' }, function (err, wallet) {
should.not.exist(err);
wallet.id.should.equal('123');
wallet.name.should.equal('my wallet');
server.createWallet(opts, function(err) {
should.exist(err);
done();
});
});
});
});
});
describe('#joinWallet', function() {
beforeEach(function() {
server = new CopayServer({
db: db,
});
});
it('should join existing wallet', function (done) {
var walletOpts = {
id: '123',
name: 'my wallet',
m: 2,
n: 3,
pubKey: 'dummy',
};
server.createWallet(walletOpts, function(err) {
should.not.exist(err);
var copayerOpts = {
walletId: '123',
id: '999',
name: 'me',
xPubKey: 'dummy',
xPubKeySignature: 'dummy',
};
server.joinWallet(copayerOpts, function (err) {
should.not.exist(err);
server.getWallet({ id: '123', includeCopayers: true }, function (err, wallet) {
wallet.id.should.equal('123');
wallet.copayers.length.should.equal(1);
var copayer = wallet.copayers[0];
copayer.id.should.equal('999');
copayer.name.should.equal('me');
done();
});
});
});
});
it('should fail to join non-existent wallet', function (done) {
var walletOpts = {
id: '123',
name: 'my wallet',
m: 2,
n: 3,
pubKey: 'dummy',
};
server.createWallet(walletOpts, function(err) {
should.not.exist(err);
var copayerOpts = {
walletId: '234',
id: '999',
name: 'me',
xPubKey: 'dummy',
xPubKeySignature: 'dummy',
};
server.joinWallet(copayerOpts, function (err) {
should.exist(err);
done();
});
});
});
it('should fail to join full wallet', function (done) {
var walletOpts = {
id: '123',
name: 'my wallet',
m: 1,
n: 1,
pubKey: 'dummy',
};
server.createWallet(walletOpts, function(err) {
should.not.exist(err);
var copayer1Opts = {
walletId: '123',
id: '111',
name: 'me',
xPubKey: 'dummy1',
xPubKeySignature: 'dummy',
};
var copayer2Opts = {
walletId: '123',
id: '222',
name: 'me 2',
xPubKey: 'dummy2',
xPubKeySignature: 'dummy',
};
server.joinWallet(copayer1Opts, function (err) {
should.not.exist(err);
server.getWallet({ id: '123' }, function (err, wallet) {
wallet.status.should.equal('complete');
server.joinWallet(copayer2Opts, function (err) {
should.exist(err);
err.should.equal('Wallet full');
done();
});
});
});
});
});
it('should fail to re-join wallet', function (done) {
var walletOpts = {
id: '123',
name: 'my wallet',
m: 1,
n: 1,
pubKey: 'dummy',
};
server.createWallet(walletOpts, function(err) {
should.not.exist(err);
var copayerOpts = {
walletId: '123',
id: '111',
name: 'me',
xPubKey: 'dummy',
xPubKeySignature: 'dummy',
};
server.joinWallet(copayerOpts, function (err) {
should.not.exist(err);
server.joinWallet(copayerOpts, function (err) {
should.exist(err);
err.should.equal('Copayer already in wallet');
done();
});
});
});
});
it('should set pkr and status = complete on last copayer joining', function (done) {
helpers.createAndJoinWallet('123', 2, 3, function (err, wallet) {
server.getWallet({ id: '123' }, function (err, wallet) {
should.not.exist(err);
wallet.status.should.equal('complete');
wallet.publicKeyRing.length.should.equal(3);
done();
});
});
});
});
var helpers = {};
helpers.createAndJoinWallet = function (id, m, n, cb) {
var walletOpts = {
id: id,
name: id + ' wallet',
m: m,
n: n,
pubKey: 'dummy',
};
server.createWallet(walletOpts, function(err) {
if (err) return cb(err);
async.each(_.range(1, n + 1), function (i, cb) {
var copayerOpts = {
walletId: id,
id: '' + i,
name: 'copayer ' + i,
xPubKey: 'dummy' + i,
xPubKeySignature: 'dummy',
};
server.joinWallet(copayerOpts, function (err) {
return cb(err);
});
}, function (err) {
if (err) return cb(err);
server.getWallet({ id: id, includeCopayers: true }, function (err, wallet) {
return cb(err, wallet);
});
});
});
};
describe('#createTx', function() {
beforeEach(function() {
server = new CopayServer({
db: db,
});
});
it.skip('should create tx', function (done) {
server._verifyMessageSignature = sinon.stub().returns(true);
helpers.createAndJoinWallet('123', 2, 2, function (err, wallet) {
var txOpts = {
copayerId: '1',
walletId: '123',
toAddress: 'dummy',
amount: 100,
message: 'some message',
otToken: 'dummy',
requestSignature: 'dummy',
};
server.createTx(txOpts, function (err, res) {
should.not.exist(err);
res.ntxid.should.exist;
res.txRaw.should.exist;
done();
});
});
});
});
});