mirror of https://github.com/BTCPrivate/copay.git
refactor PayPro at Wallet. Fetch working
This commit is contained in:
parent
ae94139236
commit
efb5b28c50
12
README.md
12
README.md
|
@ -196,4 +196,16 @@ Copay uses the Javascript library Bitcore for Bitcoin related functions. Bitcore
|
||||||
var cmd = `node util/build_bitcore.js`
|
var cmd = `node util/build_bitcore.js`
|
||||||
cd <BITCORE_HOME>
|
cd <BITCORE_HOME>
|
||||||
node $cmd
|
node $cmd
|
||||||
|
|
||||||
|
Payment Protocol
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Copay support BIP70 (Payment Protocol), with the following current limitations:
|
||||||
|
|
||||||
|
* Only one output is allowed. Payment requests is more that one output are not supported.
|
||||||
|
* Only standard Pay-to-pubkeyhash and Pay-to-scripthash scripts are supported (on payment requests). Other script types will cause the entire payment request to be rejected.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
@ -100,6 +100,38 @@ angular.module('copayApp.controllers').controller('SendController',
|
||||||
if (!window.cordova && !navigator.getUserMedia)
|
if (!window.cordova && !navigator.getUserMedia)
|
||||||
$scope.disableScanner = 1;
|
$scope.disableScanner = 1;
|
||||||
|
|
||||||
|
$scope._showError = function(err) {
|
||||||
|
copay.logger.error(err);
|
||||||
|
|
||||||
|
var msg = err.toString();
|
||||||
|
if (msg.match('BIG'))
|
||||||
|
msg = 'The transaction have too many inputs. Try creating many transactions for smaller amounts.'
|
||||||
|
|
||||||
|
if (msg.match('totalNeededAmount'))
|
||||||
|
msg = 'Not enough funds.'
|
||||||
|
|
||||||
|
var message = 'The transaction' + (w.isShared() ? ' proposal' : '') + ' could not be created: ' + msg;
|
||||||
|
$scope.error = message;
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.loadTxs();
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope._afterSend = function(err, ntxid, merchantData) {
|
||||||
|
$scope.loading = false;
|
||||||
|
|
||||||
|
if (err)
|
||||||
|
return $scope._showError(err);
|
||||||
|
|
||||||
|
|
||||||
|
if (w.requiresMultipleSignatures()) {
|
||||||
|
notification.success('Success', 'The transaction proposal created');
|
||||||
|
$scope.loadTxs();
|
||||||
|
} else {
|
||||||
|
$scope.send(ntxid);
|
||||||
|
}
|
||||||
|
$rootScope.pendingPayment = null;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.submitForm = function(form) {
|
$scope.submitForm = function(form) {
|
||||||
if (form.$invalid) {
|
if (form.$invalid) {
|
||||||
$scope.error = 'Unable to send transaction proposal';
|
$scope.error = 'Unable to send transaction proposal';
|
||||||
|
@ -112,92 +144,32 @@ angular.module('copayApp.controllers').controller('SendController',
|
||||||
var amount = parseInt((form.amount.$modelValue * w.settings.unitToSatoshi).toFixed(0));
|
var amount = parseInt((form.amount.$modelValue * w.settings.unitToSatoshi).toFixed(0));
|
||||||
var commentText = form.comment.$modelValue;
|
var commentText = form.comment.$modelValue;
|
||||||
|
|
||||||
function done(err, ntxid, merchantData) {
|
|
||||||
if (err) {
|
|
||||||
copay.logger.error(err);
|
|
||||||
|
|
||||||
var msg = err.toString();
|
var payInfo;
|
||||||
|
|
||||||
if (msg.match('BIG'))
|
|
||||||
msg = 'The transaction have too many inputs. Try creating many transactions for smaller amounts.'
|
|
||||||
|
|
||||||
if (msg.match('totalNeededAmount'))
|
|
||||||
msg = 'Not enough funds.'
|
|
||||||
|
|
||||||
var message = 'The transaction' + (w.isShared() ? ' proposal' : '') + ' could not be created: ' + msg;
|
|
||||||
$scope.error = message;
|
|
||||||
$scope.loading = false;
|
|
||||||
$scope.loadTxs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.requiresMultipleSignatures()) {
|
|
||||||
$scope.loading = false;
|
|
||||||
notification.success('Success', 'The transaction proposal created');
|
|
||||||
$scope.loadTxs();
|
|
||||||
} else {
|
|
||||||
w.sendTx(ntxid, function(txid, merchantData) {
|
|
||||||
if (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 += merchantData.pr.pd.memo;
|
|
||||||
message += ' Merchant: ' + merchantData.pr.pd.payment_url;
|
|
||||||
}
|
|
||||||
$scope.success = 'Transaction broadcasted' + message;
|
|
||||||
} else {
|
|
||||||
$scope.error = 'There was an error sending the transaction';
|
|
||||||
}
|
|
||||||
$scope.loading = false;
|
|
||||||
$scope.loadTxs();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
$rootScope.pendingPayment = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var uri;
|
|
||||||
if (address.indexOf('bitcoin:') === 0) {
|
if (address.indexOf('bitcoin:') === 0) {
|
||||||
uri = new bitcore.BIP21(address).data;
|
payInfo = (new bitcore.BIP21(address)).data;
|
||||||
} else if (/^https?:\/\//.test(address)) {
|
} else if (/^https?:\/\//.test(address)) {
|
||||||
uri = {
|
payInfo = {
|
||||||
merchant: address
|
merchant: address
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we're setting the domain, ignore the change.
|
// If we're setting the domain, ignore the change.
|
||||||
if ($rootScope.merchant && $rootScope.merchant.domain && address === $rootScope.merchant.domain) {
|
if ($rootScope.merchant && $rootScope.merchant.domain && address === $rootScope.merchant.domain) {
|
||||||
uri = {
|
payInfo = {
|
||||||
merchant: $rootScope.merchant.request_url
|
merchant: $rootScope.merchant.request_url
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uri && uri.merchant) {
|
|
||||||
w.createPaymentTx({
|
|
||||||
uri: uri.merchant,
|
|
||||||
memo: commentText
|
|
||||||
}, done);
|
|
||||||
} else {
|
|
||||||
w.createTx(address, amount, commentText, done);
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset fields
|
// reset fields
|
||||||
$scope.address = $scope.amount = $scope.commentText = null;
|
$scope.address = $scope.amount = $scope.commentText = null;
|
||||||
form.address.$pristine = form.amount.$pristine = true;
|
form.address.$pristine = form.amount.$pristine = true;
|
||||||
|
|
||||||
|
w.createTx({
|
||||||
|
toAddress: address,
|
||||||
|
amountSat: amount,
|
||||||
|
comment: commentText,
|
||||||
|
url: (payInfo && payInfo.merchant) ? url : null,
|
||||||
|
}, $scope._afterSend);
|
||||||
};
|
};
|
||||||
|
|
||||||
// QR code Scanner
|
// QR code Scanner
|
||||||
|
@ -411,13 +383,7 @@ angular.module('copayApp.controllers').controller('SendController',
|
||||||
if (!merchantData) {
|
if (!merchantData) {
|
||||||
notification.success('Success', 'Transaction broadcasted!');
|
notification.success('Success', 'Transaction broadcasted!');
|
||||||
} else {
|
} else {
|
||||||
var message = 'Transaction ID: ' + txid;
|
notification.success('Success', 'Payment sent. ' + merchantData.ack.memo);
|
||||||
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', 'Transaction sent' + message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -433,16 +399,14 @@ angular.module('copayApp.controllers').controller('SendController',
|
||||||
try {
|
try {
|
||||||
w.sign(ntxid);
|
w.sign(ntxid);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notification.error('Error','There was an error signing the transaction');
|
notification.error('Error', 'There was an error signing the transaction');
|
||||||
$scope.loadTxs();
|
$scope.loadTxs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var p = w.txProposals.getTxProposal(ntxid);
|
var p = w.txProposals.getTxProposal(ntxid);
|
||||||
if (p.builder.isFullySigned()) {
|
if (p.builder.isFullySigned()) {
|
||||||
$scope.send(ntxid, function() {
|
$scope.send(ntxid);
|
||||||
$scope.loadTxs();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
$scope.loadTxs();
|
$scope.loadTxs();
|
||||||
}
|
}
|
||||||
|
@ -546,7 +510,7 @@ angular.module('copayApp.controllers').controller('SendController',
|
||||||
}, 10 * 1000);
|
}, 10 * 1000);
|
||||||
|
|
||||||
// Payment Protocol URI (BIP-72)
|
// Payment Protocol URI (BIP-72)
|
||||||
$scope.wallet.fetchPaymentTx(uri.merchant, function(err, merchantData) {
|
$scope.wallet.fetchPaymentRequest({url: uri.merchant}, function(err, merchantData) {
|
||||||
if (!timeout) return;
|
if (!timeout) return;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ var preconditions = require('preconditions').singleton();
|
||||||
var inherits = require('inherits');
|
var inherits = require('inherits');
|
||||||
var events = require('events');
|
var events = require('events');
|
||||||
var async = require('async');
|
var async = require('async');
|
||||||
var cryptoUtil = require('../util/crypto');
|
|
||||||
|
|
||||||
var bitcore = require('bitcore');
|
var bitcore = require('bitcore');
|
||||||
var BIP21 = bitcore.BIP21;
|
var BIP21 = bitcore.BIP21;
|
||||||
|
@ -19,8 +18,10 @@ var Base58Check = bitcore.Base58.base58Check;
|
||||||
var Address = bitcore.Address;
|
var Address = bitcore.Address;
|
||||||
var PayPro = bitcore.PayPro;
|
var PayPro = bitcore.PayPro;
|
||||||
var Transaction = bitcore.Transaction;
|
var Transaction = bitcore.Transaction;
|
||||||
var log = require('../log');
|
|
||||||
|
|
||||||
|
var log = require('../log');
|
||||||
|
var cryptoUtil = require('../util/crypto');
|
||||||
|
var httpUtil = require('../util/HTTP');
|
||||||
var HDParams = require('./HDParams');
|
var HDParams = require('./HDParams');
|
||||||
var PublicKeyRing = require('./PublicKeyRing');
|
var PublicKeyRing = require('./PublicKeyRing');
|
||||||
var TxProposal = require('./TxProposal');
|
var TxProposal = require('./TxProposal');
|
||||||
|
@ -70,6 +71,7 @@ function Wallet(opts) {
|
||||||
|
|
||||||
opts.network = opts.network || Wallet._newAsync(opts.networkOpts[networkName]);
|
opts.network = opts.network || Wallet._newAsync(opts.networkOpts[networkName]);
|
||||||
opts.blockchain = opts.blockchain || Wallet._newInsight(opts.blockchainOpts[networkName]);;
|
opts.blockchain = opts.blockchain || Wallet._newInsight(opts.blockchainOpts[networkName]);;
|
||||||
|
this.httpUtil = opts.httpUtil || httpUtil;
|
||||||
|
|
||||||
//required params
|
//required params
|
||||||
['network', 'blockchain',
|
['network', 'blockchain',
|
||||||
|
@ -472,9 +474,10 @@ Wallet.prototype._processTxProposalPayPro = function(mergeInfo, cb) {
|
||||||
return cb();
|
return cb();
|
||||||
|
|
||||||
log.info('Received a Payment Protocol TX Proposal');
|
log.info('Received a Payment Protocol TX Proposal');
|
||||||
self.fetchPaymentTx(txp.paymentProtocolURL, function(err, merchantData) {
|
self.fetchPaymentRequest(txp.paymentProtocolURL, function(err, merchantData) {
|
||||||
if (err) return cb(err);
|
if (err) return cb(err);
|
||||||
|
|
||||||
|
// This will verify current TXP data vs. merchantData (e.g., out addresses)
|
||||||
try {
|
try {
|
||||||
txp.addMerchantData(merchantData);
|
txp.addMerchantData(merchantData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -1510,96 +1513,125 @@ Wallet.prototype.sendTx = function(ntxid, cb) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @desc Create a Payment Protocol transaction
|
* @desc Create a Payment Protocol transaction
|
||||||
* @param {Object|string} options - if it's a string, parse it as the uri
|
* @param {Object|string} options - if it's a string, parse it as the url
|
||||||
* @param {string} options.uri the url for the transaction
|
* @param {string} options.url the url for the transaction
|
||||||
* @param {Function} cb
|
* @param {Function} cb
|
||||||
*/
|
*/
|
||||||
Wallet.prototype.createPaymentTx = function(options, cb) {
|
Wallet.prototype.fetchPaymentRequest = function(options, cb) {
|
||||||
|
preconditions.checkArgument(_.isObject(options));
|
||||||
|
preconditions.checkArgument(options.url);
|
||||||
|
preconditions.checkArgument(options.url.indexOf('http') == 0, 'Bad PayPro URL given:' + options.url);
|
||||||
|
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
if (_.isString(options)) {
|
this.httpUtil.request({
|
||||||
options = {
|
|
||||||
uri: options
|
|
||||||
};
|
|
||||||
}
|
|
||||||
options.uri = options.uri || options.url;
|
|
||||||
|
|
||||||
if (options.uri.indexOf('bitcoin:') === 0) {
|
|
||||||
options.uri = new bitcore.BIP21(options.uri).data.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',
|
method: 'GET',
|
||||||
url: options.uri,
|
url: options.url,
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': PayPro.PAYMENT_REQUEST_CONTENT_TYPE
|
'Accept': PayPro.PAYMENT_REQUEST_CONTENT_TYPE
|
||||||
},
|
},
|
||||||
responseType: 'arraybuffer'
|
responseType: 'arraybuffer'
|
||||||
})
|
})
|
||||||
.success(function(data, status, headers, config) {
|
.success(function(rawData) {
|
||||||
data = PayPro.PaymentRequest.decode(data);
|
log.info('PayPro Request done successfully. Parsing response')
|
||||||
var pr = new PayPro();
|
|
||||||
pr = pr.makePaymentRequest(data);
|
var data = PayPro.PaymentRequest.decode(rawData);
|
||||||
return self.receivePaymentRequest(options, pr, cb);
|
var paypro = new PayPro();
|
||||||
|
var pr = paypro.makePaymentRequest(data);
|
||||||
|
var merchantData, err;
|
||||||
|
try {
|
||||||
|
merchantData = self.parsePaymentRequest(options, pr);
|
||||||
|
} catch (e) { err = e};
|
||||||
|
|
||||||
|
log.debug('PayPro request data', merchantData);
|
||||||
|
return cb(err, merchantData);
|
||||||
})
|
})
|
||||||
.error(function(data, status, headers, config) {
|
.error(function(data, status) {
|
||||||
log.debug('Server did not return PaymentRequest.');
|
log.debug('Server did not return PaymentRequest.\nXHR status: ' + status);
|
||||||
log.debug('XHR status: ' + status);
|
preconditions.checkState(options.fetch);
|
||||||
if (options.fetch) {
|
|
||||||
return cb(new Error('Status: ' + status));
|
return cb(new Error('Status: ' + status));
|
||||||
} else {
|
|
||||||
// Should never happen:
|
|
||||||
return cb(null, null, null);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/*
|
||||||
* @desc Creates a Payment TxProposal from a uri
|
* addOutputsToMerchantData
|
||||||
* @param {Object} options
|
*
|
||||||
* @param {string=} options.uri
|
* NOTE: We use to: set the TX scripts with the payment request scripts:
|
||||||
* @param {string=} options.url
|
* but this is a hack around transaction builder, so we dont do it anymore.
|
||||||
* @param {Function} cb
|
* See Readme.md. For now we only support p2scripthash or p2pubkeyhash
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
Wallet.prototype.fetchPaymentTx = function(options, cb) {
|
|
||||||
var self = this;
|
|
||||||
|
|
||||||
options = options || {};
|
Wallet.prototype._addOutputsToMerchantData = function(merchantData) {
|
||||||
if (_.isString(options)) {
|
|
||||||
options = {
|
var total = bignum(0);
|
||||||
uri: options
|
var outs = {};
|
||||||
|
|
||||||
|
_.each(merchantData.pr.pd.outputs, 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);
|
||||||
options.uri = options.uri || options.url;
|
var network = merchantData.pr.pd.network === 'main' ? 'livenet' : 'testnet';
|
||||||
options.fetch = true;
|
var addr = bitcore.Address.fromScriptPubKey(new bitcore.Script(s), network);
|
||||||
|
|
||||||
var req = this.paymentRequests[options.uri];
|
var a = addr[0].toString();
|
||||||
if (req) {
|
outs[a] = bignum.fromBuffer(v, {
|
||||||
return cb(null, req.merchantData);
|
endian: 'big',
|
||||||
}
|
size: 1
|
||||||
|
}).add(outs[a] || bignum(0));
|
||||||
|
|
||||||
return this.createPaymentTx(options, function(err, merchantData, pr) {
|
total = total.add(bignum.fromBuffer(v, {
|
||||||
if (err) return cb(err);
|
endian: 'big',
|
||||||
self.paymentRequests[options.uri] = {
|
size: 1
|
||||||
merchantData: merchantData,
|
}));
|
||||||
pr: pr
|
|
||||||
};
|
|
||||||
return cb(null, merchantData);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// for now we only support PayPro with 1 output.
|
||||||
|
if (_.size(outs) !== 1)
|
||||||
|
throw new Error('PayPro: Unsupported outputs');
|
||||||
|
|
||||||
|
var out = _.pairs(outs)[0];
|
||||||
|
|
||||||
|
merchantData.outs = [{
|
||||||
|
address: out[0],
|
||||||
|
amountSatStr: out[1].toString(10),
|
||||||
|
}];
|
||||||
|
merchantData.total = total.toString(10);
|
||||||
|
|
||||||
|
// If user is granted the privilege of choosing
|
||||||
|
// their own amount, add it to the tx.
|
||||||
|
if (merchantData.total === 0 && options.amount) {
|
||||||
|
merchant.outs[0].amountSatStr = merchantData.total = outions.amount;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @desc Analyzes a payment request and generates a transaction proposal for it.
|
* @desc Analyzes a payment request and generate merchantData
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {PayProRequest} pr
|
* @param {PayProRequest} pr
|
||||||
* @param {string} pr.payment_details_version
|
* @param {string} pr.payment_details_version
|
||||||
|
@ -1609,9 +1641,8 @@ Wallet.prototype.fetchPaymentTx = function(options, cb) {
|
||||||
* @param {string} pr.signature
|
* @param {string} pr.signature
|
||||||
* @param {string} options.memo
|
* @param {string} options.memo
|
||||||
* @param {string} options.comment
|
* @param {string} options.comment
|
||||||
* @param {Function} cb
|
|
||||||
*/
|
*/
|
||||||
Wallet.prototype.receivePaymentRequest = function(options, pr, cb) {
|
Wallet.prototype.parsePaymentRequest = function(options, pr) {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
var ver = pr.get('payment_details_version');
|
var ver = pr.get('payment_details_version');
|
||||||
|
@ -1623,18 +1654,11 @@ Wallet.prototype.receivePaymentRequest = function(options, pr, cb) {
|
||||||
var certs = PayPro.X509Certificates.decode(pki_data);
|
var certs = PayPro.X509Certificates.decode(pki_data);
|
||||||
certs = certs.certificate;
|
certs = certs.certificate;
|
||||||
|
|
||||||
// Fix for older versions of bitcore
|
|
||||||
if (!PayPro.RootCerts) {
|
|
||||||
PayPro.RootCerts = {
|
|
||||||
getTrusted: function() {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify Signature
|
// Verify Signature
|
||||||
var trust = pr.verify(true);
|
var trust = pr.verify(true);
|
||||||
|
|
||||||
if (!trust.verified) {
|
if (!trust.verified) {
|
||||||
return cb(new Error('Server sent a bad signature.'));
|
throw new Error('Server sent a bad signature.');
|
||||||
}
|
}
|
||||||
|
|
||||||
details = PayPro.PaymentDetails.decode(details);
|
details = PayPro.PaymentDetails.decode(details);
|
||||||
|
@ -1681,38 +1705,11 @@ Wallet.prototype.receivePaymentRequest = function(options, pr, cb) {
|
||||||
selfSigned: trust.selfSigned
|
selfSigned: trust.selfSigned
|
||||||
},
|
},
|
||||||
expires: expires,
|
expires: expires,
|
||||||
request_url: options.uri,
|
request_url: options.url,
|
||||||
total: bignum('0', 10).toString(10),
|
total: bignum('0', 10).toString(10),
|
||||||
};
|
};
|
||||||
|
this._addOutputsToMerchantData(merchantData, options.amount);
|
||||||
return this.getUnspent(function(err, safeUnspent, unspent) {
|
return merchantData;
|
||||||
|
|
||||||
if (options.fetch) {
|
|
||||||
if (!unspent || !unspent.length) {
|
|
||||||
return cb(new Error('No unspent outputs available.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
var err;
|
|
||||||
try {
|
|
||||||
self.createPaymentTxSync(options, merchantData, safeUnspent);
|
|
||||||
} catch (e) { err = e;}
|
|
||||||
return cb(err, merchantData, pr);
|
|
||||||
}
|
|
||||||
|
|
||||||
var ntxid = self.createPaymentTxSync(options, merchantData, safeUnspent);
|
|
||||||
if (ntxid) {
|
|
||||||
self.sendIndexes();
|
|
||||||
self.sendTxProposal(ntxid);
|
|
||||||
self.emit('txProposalsUpdated');
|
|
||||||
} else {
|
|
||||||
return cb(new Error('Error creating the transaction'));
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('You are currently on this BTC network:', network);
|
|
||||||
log.debug('The server sent you a message:', memo);
|
|
||||||
|
|
||||||
return cb(null, ntxid, merchantData);
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1827,14 +1824,14 @@ Wallet.prototype.sendPaymentTx = function(ntxid, options, cb) {
|
||||||
responseType: 'arraybuffer'
|
responseType: 'arraybuffer'
|
||||||
};
|
};
|
||||||
|
|
||||||
return Wallet.request(postInfo)
|
return this.httpUtil.request(postInfo)
|
||||||
.success(function(data, status, headers, config) {
|
.success(function(rawData) {
|
||||||
data = PayPro.PaymentACK.decode(data);
|
var data = PayPro.PaymentACK.decode(rawData);
|
||||||
var ack = new PayPro();
|
var paypro = new PayPro();
|
||||||
ack = ack.makePaymentACK(data);
|
ack = paypro.makePaymentACK(data);
|
||||||
return self.receivePaymentRequestACK(ntxid, tx, txp, ack, cb);
|
return self.receivePaymentRequestACK(ntxid, tx, txp, ack, cb);
|
||||||
})
|
})
|
||||||
.error(function(data, status, headers, config) {
|
.error(function(data, status ) {
|
||||||
log.debug('Sending to server was not met with a returned tx.');
|
log.debug('Sending to server was not met with a returned tx.');
|
||||||
log.debug('XHR status: ' + status);
|
log.debug('XHR status: ' + status);
|
||||||
self._processTxProposalSent(ntxid, function(err, txid) {
|
self._processTxProposalSent(ntxid, function(err, txid) {
|
||||||
|
@ -1906,148 +1903,6 @@ Wallet.prototype.receivePaymentRequestACK = function(ntxid, tx, txp, ack, cb) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc Create a Payment Transaction Sync (see BIP70)
|
|
||||||
* @TODO: Document better
|
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_.isUndefined(opts.spendUnconfirmed)) {
|
|
||||||
opts.spendUnconfirmed = this.spendUnconfirmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var k in Wallet.builderOpts) {
|
|
||||||
opts[k] = Wallet.builderOpts[k];
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
var a = addr[0].toString();
|
|
||||||
outs[a] = bignum.fromBuffer(v, {
|
|
||||||
endian: 'big',
|
|
||||||
size: 1
|
|
||||||
}).add(outs[a] || bignum(0));
|
|
||||||
|
|
||||||
merchantData.total = merchantData.total.add(bignum.fromBuffer(v, {
|
|
||||||
endian: 'big',
|
|
||||||
size: 1
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO, for now we only support PayPro with 1 output.
|
|
||||||
if (_.size(outs) !== 1)
|
|
||||||
throw new Error('PayPro: Unsupported outputs');
|
|
||||||
|
|
||||||
var out = _.pairs(outs)[0];
|
|
||||||
merchantData.outs = [{
|
|
||||||
address: out[0],
|
|
||||||
amountSatStr: out[1].toString(10),
|
|
||||||
}];
|
|
||||||
merchantData.total = merchantData.total.toString(10);
|
|
||||||
|
|
||||||
var b = new Builder(opts)
|
|
||||||
.setUnspent(unspent)
|
|
||||||
.setOutputs(merchantData.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();
|
|
||||||
if (selectedUtxos.length > TX_MAX_INS)
|
|
||||||
throw new Error('BIG: Resulting TX is too big:' + selectedUtxos.length + ' inputs. Aborting');
|
|
||||||
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
log.debug('Created transaction: %s', b.tx.getStandardizedObject());
|
|
||||||
|
|
||||||
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 txSize = tx.getSize();
|
|
||||||
if (txSize / 1024 > TX_MAX_SIZE_KB)
|
|
||||||
throw new Error('BIG: Resulting TX is too big ' + txSize + ' bytes. Aborting');
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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,
|
|
||||||
paymentProtocolURL: options.uri,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return ntxid;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @desc Mark that a user has seen a given TxProposal
|
* @desc Mark that a user has seen a given TxProposal
|
||||||
* @return {boolean} true if the internal state has changed
|
* @return {boolean} true if the internal state has changed
|
||||||
|
@ -2273,26 +2128,45 @@ Wallet.prototype.removeTxWithSpentInputs = function(cb) {
|
||||||
* @desc Create a transaction proposal
|
* @desc Create a transaction proposal
|
||||||
* @TODO: Document more
|
* @TODO: Document more
|
||||||
*/
|
*/
|
||||||
Wallet.prototype.createTx = function(toAddress, amountSatStr, comment, opts, cb) {
|
Wallet.prototype.createTx = function(opts, cb) {
|
||||||
|
preconditions.checkArgument(_.isObject(opts));
|
||||||
|
preconditions.checkArgument(opts.amountSat);
|
||||||
|
log.debug(opts);
|
||||||
|
|
||||||
var self = this;
|
var self = this;
|
||||||
|
var toAddress = opts.toAddress;
|
||||||
|
var amountSat = opts.amountSat;
|
||||||
|
preconditions.checkArgument(!opts.comment || opts.comment.length <= 100);
|
||||||
|
var url = opts.url;
|
||||||
|
|
||||||
if (_.isFunction(opts)) {
|
if (url && !opts.merchantData) {
|
||||||
cb = opts;
|
w.fetchPaymentRequest({
|
||||||
opts = {};
|
url: url,
|
||||||
}
|
memo: comment,
|
||||||
opts = opts || {};
|
amount: amountSat,
|
||||||
|
}, function(err, merchantData) {
|
||||||
if (_.isUndefined(opts.spendUnconfirmed)) {
|
if (err) return cb(err);
|
||||||
opts.spendUnconfirmed = this.spendUnconfirmed;
|
opts.merchantData = merchantData;
|
||||||
}
|
opts.amountSat = merchantData.outs[0].address;
|
||||||
|
opts.toAddress = merchantData.outs[0].amount;
|
||||||
|
self.createTx(opts, cb);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
preconditions.checkArgument(amountSat);
|
||||||
|
preconditions.checkArgument(toAddress);
|
||||||
|
|
||||||
this.getUnspent(function(err, safeUnspent) {
|
this.getUnspent(function(err, safeUnspent) {
|
||||||
if (err) return cb(new Error('Could not get list of UTXOs'));
|
if (err) return cb(new Error('Could not get list of UTXOs'));
|
||||||
|
|
||||||
var ntxid;
|
var ntxid;
|
||||||
try {
|
try {
|
||||||
ntxid = self.createTxSync(toAddress, amountSatStr, comment, safeUnspent, opts);
|
var txp = self.createTxProposal(toAddress, amountSat, safeUnspent, opts.builderOpts);
|
||||||
log.debug('TX Created: ntxid', ntxid);
|
|
||||||
|
if (opts.merchantData)
|
||||||
|
txp.addMerchantData(opts.merchantData);
|
||||||
|
|
||||||
|
var ntxid = self.addNewTxProposal(txp);
|
||||||
|
log.debug('TXP Added: ', ntxid);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return cb(e);
|
return cb(e);
|
||||||
}
|
}
|
||||||
|
@ -2320,36 +2194,41 @@ var sanitize = function(address) {
|
||||||
* @desc Create a transaction proposal
|
* @desc Create a transaction proposal
|
||||||
* @TODO: Document more
|
* @TODO: Document more
|
||||||
*/
|
*/
|
||||||
Wallet.prototype.createTxSync = function(toAddress, amountSatStr, comment, utxos, opts) {
|
Wallet.prototype.createTxProposal = function(toAddress, amountSat, utxos, builderOpts) {
|
||||||
|
preconditions.checkArgument(toAddress);
|
||||||
|
preconditions.checkArgument(amountSat);
|
||||||
|
preconditions.checkArgument(_.isArray(utxos));
|
||||||
|
builderOpts = builderOpts || {};
|
||||||
|
|
||||||
var pkr = this.publicKeyRing;
|
var pkr = this.publicKeyRing;
|
||||||
var priv = this.privateKey;
|
var priv = this.privateKey;
|
||||||
opts = opts || {};
|
|
||||||
toAddress = sanitize(toAddress);
|
toAddress = sanitize(toAddress);
|
||||||
|
|
||||||
preconditions.checkArgument(toAddress.network().name === this.getNetworkName(), 'networkname mismatch');
|
preconditions.checkArgument(toAddress.network().name === this.getNetworkName(), 'networkname mismatch');
|
||||||
preconditions.checkState(pkr.isComplete(), 'pubkey ring incomplete');
|
preconditions.checkState(pkr.isComplete(), 'pubkey ring incomplete');
|
||||||
preconditions.checkState(priv, 'no private key');
|
preconditions.checkState(priv, 'no private key');
|
||||||
if (comment) preconditions.checkArgument(comment.length <= 100);
|
|
||||||
|
|
||||||
if (!opts.remainderOut) {
|
if (!builderOpts.remainderOut) {
|
||||||
opts.remainderOut = {
|
builderOpts.remainderOut = {
|
||||||
address: this._doGenerateAddress(true).toString()
|
address: this._doGenerateAddress(true).toString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (_.isUndefined(builderOpts.spendUnconfirmed)) {
|
||||||
for (var k in Wallet.builderOpts) {
|
builderOpts.spendUnconfirmed = this.spendUnconfirmed;
|
||||||
opts[k] = Wallet.builderOpts[k];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var b = new Builder(opts)
|
for (var k in Wallet.builderOpts) {
|
||||||
|
builderOpts[k] = Wallet.builderOpts[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
var b = new Builder(builderOpts)
|
||||||
.setUnspent(utxos)
|
.setUnspent(utxos)
|
||||||
.setOutputs([{
|
.setOutputs([{
|
||||||
address: toAddress.data,
|
address: toAddress.data,
|
||||||
amountSatStr: amountSatStr,
|
amountSatStr: amountSat,
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
log.debug('Creating TX: Builder ready');
|
log.debug('Creating TX: Builder ready');
|
||||||
|
|
||||||
var selectedUtxos = b.getSelectedUnspent();
|
var selectedUtxos = b.getSelectedUnspent();
|
||||||
|
|
||||||
if (selectedUtxos.length > TX_MAX_INS)
|
if (selectedUtxos.length > TX_MAX_INS)
|
||||||
|
@ -2363,9 +2242,7 @@ Wallet.prototype.createTxSync = function(toAddress, amountSatStr, comment, utxos
|
||||||
b = b.setHashToScriptMap(pkr.getRedeemScriptMap(inputChainPaths));
|
b = b.setHashToScriptMap(pkr.getRedeemScriptMap(inputChainPaths));
|
||||||
|
|
||||||
var keys = priv.getForPaths(inputChainPaths);
|
var keys = priv.getForPaths(inputChainPaths);
|
||||||
var signed = b.sign(keys);
|
b.sign(keys);
|
||||||
var myId = this.getMyCopayerId();
|
|
||||||
var now = Date.now();
|
|
||||||
|
|
||||||
|
|
||||||
var tx = b.build();
|
var tx = b.build();
|
||||||
|
@ -2373,26 +2250,36 @@ Wallet.prototype.createTxSync = function(toAddress, amountSatStr, comment, utxos
|
||||||
throw new Error('Could not sign generated tx');
|
throw new Error('Could not sign generated tx');
|
||||||
|
|
||||||
var txSize = tx.getSize();
|
var txSize = tx.getSize();
|
||||||
|
|
||||||
if (txSize / 1024 > TX_MAX_SIZE_KB)
|
if (txSize / 1024 > TX_MAX_SIZE_KB)
|
||||||
throw new Error('BIG: Resulting TX is too big ' + txSize + ' bytes. Aborting');
|
throw new Error('BIG: Resulting TX is too big ' + txSize + ' bytes. Aborting');
|
||||||
|
|
||||||
|
return new TxProposal({
|
||||||
|
inputChainPaths: inputChainPaths,
|
||||||
|
comment: comment,
|
||||||
|
builder: b,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/* addNewTxProposal
|
||||||
|
* adds a transaction proposal to the list. Sets current copayer and creation metadata.
|
||||||
|
*
|
||||||
|
* @param {txp} Transaction Proposal Object
|
||||||
|
* @desc returns normalized transaction ID
|
||||||
|
* @param {ntxid}
|
||||||
|
*/
|
||||||
|
Wallet.prototype.addNewTxProposal = function(txp) {
|
||||||
|
var myId = this.getMyCopayerId();
|
||||||
|
var now = Date.now();
|
||||||
var me = {};
|
var me = {};
|
||||||
me[myId] = now;
|
me[myId] = now;
|
||||||
|
|
||||||
var meSeen = {};
|
// Add metadata to TxP
|
||||||
if (priv) meSeen[myId] = now;
|
txp.signedBy = txp.seenBy = me;
|
||||||
|
txp.creator = myId;
|
||||||
|
txp.createdTs = now;
|
||||||
|
|
||||||
var ntxid = this.txProposals.add(new TxProposal({
|
var ntxid = this.txProposals.add(txp);
|
||||||
inputChainPaths: inputChainPaths,
|
|
||||||
signedBy: me,
|
|
||||||
seenBy: meSeen,
|
|
||||||
creator: myId,
|
|
||||||
createdTs: now,
|
|
||||||
builder: b,
|
|
||||||
comment: comment
|
|
||||||
}));
|
|
||||||
return ntxid;
|
return ntxid;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2664,88 +2551,6 @@ Wallet.prototype.verifySignedJson = function(senderId, payload, signature) {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @desc Create a HTTP request
|
|
||||||
* @TODO: This shouldn't be a wallet responsibility
|
|
||||||
*/
|
|
||||||
Wallet.request = function(options, callback) {
|
|
||||||
if (_.isString(options)) {
|
|
||||||
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) {
|
|
||||||
var status;
|
|
||||||
if (xhr.status === 0 || !xhr.statusText) {
|
|
||||||
status = 'HTTP Request Error: This endpoint likely does not support cross-origin requests.';
|
|
||||||
} else {
|
|
||||||
status = xhr.statusText;
|
|
||||||
}
|
|
||||||
return ret._error(null, status, null, options);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (req.body) {
|
|
||||||
xhr.send(req.body);
|
|
||||||
} else {
|
|
||||||
xhr.send(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue