UNPKG

@owstack/ows-wallet-servlet-coinbase

Version:

An OWS Wallet servlet plugin for Coinbase.

871 lines (739 loc) 26.8 kB
'use strict'; angular.module('owsWalletPlugin.services').factory('coinbaseService', function($rootScope, $log, lodash, /* @namespace owsWalletPluginClient.api */ Constants, /* @namespace owsWalletPluginClient.api */ Host, /* @namespace owsWalletPluginClient.api */ Http, /* @namespace owsWalletPluginClient.api */ Session, /* @namespace owsWalletPluginClient.api */ Settings, /* @namespace owsWalletPluginClient.api */ Storage, /* @namespace owsWalletPluginClient.api */ Transaction) { var root = {}; var isCordova = owswallet.Plugin.isCordova(); var session = Session.getInstance(); var credentials = {}; var storage; var coinbaseApi; var coinbaseHost; // These errors from Coinbase indicate that the user is not authorized to access the Coinbase service. // A new token must be obtained to restore access. var oauthErrors = [{ coinbaseId: 'expired_token', message: 'Token expired', statusCode: 401, statusText: 'UNAUTHORIZED_EXPIRED' }, { coinbaseId: 'revoked_token', message: 'Token revoked', statusCode: 401, statusText: 'UNAUTHORIZED_REVOKED' }, { coinbaseId: 'invalid_token', message: 'Token invalid', statusCode: 401, statusText: 'UNAUTHORIZED_INVALID' }, { coinbaseId: 'invalid_grant', message: 'Authorization grant is invalid', statusCode: 401, statusText: 'UNAUTHORIZED_GRANT' }]; // Invoked via the servlet API to initialize our environment using the provided configuration. root.init = function(clientId, config, oauthCode) { return new Promise(function(resolve, reject) { if (!config) { var error = 'Could not initialize API service: no plugin configuration provided'; $log.error(error); reject(error); } // Use plugin configuration to setup for communicating with Coinbase. setCredentials(config); // Setup access to our storage space; use clientId to create a unique name space. storage = new Storage([ 'access-token', 'refresh-token', 'txs' ], clientId); // Gather some additional information for the client. This information only during this initialization sequence. var info = {}; info.urls = getUrls(); // Providing an oauth code is optional; the client may require it. if (oauthCode) { // Use the oauthCode to get an API token followed by getting the account ID. getToken(oauthCode).then(function(accessToken) { // Even if there is no token we need to set up the api provider for calls not requiring authentication. createCoinbaseApiProvider(accessToken); return resolve({ info: info, authenticated: accessToken ? true : false }); }).catch(function(error) { var oauthError = lodash.intersectionWith(oauthErrors, [error], function(val1, val2) { return val1.coinbaseId == val2.id; }); if (oauthError.length > 0) { // There should only be one error in the array. oauthError = oauthError[0]; return reject({ id: oauthError.coinbaseId, message: oauthError.message, statusCode: oauthError.statusCode, statusText: oauthError.statusText }); } else { // Unexpected error. return reject(error); }; }); } else { getTokenFromStorage().then(function(accessToken) { // Even if there is no token we need to set up the api provider for calls not requiring authentication. createCoinbaseApiProvider(accessToken); return resolve({ info: info, authenticated: accessToken ? true : false }); }); } }); }; root.logout = function(reason) { return new Promise(function(resolve, reject) { storage.removeAccessToken().then(function() { return storage.removeRefreshToken(); }).then(function() { return storage.removeTxs(); }).then(function() { $log.info('Logged out of Coinbase.'); // Logged out. Broadcast a logout event to interested plugins. session.broadcastEvent({ name: 'coinbase.logout', data: { reason: reason || 'USER_REQUESTED' } }); resolve(); }).catch(function(error) { $log.error('Could not logout: ' + error); reject(error); }); }); }; root.getExchangeRates = function(currency) { return new Promise(function(resolve, reject) { coinbaseApi.get('exchange-rates?currency=' + currency).then(function(response) { var data = response.data.data.rates; resolve(data); }).catch(function(response) { reject(getError(response, 'getExchangeRates')); }); }); }; root.getAccounts = function(accountId) { return new Promise(function(resolve, reject) { coinbaseApi.get('accounts/' + (accountId ? accountId : '')).then(function(response) { // Response object returns with pagination; access the accounts array only. var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getAccounts')); }); }); }; root.getCurrentUser = function() { return new Promise(function(resolve, reject) { coinbaseApi.get('user/').then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getCurrentUser')); }); }); }; root.getUserAuth = function() { return new Promise(function(resolve, reject) { coinbaseApi.get('user/auth').then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getUserAuth')); }); }); }; root.getBuyOrder = function(accountId, buyId) { return new Promise(function(resolve, reject) { coinbaseApi.get('accounts/' + accountId + '/buys/' + buyId).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getBuyOrder')); }); }); }; root.getTransactions = function(accountId, transactionId) { return new Promise(function(resolve, reject) { coinbaseApi.get('accounts/' + accountId + '/transactions/' + (transactionId ? transactionId : '')).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getTransactions')); }); }); }; /* root.getAddressTransactions = function(accountId, addressId) { return new Promise(function(resolve, reject) { coinbaseApi.get('accounts/' + accountId + '/addresses/' + addressId + '/transactions').then(function(response) { var data = response.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getAddressTransactions')); }); }); }; root.paginationTransactions = function(url) { return new Promise(function(resolve, reject) { coinbaseApi.get(url.replace('/v2', '')).then(function(response) { var data = response.data; resolve(data); }).catch(function(response) { reject(getError(response, 'paginationTransactions')); }); }); }; */ root.sellPrice = function(currency) { return new Promise(function(resolve, reject) { coinbaseApi.get('prices/sell?currency=' + currency).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'sellPrice')); }); }); }; root.buyPrice = function(currency) { return new Promise(function(resolve, reject) { coinbaseApi.get('prices/buy?currency=' + currency).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'buyPrice')); }); }); }; root.spotPrice = function(cryptoCurrencies) { return new Promise(function(resolve, reject) { var count = cryptoCurrencies.length; var result = {}; // Issue calls for each currency pair, resolve when all pair results have been returned. No reject() is called // since the errors are embedded in the result object. // // result: { // 'pair1': { // If success for a pair. // <coinbase result> // } // 'pair2': { // If an error for a pair. // error: <message> // } // } // lodash.forEach(cryptoCurrencies, function(c) { var pair = c + '-USD'; var label = Constants.currencyMap(c, 'name'); coinbaseApi.get('prices/' + pair + '/spot').then(function(response) { result[pair] = response.data.data; result[pair].label = label; // As a convenience, add the label to the result. count--; if (count == 0) { resolve(result); } }).catch(function(response) { getError(response, 'spotPrice'); result[pair] = {}; result[pair].error = error.message; count--; if (count == 0) { resolve(result); // Resolve here since errors are embedded in the result. } }); }); }); }; root.historicPrice = function(currencyPair, period) { return new Promise(function(resolve, reject) { coinbaseApi.get('prices/' + currencyPair + '/historic?period=' + period).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'historicPrice')); }); }); }; root.getPaymentMethods = function(paymentMethodId) { return new Promise(function(resolve, reject) { coinbaseApi.get('payment-methods/' + (paymentMethodId ? paymentMethodId : '')).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getPaymentMethods')); }); }); }; root.createAddress = function(accountId, addressData) { return new Promise(function(resolve, reject) { var data = { name: addressData.name }; coinbaseApi.post('accounts/' + accountId + '/addresses', data).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'createAddress')); }); }); }; root.getTime = function() { return new Promise(function(resolve, reject) { coinbaseApi.get('time/').then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'getTime')); }); }); }; root.sellRequest = function(accountId, requestData) { return new Promise(function(resolve, reject) { // If a walletId is specified then the sell request starts from an OWS wallet, not a Coinbase account. // Ensure the order does not commit. if (requestData.walletId) { requestData.commit = false; requestData.quote = false; } var data = { amount: requestData.amount, currency: requestData.currency, payment_method: requestData.paymentMethodId ||  null, commit: requestData.commit || false, quote: requestData.quote || false }; coinbaseApi.post('accounts/' + accountId + '/sells', data).then(function(response) { var data = response.data.data; // Pass back the walletId (if specified). data.walletId = requestData.walletId; resolve(data); }).catch(function(response) { reject(getError(response, 'sellRequest')); }); }); }; root.sellCommit = function(accountId, sellId) { return new Promise(function(resolve, reject) { coinbaseApi.post('accounts/' + accountId + '/sells/' + sellId + '/commit').then(function(response) { var data = response.data; resolve(data); }).catch(function(response) { reject(getError(response, 'sellCommit')); }); }); }; root.sellCommitFromWallet = function(accountId, walletId, amount, monitorData) { return new Promise(function(resolve, reject) { // Get the destination address from the Coinbase account. root.createAddress(accountId, { name: 'Funds to sell from wallet' }).then(function(address) { // Create a wallet transaction to send to coinbase account. var walletTx = new Transaction({ walletId: walletId, urlOrAddress: address, amount: amount }); // Broadcast a blockchain transaction. return walletTx.send(); }).then(function(tx) { monitorService.addMonitor({ accountId: accountId, walletId: walletId, txHash: tx.id, priceStopLimitAmount: monitorData.priceStopLimitAmount, pluginId: monitorData.pluginId, action: 'sell' }); }).catch(function() { reject(getError(error, 'sellCommit')); }); }); }; root.buyRequest = function(accountId, requestData) { return new Promise(function(resolve, reject) { var data = { amount: requestData.amount, currency: requestData.currency, paymentMethodId: requestData.paymentMethodId || null, commit: requestData.commit || false, quote: requestData.quote || false }; coinbaseApi.post('accounts/' + accountId + '/buys', data).then(function(response) { var data = response.data.data; // Pass the walletId (if specified) back to the caller. data.walletId = requestData.walletId; resolve(data); }).catch(function(response) { reject(getError(response, 'buyRequest')); }); }); }; root.buyCommit = function(accountId, buyId) { return new Promise(function(resolve, reject) { coinbaseApi.post('accounts/' + accountId + '/buys/' + buyId + '/commit').then(function(response) { var data = response.data; resolve(data); }).catch(function(response) { reject(getError(response, 'buyCommit')); }); }); }; root.buyCommitFromWallet = function(accountId, walletId, buyId, monitorData) { return new Promise(function(resolve, reject) { coinbaseApi.post('accounts/' + accountId + '/buys/' + buyId + '/commit').then(function(response) { var data = response.data; monitorService.addMonitor({ accountId: accountId, walletId: walletId, txId: data.id, priceStopLimitAmount: monitorData.priceStopLimitAmount, pluginId: monitorData.pluginId, action: 'buy' }); resolve(data); }).catch(function(response) { reject(getError(response, 'buyCommit')); }); }); }; root.sendTo = function(accountId, sendData) { return new Promise(function(resolve, reject) { var data = { type: 'send', to: sendData.to, amount: sendData.amount, currency: sendData.currency, description: sendData.description }; coinbaseApi.post('accounts/' + accountId + '/transactions', data).then(function(response) { var data = response.data.data; resolve(data); }).catch(function(response) { reject(getError(response, 'sendTo')); }); }); }; root.sendToWallet = function(accountId, walletId, note) { var wallet; var address; return session.getWalletById(walletId).then(function(w) { wallet = w; return wallet.getAddress(); }).then(function(a) { address = a; return wallet.getFeeRate(); }).then(function(feePerKb) { // Estimate transction size of 450 bytes to compute total fee. var fee = feePerKb.standard * (450 / 1000); var netAmount = amount - fee; return coinbaseService.sendTo(accountId, { to: address, amount: netAmount, currency: wallet.currency, description: note }); }).then(function(tx) { return tx; }).catch(function(error) { throw error; }); }; /** * Private functions */ function setCredentials(config) { // Coinbase permissions. credentials.SCOPE = '' + 'wallet:accounts:read,' + 'wallet:addresses:read,' + 'wallet:addresses:create,' + 'wallet:user:read,' + 'wallet:user:email,' + 'wallet:buys:read,' + 'wallet:buys:create,' + 'wallet:sells:read,' + 'wallet:sells:create,' + 'wallet:transactions:read,' + 'wallet:transactions:send,' + //TODO 'wallet:transactions:send:bypass-2fa,' + 'wallet:payment-methods:read,' + 'wallet:payment-methods:limits'; if (isCordova) { credentials.REDIRECT_URI = config.redirect_uri.mobile; } else { credentials.REDIRECT_URI = config.redirect_uri.desktop; } credentials.HOST = config.production.host; credentials.API = config.production.api; credentials.CLIENT_ID = config.production.client_id; credentials.CLIENT_SECRET = config.production.client_secret; // Date of this implementation. credentials.API_VERSION = '2018-01-06'; // Using these credentials, create a host provider. createCoinbaseHostProvider(); }; function createCoinbaseHostProvider() { // Create our host provider so we can establish initial communication with Coinbase. coinbaseHost = new Http(credentials.HOST, { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' } }); }; function createCoinbaseApiProvider(accessToken) { // Using the access token, create a new provider for making future API requests. coinbaseApi = new Http(credentials.API + '/v2/', { headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'CB-VERSION': credentials.API_VERSION, 'Authorization': 'Bearer ' + accessToken } }, oauthRefresh); }; function updateCoinbaseApiProviderAuthorization(apiProvider, accessToken) { // Using the access token, create a new provider for making future API requests. apiProvider.setHeaders({ 'Authorization': 'Bearer ' + accessToken }); }; function oauthRefresh(httpProvider, response) { return new Promise(function(resolve, reject) { var error = response.data; // Check for a Coinbase error reponse. If not then return, otherwise continue. if (!error.errors || (error.errors && !lodash.isArray(error.errors))) { return reject(response); } // There is a Coinbase error, check to see if the access token is the cause. var oauthError = lodash.intersectionWith(oauthErrors, error.errors, function(val1, val2) { return val1.coinbaseId == val2.id; }); if (oauthError.length > 0) { // There should only be one error in the array. oauthError = oauthError[0]; switch (oauthError.coinbaseId) { case 'expired_token': $log.info(oauthError.message + ': refreshing access token'); refreshToken().then(function(newAccessToken) { // Update the active http provider configuration using the new access token. updateCoinbaseApiProviderAuthorization(httpProvider, newAccessToken); return resolve(); }).catch(function(error) { $log.warn('Failed to refresh token, logging out'); root.logout(oauthError.statusText); return reject({ data: { errors: [{ id: oauthError.coinbaseId, message: oauthError.message + ': ' + error, statusCode: oauthError.statusCode, statusText: oauthError.statusText }] } }); }); break; case 'revoked_token': case 'invalid_token': case 'invalid_grant': $log.warn(oauthError.message + ': logging out'); root.logout(oauthError.statusText); return reject({ data: { errors: [{ id: oauthError.coinbaseId, message: oauthError.message, statusCode: oauthError.statusCode, statusText: oauthError.statusText }] } }); break; default: // Should never happen. return reject(response); }; } else { // Not an oauth error. return reject(response); } }); }; function getToken(oauthCode) { return new Promise(function(resolve, reject) { var data = { grant_type: 'authorization_code', code: oauthCode, client_id: credentials.CLIENT_ID, client_secret: credentials.CLIENT_SECRET, redirect_uri: credentials.REDIRECT_URI }; coinbaseHost.post('oauth/token/', data).then(function(response) { var data = response.data; if (data && data.access_token && data.refresh_token) { saveToken(data.access_token, data.refresh_token, function(error, accessToken) { if (error) { return reject(getError('Could not save the access token', 'getToken')); } // Re-orient the api provider using the token. createCoinbaseApiProvider(accessToken); return resolve(accessToken); }); } else { return reject(getError('No access token in response', 'getToken')); } }).catch(function(response) { reject(getError(response, 'getToken')); }); }); }; function getTokenFromStorage() { return new Promise(function(resolve, reject) { storage.getAccessToken().then(function(accessToken) { resolve(accessToken); }).catch(function(error) { reject(getError(response, 'getTokenFromStorage')); }); }); }; function saveToken(accessToken, refreshToken, cb) { storage.setAccessToken(accessToken).then(function() { return storage.setRefreshToken(refreshToken); }).then(function() { return cb(null, accessToken); }).catch(function(error) { $log.error('Coinbase: saveToken ' + error); return cb(error); }); }; function refreshToken() { return new Promise(function(resolve, reject) { storage.getRefreshToken().then(function(refreshToken) { var data = { grant_type: 'refresh_token', client_id: credentials.CLIENT_ID, client_secret: credentials.CLIENT_SECRET, redirect_uri: credentials.REDIRECT_URI, refresh_token: refreshToken }; coinbaseHost.post('oauth/token/', data).then(function(response) { var data = response.data; if (data && data.access_token && data.refresh_token) { saveToken(data.access_token, data.refresh_token, function(error, accessToken) { if (error) { return reject(getError('Could not save the access token', 'refreshToken')); } $log.info('Successfully refreshed token from Coinbase'); return resolve(accessToken); }); } else { return reject(getError('Could not get the access token', 'refreshToken')); } }).catch(function(response) { return reject(getError(response, 'refreshToken')); }); }).catch(function(error) { return reject(getError('Could not get refresh token from storage: ' + error), 'refreshToken'); }); }); }; function getUrls() { return { oauthCodeUrl: '' + credentials.HOST + '/oauth/authorize?response_type=code&account=all&client_id=' + credentials.CLIENT_ID + '&redirect_uri=' + credentials.REDIRECT_URI + '&state=SECURE_RANDOM&scope=' + credentials.SCOPE + //TODO '&meta[send_limit_amount]=1000&meta[send_limit_currency]=USD&meta[send_limit_period]=day', '&meta[send_limit_amount]=1&meta[send_limit_currency]=USD&meta[send_limit_period]=day', signupUrl: 'https://www.coinbase.com/signup', supportUrl: 'https://support.coinbase.com', privacyUrl: 'https://www.coinbase.com/legal/user_agreement' }; }; function getError(response, callerId) { // Check for JS error. if (response.message) { return { id: 'unexpected_error', message: response.message } } $log.error('Coinbase: ' + callerId + ' - ' + getErrorsAsString(response.data)); var error; if (response.status && response.status <= 0) { error = { id: 'network_error', message: 'Network error' }; } else { // Typically, Coinbase returns an array of errors with just one element. // Only 'validation_error' may return more than one error. if (response.data.error) { error = { id: response.data.error, message: response.data.error_description }; } else if (response.data.errors && lodash.isArray(response.data.errors)) { error = response.data.errors[0]; } else if (response.data) { error = response.data; } else { // A simple text string. if (typeof response == 'object') { response = JSON.stringify(response); } error = { id: 'unexpected_error', message: response.toString() }; } } return error; }; function getErrorsAsString(data) { var errData; try { if (data && data.errors) { // Generic error format. errData = data.errors; } else if (data && data.error) { // Authentication error format. errData = data.error_description; } else { return 'Unknown error'; } if (!lodash.isArray(errData)) { errData = errData && errData.message ? errData.message : errData; } else { var errStr = ''; for (var i = 0; i < errData.length; i++) { errStr = errStr + errData[i].message + '. '; } errData = errStr; } return errData; } catch(e) { $log.error(e); }; }; return root; });