@ducatus/ducatus-wallet-client-rev
Version:
Client for @ducatus/ducatus-wallet-service-rev
432 lines (387 loc) • 14.4 kB
text/typescript
;
// 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('@ducatus/ducatus-crypto-wallet-core-rev').BitcoreLib;
const _ = require('lodash');
const sha256 = Bitcore.crypto.Hash.sha256;
const BN = Bitcore.crypto.BN;
var Bitcore_ = {
btc: Bitcore,
bch: require('@ducatus/ducatus-crypto-wallet-core-rev').BitcoreLibCash
};
var MAX_FEE_PER_KB = {
btc: 10000 * 1000, // 10k sat/b
bch: 10000 * 1000, // 10k sat/b
eth: 50000000000, // 50 Gwei
xrp: 50000000000,
duc: 10000 * 1000,
ducx: 50000000000
};
// PayPro Network Map
export enum NetworkMap {
main = 'livenet',
test = 'testnet',
regtest = 'testnet'
}
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);
_.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 || 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, unsafeBypassValidation = false }) {
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,
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,
Connection: 'Keep-Alive',
'Keep-Alive': 'timeout=30, max=10'
},
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<Promise<{ payProDetails: Object}>}
*/
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,
Connection: 'Keep-Alive',
'Keep-Alive': 'timeout=30, max=10'
},
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<Object{payProDetails: Object}>}
* @private
*/
static processResponse(responseData) {
let payProDetails: any = {
payProUrl: responseData.paymentUrl,
memo: responseData.memo
};
// otherwise, it returns err.
payProDetails.verified = true;
// getPaymentOptions
if (responseData.paymentOptions) {
payProDetails.paymentOptions = responseData.paymentOptions;
payProDetails.paymentOptions.forEach(option => {
option.network = NetworkMap[option.network];
});
}
// network
if (responseData.network) {
payProDetails.network = NetworkMap[responseData.network];
}
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');
}
}
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.coin]) {
throw new Error('Fee rate too high:' + payProDetails.requiredFeeRate);
}
}
}
return payProDetails;
}
}