UNPKG

bitcore-wallet-client

Version:
510 lines (461 loc) 15.5 kB
'use strict'; import { BitcoreLib as Bitcore } from 'crypto-wallet-core'; import query from 'querystring'; import superagent from 'superagent'; import url from 'url'; import dfltTrustedKeys from '../util/JsonPaymentProtocolKeys'; import { Errors } from './errors'; const sha256 = Bitcore.crypto.Hash.sha256; const BN = Bitcore.crypto.BN; const MAX_FEE_PER_KB = { btc: 10000 * 1000, // 10k sat/b bch: 10000 * 1000, // 10k sat/b eth: 1000000000000, // 1000 Gwei matic: 1000000000000, // 1000 Gwei arb: 1000000000000, // 1000 Gwei base: 1000000000000, // 1000 Gwei op: 1000000000000, // 1000 Gwei xrp: 1000000000000, doge: 10000 * 1000, // 10k sat/b ltc: 10000 * 1000, // 10k sat/b sol: 15000 }; // PayPro Network Map export enum NetworkMap { main = 'livenet', test = 'testnet', regtest = 'regtest' }; 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<Object{rawBody: String, headers: Object}>} * @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); for (const [k, v] of Object.entries(requestOptions.headers || {})) { 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 || res.statusCode == 422) && res.body && res.body.msg ) { return reject(this.getError(res.body.msg)); } else if (res.statusCode == 404) { return reject(new Errors.INVOICE_NOT_AVAILABLE()); } else if (res.statusCode == 504) { return reject(new Errors.REQUEST_TIMEOUT()); } else if (res.statusCode == 500 && res.body && res.body.msg) { return reject(new Error(res.body.msg)); } else { return reject(new Errors.INVALID_REQUEST()); } } return reject(err); } return resolve({ rawBody: res.text, headers: res.headers }); }); }); } static getError(errMsg: string): Error { switch (true) { case errMsg.includes('Invoice no longer accepting payments'): return new Errors.INVOICE_EXPIRED(); case errMsg.includes('We were unable to parse your payment.'): return new Errors.UNABLE_TO_PARSE_PAYMENT(); case errMsg.includes('Request must include exactly one'): return new Errors.NO_TRASACTION(); case errMsg.includes('Your transaction was an in an invalid format'): return new Errors.INVALID_TX_FORMAT(); case errMsg.includes('We were unable to parse the transaction you sent'): return new Errors.UNABLE_TO_PARSE_TX(); case errMsg.includes( 'The transaction you sent does not have any output to the bitcoin address on the invoice' ): return new Errors.WRONG_ADDRESS(); case errMsg.includes('The amount on the transaction (X BTC) does'): return new Errors.WRONG_AMOUNT(); case errMsg.includes('Transaction fee (X sat/kb) is below'): return new Errors.NOT_ENOUGH_FEE(); case errMsg.includes('This invoice is priced in BTC, not BCH.'): return new Errors.BTC_NOT_BCH(); case errMsg.includes( ' One or more input transactions for your transaction were not found on the blockchain.' ): return new Errors.INPUT_NOT_FOUND(); case errMsg.includes( 'The PayPro request has timed out. Please connect to the internet or try again later.' ): return new Errors.REQUEST_TIMEOUT(); case errMsg.includes( 'One or more input transactions for your transactions are not yet confirmed in at least one block.' ): return new Errors.UNCONFIRMED_INPUTS_NOT_ACCEPTED(); default: return new Error(errMsg); } } /** * 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, Connection: 'Keep-Alive', 'Keep-Alive': 'timeout=30, max=10' } }); 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, payload, unsafeBypassValidation = false }) { if (currency === 'USDP') currency = 'PAX'; // TODO workaround. Remove this when usdp is accepted as an option let { rawBody, headers } = await PayProV2._asyncRequest({ url: paymentUrl, method: 'post', headers: { 'Content-Type': 'application/payment-request', 'x-paypro-version': 2, Connection: 'Keep-Alive', 'Keep-Alive': 'timeout=30, max=10' }, args: JSON.stringify({ chain: chain?.toUpperCase(), currency, payload }) }); 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 }) { if (currency === 'USDP') currency = 'PAX'; // TODO workaround. Remove this when usdp is accepted as an option let { rawBody, headers } = await PayProV2._asyncRequest({ url: paymentUrl, method: 'post', headers: { 'Content-Type': 'application/payment-verification', 'x-paypro-version': 2, Connection: 'Keep-Alive', 'Keep-Alive': 'timeout=30, max=10' }, args: JSON.stringify({ chain: chain?.toUpperCase(), 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<Promise<{ payProDetails: Object}>} */ static async sendSignedPayment({ paymentUrl, chain, currency, signedTransactions, unsafeBypassValidation = false, bpPartner }) { if (currency === 'USDP') currency = 'PAX'; // TODO workaround. Remove this when usdp is accepted as an option 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, Connection: 'Keep-Alive', 'Keep-Alive': 'timeout=30, max=10' }, args: JSON.stringify({ chain: chain?.toUpperCase(), 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<Object{payProDetails: Object}>} * @private */ static processResponse(responseData) { let payProDetails: any = { paymentId: responseData.paymentId, payProUrl: responseData.paymentUrl, memo: responseData.memo }; // otherwise, it returns err. payProDetails.verified = true; if (responseData.paymentOptions) { payProDetails.paymentOptions = responseData.paymentOptions; payProDetails.paymentOptions.forEach(option => { option.network = NetworkMap[option.network]; }); } if (responseData.network) { payProDetails.network = NetworkMap[responseData.network]; } if (responseData.chain) { payProDetails.chain = responseData.chain?.toLowerCase(); } if (responseData.currency) { payProDetails.currency = responseData.currency; } if (responseData.expires) { try { payProDetails.expires = new Date(responseData.expires).toISOString(); } catch (e) { throw new Error('Bad expiration'); } } if (responseData.time) { try { payProDetails.time = new Date(responseData.time).toISOString(); } catch (e) { throw new Error('Bad time'); } } if (responseData.instructions) { payProDetails.instructions = responseData.instructions; payProDetails.instructions.forEach(output => { output.toAddress = output.to || output.outputs[0].address; output.amount = output.value !== undefined ? output.value : output.outputs[0].amount; }); const { requiredFeeRate, gasPrice } = responseData.instructions[0]; payProDetails.requiredFeeRate = requiredFeeRate || gasPrice; if (payProDetails.requiredFeeRate) { if ( payProDetails.requiredFeeRate > MAX_FEE_PER_KB[payProDetails.chain] ) { throw new Error('Fee rate too high:' + payProDetails.requiredFeeRate); } } } return payProDetails; } };