From 3e6f1cfebebdf0456235c40f6984027403496cb6 Mon Sep 17 00:00:00 2001 From: Ivan Socolsky Date: Tue, 27 Jan 2015 10:18:45 -0300 Subject: [PATCH] Initial commit --- .gitignore | 30 ++++ lib/lock.js | 33 ++++ lib/model/address.js | 18 +++ lib/model/copayer.js | 27 ++++ lib/model/wallet.js | 28 ++++ lib/server.js | 235 +++++++++++++++++++++++++++++ lib/storage.js | 77 ++++++++++ package.json | 38 +++++ test/integration.js | 351 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 837 insertions(+) create mode 100644 .gitignore create mode 100644 lib/lock.js create mode 100644 lib/model/address.js create mode 100644 lib/model/copayer.js create mode 100644 lib/model/wallet.js create mode 100644 lib/server.js create mode 100644 lib/storage.js create mode 100644 package.json create mode 100644 test/integration.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eecee87 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/lib/lock.js b/lib/lock.js new file mode 100644 index 0000000..1461437 --- /dev/null +++ b/lib/lock.js @@ -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; diff --git a/lib/model/address.js b/lib/model/address.js new file mode 100644 index 0000000..6521c87 --- /dev/null +++ b/lib/model/address.js @@ -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; diff --git a/lib/model/copayer.js b/lib/model/copayer.js new file mode 100644 index 0000000..0d4906e --- /dev/null +++ b/lib/model/copayer.js @@ -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; diff --git a/lib/model/wallet.js b/lib/model/wallet.js new file mode 100644 index 0000000..63500f8 --- /dev/null +++ b/lib/model/wallet.js @@ -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; diff --git a/lib/server.js b/lib/server.js new file mode 100644 index 0000000..2830aab --- /dev/null +++ b/lib/server.js @@ -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; diff --git a/lib/storage.js b/lib/storage.js new file mode 100644 index 0000000..a967950 --- /dev/null +++ b/lib/storage.js @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 0000000..72e5335 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/test/integration.js b/test/integration.js new file mode 100644 index 0000000..a94cbd4 --- /dev/null +++ b/test/integration.js @@ -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(); + }); + }); + }); + }); +});