'use strict'; // Native const superagent = require('superagent'); const query = require('querystring'); const url = require('url'); const Errors = require('./errors'); const dfltTrustedKeys = require('../util/JsonPaymentProtocolKeys.js'); const Bitcore = require('crypto-wallet-core-mx').BitcoreLib; const _ = require('lodash'); const sha256 = Bitcore.crypto.Hash.sha256; const BN = Bitcore.crypto.BN; var Bitcore_ = { btc: Bitcore, bch: require('crypto-wallet-core-mx').BitcoreLibCash, mxbit: require('crypto-wallet-core-mx').BitcoreLibMx, }; var MAX_FEE_PER_KB = { btc: 10000 * 1000, // 10k sat/b bch: 10000 * 1000, // 10k sat/b eth: 50000000000, // 50 Gwei mxbit: 10000 * 1000 // 10k sat/b }; export class PayProV2 { static options: { headers?: any; args?: string; agent?: boolean } = { headers: {}, args: '', agent: false }; static request = superagent; static trustedKeys = dfltTrustedKeys; constructor(requestOptions = {}, trustedKeys = dfltTrustedKeys) { PayProV2.options = Object.assign({}, { agent: false }, requestOptions); PayProV2.trustedKeys = trustedKeys; if (!PayProV2.trustedKeys || !Object.keys(PayProV2.trustedKeys).length) { throw new Error('Invalid constructor, no trusted keys added to agent'); } } /** * Internal method for making requests asynchronously * @param {Object} options * @return {Promise} * @private */ static async _asyncRequest(options): Promise<{rawBody: string, headers: object}> { return new Promise((resolve, reject) => { let requestOptions = Object.assign({}, PayProV2.options, options); // Copy headers directly as they're objects requestOptions.headers = Object.assign( {}, PayProV2.options.headers, options.headers ); var r = this.request[requestOptions.method](requestOptions.url); _.each(requestOptions.headers, (v, k) => { if (v) r.set(k, v); }); r.agent(requestOptions.agent); if (requestOptions.args) { if (requestOptions.method == 'post' || requestOptions.method == 'put') { r.send(requestOptions.args); } else { r.query(requestOptions.args); } } r.end((err, res) => { if (err) { if (res && res.statusCode !== 200) { // some know codes if (res.statusCode == 400) { return reject(new Errors.INVOICE_EXPIRED); } else if (res.statusCode == 404) { return reject(new Errors.INVOICE_NOT_AVAILABLE); } else if (res.statusCode == 422) { return reject(new Errors.UNCONFIRMED_INPUTS_NOT_ACCEPTED); } else if (res.statusCode == 500 && res.body && res.body.msg) { return reject(new Error(res.body.msg)); } } return reject(err); } return resolve({ rawBody: res.text, headers: res.headers }); }); }); } /** * Makes a request to the given url and returns the raw JSON string retrieved as well as the headers * @param {string} paymentUrl the payment protocol specific url * @param {boolean} unsafeBypassValidation bypasses signature verification on the request (DO NOT USE IN PRODUCTION) */ static async getPaymentOptions({paymentUrl, unsafeBypassValidation = false}) { const paymentUrlObject = url.parse(paymentUrl); // Detect 'bitcoin:' urls and extract payment-protocol section if ( paymentUrlObject.protocol !== 'http:' && paymentUrlObject.protocol !== 'https:' ) { let uriQuery = query.decode(paymentUrlObject.query); if (!uriQuery.r) { throw new Error('Invalid payment protocol url'); } else { paymentUrl = uriQuery.r; } } const { rawBody, headers } = await PayProV2._asyncRequest({ method: 'get', url: paymentUrl, headers: { 'Accept': 'application/payment-options', 'x-paypro-version': 2 } }); return await this.verifyResponse( paymentUrl, rawBody, headers, unsafeBypassValidation ); } /** * Selects which chain and currency option the user will be using for payment * @param {string} paymentUrl the payment protocol specific url * @param chain * @param currency * @param unsafeBypassValidation * @return {Promise<{payProDetails: Object}>} */ static async selectPaymentOption({ paymentUrl, chain, currency, unsafeBypassValidation = false }) { let { rawBody, headers } = await PayProV2._asyncRequest({ url: paymentUrl, method: 'post', headers: { 'Content-Type': 'application/payment-request', 'x-paypro-version': 2 }, args: JSON.stringify({ chain, currency }) }); return await PayProV2.verifyResponse( paymentUrl, rawBody, headers, unsafeBypassValidation ); } /** * Sends an unsigned raw transaction to the server for verification of outputs and fee amount * @param {string} paymentUrl - the payment protocol specific url * @param {string} chain - The cryptocurrency chain of the payment (BTC, BCH, ETH, etc) * @param {string} currency - When spending a token on top of a chain, such as GUSD on ETH this would be GUSD, * if no token is used this should be blank * @param [{tx: string, weightedSize: number}] unsignedTransactions - Hexadecimal format unsigned transactions * @param {boolean} unsafeBypassValidation * @return {Promise<{payProDetails: Object}>} */ static async verifyUnsignedPayment({ paymentUrl, chain, currency, unsignedTransactions, unsafeBypassValidation = false }) { let { rawBody, headers } = await PayProV2._asyncRequest({ url: paymentUrl, method: 'post', headers: { 'Content-Type': 'application/payment-verification', 'x-paypro-version': 2 }, args: JSON.stringify({ chain, currency, transactions: unsignedTransactions }) }); return await this.verifyResponse( paymentUrl, rawBody, headers, unsafeBypassValidation ); } /** * Sends a signed transaction as the final step for payment * @param {string} paymentUrl the payment protocol specific url * @param {string} chain * @param {string} currency * @param {[string]} signedTransactions * @param {number} weightedSize * @param {boolean} unsafeBypassValidation * @return {Promise} */ static async sendSignedPayment({ paymentUrl, chain, currency, signedTransactions, unsafeBypassValidation = false, bpPartner }) { let { rawBody, headers } = await this._asyncRequest({ url: paymentUrl, method: 'post', headers: { 'Content-Type': 'application/payment', 'x-paypro-version': 2, 'BP_PARTNER': bpPartner.bp_partner, 'BP_PARTNER_VERSION': bpPartner.bp_partner_version }, args: JSON.stringify({ chain, currency, transactions: signedTransactions }) }); return await this.verifyResponse( paymentUrl, rawBody, headers, unsafeBypassValidation ); } /** * Verifies the signature on any response from the payment requestor * @param {String} requestUrl - Url which the request was made to * @param {String} rawBody - The raw string body of the response * @param {Object} headers - * @param {Boolean} unsafeBypassValidation * @return {Promise<{ payProDetails: Object}>} */ static async verifyResponse(requestUrl, rawBody, headers, unsafeBypassValidation) { if (!requestUrl) { throw new Error('Parameter requestUrl is required'); } if (!rawBody) { throw new Error('Parameter rawBody is required'); } if (!headers) { throw new Error('Parameter headers is required'); } let responseData; try { responseData = JSON.parse(rawBody); } catch (e) { throw new Error('Invalid JSON in response body'); } let payProDetails; try { payProDetails = this.processResponse(responseData); } catch (e) { throw e; } if (unsafeBypassValidation) { return payProDetails; } const hash = headers.digest.split('=')[1]; const signature = headers.signature; const signatureType = headers['x-signature-type']; const identity = headers['x-identity']; let host; try { host = url.parse(requestUrl).hostname; } catch (e) {} if (!host) { throw new Error('Invalid requestUrl'); } if (!signatureType) { throw new Error('Response missing x-signature-type header'); } if (typeof signatureType !== 'string') { throw new Error('Invalid x-signature-type header'); } if (signatureType !== 'ecc') { throw new Error(`Unknown signature type ${signatureType}`); } if (!signature) { throw new Error('Response missing signature header'); } if (typeof signature !== 'string') { throw new Error('Invalid signature header'); } if (!identity) { throw new Error('Response missing x-identity header'); } if (typeof identity !== 'string') { throw new Error('Invalid identity header'); } if (!PayProV2.trustedKeys[identity]) { throw new Error( `Response signed by unknown key (${identity}), unable to validate` ); } const keyData = PayProV2.trustedKeys[identity]; const actualHash = sha256(Buffer.from(rawBody, 'utf8')).toString('hex'); if (hash !== actualHash) { throw new Error( `Response body hash does not match digest header. Actual: ${actualHash} Expected: ${hash}` ); } if (!keyData.domains.includes(host)) { throw new Error( `The key on the response (${identity}) is not trusted for domain ${host}` ); } const hashbuf = Buffer.from(hash, 'hex'); const sigbuf = Buffer.from(signature, 'hex'); let s_r = BN.fromBuffer(sigbuf.slice(0, 32)); let s_s = BN.fromBuffer(sigbuf.slice(32)); let pub = Bitcore.PublicKey.fromString(keyData.publicKey); let sig = new Bitcore.crypto.Signature(s_r, s_s); let valid = Bitcore.crypto.ECDSA.verify(hashbuf, sig, pub); if (!valid) { throw new Error('Response signature invalid'); } return payProDetails; } /** * Internal method for processing response * @param {Object} responseData * @return {Promise} * @private */ static processResponse(responseData) { let payProDetails: any = { payProUrl: responseData.paymentUrl, memo: responseData.memo }; // otherwise, it returns err. payProDetails.verified = true; // network if (responseData.network == 'test') payProDetails.network = 'testnet'; if (responseData.network == 'main') payProDetails.network = 'livenet'; if (responseData.chain) { payProDetails.coin = responseData.chain.toLowerCase(); } if (responseData.expires) { try { payProDetails.expires = (new Date(responseData.expires)).toISOString(); } catch (e) { throw new Error('Bad expiration'); } } let requiredFeeRate; // BTC Response - BCH Response if (_.has(responseData, 'instructions[0].requiredFeeRate')) { requiredFeeRate = responseData.instructions[0].requiredFeeRate; } if (_.has(responseData, 'instructions[0].outputs[0].amount')) { payProDetails.amount = responseData.instructions[0].outputs[0].amount; } if (_.has(responseData, 'instructions[0].outputs[0].address')) { try { const bitcore = Bitcore_[payProDetails.coin]; payProDetails.toAddress = (bitcore.Address(responseData.instructions[0].outputs[0].address)).toString(true); } catch (e) { return new Error('Bad output address ' + e); } } // ETH Response if (_.has(responseData, 'instructions[0].value')) { payProDetails.amount = responseData.instructions[0].value; } if (_.has(responseData, 'instructions[0].to')) { payProDetails.toAddress = responseData.instructions[0].to; } if (_.has(responseData, 'instructions[0].gasPrice')) { requiredFeeRate = responseData.instructions[0].gasPrice; } if (_.has(responseData, 'instructions[0].data')) { payProDetails.data = responseData.instructions[0].data; } // ERC20 TODO if (requiredFeeRate) { if (requiredFeeRate > MAX_FEE_PER_KB[payProDetails.coin]) { throw new Error('Fee rate too high:' + requiredFeeRate); } payProDetails.requiredFeeRate = requiredFeeRate; } return payProDetails; } }