Ivan Socolsky
10 years ago
18 changed files with 820 additions and 449 deletions
@ -0,0 +1,25 @@ |
|||
|
|||
var _ = require('lodash'); |
|||
|
|||
var Bitcore = require('bitcore'); |
|||
var BitcoreAddress = Bitcore.Address; |
|||
|
|||
function BitcoinUtils () {}; |
|||
|
|||
BitcoinUtils.deriveAddress = function(publicKeyRing, path, m, network) { |
|||
|
|||
var publicKeys = _.map(publicKeyRing, function(xPubKey) { |
|||
var xpub = new Bitcore.HDPublicKey(xPubKey); |
|||
return xpub.derive(path).publicKey; |
|||
}); |
|||
|
|||
var bitcoreAddress = BitcoreAddress.createMultisig(publicKeys, m, network); |
|||
|
|||
return { |
|||
address: bitcoreAddress.toString(), |
|||
path: path, |
|||
publicKeys: _.invoke(publicKeys, 'toString'), |
|||
}; |
|||
}; |
|||
|
|||
module.exports = BitcoinUtils; |
@ -1,382 +0,0 @@ |
|||
'use strict'; |
|||
|
|||
var _ = require('lodash'); |
|||
var util = require('util'); |
|||
var async = require('async'); |
|||
var log = require('npmlog'); |
|||
var request = require('request') |
|||
log.debug = log.verbose; |
|||
|
|||
var Bitcore = require('bitcore') |
|||
var SignUtils = require('../signutils'); |
|||
|
|||
var BASE_URL = 'http://localhost:3001/copay/api'; |
|||
|
|||
function _createProposalOpts(opts, signingKey) { |
|||
var msg = opts.toAddress + '|' + opts.amount + '|' + opts.message; |
|||
opts.proposalSignature = SignUtils.sign(msg, signingKey); |
|||
return opts; |
|||
}; |
|||
|
|||
function _getUrl(path) { |
|||
return BASE_URL + path; |
|||
}; |
|||
|
|||
function _parseError(body) { |
|||
if (_.isString(body)) { |
|||
try { |
|||
body = JSON.parse(body); |
|||
} catch (e) { |
|||
body = { |
|||
error: body |
|||
}; |
|||
} |
|||
} |
|||
var code = body.code || 'ERROR'; |
|||
var message = body.error || 'There was an unknown error processing the request'; |
|||
log.error(code, message); |
|||
}; |
|||
|
|||
function _signRequest(method, url, args, privKey) { |
|||
var message = method.toLowerCase() + '|' + url + '|' + JSON.stringify(args); |
|||
return SignUtils.sign(message, privKey); |
|||
}; |
|||
|
|||
function _createXPrivKey(network) { |
|||
return new Bitcore.HDPrivateKey(network).toString(); |
|||
}; |
|||
|
|||
function API(opts) { |
|||
if (!opts.storage) { |
|||
throw new Error('Must provide storage option'); |
|||
} |
|||
this.storage = opts.storage; |
|||
this.verbose = !!opts.verbose; |
|||
if (this.verbose) { |
|||
log.level = 'debug'; |
|||
} |
|||
}; |
|||
|
|||
|
|||
API.prototype._loadAndCheck = function() { |
|||
var data = this.storage.load(); |
|||
if (!data) { |
|||
log.error('Wallet file not found.'); |
|||
process.exit(1); |
|||
} |
|||
|
|||
if (data.verified == 'corrupt') { |
|||
log.error('The wallet is tagged as corrupt. Some of the copayers cannot be verified to have known the wallet secret.'); |
|||
process.exit(1); |
|||
} |
|||
if (data.n > 1) { |
|||
var pkrComplete = data.publicKeyRing && data.m && data.publicKeyRing.length === data.n; |
|||
if (!pkrComplete) { |
|||
log.warn('The file ' + this.filename + ' is incomplete. It will allow you to operate with the wallet but it should not be trusted as a backup. Please wait for all copayers to join the wallet and run the tool with -export flag.') |
|||
} |
|||
} |
|||
return data; |
|||
}; |
|||
|
|||
API.prototype._doRequest = function(method, url, args, data, cb) { |
|||
var reqSignature = _signRequest(method, url, args, data.signingPrivKey); |
|||
var absUrl = _getUrl(url); |
|||
var args = { |
|||
headers: { |
|||
'x-identity': data.copayerId, |
|||
'x-signature': reqSignature, |
|||
}, |
|||
method: method, |
|||
url: absUrl, |
|||
body: args, |
|||
json: true, |
|||
}; |
|||
log.verbose('Request Args', util.inspect(args)); |
|||
request(args, function(err, res, body) { |
|||
log.verbose('Response:', err, body); |
|||
|
|||
if (err) return cb(err); |
|||
if (res.statusCode != 200) { |
|||
_parseError(body); |
|||
return cb('Request error'); |
|||
} |
|||
return cb(null, body); |
|||
}); |
|||
}; |
|||
|
|||
|
|||
API.prototype._doPostRequest = function(url, args, data, cb) { |
|||
return this._doRequest('post', url, args, data, cb); |
|||
}; |
|||
|
|||
API.prototype._doGetRequest = function(url, data, cb) { |
|||
return this._doRequest('get', url, {}, data, cb); |
|||
}; |
|||
|
|||
|
|||
|
|||
API.prototype.createWallet = function(walletName, copayerName, m, n, network, cb) { |
|||
var self = this; |
|||
network = network || 'livenet'; |
|||
if (!_.contains(['testnet', 'livenet'], network)) |
|||
return cb('Invalid network'); |
|||
|
|||
var data = this.storage.load(); |
|||
if (data) return cb('File ' + this.filename + ' already contains a wallet'); |
|||
|
|||
// Generate wallet key pair to verify copayers
|
|||
var privKey = new Bitcore.PrivateKey(null, network); |
|||
var pubKey = privKey.toPublicKey(); |
|||
|
|||
data = { |
|||
m: m, |
|||
n: n, |
|||
walletPrivKey: privKey.toString(), |
|||
}; |
|||
|
|||
var args = { |
|||
name: walletName, |
|||
m: m, |
|||
n: n, |
|||
pubKey: pubKey.toString(), |
|||
network: network, |
|||
}; |
|||
var url = '/v1/wallets/'; |
|||
|
|||
this._doPostRequest(url, args, data, function(err, body) { |
|||
if (err) return cb(err); |
|||
|
|||
var walletId = body.walletId; |
|||
var secret = walletId + ':' + privKey.toString() + ':' + (network == 'testnet' ? 'T' : 'L'); |
|||
var ret; |
|||
|
|||
if (n > 1) |
|||
ret = data.secret = secret; |
|||
|
|||
self.storage.save(data); |
|||
self._joinWallet(data, secret, copayerName, function(err) { |
|||
if (err) return cb(err); |
|||
|
|||
return cb(null, ret); |
|||
}); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype._joinWallet = function(data, secret, copayerName, cb) { |
|||
var self = this; |
|||
data = data || {}; |
|||
|
|||
var secretSplit = secret.split(':'); |
|||
var walletId = secretSplit[0]; |
|||
|
|||
var walletPrivKey = Bitcore.PrivateKey.fromString(secretSplit[1]); |
|||
var network = secretSplit[2] == 'T' ? 'testnet' : 'livenet'; |
|||
data.xPrivKey = _createXPrivKey(network); |
|||
|
|||
var xPubKey = new Bitcore.HDPublicKey(data.xPrivKey); |
|||
var xPubKeySignature = SignUtils.sign(xPubKey.toString(), walletPrivKey); |
|||
|
|||
var signingPrivKey = (new Bitcore.HDPrivateKey(data.xPrivKey)).derive('m/1/0').privateKey; |
|||
var args = { |
|||
walletId: walletId, |
|||
name: copayerName, |
|||
xPubKey: xPubKey.toString(), |
|||
xPubKeySignature: xPubKeySignature, |
|||
}; |
|||
var url = '/v1/wallets/' + walletId + '/copayers'; |
|||
|
|||
this._doPostRequest(url, args, data, function(err, body) { |
|||
var wallet = body.wallet; |
|||
data.copayerId = body.copayerId; |
|||
data.walletPrivKey = walletPrivKey; |
|||
data.signingPrivKey = signingPrivKey.toString(); |
|||
data.m = wallet.m; |
|||
data.n = wallet.n; |
|||
data.publicKeyRing = wallet.publicKeyRing; |
|||
self.storage.save(data); |
|||
|
|||
return cb(); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.joinWallet = function(secret, copayerName, cb) { |
|||
var self = this; |
|||
|
|||
var data = this.storage.load(); |
|||
if (data) return cb('File ' + this.filename + ' already contains a wallet'); |
|||
|
|||
self._joinWallet(data, secret, copayerName, cb); |
|||
}; |
|||
|
|||
API.prototype.getStatus = function(cb) { |
|||
var self = this; |
|||
|
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/wallets/'; |
|||
this._doGetRequest(url, data, function(err, body) { |
|||
if (err) return cb(err); |
|||
|
|||
var wallet = body; |
|||
if (wallet.n > 0 && wallet.status === 'complete' && !data.verified) { |
|||
var pubKey = Bitcore.PrivateKey.fromString(data.walletPrivKey).toPublicKey().toString(); |
|||
var fake = []; |
|||
_.each(wallet.copayers, function(copayer) { |
|||
|
|||
|
|||
console.log('[clilib.js.224]', copayer.xPubKey, copayer.xPubKeySignature, pubKey); //TODO
|
|||
if (!SignUtils.verify(copayer.xPubKey, copayer.xPubKeySignature, pubKey)) { |
|||
|
|||
console.log('[clilib.js.227] FAKE'); //TODO
|
|||
fake.push(copayer); |
|||
} |
|||
}); |
|||
if (fake.length > 0) { |
|||
log.error('Some copayers in the wallet could not be verified to have known the wallet secret'); |
|||
data.verified = 'corrupt'; |
|||
} else { |
|||
data.verified = 'ok'; |
|||
} |
|||
self.storage.save(data); |
|||
} |
|||
|
|||
return cb(null, wallet); |
|||
}); |
|||
}; |
|||
|
|||
/** |
|||
* send |
|||
* |
|||
* @param inArgs |
|||
* @param inArgs.toAddress |
|||
* @param inArgs.amount |
|||
* @param inArgs.message |
|||
*/ |
|||
API.prototype.sendTxProposal = function(inArgs, cb) { |
|||
var self = this; |
|||
|
|||
var data = this._loadAndCheck(); |
|||
var args = _createProposalOpts(inArgs, data.signingPrivKey); |
|||
|
|||
var url = '/v1/txproposals/'; |
|||
this._doPostRequest(url, args, data, cb); |
|||
}; |
|||
|
|||
// Get addresses
|
|||
API.prototype.getAddresses = function(cb) { |
|||
var self = this; |
|||
|
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/addresses/'; |
|||
this._doGetRequest(url, data, cb); |
|||
}; |
|||
|
|||
|
|||
// Creates a new address
|
|||
// TODO: verify derivation!!
|
|||
API.prototype.createAddress = function(cb) { |
|||
var self = this; |
|||
|
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/addresses/'; |
|||
this._doPostRequest(url, {}, data, cb); |
|||
}; |
|||
|
|||
API.prototype.history = function(limit, cb) { |
|||
|
|||
}; |
|||
|
|||
API.prototype.getBalance = function(cb) { |
|||
var self = this; |
|||
|
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/balance/'; |
|||
this._doGetRequest(url, data, cb); |
|||
}; |
|||
|
|||
|
|||
API.prototype.getTxProposals = function(opts, cb) { |
|||
var self = this; |
|||
|
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/txproposals/'; |
|||
this._doGetRequest(url, data, cb); |
|||
}; |
|||
|
|||
API.prototype.signTxProposal = function(txp, cb) { |
|||
var self = this; |
|||
var data = this._loadAndCheck(); |
|||
|
|||
|
|||
//Derive proper key to sign, for each input
|
|||
var privs = [], |
|||
derived = {}; |
|||
|
|||
var network = new Bitcore.Address(txp.toAddress).network.name; |
|||
var xpriv = new Bitcore.HDPrivateKey(data.xPrivKey, network); |
|||
|
|||
_.each(txp.inputs, function(i) { |
|||
if (!derived[i.path]) { |
|||
derived[i.path] = xpriv.derive(i.path).privateKey; |
|||
} |
|||
privs.push(derived[i.path]); |
|||
}); |
|||
|
|||
var t = new Bitcore.Transaction(); |
|||
_.each(txp.inputs, function(i) { |
|||
t.from(i, i.publicKeys, txp.requiredSignatures); |
|||
}); |
|||
|
|||
t.to(txp.toAddress, txp.amount) |
|||
.change(txp.changeAddress) |
|||
.sign(privs); |
|||
|
|||
var signatures = []; |
|||
_.each(privs, function(p) { |
|||
var s = t.getSignatures(p)[0].signature.toDER().toString('hex'); |
|||
signatures.push(s); |
|||
}); |
|||
|
|||
var url = '/v1/txproposals/' + txp.id + '/signatures/'; |
|||
var args = { |
|||
signatures: signatures |
|||
}; |
|||
|
|||
this._doPostRequest(url, args, data, cb); |
|||
}; |
|||
|
|||
API.prototype.rejectTxProposal = function(txp, reason, cb) { |
|||
var self = this; |
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/txproposals/' + txp.id + '/rejections/'; |
|||
var args = { |
|||
reason: reason || '', |
|||
}; |
|||
this._doPostRequest(url, args, data, cb); |
|||
}; |
|||
|
|||
API.prototype.broadcastTxProposal = function(txp, cb) { |
|||
var self = this; |
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/txproposals/' + txp.id + '/broadcast/'; |
|||
this._doPostRequest(url, {}, data, cb); |
|||
}; |
|||
|
|||
|
|||
|
|||
API.prototype.removeTxProposal = function(txp, cb) { |
|||
var self = this; |
|||
var data = this._loadAndCheck(); |
|||
|
|||
var url = '/v1/txproposals/' + txp.id; |
|||
|
|||
this._doRequest('delete', url, {}, data, cb); |
|||
}; |
|||
|
|||
module.exports = API; |
@ -1,24 +0,0 @@ |
|||
|
|||
var fs = require('fs') |
|||
|
|||
function FileStorage(opts) { |
|||
if (!opts.filename) { |
|||
throw new Error('Please set the config filename'); |
|||
} |
|||
this.filename = opts.filename; |
|||
}; |
|||
|
|||
|
|||
FileStorage.prototype.save = function(data) { |
|||
fs.writeFileSync(this.filename, JSON.stringify(data)); |
|||
}; |
|||
|
|||
FileStorage.prototype.load = function() { |
|||
try { |
|||
return JSON.parse(fs.readFileSync(this.filename)); |
|||
} catch (ex) {} |
|||
}; |
|||
|
|||
|
|||
module.exports = FileStorage; |
|||
|
@ -0,0 +1,59 @@ |
|||
var $ = require('preconditions').singleton(); |
|||
var _ = require('lodash'); |
|||
var log = require('npmlog'); |
|||
|
|||
var Bitcore = require('bitcore'); |
|||
var BitcoinUtils = require('../bitcoinutils') |
|||
var SignUtils = require('../signutils'); |
|||
|
|||
/* |
|||
* Checks data given by the server |
|||
*/ |
|||
|
|||
function Verifier(opts) {}; |
|||
|
|||
Verifier.checkAddress = function(data, address) { |
|||
var local = BitcoinUtils.deriveAddress(data.publicKeyRing, address.path, data.m, data.network); |
|||
return (local.address == address.address && JSON.stringify(local.publicKeys) == JSON.stringify(address.publicKeys)); |
|||
}; |
|||
|
|||
|
|||
//
|
|||
|
|||
Verifier.checkCopayers = function(copayers, walletPrivKey, myXPrivKey, n) { |
|||
|
|||
var walletPubKey = Bitcore.PrivateKey.fromString(walletPrivKey).toPublicKey().toString(); |
|||
|
|||
if (copayers.length != n) { |
|||
log.error('Missing public keys in server response'); |
|||
return false; |
|||
} |
|||
|
|||
// Repeated xpub kes?
|
|||
var uniq = []; |
|||
var error; |
|||
_.each(copayers, function(copayer) { |
|||
if (uniq[copayers.xPubKey]++) { |
|||
log.error('Repeated public keys in server response'); |
|||
error = true; |
|||
} |
|||
|
|||
// Not signed pub keys
|
|||
if (!SignUtils.verify(copayer.xPubKey, copayer.xPubKeySignature, walletPubKey)) { |
|||
log.error('Invalid signatures in server response'); |
|||
error = true; |
|||
} |
|||
}); |
|||
if (error) |
|||
return false; |
|||
|
|||
var myXPubKey = new Bitcore.HDPublicKey(myXPrivKey).toString(); |
|||
if (!_.contains(_.pluck(copayers, 'xPubKey'), myXPubKey)) { |
|||
log.error('Server response does not contains our public keys') |
|||
return false; |
|||
} |
|||
return true; |
|||
}; |
|||
|
|||
|
|||
module.exports = Verifier; |
@ -0,0 +1,410 @@ |
|||
'use strict'; |
|||
|
|||
var _ = require('lodash'); |
|||
var $ = require('preconditions').singleton(); |
|||
var util = require('util'); |
|||
var async = require('async'); |
|||
var log = require('npmlog'); |
|||
var request = require('request') |
|||
log.debug = log.verbose; |
|||
|
|||
var Bitcore = require('bitcore') |
|||
var SignUtils = require('../signutils'); |
|||
var Verifier = require('./verifier'); |
|||
var ServerCompromisedError = require('./servercompromisederror') |
|||
|
|||
var BASE_URL = 'http://localhost:3001/copay/api'; |
|||
|
|||
function _createProposalOpts(opts, signingKey) { |
|||
var msg = opts.toAddress + '|' + opts.amount + '|' + opts.message; |
|||
opts.proposalSignature = SignUtils.sign(msg, signingKey); |
|||
return opts; |
|||
}; |
|||
|
|||
function _getUrl(path) { |
|||
return BASE_URL + path; |
|||
}; |
|||
|
|||
function _parseError(body) { |
|||
if (_.isString(body)) { |
|||
try { |
|||
body = JSON.parse(body); |
|||
} catch (e) { |
|||
body = { |
|||
error: body |
|||
}; |
|||
} |
|||
} |
|||
var code = body.code || 'ERROR'; |
|||
var message = body.error || 'There was an unknown error processing the request'; |
|||
log.error(code, message); |
|||
}; |
|||
|
|||
function _signRequest(method, url, args, privKey) { |
|||
var message = method.toLowerCase() + '|' + url + '|' + JSON.stringify(args); |
|||
return SignUtils.sign(message, privKey); |
|||
}; |
|||
|
|||
function _createXPrivKey(network) { |
|||
return new Bitcore.HDPrivateKey(network).toString(); |
|||
}; |
|||
|
|||
function API(opts) { |
|||
if (!opts.storage) { |
|||
throw new Error('Must provide storage option'); |
|||
} |
|||
this.storage = opts.storage; |
|||
this.verbose = !!opts.verbose; |
|||
this.request = request || opts.request; |
|||
if (this.verbose) { |
|||
log.level = 'debug'; |
|||
} |
|||
}; |
|||
|
|||
|
|||
|
|||
API.prototype._tryToComplete = function(data, cb) { |
|||
var self = this; |
|||
|
|||
var url = '/v1/wallets/'; |
|||
self._doGetRequest(url, data, function(err, ret) { |
|||
if (err) return cb(err); |
|||
var wallet = ret.wallet; |
|||
|
|||
if (wallet.status != 'complete') |
|||
return cb('Wallet Incomplete'); |
|||
|
|||
if (!Verifier.checkCopayers(wallet.copayers, data.walletPrivKey, data.xPrivKey, data.n)) |
|||
return cb('Some copayers in the wallet could not be verified to have known the wallet secret'); |
|||
|
|||
data.publicKeyRing = _.pluck(wallet.copayers, 'xPubKey') |
|||
|
|||
self.storage.save(data, function(err) { |
|||
return cb(err, data); |
|||
}); |
|||
}); |
|||
}; |
|||
|
|||
|
|||
API.prototype._loadAndCheck = function(cb) { |
|||
var self = this; |
|||
|
|||
this.storage.load(function(err, data) { |
|||
if (err || !data) { |
|||
return cb(err || 'Wallet file not found.'); |
|||
} |
|||
if (data.n > 1) { |
|||
var pkrComplete = data.publicKeyRing && data.m && data.publicKeyRing.length === data.n; |
|||
|
|||
if (!pkrComplete) { |
|||
return self._tryToComplete(data, cb); |
|||
} |
|||
} |
|||
return cb(null, data); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype._doRequest = function(method, url, args, data, cb) { |
|||
var reqSignature = _signRequest(method, url, args, data.signingPrivKey); |
|||
var absUrl = _getUrl(url); |
|||
var args = { |
|||
headers: { |
|||
'x-identity': data.copayerId, |
|||
'x-signature': reqSignature, |
|||
}, |
|||
method: method, |
|||
url: absUrl, |
|||
body: args, |
|||
json: true, |
|||
}; |
|||
log.verbose('Request Args', util.inspect(args, { |
|||
depth: 10 |
|||
})); |
|||
this.request(args, function(err, res, body) { |
|||
log.verbose(util.inspect(body, { |
|||
depth: 10 |
|||
})); |
|||
if (err) return cb(err); |
|||
if (res.statusCode != 200) { |
|||
_parseError(body); |
|||
return cb('Request error'); |
|||
} |
|||
|
|||
return cb(null, body); |
|||
}); |
|||
}; |
|||
|
|||
|
|||
API.prototype._doPostRequest = function(url, args, data, cb) { |
|||
return this._doRequest('post', url, args, data, cb); |
|||
}; |
|||
|
|||
API.prototype._doGetRequest = function(url, data, cb) { |
|||
return this._doRequest('get', url, {}, data, cb); |
|||
}; |
|||
|
|||
|
|||
|
|||
API.prototype.createWallet = function(walletName, copayerName, m, n, network, cb) { |
|||
var self = this; |
|||
network = network || 'livenet'; |
|||
if (!_.contains(['testnet', 'livenet'], network)) |
|||
return cb('Invalid network'); |
|||
|
|||
this.storage.load(function(err, data) { |
|||
if (data) |
|||
return cb('Storage already contains a wallet'); |
|||
|
|||
console.log('[API.js.132]'); //TODO
|
|||
// Generate wallet key pair to verify copayers
|
|||
var privKey = new Bitcore.PrivateKey(null, network); |
|||
var pubKey = privKey.toPublicKey(); |
|||
|
|||
data = { |
|||
m: m, |
|||
n: n, |
|||
walletPrivKey: privKey.toWIF(), |
|||
network: network, |
|||
}; |
|||
|
|||
var args = { |
|||
name: walletName, |
|||
m: m, |
|||
n: n, |
|||
pubKey: pubKey.toString(), |
|||
network: network, |
|||
}; |
|||
var url = '/v1/wallets/'; |
|||
|
|||
self._doPostRequest(url, args, data, function(err, body) { |
|||
if (err) return cb(err); |
|||
|
|||
var walletId = body.walletId; |
|||
var secret = walletId + ':' + privKey.toString() + ':' + (network == 'testnet' ? 'T' : 'L'); |
|||
var ret; |
|||
|
|||
if (n > 1) |
|||
ret = data.secret = secret; |
|||
|
|||
self.storage.save(data, function(err) { |
|||
if (err) return cb(err); |
|||
self._joinWallet(data, secret, copayerName, function(err) { |
|||
return cb(err, ret); |
|||
}); |
|||
|
|||
}); |
|||
}); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype._joinWallet = function(data, secret, copayerName, cb) { |
|||
var self = this; |
|||
data = data || {}; |
|||
|
|||
var secretSplit = secret.split(':'); |
|||
var walletId = secretSplit[0]; |
|||
|
|||
var walletPrivKey = Bitcore.PrivateKey.fromString(secretSplit[1]); |
|||
var network = secretSplit[2] == 'T' ? 'testnet' : 'livenet'; |
|||
data.xPrivKey = _createXPrivKey(network); |
|||
|
|||
var xPubKey = new Bitcore.HDPublicKey(data.xPrivKey); |
|||
var xPubKeySignature = SignUtils.sign(xPubKey.toString(), walletPrivKey); |
|||
|
|||
var signingPrivKey = (new Bitcore.HDPrivateKey(data.xPrivKey)).derive('m/1/0').privateKey; |
|||
var args = { |
|||
walletId: walletId, |
|||
name: copayerName, |
|||
xPubKey: xPubKey.toString(), |
|||
xPubKeySignature: xPubKeySignature, |
|||
}; |
|||
var url = '/v1/wallets/' + walletId + '/copayers'; |
|||
|
|||
this._doPostRequest(url, args, data, function(err, body) { |
|||
var wallet = body.wallet; |
|||
data.copayerId = body.copayerId; |
|||
data.walletPrivKey = walletPrivKey.toWIF(); |
|||
data.signingPrivKey = signingPrivKey.toString(); |
|||
data.m = wallet.m; |
|||
data.n = wallet.n; |
|||
data.publicKeyRing = wallet.publicKeyRing; |
|||
data.network = wallet.network, |
|||
self.storage.save(data, cb); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.joinWallet = function(secret, copayerName, cb) { |
|||
var self = this; |
|||
|
|||
this.storage.load(function(err, data) { |
|||
if (data) |
|||
return cb('Storage already contains a wallet'); |
|||
|
|||
self._joinWallet(data, secret, copayerName, cb); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.getStatus = function(cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck(function(err, data) { |
|||
if (err) return cb(err); |
|||
|
|||
var url = '/v1/wallets/'; |
|||
self._doGetRequest(url, data, function(err, body) { |
|||
return cb(err, body); |
|||
}); |
|||
}); |
|||
}; |
|||
|
|||
/** |
|||
* send |
|||
* |
|||
* @param inArgs |
|||
* @param inArgs.toAddress |
|||
* @param inArgs.amount |
|||
* @param inArgs.message |
|||
*/ |
|||
API.prototype.sendTxProposal = function(inArgs, cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck(function(err, data) { |
|||
if (err) return cb(err); |
|||
|
|||
var args = _createProposalOpts(inArgs, data.signingPrivKey); |
|||
|
|||
var url = '/v1/txproposals/'; |
|||
self._doPostRequest(url, args, data, cb); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.createAddress = function(cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck(function(err, data) { |
|||
if (err) return cb(err); |
|||
|
|||
var url = '/v1/addresses/'; |
|||
self._doPostRequest(url, {}, data, function(err, address) { |
|||
if (err) return cb(err); |
|||
if (!Verifier.checkAddress(data, address)) { |
|||
return cb(new ServerCompromisedError('Server sent fake address')); |
|||
} |
|||
|
|||
return cb(null, address); |
|||
}); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.history = function(limit, cb) { |
|||
|
|||
}; |
|||
|
|||
API.prototype.getBalance = function(cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck(function(err, data) { |
|||
if (err) return cb(err); |
|||
var url = '/v1/balance/'; |
|||
self._doGetRequest(url, data, cb); |
|||
}); |
|||
}; |
|||
|
|||
|
|||
API.prototype.getTxProposals = function(opts, cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck( |
|||
function(err, data) { |
|||
if (err) return cb(err); |
|||
var url = '/v1/txproposals/'; |
|||
self._doGetRequest(url, data, cb); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.signTxProposal = function(txp, cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck( |
|||
function(err, data) { |
|||
if (err) return cb(err); |
|||
|
|||
|
|||
//Derive proper key to sign, for each input
|
|||
var privs = [], |
|||
derived = {}; |
|||
|
|||
var network = new Bitcore.Address(txp.toAddress).network.name; |
|||
var xpriv = new Bitcore.HDPrivateKey(data.xPrivKey, network); |
|||
|
|||
_.each(txp.inputs, function(i) { |
|||
if (!derived[i.path]) { |
|||
derived[i.path] = xpriv.derive(i.path).privateKey; |
|||
} |
|||
privs.push(derived[i.path]); |
|||
}); |
|||
|
|||
var t = new Bitcore.Transaction(); |
|||
_.each(txp.inputs, function(i) { |
|||
t.from(i, i.publicKeys, txp.requiredSignatures); |
|||
}); |
|||
|
|||
t.to(txp.toAddress, txp.amount) |
|||
.change(txp.changeAddress) |
|||
.sign(privs); |
|||
|
|||
var signatures = []; |
|||
_.each(privs, function(p) { |
|||
var s = t.getSignatures(p)[0].signature.toDER().toString('hex'); |
|||
signatures.push(s); |
|||
}); |
|||
|
|||
var url = '/v1/txproposals/' + txp.id + '/signatures/'; |
|||
var args = { |
|||
signatures: signatures |
|||
}; |
|||
|
|||
self._doPostRequest(url, args, data, cb); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.rejectTxProposal = function(txp, reason, cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck( |
|||
function(err, data) { |
|||
if (err) return cb(err); |
|||
|
|||
var url = '/v1/txproposals/' + txp.id + '/rejections/'; |
|||
var args = { |
|||
reason: reason || '', |
|||
}; |
|||
self._doPostRequest(url, args, data, cb); |
|||
}); |
|||
}; |
|||
|
|||
API.prototype.broadcastTxProposal = function(txp, cb) { |
|||
var self = this; |
|||
|
|||
this._loadAndCheck( |
|||
function(err, data) { |
|||
if (err) return cb(err); |
|||
|
|||
var url = '/v1/txproposals/' + txp.id + '/broadcast/'; |
|||
self._doPostRequest(url, {}, data, cb); |
|||
}); |
|||
}; |
|||
|
|||
|
|||
|
|||
API.prototype.removeTxProposal = function(txp, cb) { |
|||
var self = this; |
|||
this._loadAndCheck( |
|||
function(err, data) { |
|||
if (err) return cb(err); |
|||
var url = '/v1/txproposals/' + txp.id; |
|||
self._doRequest('delete', url, {}, data, cb); |
|||
}); |
|||
}; |
|||
|
|||
module.exports = API; |
@ -0,0 +1,30 @@ |
|||
|
|||
var fs = require('fs') |
|||
|
|||
function FileStorage(opts) { |
|||
if (!opts.filename) { |
|||
throw new Error('Please set the config filename'); |
|||
} |
|||
this.filename = opts.filename; |
|||
this.fs = opts.fs || fs; |
|||
}; |
|||
|
|||
|
|||
FileStorage.prototype.save = function(data, cb) { |
|||
this.fs.writeFile(this.filename, JSON.stringify(data), cb); |
|||
}; |
|||
|
|||
FileStorage.prototype.load = function(cb) { |
|||
this.fs.readFile(this.filename, 'utf8', function(err,data) { |
|||
if (err) return cb(err); |
|||
try { |
|||
data = JSON.parse(data); |
|||
} catch (e) { |
|||
} |
|||
return cb(null, data); |
|||
}); |
|||
}; |
|||
|
|||
|
|||
module.exports = FileStorage; |
|||
|
@ -1,8 +1,5 @@ |
|||
//var client = ;
|
|||
|
|||
var client = module.exports = require('./API'); |
|||
client.FileStorage = require('./FileStorage'); |
|||
|
|||
|
|||
// TODO
|
|||
//module.exports.storage = require('./storage');
|
|||
var client = module.exports = require('./api'); |
|||
client.FileStorage = require('./filestorage'); |
|||
client.Verifier = require('./verifier'); |
|||
|
@ -0,0 +1,6 @@ |
|||
function ServerCompromisedError(message) { |
|||
this.code = 'SERVERCOMPROMISED'; |
|||
this.message = message; |
|||
}; |
|||
|
|||
module.exports = ServerCompromisedError; |
@ -0,0 +1,153 @@ |
|||
'use strict'; |
|||
|
|||
var _ = require('lodash'); |
|||
var chai = require('chai'); |
|||
var sinon = require('sinon'); |
|||
var should = chai.should(); |
|||
var Client = require('../../lib/client'); |
|||
var API = Client.API; |
|||
var Bitcore = require('bitcore'); |
|||
var TestData = require('./clienttestdata'); |
|||
|
|||
describe(' client API ', function() { |
|||
|
|||
var client; |
|||
|
|||
beforeEach(function() { |
|||
|
|||
var fsmock = {};; |
|||
fsmock.readFile = sinon.mock().yields(null, JSON.stringify(TestData.storage.wallet11)); |
|||
fsmock.writeFile = sinon.mock().yields(); |
|||
var storage = new Client.FileStorage({ |
|||
filename: 'dummy', |
|||
fs: fsmock, |
|||
}); |
|||
client = new Client({ |
|||
storage: storage |
|||
}); |
|||
}); |
|||
|
|||
describe(' _tryToComplete ', function() { |
|||
it('should complete a wallet ', function(done) { |
|||
var request = sinon.stub(); |
|||
|
|||
// Wallet request
|
|||
request.onCall(0).yields(null, { |
|||
statusCode: 200, |
|||
}, TestData.serverResponse.completeWallet); |
|||
request.onCall(1).yields(null, { |
|||
statusCode: 200, |
|||
}, "pepe"); |
|||
|
|||
client.request = request; |
|||
client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); |
|||
client.getBalance(function(err, x) { |
|||
should.not.exist(err); |
|||
done(); |
|||
}); |
|||
}) |
|||
|
|||
|
|||
it('should handle incomple wallets', function(done) { |
|||
var request = sinon.stub(); |
|||
|
|||
// Wallet request
|
|||
request.onCall(0).yields(null, { |
|||
statusCode: 200, |
|||
}, TestData.serverResponse.incompleteWallet); |
|||
|
|||
client.request = request; |
|||
client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); |
|||
client.createAddress(function(err, x) { |
|||
err.should.contain('Incomplete'); |
|||
done(); |
|||
}); |
|||
}) |
|||
|
|||
it('should reject wallets with bad signatures', function(done) { |
|||
var request = sinon.stub(); |
|||
// Wallet request
|
|||
request.onCall(0).yields(null, { |
|||
statusCode: 200, |
|||
}, TestData.serverResponse.corruptWallet22); |
|||
|
|||
client.request = request; |
|||
client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); |
|||
client.createAddress(function(err, x) { |
|||
err.should.contain('verified'); |
|||
done(); |
|||
}); |
|||
}) |
|||
it('should reject wallets with missing signatures ', function(done) { |
|||
var request = sinon.stub(); |
|||
// Wallet request
|
|||
request.onCall(0).yields(null, { |
|||
statusCode: 200, |
|||
}, TestData.serverResponse.corruptWallet222); |
|||
|
|||
client.request = request; |
|||
client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); |
|||
client.createAddress(function(err, x) { |
|||
err.should.contain('verified'); |
|||
done(); |
|||
}); |
|||
}) |
|||
|
|||
it('should reject wallets missing caller"s pubkey', function(done) { |
|||
var request = sinon.stub(); |
|||
// Wallet request
|
|||
request.onCall(0).yields(null, { |
|||
statusCode: 200, |
|||
}, TestData.serverResponse.missingMyPubKey); |
|||
|
|||
client.request = request; |
|||
client.storage.fs.readFile = sinon.stub().yields(null, JSON.stringify(TestData.storage.incompleteWallet22)); |
|||
client.createAddress(function(err, x) { |
|||
err.should.contain('verified'); |
|||
done(); |
|||
}); |
|||
}) |
|||
|
|||
|
|||
}); |
|||
|
|||
describe(' createAddress ', function() { |
|||
it(' should check address ', function(done) { |
|||
|
|||
var response = { |
|||
createdOn: 1424105995, |
|||
address: '2N3fA6wDtnebzywPkGuNK9KkFaEzgbPRRTq', |
|||
path: 'm/2147483647/0/7', |
|||
publicKeys: ['03f6a5fe8db51bfbaf26ece22a3e3bc242891a47d3048fc70bc0e8c03a071ad76f'] |
|||
}; |
|||
var request = sinon.mock().yields(null, { |
|||
statusCode: 200 |
|||
}, response); |
|||
client.request = request; |
|||
|
|||
|
|||
client.createAddress(function(err, x) { |
|||
should.not.exist(err); |
|||
x.address.should.equal('2N3fA6wDtnebzywPkGuNK9KkFaEzgbPRRTq'); |
|||
done(); |
|||
}); |
|||
}) |
|||
it(' should detect fake addresses ', function(done) { |
|||
var response = { |
|||
createdOn: 1424105995, |
|||
address: '2N3fA6wDtnebzywPkGuNK9KkFaEzgbPRRTq', |
|||
path: 'm/2147483647/0/8', |
|||
publicKeys: ['03f6a5fe8db51bfbaf26ece22a3e3bc242891a47d3048fc70bc0e8c03a071ad76f'] |
|||
}; |
|||
var request = sinon.mock().yields(null, { |
|||
statusCode: 200 |
|||
}, response); |
|||
client.request = request; |
|||
client.createAddress(function(err, x) { |
|||
err.code.should.equal('SERVERCOMPROMISED'); |
|||
err.message.should.contain('fake address'); |
|||
done(); |
|||
}); |
|||
}) |
|||
}) |
|||
}); |
@ -0,0 +1,116 @@ |
|||
var storage = { |
|||
wallet11: { |
|||
"m": 1, |
|||
"n": 1, |
|||
"walletPrivKey": "{\"bn\":\"6b862ffbfc90a37a2fedbbcfea91c6a4e49f49b6aaa322b6e16c46bfdbe71a38\",\"compressed\":true,\"network\":\"livenet\"}", |
|||
"network": "testnet", |
|||
"xPrivKey": "tprv8ZgxMBicQKsPeisyNJteQXZnb7CnhYc4TVAyxxicXuxMjK1rmaqVq1xnXtbSTPxUKKL9h5xJhUvw1AKfDD3i98A82eJWSYRWYjmPksewFKR", |
|||
"copayerId": "a84daa08-17b5-45ad-84cd-e275f3b07123", |
|||
"signingPrivKey": "42798f82c4ed9ace4d66335165071edf180e70bc0fc08dacb3e35185a2141d5b", |
|||
"publicKeyRing": ["tpubD6NzVbkrYhZ4YBumFxZEowDuA8iirsny2nmmFUkuxBkkZoGdPyf61Waei3tDYvVa1yqW82Xhmmd6oiibeDyM1MS3zTiky7Yg75UEV9oQhFJ"] |
|||
}, |
|||
|
|||
incompleteWallet22: { |
|||
"m": 2, |
|||
"n": 2, |
|||
"walletPrivKey":"L2Fu6TM1AqSNBaQcjgjvYjGf3EzS3MVSTwEeTw3bvy52x7ZkffWj", |
|||
"network": "testnet", |
|||
"secret": "b6f57154-0df8-4845-a61d-47ecd648c2d4:eab5a55d9214845ee8d13ea1033e42ec8d7f780ae6e521d830252a80433e91a5:T", |
|||
"xPrivKey": "tprv8ZgxMBicQKsPfFVXegcKyJjy2Y5DSrHNrtGBHG1f9pPX75QQdHwHGjWUtR7cCUXV7QcCCDon4cieHWTYscy8M7oXwF3qd3ssfBiV9M68bPB", |
|||
"copayerId": "3fc03e7a-6ebc-409b-a4b7-45b14d5a8199", |
|||
"signingPrivKey": "0d3c796fb12e387c4b5a5c566312b2b22fa0553ca041d859e3f0987215ca3a4f", |
|||
"publicKeyRing": [] |
|||
} |
|||
}; |
|||
|
|||
var serverResponse = { |
|||
completeWallet: { |
|||
wallet: { |
|||
m: 2, |
|||
n: 2, |
|||
status: 'complete', |
|||
publicKeyRing: ['tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
'tpubD6NzVbkrYhZ4WSuBBLyubi8DHMipbFQcZoLJHjb21gEtznCEJMJhwkvaSshHVLtq8C1uNMKD4GtADVYY6WZt1cyT218JUm3PiNKYVkMATWV' |
|||
], |
|||
addressIndex: 0, |
|||
copayers: [{ |
|||
xPubKey: 'tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
xPubKeySignature: '3045022100ef86122060bbb7681db05486f8b1ee1579c5800e8da78182a87384f05542a4cc0220215ce7ef8c484b64178779414efdf2b7033d25ed752eebf4eb3241f9fa8e6b67', |
|||
}, { |
|||
xPubKey: 'tpubD6NzVbkrYhZ4YiXKYLGvNiQ5bZb9cBUHSBrxZn3xa6BuwZfBFgksTE8M4ZFBLWVJ4PLnAJs2JKhkpJVqsrJEAkGpb62rx62Bk4o4N5Lz8dQ', |
|||
xPubKeySignature: '3045022100e03b069db333428153c306c9bf66ebc7f25e7d7f3d087e1ca7234fbbb1a47efa02207421fb375d0dd7a7f2116301f2cdf1bce88554a6c88a82d4ec9fb37fb6680ae8', |
|||
}], |
|||
pubKey: ' { "x": "b2903ab878ed1316f82b859e9807e23bab3d579175563e1068d2ed9c9e37873c", "y": "5f30165915557394223a58329c1527dfa0f34f483d8aed02e0638f9124dbddef", "compressed": true }', |
|||
network: 'testnet', |
|||
}}, |
|||
|
|||
missingMyPubKey: { |
|||
wallet: { |
|||
m: 2, |
|||
n: 2, |
|||
status: 'complete', |
|||
publicKeyRing: ['tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
'tpubD6NzVbkrYhZ4WSuBBLyubi8DHMipbFQcZoLJHjb21gEtznCEJMJhwkvaSshHVLtq8C1uNMKD4GtADVYY6WZt1cyT218JUm3PiNKYVkMATWV' |
|||
], |
|||
addressIndex: 0, |
|||
copayers: [{ |
|||
xPubKey: 'tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
xPubKeySignature: '3045022100ef86122060bbb7681db05486f8b1ee1579c5800e8da78182a87384f05542a4cc0220215ce7ef8c484b64178779414efdf2b7033d25ed752eebf4eb3241f9fa8e6b67', |
|||
}, { |
|||
xPubKey: 'tpubD6NzVbkrYhZ4WSuBBLyubi8DHMipbFQcZoLJHjb21gEtznCEJMJhwkvaSshHVLtq8C1uNMKD4GtADVYY6WZt1cyT218JUm3PiNKYVkMATWV', |
|||
xPubKeySignature: '3044022025c93b418ebdbb66a0f2b21af709420e8ae769bf054f29aaa252cb5417c46a2302205e0c8b931324736b7eea4971a48039614e19abe26e13ab0ef1547aef92b55aab', |
|||
}], |
|||
pubKey: ' { "x": "b2903ab878ed1316f82b859e9807e23bab3d579175563e1068d2ed9c9e37873c", "y": "5f30165915557394223a58329c1527dfa0f34f483d8aed02e0638f9124dbddef", "compressed": true }', |
|||
network: 'testnet', |
|||
}}, |
|||
|
|||
|
|||
incompleteWallet: { |
|||
wallet: { |
|||
m: 2, |
|||
n: 2, |
|||
status: 'pending', |
|||
publicKeyRing: ['tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
'tpubD6NzVbkrYhZ4WSuBBLyubi8DHMipbFQcZoLJHjb21gEtznCEJMJhwkvaSshHVLtq8C1uNMKD4GtADVYY6WZt1cyT218JUm3PiNKYVkMATWV' |
|||
], |
|||
addressIndex: 0, |
|||
copayers: [{ |
|||
xPubKey: 'tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
xPubKeySignature: '3045022100ef86122060bbb7681db05486f8b1ee1579c5800e8da78182a87384f05542a4cc0220215ce7ef8c484b64178779414efdf2b7033d25ed752eebf4eb3241f9fa8e6b67', |
|||
}], |
|||
pubKey: ' { "x": "b2903ab878ed1316f82b859e9807e23bab3d579175563e1068d2ed9c9e37873c", "y": "5f30165915557394223a58329c1527dfa0f34f483d8aed02e0638f9124dbddef", "compressed": true }', |
|||
network: 'testnet', |
|||
}}, |
|||
|
|||
corruptWallet22: { |
|||
wallet: { |
|||
m: 2, |
|||
n: 2, |
|||
status: 'complete', |
|||
publicKeyRing: ['tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
'tpubD6NzVbkrYhZ4WSuBBLyubi8DHMipbFQcZoLJHjb21gEtznCEJMJhwkvaSshHVLtq8C1uNMKD4GtADVYY6WZt1cyT218JUm3PiNKYVkMATWV' |
|||
], |
|||
copayers: [{ |
|||
xPubKey: 'tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
xPubKeySignature: '3045022100ef86122060bbb7681db05486f8b1ee1579c5800e8da78182a87384f05542a4cc0220215ce7ef8c484b64178779414efdf2b7033d25ed752eebf4eb3241f9fa8e6b67', |
|||
}, { |
|||
xPubKey: 'tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
xPubKeySignature: 'bababa', |
|||
}], |
|||
}}, |
|||
corruptWallet222: { |
|||
wallet: { |
|||
m: 2, |
|||
n: 2, |
|||
status: 'complete', |
|||
publicKeyRing: ['tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
'tpubD6NzVbkrYhZ4WSuBBLyubi8DHMipbFQcZoLJHjb21gEtznCEJMJhwkvaSshHVLtq8C1uNMKD4GtADVYY6WZt1cyT218JUm3PiNKYVkMATWV' |
|||
], |
|||
copayers: [{ |
|||
xPubKey: 'tpubD6NzVbkrYhZ4Y1CGuCZ88eZvhDSTjAqjotZWGXC7e4GEoyXq3SQgZK9iRz4qC2h8MrzqrYBndCMQDiaaLdqpY8ihYmJC9Msvns83jGopb3E', |
|||
}, ], |
|||
}}, |
|||
}; |
|||
|
|||
module.exports.serverResponse = serverResponse; |
|||
module.exports.storage = storage; |
@ -0,0 +1 @@ |
|||
--recursive |
Loading…
Reference in new issue