diff --git a/Gruntfile.js b/Gruntfile.js index 809a2cc95..53942da0b 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -24,7 +24,7 @@ module.exports = function(grunt) { tasks: ['markdown'] }, scripts: { - files: ['*.js', '**/*.js', '*.html', '!**/node_modules/**', '!lib/**js', '!browser/vendor-bundle.js', '!js/copayBundle.js'], + files: ['*.js', '*/*/*.js', '**/*.js', '*.html', '!**/node_modules/**', '!lib/**js', '!browser/vendor-bundle.js', '!js/copayBundle.js'], tasks: ['shell'], }, }, diff --git a/app.js b/app.js index ebc3b7826..d531b0274 100644 --- a/app.js +++ b/app.js @@ -12,6 +12,28 @@ app.start = function(port, callback) { app.set('port', port); app.use(express.static(__dirname)); + if (process.env.USE_HTTPS) { + var path = require('path'); + + var bc = path.dirname(require.resolve('bitcore/package.json')); + var pserver = require(bc + '/examples/PayPro/server.js'); + + pserver.removeListener('request', pserver.app); + + pserver.on('request', function(req, res) { + if (req.url.indexOf('/-/') === 0) { + return pserver.app(req, res); + } + return app(req, res); + }); + + pserver.listen(port, function() { + callback('https://localhost:' + port); + }); + + return; + } + app.listen(port, function() { callback('http://localhost:' + port); }); diff --git a/bower.json b/bower.json index d96cd64d8..f38b3fc68 100644 --- a/bower.json +++ b/bower.json @@ -18,7 +18,7 @@ "sjcl": "1.0.0", "file-saver": "*", "qrcode-decoder-js": "*", - "bitcore": "0.1.34", + "bitcore": "git://github.com/bitpay/bitcore.git#master", "angular-moment": "~0.7.1", "socket.io-client": ">=1.0.0", "mousetrap": "1.4.6", diff --git a/copay.js b/copay.js index 5b2711dc9..e4cda4988 100644 --- a/copay.js +++ b/copay.js @@ -14,5 +14,10 @@ var Insight = module.exports.Insight = require('./js/models/blockchain/Insight') var StorageLocalEncrypted = module.exports.StorageLocalEncrypted = require('./js/models/storage/LocalEncrypted'); module.exports.WalletFactory = require('./js/models/core/WalletFactory'); +module.exports.Wallet = require('./js/models/core/Wallet'); module.exports.version = require('./version'); module.exports.API = require('./API'); + + +// test hack :s, will fix +module.exports.FakePayProServer = require('./test/mocks/FakePayProServer'); diff --git a/js/controllers/send.js b/js/controllers/send.js index dbf98b6aa..1417002c0 100644 --- a/js/controllers/send.js +++ b/js/controllers/send.js @@ -61,16 +61,44 @@ angular.module('copayApp.controllers').controller('SendController', var w = $rootScope.wallet; - w.createTx(address, amount, commentText, function(ntxid) { + function done(ntxid, merchantData) { + // If user is granted the privilege of choosing + // their own amount, add it to the tx. + if (merchantData && +merchantData.total === 0) { + var txp = w.txProposals.get(ntxid); + var tx = txp.builder.tx = txp.builder.tx || txp.builder.build(); + tx.outs[0].v = bitcore.Bignum(amount + '', 10).toBuffer({ + // XXX This may not work in node due + // to the bignum only-big endian bug: + endian: 'little', + size: 1 + }); + } + if (w.isShared()) { $scope.loading = false; var message = 'The transaction proposal has been created'; + if (merchantData) { + if (merchantData.pr.ca) { + message += ' This payment protocol transaction' + ' has been verified through ' + merchantData.pr.ca + '.'; + } + message += ' Message from server: ' + merchantData.ack.memo; + message += ' For merchant: ' + merchantData.pr.pd.payment_url; + } notification.success('Success!', message); $scope.loadTxs(); } else { - w.sendTx(ntxid, function(txid) { + w.sendTx(ntxid, function(txid, merchantData) { if (txid) { - notification.success('Transaction broadcast', 'Transaction id: ' + txid); + var message = 'Transaction id: ' + txid; + if (merchantData) { + if (merchantData.pr.ca) { + message += ' This payment protocol transaction' + ' has been verified through ' + merchantData.pr.ca + '.'; + } + message += ' Message from server: ' + merchantData.ack.memo; + message += ' For merchant: ' + merchantData.pr.pd.payment_url; + } + notification.success('Transaction broadcast', message); } else { notification.error('Error', 'There was an error sending the transaction.'); } @@ -79,7 +107,29 @@ angular.module('copayApp.controllers').controller('SendController', }); } $rootScope.pendingPayment = null; - }); + } + + var uri; + if (address.indexOf('bitcoin:') === 0) { + uri = copay.HDPath.parseBitcoinURI(address); + } else if (address.indexOf('Merchant: ') === 0) { + uri = { + merchant: address.split(/\s+/)[1] + }; + } else if (/^https?:\/\//.test(address)) { + uri = { + merchant: address + }; + } + + if (uri && uri.merchant) { + w.createPaymentTx({ + uri: uri.merchant, + memo: commentText + }, done); + } else { + w.createTx(address, amount, commentText, done); + } // reset fields $scope.address = $scope.amount = $scope.commentText = null; @@ -118,7 +168,6 @@ angular.module('copayApp.controllers').controller('SendController', qrcode.imagedata = context.getImageData(0, 0, qrcode.width, qrcode.height); try { - //alert(JSON.stringify(qrcode.process(context))); qrcode.decode(); } catch (e) { // error decoding QR @@ -299,11 +348,21 @@ angular.module('copayApp.controllers').controller('SendController', $scope.loading = true; $rootScope.txAlertCount = 0; var w = $rootScope.wallet; - w.sendTx(ntxid, function(txid) { + w.sendTx(ntxid, function(txid, merchantData) { if (!txid) { notification.error('Error', 'There was an error sending the transaction'); } else { - notification.success('Transaction broadcast', 'Transaction id: '+txid); + if (!merchantData) { + notification.success('Transaction broadcast', 'Transaction id: ' + txid); + } else { + var message = 'Transaction ID: ' + txid; + if (merchantData.pr.ca) { + message += ' This payment protocol transaction' + ' has been verified through ' + merchantData.pr.ca + '.'; + } + message += ' Message from server: ' + merchantData.ack.memo; + message += ' For merchant: ' + merchantData.pr.pd.payment_url; + notification.success('Transaction sent', message); + } } if (cb) return cb(); @@ -340,4 +399,101 @@ angular.module('copayApp.controllers').controller('SendController', $scope.loadTxs(); }; + + $scope.onChanged = function() { + var scope = $scope; + var value = scope.address; + var uri; + + if (/^https?:\/\//.test(value)) { + uri = { + merchant: value + }; + } else { + uri = copay.HDPath.parseBitcoinURI(value); + } + if (!uri || !uri.merchant) { + return; + } + notification.info('Fetching Payment', + 'Retrieving Payment Request from ' + uri.merchant); + + // Payment Protocol URI (BIP-72) + scope.wallet.fetchPaymentTx(uri.merchant, function(err, merchantData) { + var balance = $rootScope.availableBalance; + var available = +(balance * config.unitToSatoshi).toFixed(0); + + if (merchantData && available < +merchantData.total) { + err = new Error('No unspent outputs available.'); + err.amount = merchantData.total; + } + + if (err) { + scope.sendForm.address.$isValid = false; + + if (err.amount) { + scope.sendForm.amount.$setViewValue(+err.amount / config.unitToSatoshi); + scope.sendForm.amount.$render(); + scope.sendForm.amount.$isValid = false; + scope.notEnoughAmount = true; + $rootScope.merchantError = true; + var lastAddr = scope.sendForm.address.$viewValue; + var unregister = scope.$watch('address', function() { + if (scope.sendForm.address.$viewValue !== lastAddr) { + delete $rootScope.merchantError; + scope.sendForm.amount.$setViewValue(''); + scope.sendForm.amount.$render(); + unregister(); + if ($rootScope.$$phase !== '$apply' && $rootScope.$$phase !== '$digest') { + $rootScope.$apply(); + } + } + }); + } + + notification.error('Error', err.message || 'Bad payment server.'); + + if ($rootScope.$$phase !== '$apply' && $rootScope.$$phase !== '$digest') { + $rootScope.$apply(); + } + return; + } + + merchantData.unitTotal = (+merchantData.total / config.unitToSatoshi) + ''; + merchantData.expiration = new Date( + merchantData.pr.pd.expires * 1000).toISOString(); + + $rootScope.merchant = merchantData; + + scope.sendForm.address.$isValid = true; + + scope.sendForm.amount.$setViewValue(merchantData.unitTotal); + scope.sendForm.amount.$render(); + scope.sendForm.amount.$isValid = true; + + // If the address changes to a non-payment-protocol one, + // delete the `merchant` property from the scope. + var unregister = scope.$watch('address', function() { + var val = scope.sendForm.address.$viewValue || ''; + var uri = copay.HDPath.parseBitcoinURI(val); + if (!uri || !uri.merchant) { + delete $rootScope.merchant; + scope.sendForm.amount.$setViewValue(''); + scope.sendForm.amount.$render(); + unregister(); + if ($rootScope.$$phase !== '$apply' && $rootScope.$$phase !== '$digest') { + $rootScope.$apply(); + } + } + }); + + if ($rootScope.$$phase !== '$apply' && $rootScope.$$phase !== '$digest') { + $rootScope.$apply(); + } + + notification.info('Payment Request', + 'Server is requesting ' + merchantData.unitTotal + ' ' + config.unitName + '.' + ' Message: ' + merchantData.pr.pd.memo); + }); + }; + }); diff --git a/js/directives.js b/js/directives.js index 03681d02b..d704b6073 100644 --- a/js/directives.js +++ b/js/directives.js @@ -1,28 +1,42 @@ 'use strict'; angular.module('copayApp.directives') - .directive('validAddress', [ + .directive('validAddress', function() { + var bitcore = require('bitcore'); + var Address = bitcore.Address; + var bignum = bitcore.Bignum; - function() { + return { + require: 'ngModel', + link: function(scope, elem, attrs, ctrl) { + var validator = function(value) { + var uri; - var bitcore = require('bitcore'); - var Address = bitcore.Address; + if (/^https?:\/\//.test(value)) { + uri = { + merchant: value + }; + } else { + uri = copay.HDPath.parseBitcoinURI(value); + } - return { - require: 'ngModel', - link: function(scope, elem, attrs, ctrl) { - var validator = function(value) { + // Regular Address + if (!uri || !uri.merchant) { var a = new Address(value); ctrl.$setValidity('validAddress', a.isValid() && a.network().name === config.networkName); return value; - }; + } - ctrl.$parsers.unshift(validator); - ctrl.$formatters.unshift(validator); - } - }; - } - ]) + + ctrl.$setValidity('validAddress', true); + return uri.merchant; + }; + + ctrl.$parsers.unshift(validator); + ctrl.$formatters.unshift(validator); + } + }; + }) .directive('enoughAmount', ['$rootScope', function($rootScope) { var bitcore = require('bitcore'); @@ -203,32 +217,34 @@ angular.module('copayApp.directives') restrict: 'A', link: function(scope, element, attrs) { element.bind('click', function() { - window.open('bitcoin:'+attrs.address, '_blank'); + window.open('bitcoin:' + attrs.address, '_blank'); }); } } }) - // From https://gist.github.com/asafge/7430497 - .directive('ngReallyClick', [function() { - return { - restrict: 'A', - link: function(scope, element, attrs) { - element.bind('click', function() { - var message = attrs.ngReallyMessage; - if (message && confirm(message)) { - scope.$apply(attrs.ngReallyClick); - } - }); - } +// From https://gist.github.com/asafge/7430497 +.directive('ngReallyClick', [ + + function() { + return { + restrict: 'A', + link: function(scope, element, attrs) { + element.bind('click', function() { + var message = attrs.ngReallyMessage; + if (message && confirm(message)) { + scope.$apply(attrs.ngReallyClick); + } + }); } } - ]) - .directive('match', function () { + } +]) + .directive('match', function() { return { require: 'ngModel', restrict: 'A', scope: { - match: '=' + match: '=' }, link: function(scope, elem, attrs, ctrl) { scope.$watch(function() { @@ -249,16 +265,18 @@ angular.module('copayApp.directives') return { restric: 'A', - scope: { clipCopy: '=clipCopy' }, + scope: { + clipCopy: '=clipCopy' + }, link: function(scope, elm) { var client = new ZeroClipboard(elm); - client.on( 'ready', function(event) { - client.on( 'copy', function(event) { + client.on('ready', function(event) { + client.on('copy', function(event) { event.clipboardData.setData('text/plain', scope.clipCopy); }); - client.on( 'aftercopy', function(event) { + client.on('aftercopy', function(event) { elm.removeClass('btn-copy').addClass('btn-copied').html('Copied!'); setTimeout(function() { elm.addClass('btn-copy').removeClass('btn-copied').html(''); @@ -266,8 +284,8 @@ angular.module('copayApp.directives') }); }); - client.on( 'error', function(event) { - console.log( 'ZeroClipboard error of type "' + event.name + '": ' + event.message ); + client.on('error', function(event) { + console.log('ZeroClipboard error of type "' + event.name + '": ' + event.message); ZeroClipboard.destroy(); }); } diff --git a/js/models/core/HDPath.js b/js/models/core/HDPath.js index 126b84a0a..7b0be32e8 100644 --- a/js/models/core/HDPath.js +++ b/js/models/core/HDPath.js @@ -55,18 +55,23 @@ HDPath.parseBitcoinURI = function(uri) { var data = decodeURIComponent(uri); var splitDots = data.split(':'); ret.protocol = splitDots[0]; - data = splitDots[1]; + data = splitDots.slice(1).join(':'); var splitQuestion = data.split('?'); ret.address = splitQuestion[0]; if (splitQuestion.length > 1) { + var data = {}; var search = splitQuestion[1]; - data = JSON.parse('{"' + search.replace(/&/g, '","').replace(/=/g, '":"') + '"}', - function(key, value) { - return key === "" ? value : decodeURIComponent(value); - }); - ret.amount = parseFloat(data.amount); - ret.message = data.message; + var parts = search.split('&'); + var part; + var i = 0; + for (; i < parts.length; i++) { + part = parts[i].split('='); + data[part[0]] = decodeURIComponent(part[1]); + } + ret.amount = parseFloat(data.amount); + ret.message = data.message; + ret.merchant = data.r; } return ret; diff --git a/js/models/core/TxProposal.js b/js/models/core/TxProposal.js index 3ddeac22e..fb258af51 100644 --- a/js/models/core/TxProposal.js +++ b/js/models/core/TxProposal.js @@ -36,6 +36,7 @@ function TxProposal(opts) { this.sentTxid = opts.sentTxid || null; this.comment = opts.comment || null; this.readonly = opts.readonly || null; + this.merchant = opts.merchant || null; this._sync(); } diff --git a/js/models/core/TxProposals.js b/js/models/core/TxProposals.js index f0933afd6..558d9c501 100644 --- a/js/models/core/TxProposals.js +++ b/js/models/core/TxProposals.js @@ -11,7 +11,6 @@ var Key = bitcore.Key; var buffertools = bitcore.buffertools; var preconditions = require('preconditions').instance(); - function TxProposals(opts) { opts = opts || {}; this.walletId = opts.walletId; diff --git a/js/models/core/Wallet.js b/js/models/core/Wallet.js index 015b46ad9..e1cf67666 100644 --- a/js/models/core/Wallet.js +++ b/js/models/core/Wallet.js @@ -5,6 +5,7 @@ var http = require('http'); var EventEmitter = imports.EventEmitter || require('events').EventEmitter; var async = require('async'); var preconditions = require('preconditions').singleton(); +var parseBitcoinURI = require('./HDPath').parseBitcoinURI; var bitcore = require('bitcore'); var bignum = bitcore.Bignum; @@ -14,6 +15,8 @@ var Builder = bitcore.TransactionBuilder; var SecureRandom = bitcore.SecureRandom; var Base58Check = bitcore.Base58.base58Check; var Address = bitcore.Address; +var PayPro = bitcore.PayPro; +var Transaction = bitcore.Transaction; var HDParams = require('./HDParams'); var PublicKeyRing = require('./PublicKeyRing'); @@ -53,6 +56,8 @@ function Wallet(opts) { this.addressBook = opts.addressBook || {}; this.publicKey = this.privateKey.publicHex; + this.paymentRequests = opts.paymentRequests || {}; + //network nonces are 8 byte buffers, representing a big endian number //one nonce for oneself, and then one nonce for each copayer this.network.setHexNonce(opts.networkNonce); @@ -108,7 +113,7 @@ Wallet.prototype.unlock = function() { Wallet.prototype.checkAndLock = function() { if (this.getLock()) { return true; - } + } this.setLock(); return false; @@ -709,6 +714,14 @@ Wallet.prototype.sign = function(ntxid, cb) { // if (cb) cb(false); // } // + + // If this is a payment protocol request, + // ensure it hasn't been tampered with. + if (!self.verifyPaymentRequest(ntxid)) { + if (cb) cb(false); + return; + } + var keys = self.privateKey.getForPaths(txp.inputChainPaths); var b = txp.builder; @@ -730,6 +743,11 @@ Wallet.prototype.sign = function(ntxid, cb) { Wallet.prototype.sendTx = function(ntxid, cb) { var txp = this.txProposals.get(ntxid); + + if (txp.merchant) { + return this.sendPaymentTx(ntxid, cb); + } + var tx = txp.builder.build(); if (!tx.isComplete()) throw new Error('Tx is not complete. Can not broadcast'); @@ -760,6 +778,629 @@ Wallet.prototype.sendTx = function(ntxid, cb) { }); }; +Wallet.prototype.createPaymentTx = function(options, cb) { + var self = this; + + if (typeof options === 'string') { + options = { uri: options }; + } + options.uri = options.uri || options.url; + + if (options.uri.indexOf('bitcoin:') === 0) { + options.uri = parseBitcoinURI(options.uri).merchant; + if (!options.uri) { + return cb(new Error('No URI.')); + } + } + + var req = this.paymentRequests[options.uri]; + if (req) { + delete this.paymentRequests[options.uri]; + this.receivePaymentRequest(options, req.pr, cb); + return; + } + + return Wallet.request({ + method: 'GET', + url: options.uri, + headers: { + 'Accept': PayPro.PAYMENT_REQUEST_CONTENT_TYPE + }, + responseType: 'arraybuffer' + }) + .success(function(data, status, headers, config) { + data = PayPro.PaymentRequest.decode(data); + var pr = new PayPro(); + pr = pr.makePaymentRequest(data); + return self.receivePaymentRequest(options, pr, cb); + }) + .error(function(data, status, headers, config) { + return cb(new Error('Status: ' + JSON.stringify(status))); + }); +}; + +Wallet.prototype.fetchPaymentTx = function(options, cb) { + var self = this; + + options = options || {}; + if (typeof options === 'string') { + options = { uri: options }; + } + options.uri = options.uri || options.url; + options.fetch = true; + + var req = this.paymentRequests[options.uri]; + if (req) { + return cb(null, req.merchantData); + } + + return this.createPaymentTx(options, function(err, merchantData, pr) { + if (err) return cb(err); + self.paymentRequests[options.uri] = { + merchantData: merchantData, + pr: pr + }; + return cb(null, merchantData); + }); +}; + +Wallet.prototype.receivePaymentRequest = function(options, pr, cb) { + var self = this; + + var ver = pr.get('payment_details_version'); + var pki_type = pr.get('pki_type'); + var pki_data = pr.get('pki_data'); + var details = pr.get('serialized_payment_details'); + var sig = pr.get('signature'); + + var certs = PayPro.X509Certificates.decode(pki_data); + certs = certs.certificate; + + // Fix for older versions of bitcore + if (!PayPro.RootCerts) { + PayPro.RootCerts = { + getTrusted: function() {} + }; + } + + var trusted = certs.map(function(cert) { + var der = cert.toString('hex'); + var pem = PayPro.prototype._DERtoPEM(der, 'CERTIFICATE'); + return PayPro.RootCerts.getTrusted(pem); + }).filter(Boolean); + + // Verify Signature + var verified = pr.verify(); + + if (!verified) { + return cb(new Error('Server sent a bad signature.')); + } + + var ca = trusted[0]; + + details = PayPro.PaymentDetails.decode(details); + var pd = new PayPro(); + pd = pd.makePaymentDetails(details); + + var network = pd.get('network'); + var outputs = pd.get('outputs'); + var time = pd.get('time'); + var expires = pd.get('expires'); + var memo = pd.get('memo'); + var payment_url = pd.get('payment_url'); + var merchant_data = pd.get('merchant_data'); + + var merchantData = { + pr: { + payment_details_version: ver, + pki_type: pki_type, + pki_data: certs, + pd: { + network: network, + outputs: outputs.map(function(output) { + return { + amount: output.get('amount'), + script: { + offset: output.get('script').offset, + limit: output.get('script').limit, + // NOTE: For some reason output.script.buffer + // is only an ArrayBuffer + buffer: new Buffer(new Uint8Array( + output.get('script').buffer)).toString('hex') + } + }; + }), + time: time, + expires: expires, + memo: memo || 'This server would like some BTC from you.', + payment_url: payment_url, + merchant_data: merchant_data.toString('hex') + }, + signature: sig.toString('hex'), + ca: ca, + untrusted: !ca + }, + request_url: options.uri, + total: bignum('0', 10).toString(10), + // Expose so other copayers can verify signature + // and identity, not to mention data. + raw: pr.serialize().toString('hex') + }; + + return this.getUnspent(function(err, safeUnspent, unspent) { + if (options.fetch) { + if (!unspent || !unspent.length) { + return cb(new Error('No unspent outputs available.')); + } + try { + self.createPaymentTxSync(options, merchantData, unspent); + } catch (e) { + var msg = e.message || ''; + if (msg.indexOf('not enough unspent tx outputs to fulfill')) { + var sat = /(\d+)/.exec(msg)[1]; + e = new Error('No unspent outputs available.'); + e.amount = sat; + return cb(e); + } + } + return cb(null, merchantData, pr); + } + + var ntxid = self.createPaymentTxSync(options, merchantData, unspent); + if (ntxid) { + self.sendIndexes(); + self.sendTxProposal(ntxid); + self.store(); + self.emit('txProposalsUpdated'); + } + + self.log('You are currently on this BTC network:'); + self.log(network); + self.log('The server sent you a message:'); + self.log(memo); + + return cb(ntxid, merchantData); + }); +}; + +Wallet.prototype.sendPaymentTx = function(ntxid, options, cb) { + var self = this; + + if (!cb) { + cb = options; + options = {}; + } + + var txp = this.txProposals.get(ntxid); + if (!txp) return; + + var tx = txp.builder.build(); + if (!tx.isComplete()) return; + this.log('Sending Transaction'); + + var refund_outputs = []; + + options.refund_to = options.refund_to + || this.publicKeyRing.getPubKeys(0, false, this.getMyCopayerId())[0]; + + if (options.refund_to) { + var total = txp.merchant.pr.pd.outputs.reduce(function(total, _, i) { + // XXX reverse endianness to work around bignum bug: + var txv = tx.outs[i].v; + var v = new Buffer(8); + for (var j = 0; j < 8; j++) v[j] = txv[7 - j]; + return total.add(bignum.fromBuffer(v, { + endian: 'big', + size: 1 + })); + }, bignum('0', 10)); + + var rpo = new PayPro(); + rpo = rpo.makeOutput(); + + // XXX Bad - the amount *has* to be a Number in protobufjs + // Possibly does not matter - server can ignore the amount anyway. + rpo.set('amount', +total.toString(10)); + + rpo.set('script', + Buffer.concat([ + new Buffer([ + 118, // OP_DUP + 169, // OP_HASH160 + 76, // OP_PUSHDATA1 + 20, // number of bytes + ]), + // needs to be ripesha'd + bitcore.util.sha256ripe160(options.refund_to), + new Buffer([ + 136, // OP_EQUALVERIFY + 172 // OP_CHECKSIG + ]) + ]) + ); + + refund_outputs.push(rpo.message); + } + + // We send this to the serve after receiving a PaymentRequest + var pay = new PayPro(); + pay = pay.makePayment(); + var merchant_data = txp.merchant.pr.pd.merchant_data; + merchant_data = new Buffer(merchant_data, 'hex'); + pay.set('merchant_data', merchant_data); + pay.set('transactions', [tx.serialize()]); + pay.set('refund_to', refund_outputs); + + options.memo = options.memo || options.comment + || 'Hi server, I would like to give you some money.'; + + pay.set('memo', options.memo); + + pay = pay.serialize(); + + this.log('Sending Payment Message:'); + this.log(pay.toString('hex')); + + var buf = new ArrayBuffer(pay.length); + var view = new Uint8Array(buf); + for (var i = 0; i < pay.length; i++) { + view[i] = pay[i]; + } + + return Wallet.request({ + method: 'POST', + url: txp.merchant.pr.pd.payment_url, + headers: { + // BIP-71 + 'Accept': PayPro.PAYMENT_ACK_CONTENT_TYPE, + 'Content-Type': PayPro.PAYMENT_CONTENT_TYPE + // XHR does not allow these: + // 'Content-Length': (pay.byteLength || pay.length) + '', + // 'Content-Transfer-Encoding': 'binary' + }, + // Technically how this should be done via XHR (used to + // be the ArrayBuffer, now you send the View instead). + data: view, + responseType: 'arraybuffer' + }) + .success(function(data, status, headers, config) { + data = PayPro.PaymentACK.decode(data); + var ack = new PayPro(); + ack = ack.makePaymentACK(data); + return self.receivePaymentRequestACK(ntxid, tx, txp, ack, cb); + }) + .error(function(data, status, headers, config) { + return cb(new Error('Status: ' + JSON.stringify(status))); + }); +}; + +Wallet.prototype.receivePaymentRequestACK = function(ntxid, tx, txp, ack, cb) { + var self = this; + + var payment = ack.get('payment'); + var memo = ack.get('memo'); + + this.log('Our payment was acknowledged!'); + this.log('Message from Merchant: %s', memo); + + payment = PayPro.Payment.decode(payment); + var pay = new PayPro(); + payment = pay.makePayment(payment); + + txp.merchant.ack = { + memo: memo + }; + + var tx = payment.message.transactions[0]; + + if (!tx) { + this.log('Sending to server was not met with a returned tx.'); + return this._checkSentTx(ntxid, function(txid) { + self.log('[Wallet.js.1048:txid:%s]', txid); + if (txid) self.store(); + return cb(txid, txp.merchant); + }); + } + + if (tx.buffer) { + tx.buffer = new Buffer(new Uint8Array(tx.buffer)); + tx.buffer = tx.buffer.slice(tx.offset, tx.limit); + var ptx = new bitcore.Transaction(); + ptx.parse(tx.buffer); + tx = ptx; + } + + var txid = tx.getHash().toString('hex'); + var txHex = tx.serialize().toString('hex'); + this.log('Raw transaction: ', txHex); + this.log('BITCOIND txid:', txid); + this.txProposals.get(ntxid).setSent(txid); + this.sendTxProposal(ntxid); + this.store(); + + return cb(txid, txp.merchant); +}; + +Wallet.prototype.createPaymentTxSync = function(options, merchantData, unspent) { + var self = this; + var priv = this.privateKey; + var pkr = this.publicKeyRing; + + preconditions.checkState(pkr.isComplete()); + if (options.memo) { + preconditions.checkArgument(options.memo.length <= 100); + } + + var opts = { + remainderOut: { + address: this._doGenerateAddress(true).toString() + } + }; + + merchantData.total = bignum(merchantData.total, 10); + + var outs = []; + merchantData.pr.pd.outputs.forEach(function(output) { + var amount = output.amount; + + // big endian + var v = new Buffer(8); + v[0] = (amount.high >> 24) & 0xff; + v[1] = (amount.high >> 16) & 0xff; + v[2] = (amount.high >> 8) & 0xff; + v[3] = (amount.high >> 0) & 0xff; + v[4] = (amount.low >> 24) & 0xff; + v[5] = (amount.low >> 16) & 0xff; + v[6] = (amount.low >> 8) & 0xff; + v[7] = (amount.low >> 0) & 0xff; + + var script = { + offset: output.script.offset, + limit: output.script.limit, + buffer: new Buffer(output.script.buffer, 'hex') + }; + var s = script.buffer.slice(script.offset, script.limit); + var network = merchantData.pr.pd.network === 'main' ? 'livenet' : 'testnet'; + var addr = bitcore.Address.fromScriptPubKey(new bitcore.Script(s), network); + + outs.push({ + address: addr[0].toString(), + amountSatStr: bignum.fromBuffer(v, { + endian: 'big', + size: 1 + }).toString(10) + }); + + merchantData.total = merchantData.total.add(bignum.fromBuffer(v, { + endian: 'big', + size: 1 + })); + }); + + merchantData.total = merchantData.total.toString(10); + + var b = new Builder(opts) + .setUnspent(unspent) + .setOutputs(outs); + + merchantData.pr.pd.outputs.forEach(function(output, i) { + var script = { + offset: output.script.offset, + limit: output.script.limit, + buffer: new Buffer(output.script.buffer, 'hex') + }; + var s = script.buffer.slice(script.offset, script.limit); + b.tx.outs[i].s = s; + }); + + var selectedUtxos = b.getSelectedUnspent(); + var inputChainPaths = selectedUtxos.map(function(utxo) { + return pkr.pathForAddress(utxo.address); + }); + + b = b.setHashToScriptMap(pkr.getRedeemScriptMap(inputChainPaths)); + + var keys = priv.getForPaths(inputChainPaths); + var signed = b.sign(keys); + + if (options.fetch) return; + + this.log(''); + this.log('Created transaction:'); + this.log(b.tx.getStandardizedObject()); + this.log(''); + + var myId = this.getMyCopayerId(); + var now = Date.now(); + + var tx = b.build(); + if (!tx.countInputSignatures(0)) + throw new Error('Could not sign generated tx'); + + var me = {}; + me[myId] = now; + var meSeen = {}; + if (priv) meSeen[myId] = now; + + var ntxid = this.txProposals.add(new TxProposal({ + inputChainPaths: inputChainPaths, + signedBy: me, + seenBy: meSeen, + creator: myId, + createdTs: now, + builder: b, + comment: options.memo, + merchant: merchantData + })); + return ntxid; +}; + +// This essentially ensures that a copayer hasn't tampered with a +// PaymentRequest message from a payment server. It verifies the signature +// based on the cert, and checks to ensure the desired outputs are the same as +// the ones on the tx proposal. +Wallet.prototype.verifyPaymentRequest = function(ntxid) { + if (!ntxid) return false; + + var txp = typeof ntxid !== 'object' + ? this.txProposals.get(ntxid) + : ntxid; + + // If we're not a payment protocol proposal, ignore. + if (!txp.merchant) return true; + + // The copayer didn't send us the raw payment request, unverifiable. + if (!txp.merchant.raw) return false; + + // var tx = txp.builder.tx; + var tx = txp.builder.build(); + + var data = new Buffer(txp.merchant.raw, 'hex'); + data = PayPro.PaymentRequest.decode(data); + var pr = new PayPro(); + pr = pr.makePaymentRequest(data); + + // Verify the signature so we know this is the real request. + if (!pr.verify()) { + // Signature does not match cert. It may have + // been modified by an untrustworthy person. + // We should not sign this transaction proposal! + return false; + } + + var details = pr.get('serialized_payment_details'); + details = PayPro.PaymentDetails.decode(details); + var pd = new PayPro(); + pd = pd.makePaymentDetails(details); + + var outputs = pd.get('outputs'); + + if (tx.outs.length < outputs.length) { + // Outputs do not and cannot match. + return false; + } + + // Figure out whether the user is supposed + // to decide the value of the outputs. + var undecided = false; + var total = bignum('0', 10); + for (var i = 0; i < outputs.length; i++) { + var output = outputs[i]; + var amount = output.get('amount'); + // big endian + var v = new Buffer(8); + v[0] = (amount.high >> 24) & 0xff; + v[1] = (amount.high >> 16) & 0xff; + v[2] = (amount.high >> 8) & 0xff; + v[3] = (amount.high >> 0) & 0xff; + v[4] = (amount.low >> 24) & 0xff; + v[5] = (amount.low >> 16) & 0xff; + v[6] = (amount.low >> 8) & 0xff; + v[7] = (amount.low >> 0) & 0xff; + total = total.add(bignum.fromBuffer(v, { + endian: 'big', + size: 1 + })); + } + if (+total.toString(10) === 0) { + undecided = true; + } + + for (var i = 0; i < outputs.length; i++) { + var output = outputs[i]; + + var amount = output.get('amount'); + var script = { + offset: output.get('script').offset, + limit: output.get('script').limit, + buffer: new Buffer(new Uint8Array(output.get('script').buffer)) + }; + + // Expected value + // little endian (keep this LE to compare with tx output value) + var ev = new Buffer(8); + ev[0] = (amount.low >> 0) & 0xff; + ev[1] = (amount.low >> 8) & 0xff; + ev[2] = (amount.low >> 16) & 0xff; + ev[3] = (amount.low >> 24) & 0xff; + ev[4] = (amount.high >> 0) & 0xff; + ev[5] = (amount.high >> 8) & 0xff; + ev[6] = (amount.high >> 16) & 0xff; + ev[7] = (amount.high >> 24) & 0xff; + + // Expected script + var es = script.buffer.slice(script.offset, script.limit); + + // Actual value + var av = tx.outs[i].v; + + // Actual script + var as = tx.outs[i].s; + + // XXX allow changing of script as long as address is same + // var as = es; + + // XXX allow changing of script as long as address is same + // var network = pd.get('network') === 'main' ? 'livenet' : 'testnet'; + // var es = bitcore.Address.fromScriptPubKey(new bitcore.Script(es), network)[0]; + // var as = bitcore.Address.fromScriptPubKey(new bitcore.Script(tx.outs[i].s), network)[0]; + + if (undecided) { + av = ev = new Buffer([0]); + } + + // Make sure the tx's output script and values match the payment request's. + if (av.toString('hex') !== ev.toString('hex') + || as.toString('hex') !== es.toString('hex')) { + // Verifiable outputs do not match outputs of merchant + // data. We should not sign this transaction proposal! + return false; + } + + // Checking the merchant data itself isn't technically + // necessary as long as we check the transaction, but + // we can do it for good measure. + var ro = txp.merchant.pr.pd.outputs[i]; + + // Actual value + // little endian (keep this LE to compare with the ev above) + var av = new Buffer(8); + av[0] = (ro.amount.low >> 0) & 0xff; + av[1] = (ro.amount.low >> 8) & 0xff; + av[2] = (ro.amount.low >> 16) & 0xff; + av[3] = (ro.amount.low >> 24) & 0xff; + av[4] = (ro.amount.high >> 0) & 0xff; + av[5] = (ro.amount.high >> 8) & 0xff; + av[6] = (ro.amount.high >> 16) & 0xff; + av[7] = (ro.amount.high >> 24) & 0xff; + + // Actual script + var as = new Buffer(ro.script.buffer, 'hex') + .slice(ro.script.offset, ro.script.limit); + + if (av.toString('hex') !== ev.toString('hex') + || as.toString('hex') !== es.toString('hex')) { + return false; + } + } + + return true; +}; + +Wallet.prototype.addSeenToTxProposals = function() { + var ret = false; + var myId = this.getMyCopayerId(); + + for (var k in this.txProposals.txps) { + var txp = this.txProposals.txps[k]; + if (!txp.seenBy[myId]) { + + txp.seenBy[myId] = Date.now(); + ret = true; + } + } + return ret; +}; // TODO: remove this method and use getAddressesInfo everywhere Wallet.prototype.getAddresses = function(opts) { @@ -853,6 +1494,20 @@ Wallet.prototype.getUnspent = function(cb) { Wallet.prototype.createTx = function(toAddress, amountSatStr, comment, opts, cb) { var self = this; + + if (typeof amountSatStr === 'function') { + var cb = amountSatStr; + var merchant = toAddress; + return this.createPaymentTx({ uri: merchant }, cb); + } + + if (typeof comment === 'function') { + var cb = comment; + var merchant = toAddress; + var comment = amountSatStr; + return this.createPaymentTx({ uri: merchant, memo: comment }, cb); + } + if (typeof opts === 'function') { cb = opts; opts = {}; @@ -1105,4 +1760,85 @@ Wallet.prototype.verifySignedJson = function(senderId, payload, signature) { return v; } +// NOTE: Angular $http module does not send ArrayBuffers correctly, so we're +// not going to use it. We'll have to write our own. Otherwise, we could +// hex-encoded our messages and decode them on the other side, but that +// deviates from BIP-70. + +// if (typeof angular !== 'undefined') { +// var $http = angular.bootstrap().get('$http'); +// } + +Wallet.request = function(options, callback) { + if (typeof options === 'string') { + options = { uri: options }; + } + + options.method = options.method || 'GET'; + options.headers = options.headers || {}; + + var ret = { + success: function(cb) { + this._success = cb; + return this; + }, + error: function(cb) { + this._error = cb; + return this; + }, + _success: function() { + ; + }, + _error: function(_, err) { + throw err; + } + }; + + var method = (options.method || 'GET').toUpperCase(); + var uri = options.uri || options.url; + var req = options; + + req.headers = req.headers || {}; + req.body = req.body || req.data || {}; + + var xhr = new XMLHttpRequest(); + xhr.open(method, uri, true); + + Object.keys(req.headers).forEach(function(key) { + var val = req.headers[key]; + if (key === 'Content-Length') return; + if (key === 'Content-Transfer-Encoding') return; + xhr.setRequestHeader(key, val); + }); + + if (req.responseType) { + xhr.responseType = req.responseType; + } + + xhr.onload = function(event) { + var response = xhr.response; + var buf = new Uint8Array(response); + var headers = {}; + (xhr.getAllResponseHeaders() || '').replace( + /(?:\r?\n|^)([^:\r\n]+): *([^\r\n]+)/g, + function($0, $1, $2) { + headers[$1.toLowerCase()] = $2; + } + ); + return ret._success(buf, xhr.status, headers, options); + }; + + xhr.onerror = function(event) { + return ret._error(null, new Error(event.message), null, options); + }; + + if (req.body) { + xhr.send(req.body); + } else { + xhr.send(null); + } + + return ret; +}; + module.exports = require('soop')(Wallet); diff --git a/karma.conf.js b/karma.conf.js index fd2c586fe..5b9d32890 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -43,9 +43,9 @@ module.exports = function(config) { //App-specific Code 'js/app.js', 'js/routes.js', + 'js/services/*.js', 'js/directives.js', 'js/filters.js', - 'js/services/*.js', 'js/controllers/*.js', 'js/init.js', @@ -54,6 +54,7 @@ module.exports = function(config) { 'test/lib/chai-should.js', 'test/lib/chai-expect.js', 'test/mocks/FakeWallet.js', + 'test/mocks/FakePayProServer.js', 'test/mocha.conf.js', diff --git a/package.json b/package.json index 6504fb200..9600d1de6 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,11 @@ }, "version": "0.4.2", "dependencies": { - "preconditions": "^1.0.7", - "sinon": "1.9.1", + "mocha": "^1.18.2", "mocha-lcov-reporter": "0.0.1", - "mocha": "^1.18.2" + "optimist": "^0.6.1", + "preconditions": "^1.0.7", + "sinon": "1.9.1" }, "scripts": { "shell": "node shell/scripts/launch.js", @@ -48,7 +49,7 @@ "github-releases": "0.2.0", "grunt-markdown": "0.5.0", "browser-pack": "2.0.1", - "bitcore": "0.1.34", + "bitcore": "git://github.com/bitpay/bitcore.git#master", "node-cryptojs-aes": "0.4.0", "blanket": "1.1.6", "express": "4.0.0", diff --git a/test/index.html b/test/index.html index 01de4001a..f755dc486 100644 --- a/test/index.html +++ b/test/index.html @@ -12,11 +12,12 @@ - + + diff --git a/test/mocks/FakePayProServer.js b/test/mocks/FakePayProServer.js new file mode 100644 index 000000000..16f6dbafb --- /dev/null +++ b/test/mocks/FakePayProServer.js @@ -0,0 +1,346 @@ +'use strict'; + +var is_browser = typeof process == 'undefined' + || typeof process.versions === 'undefined'; +var bitcore = bitcore || require('bitcore'); +var Buffer = bitcore.Buffer; +var PayPro = bitcore.PayPro; +if (is_browser) { + var copay = require('copay'); //browser +} else { + var copay = require('../../copay'); //node +} +var Wallet = copay.Wallet; + +var x509 = { + priv: '' + + 'LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBeFRKdUsyYUdM' + + 'bjFkWEpLRGg0TXdQTFVrbDNISTVwR25HNWFjNGwvMGlobXE4Y3dDCitGVlBnWk1TNTlheWtpc0Ir' + + 'ekM3dnR2a0prL2J2K0JTT1g3b3hkSXN1TDNkS1FGcHVYWFZmcmRiOTV3WW40TSsKL25qRWhYTWxo' + + 'Vk1IL09DaUFnOUpLaFRLV0w2R1JXWkFBaEE3bEJSaGdTTkRUaVRDNTFDYmlLN3hBNnBONCt0UQpI' + + 'eG9tSlBYclpSa2JCMmtsT2ZXd2J2OTNZM0oxS0ZEK2kwUE1RSEx3N3JoRXVteEM5MytISFVWWVZI' + + 'N0gxVFBaCkgxYmRVSkowMmdRZXlsSnNzWUNKeWRaUHpOVC96dXRzL0tKV2RSdjVseHdHOXU5dE1O' + + 'TWdoSmJtQWFNa01HaSsKbzdQTkV5UDNxSEZyWXBZaHM1cHFMSE1STkI3OFFNOUllTmpMRndJREFR' + + 'QUJBb0lCQVFERVJyalBiQUdjbmwxaAorZGIrOTczNGZ0aElBUkpWSko1dTRFK1JKcThSRWhGTEVL' + + 'UFlKNW0yUC94dVZBMXpYV2xnYXhaRUZ6d1VRaUpZCjdsOEpLVjlwSHhReVlaQ1M4dndYZzhpWGtz' + + 'dndQaWRvQmN1YW4vd0RWQ1FCZXk2VkxjVXpSYUd1Ui9sTHNYK1YKN2Z0QjBvUnFsSXFrYmNQZE1N' + + 'dnFUeG93UnVoUG11Q3JWVGpPNHBiTnFuU09OUExPaUovRkFYYjJwZnpGZnBCUgpHeCtFTW16d2Ur' + + 'SEZuSkJHRGhIWjk5bm4vVEJmYUp6TlZDcURZLzNid3o1WDdIUU5ZN1QrSnlUVUZzZVE5NHhzCnpy' + + 'a2lidGRmVGNUanB1K1VoWm80c1p6Q3IrZkhHWm9FOUdEUHF0ZDRnQ3ByazRFS0pzbXFCRVN4QlhT' + + 'RGhZZ04KOXBVRDM4c1pBb0dCQU9yZkRqdDZaL0ZDamFuVThXek5GaWYrOVQxQTJ4b013RDVWU2xN' + + 'dVJyWW1HbGZyMEM5TQpmMUVvZ2l2dVRrYnA3cmtnZFRhWVRTYndmTnFaQkt4Y3R5YzdCaGRwWnhE' + + 'RVdKa2Z5cThxVngvem1Cek1JK1ZzCjJLYi9hcHZXcmJlb3NET0NyeUg1YzhKc1VUOXhUWDNYYnhF' + + 'anlPSlFCU1lHRE1qUHlKNkU5czZMQW9HQkFOYnYKd2d0S2Nra0tLbDJhNXZzaGR2RENnNnFLL1Fn' + + 'T20vNktUSlVKRVNqaHoydFIrZlBWUjcwVEg5UmhoVFJscERXQgpCd3oyU2NCc1RRNDIvTGsxRnky' + + 'MFQvck12S3VmSEw1VE1BNGZ6NWRxMUxIbmN6ejZVazVnWEtBT09rUjlVdVhpClR0eTNoREcyQkM4' + + 'Nk1LTVJ4SjUxRWJxam94d0VSMTAwU2FuTVBmTWxBb0dBSUhLY1pyOHNhUHBHMC9XbFBPREEKZE5v' + + 'V1MxWVFidkxnQkR5SVBpR2doejJRV2lFcjY3em53ZkNVdXpqNiszVUtFKzFXQkNyYVRjemZrdHVj' + + 'OTZyLwphcDRPNDJFZWFnU1dNT0ZoZ1AyYWQ4R1JmRGovcEl4N0NlY3pkVUFkVThnc1A1R0lYR3M0' + + 'QU40eUEwL0Y0dUxHCloxbklRT3ZKS2syZnFvWjZNdHd2dEswQ2dZRUFnSjdGTGVDRTkzUmYyZGdD' + + 'ZFRHWGJZZlpKc3M1bEFLNkV0NUwKNmJ1ZFN5dWw1Z0VPWkgyekNsQlJjZFJSMUFNbSt1V1ZoSW8x' + + 'cERLckFlQ2g1MnIvemRmakxLQXNIejkrQWQ3aQpHUEdzVmw0Vm5jaDFTMzQ0bHJKUGUzQklLZ2dj' + + 'L1hncDNTYnNzcHJMY2orT0wyZElrOUpXbzZ1Y3hmMUJmMkwwCjJlbGhBUWtDZ1lCWHN5elZWL1pK' + + 'cVhOcFdDZzU1TDNVRm9UTHlLU3FsVktNM1dpRzVCS240QWF6VkNITCtHUVUKeHd4U2dSOWZRNElu' + + 'dStyUHJOM0lteWswbEtQR0Y5U3pDUlJUaUpGUjcyc05xbE82bDBWOENXUkFQVFBKY2dxVgoxVThO' + + 'SEs4YjNaaUlvR0orbXNOenBkeHJqNjJIM0E2K1krQXNOWTRTbVVUWEg5eWpnK251a2c9PQotLS0t' + + 'LUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=', + pub: '' + + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FR' + + 'OEFNSUlCQ2dLQ0FRRUF4VEp1SzJhR0xuMWRYSktEaDRNdwpQTFVrbDNISTVwR25HNWFjNGwvMGlo' + + 'bXE4Y3dDK0ZWUGdaTVM1OWF5a2lzQit6Qzd2dHZrSmsvYnYrQlNPWDdvCnhkSXN1TDNkS1FGcHVY' + + 'WFZmcmRiOTV3WW40TSsvbmpFaFhNbGhWTUgvT0NpQWc5SktoVEtXTDZHUldaQUFoQTcKbEJSaGdT' + + 'TkRUaVRDNTFDYmlLN3hBNnBONCt0UUh4b21KUFhyWlJrYkIya2xPZld3YnY5M1kzSjFLRkQraTBQ' + + 'TQpRSEx3N3JoRXVteEM5MytISFVWWVZIN0gxVFBaSDFiZFVKSjAyZ1FleWxKc3NZQ0p5ZFpQek5U' + + 'L3p1dHMvS0pXCmRSdjVseHdHOXU5dE1OTWdoSmJtQWFNa01HaStvN1BORXlQM3FIRnJZcFloczVw' + + 'cUxITVJOQjc4UU05SWVOakwKRndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==', + der: '' + + 'MIIDBjCCAe4CCQDI2qWdA3/VpDANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQGEwJBVTETMBEGA1UE' + + 'CAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMB4XDTE0MDcx' + + 'NjAxMzM1MVoXDTE1MDcxNjAxMzM1MVowRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3Rh' + + 'dGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDCCASIwDQYJKoZIhvcNAQEBBQAD' + + 'ggEPADCCAQoCggEBAMUybitmhi59XVySg4eDMDy1JJdxyOaRpxuWnOJf9IoZqvHMAvhVT4GTEufW' + + 'spIrAfswu77b5CZP27/gUjl+6MXSLLi93SkBabl11X63W/ecGJ+DPv54xIVzJYVTB/zgogIPSSoU' + + 'yli+hkVmQAIQO5QUYYEjQ04kwudQm4iu8QOqTePrUB8aJiT162UZGwdpJTn1sG7/d2NydShQ/otD' + + 'zEBy8O64RLpsQvd/hx1FWFR+x9Uz2R9W3VCSdNoEHspSbLGAicnWT8zU/87rbPyiVnUb+ZccBvbv' + + 'bTDTIISW5gGjJDBovqOzzRMj96hxa2KWIbOaaixzETQe/EDPSHjYyxcCAwEAATANBgkqhkiG9w0B' + + 'AQUFAAOCAQEAL6AMMfC3TlRcmsIgHxjVD4XYtISlldnrn2X9zvFbJKCpNy8XQQosQxrhyfzPHQKj' + + 'lS2L/KCGMnjx9QkYD2Hlp1MJ1uVv9888th/gcZOv3Or3hQyi5K1Sh5xCG+69lUOqUEGu9B4irsqo' + + 'FomQVbQolSy+t4apdJi7kuEDwFDk4gZiVEfsuX+naN5a6pCnWnhX1Vf4fKwfkLobKKXm2zQVsjxl' + + 'wBAqOEmJGDLoRMXH56qJnEZ/dqsczaJOHQSi9mFEHL0r5rsEDTT5AVxdnBfNnyGaCH7/zANEko+F' + + 'GBj1JdJaJgFTXdbxDoyoPTPD+LJqSK5XYToo46y/T0u9CLveNA==', + pem: '' + + 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCakNDQWU0Q0NRREkycVdkQTMvVnBEQU5C' + + 'Z2txaGtpRzl3MEJBUVVGQURCRk1Rc3dDUVlEVlFRR0V3SkIKVlRFVE1CRUdBMVVFQ0F3S1UyOXRa' + + 'UzFUZEdGMFpURWhNQjhHQTFVRUNnd1lTVzUwWlhKdVpYUWdWMmxrWjJsMApjeUJRZEhrZ1RIUmtN' + + 'QjRYRFRFME1EY3hOakF4TXpNMU1Wb1hEVEUxTURjeE5qQXhNek0xTVZvd1JURUxNQWtHCkExVUVC' + + 'aE1DUVZVeEV6QVJCZ05WQkFnTUNsTnZiV1V0VTNSaGRHVXhJVEFmQmdOVkJBb01HRWx1ZEdWeWJt' + + 'VjAKSUZkcFpHZHBkSE1nVUhSNUlFeDBaRENDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFE' + + 'Q0NBUW9DZ2dFQgpBTVV5Yml0bWhpNTlYVnlTZzRlRE1EeTFKSmR4eU9hUnB4dVduT0pmOUlvWnF2' + + 'SE1BdmhWVDRHVEV1ZldzcElyCkFmc3d1NzdiNUNaUDI3L2dVamwrNk1YU0xMaTkzU2tCYWJsMTFY' + + 'NjNXL2VjR0orRFB2NTR4SVZ6SllWVEIvemcKb2dJUFNTb1V5bGkraGtWbVFBSVFPNVFVWVlFalEw' + + 'NGt3dWRRbTRpdThRT3FUZVByVUI4YUppVDE2MlVaR3dkcApKVG4xc0c3L2QyTnlkU2hRL290RHpF' + + 'Qnk4TzY0Ukxwc1F2ZC9oeDFGV0ZSK3g5VXoyUjlXM1ZDU2ROb0VIc3BTCmJMR0FpY25XVDh6VS84' + + 'N3JiUHlpVm5VYitaY2NCdmJ2YlREVElJU1c1Z0dqSkRCb3ZxT3p6Uk1qOTZoeGEyS1cKSWJPYWFp' + + 'eHpFVFFlL0VEUFNIall5eGNDQXdFQUFUQU5CZ2txaGtpRzl3MEJBUVVGQUFPQ0FRRUFMNkFNTWZD' + + 'MwpUbFJjbXNJZ0h4alZENFhZdElTbGxkbnJuMlg5enZGYkpLQ3BOeThYUVFvc1F4cmh5ZnpQSFFL' + + 'amxTMkwvS0NHCk1uang5UWtZRDJIbHAxTUoxdVZ2OTg4OHRoL2djWk92M09yM2hReWk1SzFTaDV4' + + 'Q0crNjlsVU9xVUVHdTlCNGkKcnNxb0ZvbVFWYlFvbFN5K3Q0YXBkSmk3a3VFRHdGRGs0Z1ppVkVm' + + 'c3VYK25hTjVhNnBDblduaFgxVmY0Zkt3ZgprTG9iS0tYbTJ6UVZzanhsd0JBcU9FbUpHRExvUk1Y' + + 'SDU2cUpuRVovZHFzY3phSk9IUVNpOW1GRUhMMHI1cnNFCkRUVDVBVnhkbkJmTm55R2FDSDcvekFO' + + 'RWtvK0ZHQmoxSmRKYUpnRlRYZGJ4RG95b1BUUEQrTEpxU0s1WFlUb28KNDZ5L1QwdTlDTHZlTkE9' + + 'PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==' +}; + +x509.priv = new Buffer(x509.priv, 'base64'); +x509.pub = new Buffer(x509.pub, 'base64'); +x509.der = new Buffer(x509.der, 'base64'); +x509.pem = new Buffer(x509.pem, 'base64'); + +function startServer(cb) { + if (Wallet.request._server) { + setTimeout(function() { + return cb(null, Wallet.request._server); + }, 1); + return; + } + + var old = Wallet.request; + + var server = { + GET: { + + /** + * Receive "I want to pay" + */ + + '/-/request': function(req, cb) { + var res = { + statusCode: 200, + headers: {}, + body: {} + }; + + var uid = 0; + + console.log('Received payment "request" from %s.', req.socket.remoteAddress); + + var outputs = []; + + [2000, 1000].forEach(function(value) { + var po = new PayPro(); + po = po.makeOutput(); + // number of satoshis to be paid + po.set('amount', value); + // a TxOut script where the payment should be sent. similar to OP_CHECKSIG + po.set('script', new Buffer([ + 118, // OP_DUP + 169, // OP_HASH160 + 76, // OP_PUSHDATA1 + 20, // number of bytes + 55, + 48, + 254, + 188, + 186, + 4, + 186, + 208, + 205, + 71, + 108, + 251, + 130, + 15, + 156, + 55, + 215, + 70, + 111, + 217, + 136, // OP_EQUALVERIFY + 172 // OP_CHECKSIG + ])); + outputs.push(po.message); + }); + + /** + * Payment Details + */ + + var mdata = new Buffer([0]); + uid++; + if (uid > 0xffff) { + throw new Error('UIDs bigger than 0xffff not supported.'); + } else if (uid > 0xff) { + mdata = new Buffer([(uid >> 8) & 0xff, (uid >> 0) & 0xff]) + } else { + mdata = new Buffer([0, uid]) + } + var now = Date.now() / 1000 | 0; + var pd = new PayPro(); + pd = pd.makePaymentDetails(); + pd.set('network', 'test'); + pd.set('outputs', outputs); + pd.set('time', now); + pd.set('expires', now + 60 * 60 * 24); + pd.set('memo', 'Hello, this is the server, we would like some money.'); + var port = +req.headers.host.split(':')[1] || server.port; + pd.set('payment_url', 'https://localhost:' + port + '/-/pay'); + pd.set('merchant_data', mdata); + + /* + * PaymentRequest + */ + + var cr = new PayPro(); + cr = cr.makeX509Certificates(); + cr.set('certificate', [x509.der]); + + // We send the PaymentRequest to the customer + var pr = new PayPro(); + pr = pr.makePaymentRequest(); + pr.set('payment_details_version', 1); + pr.set('pki_type', 'x509+sha256'); + pr.set('pki_data', cr.serialize()); + pr.set('serialized_payment_details', pd.serialize()); + pr.sign(x509.priv); + + pr = pr.serialize(); + + // BIP-71 - set the content-type + res.headers['Content-Type'] = PayPro.PAYMENT_REQUEST_CONTENT_TYPE; + res.headers['Content-Length'] = pr.length + ''; + res.headers['Content-Transfer-Encoding'] = 'binary'; + + res.body = pr; + + return cb(null, res, res.body); + }, + }, + + /** + * Receive Payment + */ + + POST: { + '/-/pay': function(req, cb) { + var body = req.body; + + console.log('Received Payment Message Body:'); + console.log(body.toString('hex')); + + var res = { + statusCode: 200, + headers: {}, + body: {} + }; + + body = PayPro.Payment.decode(body); + + var pay = new PayPro(); + pay = pay.makePayment(body); + var merchant_data = pay.get('merchant_data'); + var transactions = pay.get('transactions'); + var refund_to = pay.get('refund_to'); + var memo = pay.get('memo'); + + console.log('Received Payment from %s.', req.socket.remoteAddress); + console.log('Customer Message: %s', memo); + console.log('Payment Message:'); + console.log(pay); + + // We send this to the customer after receiving a Payment + // Then we propogate the transaction through bitcoin network + var ack = new PayPro(); + ack = ack.makePaymentACK(); + ack.set('payment', pay.message); + ack.set('memo', 'Thank you for your payment!'); + + ack = ack.serialize(); + + // BIP-71 - set the content-type + res.headers['Content-Type'] = PayPro.PAYMENT_ACK_CONTENT_TYPE; + res.headers['Content-Length'] = ack.length + ''; + res.headers['Content-Transfer-Encoding'] = 'binary'; + + transactions = transactions.map(function(tx) { + tx.buffer = new Buffer(new Uint8Array(tx.buffer)); + tx.buffer = tx.buffer.slice(tx.offset, tx.limit); + var ptx = new bitcore.Transaction(); + ptx.parse(tx.buffer); + return ptx; + }); + + transactions.forEach(function(tx) { + var id = tx.getHash().toString('hex'); + console.log(''); + console.log('Sending transaction with txid: %s', id); + console.log(tx.getStandardizedObject()); + }); + + res.body = ack; + + return cb(null, res, res.body); + } + }, + listen: function(port, cb) { + if (cb) return cb(); + }, + close: function(cb) { + Wallet.request = old; + return cb(); + } + }; + + Wallet.request = function(options) { + var ret = { + success: function(cb) { + this._success = cb; + return this; + }, + error: function(cb) { + this._error = cb; + return this; + }, + _success: function() { + ; + }, + _error: function(_, err) { + throw err; + } + }; + var method = (options.method || 'GET').toUpperCase(); + var uri = options.uri || options.url; + var path = uri.replace(/^https?:\/\/[^\/]+/, ''); + var req = options; + req.headers = req.headers || {}; + req.body = req.data || req.body || {}; + req.socket = { + remoteAddress: 'localhost' + }; + req.headers['Host'] = 'localhost:8080'; + Object.keys(req.headers).forEach(function(key) { + req.headers[key] = req.headers[key] + ''; + req.headers[key.toLowerCase()] = req.headers[key] + ''; + }); + setTimeout(function() { + server[method][path](req, function(err, res, body) { + if (err) return ret._error(null, err, null, options); + Object.keys(res.headers).forEach(function(key) { + res.headers[key] = res.headers[key] + ''; + res.headers[key.toLowerCase()] = res.headers[key] + ''; + }); + return ret._success(body, res.statusCode, res.headers, options); + }); + }, 1); + return ret; + }; + + Wallet.request._server = server; + + setTimeout(function() { + return cb(null, server); + }, 1); +} + +module.exports = startServer; diff --git a/test/mocks/FakeWallet.js b/test/mocks/FakeWallet.js index 48b4af4a6..58d58a458 100644 --- a/test/mocks/FakeWallet.js +++ b/test/mocks/FakeWallet.js @@ -1,3 +1,14 @@ + +var is_browser = typeof process == 'undefined' + || typeof process.versions === 'undefined'; +if (is_browser) { + var copay = require('copay'); //browser +} else { + var copay = require('../copay'); //node +} +var Wallet = copay.Wallet; + + var FakeWallet = function() { this.id = 'testID'; this.balance = 10000; @@ -53,7 +64,20 @@ FakeWallet.prototype.isShared = function() { FakeWallet.prototype.isReady = function() { return true; -} +}; + +FakeWallet.prototype.fetchPaymentTx = function(opts, cb) { + cb(null, { + pr: { + pd: { + expires: 12 + } + } + }); +}; + + +FakeWallet.prototype.createPaymentTx = Wallet.prototype.createPaymentTx; FakeWallet.prototype.getBalance = function(cb) { diff --git a/test/test.PayPro.js b/test/test.PayPro.js new file mode 100644 index 000000000..af6107e35 --- /dev/null +++ b/test/test.PayPro.js @@ -0,0 +1,896 @@ +'use strict'; + +var chai = chai || require('chai'); +var should = chai.should(); +var sinon = require('sinon'); +var is_browser = typeof process == 'undefined' + || typeof process.versions === 'undefined'; +if (is_browser) { + var copay = require('copay'); //browser +} else { + var copay = require('../copay'); //node +} +var copayConfig = require('../config'); +var Wallet = copay.Wallet; +var PrivateKey = copay.PrivateKey; +var Storage = require('./mocks/FakeStorage'); +var Network = require('./mocks/FakeNetwork'); +var Blockchain = require('./mocks/FakeBlockchain'); +var bitcore = bitcore || require('bitcore'); +var TransactionBuilder = bitcore.TransactionBuilder; +var Transaction = bitcore.Transaction; +var Address = bitcore.Address; +var PayPro = bitcore.PayPro; +var bignum = bitcore.Bignum; +var startServer = copay.FakePayProServer; // TODO should be require('./mocks/FakePayProServer'); + +var server; + +var config = { + requiredCopayers: 1, + totalCopayers: 1, + spendUnconfirmed: true, + reconnectDelay: 100, + networkName: 'testnet', +}; + +var getNewEpk = function() { + return new PrivateKey({ + networkName: config.networkName, + }) + .deriveBIP45Branch() + .extendedPublicKeyString(); +}; + +describe('PayPro (in Wallet) model', function() { + var createW = function(N, conf) { + var c = JSON.parse(JSON.stringify(conf || config)); + if (!N) N = c.totalCopayers; + + var mainPrivateKey = new copay.PrivateKey({ + networkName: config.networkName + }); + var mainCopayerEPK = mainPrivateKey.deriveBIP45Branch().extendedPublicKeyString(); + c.privateKey = mainPrivateKey; + + c.publicKeyRing = new copay.PublicKeyRing({ + networkName: c.networkName, + requiredCopayers: Math.min(N, c.requiredCopayers), + totalCopayers: N, + }); + c.publicKeyRing.addCopayer(mainCopayerEPK); + + c.txProposals = new copay.TxProposals({ + networkName: c.networkName, + }); + + var storage = new Storage(config.storage); + var network = new Network(config.network); + var blockchain = new Blockchain(config.blockchain); + c.storage = storage; + c.network = network; + c.blockchain = blockchain; + + c.addressBook = { + '2NFR2kzH9NUdp8vsXTB4wWQtTtzhpKxsyoJ': { + label: 'John', + copayerId: '026a55261b7c898fff760ebe14fd22a71892295f3b49e0ca66727bc0a0d7f94d03', + createdTs: 1403102115, + hidden: false + }, + '2MtP8WyiwG7ZdVWM96CVsk2M1N8zyfiVQsY': { + label: 'Jennifer', + copayerId: '032991f836543a492bd6d0bb112552bfc7c5f3b7d5388fcbcbf2fbb893b44770d7', + createdTs: 1403103115, + hidden: false + } + }; + + c.networkName = config.networkName; + c.verbose = config.verbose; + c.version = '0.0.1'; + + return new Wallet(c); + } + + var unspentTest = [{ + "address": "dummy", + "scriptPubKey": "dummy", + "txid": "2ac165fa7a3a2b535d106a0041c7568d03b531e58aeccdd3199d7289ab12cfc1", + "vout": 1, + "amount": 10, + "confirmations": 7 + }]; + + var createW2 = function(privateKeys, N, conf) { + if (!N) N = 3; + var w = createW(N, conf); + should.exist(w); + + var pkr = w.publicKeyRing; + + for (var i = 0; i < N - 1; i++) { + if (privateKeys) { + var k = privateKeys[i]; + pkr.addCopayer(k ? k.deriveBIP45Branch().extendedPublicKeyString() : getNewEpk()); + } else { + pkr.addCopayer(getNewEpk()); + } + } + + return w; + }; + + var cachedW2 = null; + var cachedW2obj = null; + var cachedCreateW2 = function() { + if (!cachedW2) { + cachedW2 = createW2(); + cachedW2obj = cachedW2.toObj(); + cachedW2obj.opts.reconnectDelay = 100; + } + var w = Wallet.fromObj(cachedW2obj, cachedW2.storage, cachedW2.network, cachedW2.blockchain); + return w; + }; + + var createWallet = function() { + var w = cachedCreateW2(); + unspentTest[0].address = w.publicKeyRing.getAddress(1, true, w.publicKey).toString(); + unspentTest[0].scriptPubKey = w.publicKeyRing.getScriptPubKeyHex(1, true, w.publicKey); + w.getUnspent = function(cb) { + return setTimeout(function() { + return cb(null, unspentTest, unspentTest); + }, 1); + }; + return w; + }; + + it('#start the example server', function(done) { + startServer(function(err, s) { + if (err) return done(err); + server = s; + server.uri = 'https://localhost:8080/-'; + done(); + }); + }); + + var pr; + var ppw; + + ppw = createWallet(); + + it('#retrieve a payment request message via http', function(done) { + var w = ppw; + should.exist(w); + + var req = { + headers: { + 'Host': 'localhost:8080', + 'Accept': PayPro.PAYMENT_REQUEST_CONTENT_TYPE + + ', ' + PayPro.PAYMENT_ACK_CONTENT_TYPE, + 'Content-Type': 'application/octet-stream', + 'Content-Length': '0' + }, + socket: { + remoteAddress: 'localhost', + remotePort: 8080 + }, + body: {} + }; + + Object.keys(req.headers).forEach(function(key) { + req.headers[key.toLowerCase()] = req.headers[key]; + }); + + server.GET['/-/request'](req, function(err, res, body) { + var data = PayPro.PaymentRequest.decode(body); + pr = new PayPro(); + pr = pr.makePaymentRequest(data); + done(); + }); + }); + + it('#send a payment message via http', function(done) { + var w = ppw; + should.exist(w); + + var ver = pr.get('payment_details_version'); + var pki_type = pr.get('pki_type'); + var pki_data = pr.get('pki_data'); + var details = pr.get('serialized_payment_details'); + var sig = pr.get('signature'); + + var certs = PayPro.X509Certificates.decode(pki_data); + certs = certs.certificate; + + var verified = pr.verify(); + + if (!verified) { + return done(new Error('Server sent a bad signature.')); + } + + details = PayPro.PaymentDetails.decode(details); + var pd = new PayPro(); + pd = pd.makePaymentDetails(details); + + var network = pd.get('network'); + var outputs = pd.get('outputs'); + var time = pd.get('time'); + var expires = pd.get('expires'); + var memo = pd.get('memo'); + var payment_url = pd.get('payment_url'); + var merchant_data = pd.get('merchant_data'); + + var priv = w.privateKey; + var pkr = w.publicKeyRing; + + var opts = { + remainderOut: { + address: w._doGenerateAddress(true).toString() + } + }; + + var outs = []; + outputs.forEach(function(output) { + var amount = output.get('amount'); + var script = { + offset: output.get('script').offset, + limit: output.get('script').limit, + buffer: new Buffer(new Uint8Array( + output.get('script').buffer)) + }; + + // big endian + var v = new Buffer(8); + v[0] = (amount.high >> 24) & 0xff; + v[1] = (amount.high >> 16) & 0xff; + v[2] = (amount.high >> 8) & 0xff; + v[3] = (amount.high >> 0) & 0xff; + v[4] = (amount.low >> 24) & 0xff; + v[5] = (amount.low >> 16) & 0xff; + v[6] = (amount.low >> 8) & 0xff; + v[7] = (amount.low >> 0) & 0xff; + + var s = script.buffer.slice(script.offset, script.limit); + var net = network === 'main' ? 'livenet' : 'testnet'; + var addr = bitcore.Address.fromScriptPubKey(new bitcore.Script(s), net); + + outs.push({ + address: addr[0].toString(), + amountSatStr: bitcore.Bignum.fromBuffer(v, { + // XXX for some reason, endian is ALWAYS 'big' + // in node (in the browser it behaves correctly) + endian: 'big', + size: 1 + }).toString(10) + }); + }); + + var b = new bitcore.TransactionBuilder(opts) + .setUnspent(unspentTest) + .setOutputs(outs); + + outputs.forEach(function(output, i) { + var script = { + offset: output.get('script').offset, + limit: output.get('script').limit, + buffer: new Buffer(new Uint8Array( + output.get('script').buffer)) + }; + var s = script.buffer.slice(script.offset, script.limit); + b.tx.outs[i].s = s; + }); + + var selectedUtxos = b.getSelectedUnspent(); + var inputChainPaths = selectedUtxos.map(function(utxo) { + return pkr.pathForAddress(utxo.address); + }); + + b = b.setHashToScriptMap(pkr.getRedeemScriptMap(inputChainPaths)); + + if (priv) { + var keys = priv.getForPaths(inputChainPaths); + var signed = b.sign(keys); + } + + var tx = b.build(); + + var refund_outputs = []; + + var refund_to = w.publicKeyRing.getPubKeys(0, false, w.getMyCopayerId())[0]; + + var total = outputs.reduce(function(total, _, i) { + // XXX reverse endianness to work around bignum bug: + var txv = tx.outs[i].v; + var v = new Buffer(8); + for (var j = 0; j < 8; j++) v[j] = txv[7 - j]; + return total.add(bignum.fromBuffer(v, { + endian: 'big', + size: 1 + })); + }, bitcore.Bignum('0', 10)); + + var rpo = new PayPro(); + rpo = rpo.makeOutput(); + + rpo.set('amount', +total.toString(10)); + + rpo.set('script', + Buffer.concat([ + new Buffer([ + 118, // OP_DUP + 169, // OP_HASH160 + 76, // OP_PUSHDATA1 + 20, // number of bytes + ]), + // needs to be ripesha'd + bitcore.util.sha256ripe160(refund_to), + new Buffer([ + 136, // OP_EQUALVERIFY + 172 // OP_CHECKSIG + ]) + ]) + ); + + refund_outputs.push(rpo.message); + + var pay = new PayPro(); + pay = pay.makePayment(); + pay.set('merchant_data', new Buffer([0, 1])); + pay.set('transactions', [tx.serialize()]); + pay.set('refund_to', refund_outputs); + pay.set('memo', 'Hi server, I would like to give you some money.'); + + pay = pay.serialize(); + + var req = { + headers: { + 'Host': 'localhost:8080', + 'Accept': PayPro.PAYMENT_REQUEST_CONTENT_TYPE + + ', ' + PayPro.PAYMENT_ACK_CONTENT_TYPE, + 'Content-Type': PayPro.PAYMENT_CONTENT_TYPE, + 'Content-Length': pay.length + '' + }, + socket: { + remoteAddress: 'localhost', + remotePort: 8080 + }, + body: pay, + data: pay + }; + + Object.keys(req.headers).forEach(function(key) { + req.headers[key.toLowerCase()] = req.headers[key]; + }); + + server.POST['/-/pay'](req, function(err, res, body) { + if (err) return done(err); + + var data = PayPro.PaymentACK.decode(body); + var ack = new PayPro(); + ack = ack.makePaymentACK(data); + + var payment = ack.get('payment'); + var memo = ack.get('memo'); + + payment = PayPro.Payment.decode(payment); + var pay = new PayPro(); + payment = pay.makePayment(payment); + + var tx = payment.message.transactions[0]; + + if (!tx) { + return done(new Error('No tx in payment ACK.')); + } + + if (tx.buffer) { + tx.buffer = new Buffer(new Uint8Array(tx.buffer)); + tx.buffer = tx.buffer.slice(tx.offset, tx.limit); + var ptx = new bitcore.Transaction(); + ptx.parse(tx.buffer); + tx = ptx; + } + + var ackTotal = outputs.reduce(function(total, _, i) { + // XXX reverse endianness to work around bignum bug: + var txv = tx.outs[i].v; + var v = new Buffer(8); + for (var j = 0; j < 8; j++) v[j] = txv[7 - j]; + return total.add(bignum.fromBuffer(v, { + endian: 'big', + size: 1 + })); + }, bitcore.Bignum('0', 10)); + + ackTotal.toString(10).should.equal(total.toString(10)); + + done(); + }); + }); + + it('#retrieve a payment request message via http', function(done) { + var w = ppw; + should.exist(w); + + var req = { + headers: { + 'Host': 'localhost:8080', + 'Accept': PayPro.PAYMENT_REQUEST_CONTENT_TYPE + + ', ' + PayPro.PAYMENT_ACK_CONTENT_TYPE, + 'Content-Type': 'application/octet-stream', + 'Content-Length': '0' + }, + socket: { + remoteAddress: 'localhost', + remotePort: 8080 + }, + body: {} + }; + + Object.keys(req.headers).forEach(function(key) { + req.headers[key.toLowerCase()] = req.headers[key]; + }); + + server.GET['/-/request'](req, function(err, res, body) { + var data = PayPro.PaymentRequest.decode(body); + pr = new PayPro(); + pr = pr.makePaymentRequest(data); + done(); + }); + }); + + it('#send a payment message via http', function(done) { + var w = ppw; + should.exist(w); + + var ver = pr.get('payment_details_version'); + var pki_type = pr.get('pki_type'); + var pki_data = pr.get('pki_data'); + var details = pr.get('serialized_payment_details'); + var sig = pr.get('signature'); + + var certs = PayPro.X509Certificates.decode(pki_data); + certs = certs.certificate; + + var verified = pr.verify(); + + if (!verified) { + return done(new Error('Server sent a bad signature.')); + } + + details = PayPro.PaymentDetails.decode(details); + var pd = new PayPro(); + pd = pd.makePaymentDetails(details); + + var network = pd.get('network'); + var outputs = pd.get('outputs'); + var time = pd.get('time'); + var expires = pd.get('expires'); + var memo = pd.get('memo'); + var payment_url = pd.get('payment_url'); + var merchant_data = pd.get('merchant_data'); + + var priv = w.privateKey; + var pkr = w.publicKeyRing; + + var opts = { + remainderOut: { + address: w._doGenerateAddress(true).toString() + } + }; + + var outs = []; + outputs.forEach(function(output) { + var amount = output.get('amount'); + var script = { + offset: output.get('script').offset, + limit: output.get('script').limit, + buffer: new Buffer(new Uint8Array( + output.get('script').buffer)) + }; + + // big endian + var v = new Buffer(8); + v[0] = (amount.high >> 24) & 0xff; + v[1] = (amount.high >> 16) & 0xff; + v[2] = (amount.high >> 8) & 0xff; + v[3] = (amount.high >> 0) & 0xff; + v[4] = (amount.low >> 24) & 0xff; + v[5] = (amount.low >> 16) & 0xff; + v[6] = (amount.low >> 8) & 0xff; + v[7] = (amount.low >> 0) & 0xff; + + var s = script.buffer.slice(script.offset, script.limit); + var net = network === 'main' ? 'livenet' : 'testnet'; + var addr = bitcore.Address.fromScriptPubKey(new bitcore.Script(s), net); + + outs.push({ + address: addr[0].toString(), + amountSatStr: bitcore.Bignum.fromBuffer(v, { + // XXX for some reason, endian is ALWAYS 'big' + // in node (in the browser it behaves correctly) + endian: 'big', + size: 1 + }).toString(10) + }); + }); + + var b = new bitcore.TransactionBuilder(opts) + .setUnspent(unspentTest) + .setOutputs(outs); + + outputs.forEach(function(output, i) { + var script = { + offset: output.get('script').offset, + limit: output.get('script').limit, + buffer: new Buffer(new Uint8Array( + output.get('script').buffer)) + }; + var s = script.buffer.slice(script.offset, script.limit); + b.tx.outs[i].s = s; + }); + + var selectedUtxos = b.getSelectedUnspent(); + var inputChainPaths = selectedUtxos.map(function(utxo) { + return pkr.pathForAddress(utxo.address); + }); + + b = b.setHashToScriptMap(pkr.getRedeemScriptMap(inputChainPaths)); + + if (priv) { + var keys = priv.getForPaths(inputChainPaths); + var signed = b.sign(keys); + } + + var tx = b.build(); + + var refund_outputs = []; + + var refund_to = w.publicKeyRing.getPubKeys(0, false, w.getMyCopayerId())[0]; + + var total = outputs.reduce(function(total, _, i) { + // XXX reverse endianness to work around bignum bug: + var txv = tx.outs[i].v; + var v = new Buffer(8); + for (var j = 0; j < 8; j++) v[j] = txv[7 - j]; + return total.add(bignum.fromBuffer(v, { + endian: 'big', + size: 1 + })); + }, bitcore.Bignum('0', 10)); + + var rpo = new PayPro(); + rpo = rpo.makeOutput(); + + rpo.set('amount', +total.toString(10)); + + rpo.set('script', + Buffer.concat([ + new Buffer([ + 118, // OP_DUP + 169, // OP_HASH160 + 76, // OP_PUSHDATA1 + 20, // number of bytes + ]), + // needs to be ripesha'd + bitcore.util.sha256ripe160(refund_to), + new Buffer([ + 136, // OP_EQUALVERIFY + 172 // OP_CHECKSIG + ]) + ]) + ); + + refund_outputs.push(rpo.message); + + var pay = new PayPro(); + pay = pay.makePayment(); + pay.set('merchant_data', new Buffer([0, 1])); + pay.set('transactions', [tx.serialize()]); + pay.set('refund_to', refund_outputs); + pay.set('memo', 'Hi server, I would like to give you some money.'); + + pay = pay.serialize(); + + var req = { + headers: { + 'Host': 'localhost:8080', + 'Accept': PayPro.PAYMENT_REQUEST_CONTENT_TYPE + + ', ' + PayPro.PAYMENT_ACK_CONTENT_TYPE, + 'Content-Type': PayPro.PAYMENT_CONTENT_TYPE, + 'Content-Length': pay.length + '' + }, + socket: { + remoteAddress: 'localhost', + remotePort: 8080 + }, + body: pay, + data: pay + }; + + Object.keys(req.headers).forEach(function(key) { + req.headers[key.toLowerCase()] = req.headers[key]; + }); + + server.POST['/-/pay'](req, function(err, res, body) { + if (err) return done(err); + + var data = PayPro.PaymentACK.decode(body); + var ack = new PayPro(); + ack = ack.makePaymentACK(data); + + var payment = ack.get('payment'); + var memo = ack.get('memo'); + + payment = PayPro.Payment.decode(payment); + var pay = new PayPro(); + payment = pay.makePayment(payment); + + var tx = payment.message.transactions[0]; + + if (!tx) { + return done(new Error('No tx in payment ACK.')); + } + + if (tx.buffer) { + tx.buffer = new Buffer(new Uint8Array(tx.buffer)); + tx.buffer = tx.buffer.slice(tx.offset, tx.limit); + var ptx = new bitcore.Transaction(); + ptx.parse(tx.buffer); + tx = ptx; + } + + var ackTotal = outputs.reduce(function(total, _, i) { + // XXX reverse endianness to work around bignum bug: + var txv = tx.outs[i].v; + var v = new Buffer(8); + for (var j = 0; j < 8; j++) v[j] = txv[7 - j]; + return total.add(bignum.fromBuffer(v, { + endian: 'big', + size: 1 + })); + }, bitcore.Bignum('0', 10)); + + ackTotal.toString(10).should.equal(total.toString(10)); + + should.exist(ack); + memo.should.equal('Thank you for your payment!'); + + done(); + }); + }); + + ppw = createWallet(); + + it('#retrieve a payment request message via model', function(done) { + var w = ppw; + should.exist(w); + // Caches Payment Request but does not add TX proposal + w.fetchPaymentTx({ + uri: 'https://localhost:8080/-/request' + }, function(err, merchantData) { + if (err) return done(err); + merchantData.pr.pd.payment_url.should.equal('https://localhost:8080/-/pay'); + return done(); + }); + }); + + it('#add tx proposal based on payment message via model', function(done) { + var w = ppw; + should.exist(w); + var options = { + uri: 'https://localhost:8080/-/request' + }; + var req = w.paymentRequests[options.uri]; + should.exist(req); + delete w.paymentRequests[options.uri]; + w.receivePaymentRequest(options, req.pr, function(ntxid, merchantData) { + should.exist(ntxid); + should.exist(merchantData); + w._ntxid = ntxid; + merchantData.pr.pd.payment_url.should.equal('https://localhost:8080/-/pay'); + return done(); + }); + }); + + it('#add tx proposal based on payment message via model', function(done) { + var w = ppw; + should.exist(w); + w.sendPaymentTx(w._ntxid, function(txid, merchantData) { + should.exist(txid); + should.exist(merchantData); + should.exist(merchantData.ack); + merchantData.ack.memo.should.equal('Thank you for your payment!'); + return done(); + }); + }); + + it('#send a payment request using payment api', function(done) { + var w = createWallet(); + should.exist(w); + var uri = 'bitcoin:2NBzZdFBoQymDgfzH2Pmnthser1E71MmU47?amount=0.00003&r=' + server.uri + '/request'; + var memo = 'Hello, server. I\'d like to make a payment.'; + w.createPaymentTx({ + uri: uri, + memo: memo + }, function(ntxid, merchantData) { + should.exist(ntxid); + should.exist(merchantData); + if (w.isShared()) { + return done(); + } else { + w.sendPaymentTx(ntxid, { memo: memo }, function(txid, merchantData) { + should.exist(txid); + should.exist(merchantData); + return done(); + }); + } + }); + }); + + it('#send a payment request with merchant prefix', function(done) { + var w = createWallet(); + should.exist(w); + var address = 'Merchant: ' + server.uri + '/request\nMemo: foo'; + var commentText = 'Hello, server. I\'d like to make a payment.'; + var uri; + + // Replicates code in controllers/send.js: + if (address.indexOf('bitcoin:') === 0) { + uri = copay.HDPath.parseBitcoinURI(address); + } else if (address.indexOf('Merchant: ') === 0) { + uri = address.split(/\s+/)[1]; + } + + w.createTx(uri, commentText, function(ntxid, merchantData) { + if (w.isShared()) { + should.exist(ntxid); + should.exist(merchantData); + return done(); + } else { + should.exist(merchantData); + w.sendTx(ntxid, function(txid, merchantData) { + should.exist(txid); + should.exist(merchantData); + return done(); + }); + } + }); + }); + + it('#send a payment request with bitcoin uri', function(done) { + var w = createWallet(); + should.exist(w); + var address = 'bitcoin:2NBzZdFBoQymDgfzH2Pmnthser1E71MmU47?amount=0.00003&r=' + server.uri + '/request'; + var commentText = 'Hello, server. I\'d like to make a payment.'; + w.createTx(address, commentText, function(ntxid, merchantData) { + if (w.isShared()) { + should.exist(ntxid); + should.exist(merchantData); + return done(); + } else { + w.sendTx(ntxid, function(txid, merchantData) { + should.exist(txid); + should.exist(merchantData); + return done(); + }); + } + }); + }); + + it('#try to sign a tampered payment request (raw)', function(done) { + var w = createWallet(); + should.exist(w); + var address = 'bitcoin:2NBzZdFBoQymDgfzH2Pmnthser1E71MmU47?amount=0.00003&r=' + server.uri + '/request'; + var commentText = 'Hello, server. I\'d like to make a payment.'; + w.createTx(address, commentText, function(ntxid, merchantData) { + should.exist(ntxid); + should.exist(merchantData); + + // Tamper with payment request in its raw form: + var data = new Buffer(merchantData.raw, 'hex'); + data = PayPro.PaymentRequest.decode(data); + var pr = new PayPro(); + pr = pr.makePaymentRequest(data); + var details = pr.get('serialized_payment_details'); + details = PayPro.PaymentDetails.decode(details); + var pd = new PayPro(); + pd = pd.makePaymentDetails(details); + var outputs = pd.get('outputs'); + outputs[outputs.length - 1].set('amount', 1000000000); + pd.set('outputs', outputs); + pr.set('serialized_payment_details', pd.serialize()); + merchantData.raw = pr.serialize().toString('hex'); + + var myId = w.getMyCopayerId(); + var txp = w.txProposals.get(ntxid); + should.exist(txp); + should.exist(txp.signedBy[myId]); + should.not.exist(txp.rejectedBy[myId]); + + w.verifyPaymentRequest(ntxid).should.equal(false); + + return done(); + }); + }); + + it('#try to sign a tampered payment request (abstract)', function(done) { + var w = createWallet(); + should.exist(w); + var address = 'bitcoin:2NBzZdFBoQymDgfzH2Pmnthser1E71MmU47?amount=0.00003&r=' + server.uri + '/request'; + var commentText = 'Hello, server. I\'d like to make a payment.'; + w.createTx(address, commentText, function(ntxid, merchantData) { + should.exist(ntxid); + should.exist(merchantData); + + // Tamper with payment request in its abstract form: + var outputs = merchantData.pr.pd.outputs; + var output = outputs[outputs.length - 1]; + var amount = output.amount; + amount.low = 2; + + var myId = w.getMyCopayerId(); + var txp = w.txProposals.get(ntxid); + should.exist(txp); + should.exist(txp.signedBy[myId]); + should.not.exist(txp.rejectedBy[myId]); + + w.verifyPaymentRequest(ntxid).should.equal(false); + + return done(); + }); + }); + + it('#try to sign a tampered txp tx (abstract)', function(done) { + var w = createWallet(); + should.exist(w); + var address = 'bitcoin:2NBzZdFBoQymDgfzH2Pmnthser1E71MmU47?amount=0.00003&r=' + server.uri + '/request'; + var commentText = 'Hello, server. I\'d like to make a payment.'; + w.createTx(address, commentText, function(ntxid, merchantData) { + should.exist(ntxid); + should.exist(merchantData); + + // Tamper with payment request in its abstract form: + var txp = w.txProposals.get(ntxid); + var tx = txp.builder.tx || txp.builder.build(); + tx.outs[0].v = new Buffer([2, 0, 0, 0, 0, 0, 0, 0]); + + var myId = w.getMyCopayerId(); + var txp = w.txProposals.get(ntxid); + should.exist(txp); + should.exist(txp.signedBy[myId]); + should.not.exist(txp.rejectedBy[myId]); + + w.verifyPaymentRequest(ntxid).should.equal(false); + + return done(); + }); + }); + + it('#sign an untampered payment request', function(done) { + var w = createWallet(); + should.exist(w); + var address = 'bitcoin:2NBzZdFBoQymDgfzH2Pmnthser1E71MmU47?amount=0.00003&r=' + server.uri + '/request'; + var commentText = 'Hello, server. I\'d like to make a payment.'; + w.createTx(address, commentText, function(ntxid, merchantData) { + should.exist(ntxid); + should.exist(merchantData); + + var myId = w.getMyCopayerId(); + var txp = w.txProposals.get(ntxid); + should.exist(txp); + should.exist(txp.signedBy[myId]); + should.not.exist(txp.rejectedBy[myId]); + + w.verifyPaymentRequest(ntxid).should.equal(true); + + return done(); + }); + }); + + it('#close payment server', function(done) { + server.close(function() { + return done(); + }); + }); +}); diff --git a/test/test.TxProposal.js b/test/test.TxProposal.js index 3ac2f310d..1ac3a1981 100644 --- a/test/test.TxProposal.js +++ b/test/test.TxProposal.js @@ -13,9 +13,11 @@ var TransactionBuilder = bitcore.TransactionBuilder; var util = bitcore.util; var networks = bitcore.networks; var sinon = require('sinon'); -try { +var is_browser = typeof process == 'undefined' + || typeof process.versions === 'undefined'; +if (is_browser) { var copay = require('copay'); //browser -} catch (e) { +} else { var copay = require('../copay'); //node } diff --git a/test/test.TxProposals.js b/test/test.TxProposals.js index 94f51964a..a7a25e727 100644 --- a/test/test.TxProposals.js +++ b/test/test.TxProposals.js @@ -13,7 +13,11 @@ var TransactionBuilder = bitcore.TransactionBuilder; var util = bitcore.util; var networks = bitcore.networks; var sinon = require('sinon'); -var copay = require('../copay'); +try { + var copay = require('copay'); //browser +} catch (e) { + var copay = require('../copay'); //node +} var FakeBuilder = require('./mocks/FakeBuilder'); var TxProposal = copay.TxProposal; diff --git a/test/test.Wallet.js b/test/test.Wallet.js index 4139da669..75a2d9988 100644 --- a/test/test.Wallet.js +++ b/test/test.Wallet.js @@ -3,13 +3,14 @@ var chai = chai || require('chai'); var should = chai.should(); var sinon = require('sinon'); -try { +var is_browser = (typeof process == 'undefined' || typeof process.versions === 'undefined'); +if (is_browser) { var copay = require('copay'); //browser -} catch (e) { +} else { var copay = require('../copay'); //node } var copayConfig = require('../config'); -var Wallet = require('../js/models/core/Wallet'); +var Wallet = copay.Wallet; var PrivateKey = copay.PrivateKey; var Storage = require('./mocks/FakeStorage'); var Network = require('./mocks/FakeNetwork'); diff --git a/test/test.storage.LocalEncrypted.js b/test/test.storage.LocalEncrypted.js index 8ad081f06..a25f6cc92 100644 --- a/test/test.storage.LocalEncrypted.js +++ b/test/test.storage.LocalEncrypted.js @@ -19,7 +19,13 @@ CryptoJS.AES.decrypt = function(a) { 'use strict'; var chai = chai || require('chai'); var should = chai.should(); -var copay = copay || require('../copay'); +var is_browser = typeof process == 'undefined' + || typeof process.versions === 'undefined'; +if (is_browser) { + var copay = require('copay'); //browser +} else { + var copay = require('../copay'); //node +} var LocalEncrypted = copay.StorageLocalEncrypted; var fakeWallet = 'fake-wallet-id'; diff --git a/test/unit/controllers/controllersSpec.js b/test/unit/controllers/controllersSpec.js index 818eac500..ed257b8ef 100644 --- a/test/unit/controllers/controllersSpec.js +++ b/test/unit/controllers/controllersSpec.js @@ -10,6 +10,7 @@ saveAs = function(o) { saveAsLastCall = o; }; +var startServer = require('../../mocks/FakePayProServer'); describe("Unit: Controllers", function() { var invalidForm = { @@ -18,6 +19,8 @@ describe("Unit: Controllers", function() { var scope; + var server; + beforeEach(module('copayApp.services')); beforeEach(module('copayApp.controllers')); @@ -150,6 +153,7 @@ describe("Unit: Controllers", function() { scope.$digest(); form = scope.form; sendForm = scope.form2; + scope.sendForm = sendForm; })); it('should have a SendController controller', function() { diff --git a/util/build.js b/util/build.js index f1a7a2f98..cf90b861e 100644 --- a/util/build.js +++ b/util/build.js @@ -13,7 +13,7 @@ var puts = function(error, stdout, stderr) { //sys.puts(stderr); }; -var pack = function (params) { +var pack = function(params) { var file = require.resolve('soop'); var dir = file.substr(0, file.length - String('soop.js').length); var preludePath = dir + 'example/custom_prelude.js'; @@ -48,9 +48,6 @@ var createBundle = function(opts) { b.require('./copay', { expose: 'copay' }); - b.require('./copay', { - expose: '../copay' - }); b.require('./version'); // b.external('bitcore'); b.require('./js/models/core/WalletFactory', { @@ -60,26 +57,35 @@ var createBundle = function(opts) { b.require('./js/models/core/Wallet', { expose: '../js/models/core/Wallet' }); + b.require('./js/models/core/Wallet', { + expose: '../../js/models/core/Wallet' + }); b.require('./test/mocks/FakeStorage', { expose: './mocks/FakeStorage' }); - b.require('./test/mocks/FakeBlockchain', { - expose: './mocks/FakeBlockchain' - }); b.require('./test/mocks/FakeLocalStorage', { expose: './mocks/FakeLocalStorage' }); - b.require('./js/models/core/Wallet', { - expose: '../js/models/core/Wallet' + b.require('./js/models/core/Message', { + expose: '../js/models/core/Message' + }); + b.require('./test/mocks/FakeBlockchain', { + expose: './mocks/FakeBlockchain' }); b.require('./test/mocks/FakeNetwork', { expose: './mocks/FakeNetwork' }); + b.require('./test/mocks/FakePayProServer', { + expose: './mocks/FakePayProServer' + }); + b.require('./test/mocks/FakePayProServer', { + expose: '../../mocks/FakePayProServer' + }); b.require('./test/mocks/FakeBuilder', { expose: './mocks/FakeBuilder' }); b.require('./js/models/network/WebRTC', { - expose: '../js/models/network/WebRTC' + expose: '../js/models/network/WebRTC' }); b.require('./js/models/blockchain/Insight', { expose: '../js/models/blockchain/Insight' @@ -93,24 +99,21 @@ var createBundle = function(opts) { b.require('./js/models/core/Passphrase', { expose: '../js/models/core/Passphrase' }); - b.require('./js/models/core/Message', { - expose: '../js/models/core/Message' + b.require('./js/models/core/HDPath', { + expose: '../js/models/core/HDPath' }); b.require('./config', { expose: '../config' - }); - b.require('./js/models/core/HDPath', { - expose: '../js/models/core/HDPath' - }); + }); - if (opts.debug) { + if (opts.dontminify) { //include dev dependencies b.require('sinon'); b.require('blanket'); b.require('soop'); } - if (!opts.debug) { + if (!opts.dontminify) { b.transform({ global: true }, 'uglifyify'); @@ -125,10 +128,10 @@ if (require.main === module) { }; var program = require('commander'); program - .version('0.0.1') - .option('-d, --debug', 'Development. Don\'t minify the codem and include debug packages.') - .option('-o, --stdout', 'Specify output as stdout') - .parse(process.argv); + .version('0.0.1') + .option('-d, --dontminify', 'Development. Don\'t minify the code.') + .option('-o, --stdout', 'Specify output as stdout') + .parse(process.argv); createVersion(); var copayBundle = createBundle(program); diff --git a/views/send.html b/views/send.html index a3c719162..05328c8e3 100644 --- a/views/send.html +++ b/views/send.html @@ -21,7 +21,7 @@
{{commentText}}
-{{commentText}}
++ Note: This is a payment protocol transaction. +
++ {{$root.merchant.pr.pd.memo}} +
++ {{$root.merchant.pr.ca}} + Untrusted +
++ {{$root.merchant.expiration}} +
+