diff --git a/auth.js b/auth.js new file mode 100644 index 0000000..0f1eb33 --- /dev/null +++ b/auth.js @@ -0,0 +1,10 @@ +/* eslint-disable camelcase */ + +const crypto = require('crypto') + +exports.signatureFor = ({ timestamp, method, path = String.prototype, body = String.prototype }, { npm_config_coinbase_pro_api_secret = String.prototype } = process.env, { createHmac } = crypto) => { + const buffer = Buffer.from(npm_config_coinbase_pro_api_secret, 'base64') + return createHmac('sha256', buffer) + .update(timestamp + method.toUpperCase() + path + body) + .digest('base64') +} diff --git a/client.js b/client.js new file mode 100644 index 0000000..51b0ece --- /dev/null +++ b/client.js @@ -0,0 +1,42 @@ +/* eslint-disable camelcase */ + +const https = require('https') + +const info = require('./package') +const auth = require('./auth') + +exports.request = (options, env = process.env, { request } = https, { hostnameFor, headersFor, toString } = exports) => { + return new Promise((resolve, reject) => { + options.hostname = hostnameFor(env) + options.headers = headersFor(options, env) + + request(options, resolve) + .on('error', reject) + .end(options.body) + }) +} + +exports.toString = (stream, chunks = [], encoding = 'utf8', { concat } = Buffer) => { + return new Promise((resolve, reject) => { + stream.on('data', (data) => chunks.push(data)) + stream.on('error', reject) + stream.on('end', (_) => resolve(concat(chunks).toString(encoding))) + }) +} + +exports.hostnameFor = ({ npm_config_coinbase_pro_api_hostname, npm_config_coinbase_pro_api_sandbox } = process.env, { coinbase_pro_api_sandbox_hostname, coinbase_pro_api_hostname } = info.config) => { + return npm_config_coinbase_pro_api_hostname || (['true', '1'].includes(npm_config_coinbase_pro_api_sandbox) ? coinbase_pro_api_sandbox_hostname : coinbase_pro_api_hostname) +} + +exports.headersFor = (options, { npm_config_coinbase_pro_api_key = String.prototype, npm_config_coinbase_pro_api_passphrase = String.prototype, npm_config_coinbase_pro_api_secret = String.prototype } = process.env, { signatureFor } = auth, { name, version } = info) => { + const timestamp = 1e-3 * Date.now() // fixme: not sure if timestamp should be created here + return { + 'User-Agent': name + '/' + version, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'CB-ACCESS-KEY': npm_config_coinbase_pro_api_key, + 'CB-ACCESS-PASSPHRASE': npm_config_coinbase_pro_api_passphrase, + 'CB-ACCESS-SIGN': signatureFor(Object.assign(options, { timestamp }), { npm_config_coinbase_pro_api_secret }), + 'CB-ACCESS-TIMESTAMP': timestamp + } +} diff --git a/index.js b/index.js index 58f77e8..b73e8eb 100644 --- a/index.js +++ b/index.js @@ -1,114 +1,114 @@ const querystring = require('querystring') -const client = require('./lib/client') +const client = require('./client') -exports.getProducts = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getProducts = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/products?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getProductOrderBook = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getProductOrderBook = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/products/' + productId + '/book?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getProductTicker = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getProductTicker = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/products/' + productId + '/ticker?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getProductTrades = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getProductTrades = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/products/' + productId + '/trades?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getProductHistoricRates = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getProductHistoricRates = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/products/' + productId + '/candles?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getProduct24HrStats = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getProduct24HrStats = (productId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/products/' + productId + '/stats?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getCurrencies = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getCurrencies = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/currencies?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getTime = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getTime = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/time?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getCoinbaseAccounts = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getCoinbaseAccounts = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/coinbase-accounts?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getPaymentMethods = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getPaymentMethods = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/payment-methods?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getAccounts = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getAccounts = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/accounts?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getAccount = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getAccount = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/accounts/' + accountId + '?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getAccountHistory = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getAccountHistory = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/accounts/' + accountId + '/ledger?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getAccountTransfers = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getAccountTransfers = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/accounts/' + accountId + '/transfers?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getAccountHolds = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getAccountHolds = (accountId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/accounts/' + accountId + '/holds?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.placeOrder = (data, { parse } = JSON, { stringify } = JSON, { request } = client) => request({ +exports.placeOrder = (data, { parse } = JSON, { stringify } = JSON, { env } = process, { toString, request } = client) => request({ method: 'post', path: '/orders', body: stringify(data) -}).then(parse) +}, env).then(toString).then(parse) -exports.cancelOrder = (orderId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.cancelOrder = (orderId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'delete', path: '/orders/' + orderId + '?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.cancelOrders = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.cancelOrders = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'delete', path: '/orders?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getOrders = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getOrders = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/orders?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getOrder = (orderId, query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getOrder = (orderId, query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/orders/' + orderId + '?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.getFills = (query = {}, { parse } = JSON, { stringify } = querystring, { request } = client) => request({ +exports.getFills = (query = {}, { parse } = JSON, { stringify } = querystring, { env } = process, { toString, request } = client) => request({ method: 'get', path: '/fills?' + stringify(query) -}).then(parse) +}, env).then(toString).then(parse) -exports.convert = (data, { parse } = JSON, { stringify } = JSON, { request } = client) => request({ +exports.convert = (data, { parse } = JSON, { stringify } = JSON, { env } = process, { toString, request } = client) => request({ method: 'post', path: '/conversions', body: stringify(data) -}).then(parse) +}, env).then(toString).then(parse) diff --git a/lib/client.js b/lib/client.js deleted file mode 100644 index 6b30725..0000000 --- a/lib/client.js +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-disable camelcase */ - -const crypto = require('crypto') -const https = require('https') -const info = require('../package') - -exports.signatureFor = ({ - path, - method = String.prototype, - body = String.prototype -}, { - npm_config_coinbase_pro_api_key = String.prototype, - npm_config_coinbase_pro_api_passphrase = String.prototype, - npm_config_coinbase_pro_api_secret = String.prototype -} = process.env, { - createHmac -} = crypto) => { - const timestamp = 1e-3 * Date.now() - const digest = timestamp + method.toUpperCase() + path + body - return { - 'CB-ACCESS-KEY': npm_config_coinbase_pro_api_key, - 'CB-ACCESS-PASSPHRASE': npm_config_coinbase_pro_api_passphrase, - 'CB-ACCESS-SIGN': createHmac('sha256', Buffer.from(npm_config_coinbase_pro_api_secret, 'base64')).update(digest).digest('base64'), - 'CB-ACCESS-TIMESTAMP': timestamp - } -} - -exports.headersFor = (options, { assign } = Object, { name, version } = info, { signatureFor } = exports) => { - const signature = signatureFor(options) - return assign({ - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'User-Agent': name + ' ' + version - }, signature) -} - -exports.hostnameFor = (options, { npm_config_coinbase_pro_api_hostname = 'api.pro.coinbase.com' } = process.env) => { - return npm_config_coinbase_pro_api_hostname -} - -exports.request = (options, { assign } = Object, { request } = https, { hostnameFor, headersFor, toString } = exports) => { - const hostname = hostnameFor(options) // fixme: this should be done by main script - const headers = headersFor(options) // fixme: signature is not always mandatory - return new Promise((resolve, reject) => { - request(Object.assign({ hostname, headers }, options), resolve) - .on('error', reject) - .end(options.body) - }).then(toString) -} - -// this should go into index.js -exports.toString = (stream, chunks = [], encoding = 'utf8', { concat } = Buffer) => { - return new Promise((resolve, reject) => { - stream.on('data', (data) => chunks.push(data)) - stream.on('error', reject) - stream.on('end', (_) => resolve(concat(chunks).toString(encoding))) - }) -} diff --git a/package.json b/package.json index da0adb5..58a0f63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coinbase-pro-api", - "version": "0.0.2", + "version": "0.0.3", "description": "lightweight coinbase pro api implementation", "main": "index.js", "config": { diff --git a/test/auth.js b/test/auth.js new file mode 100644 index 0000000..1db1f30 --- /dev/null +++ b/test/auth.js @@ -0,0 +1,26 @@ +test('coinbase-pro-api/auth', () => { + const { deepStrictEqual } = require('assert') + + test('.signatureFor', () => { + const { signatureFor } = require('../auth') + const { createHmac } = require('crypto') + + test('is callable', () => { + deepStrictEqual(typeof signatureFor, 'function') + }) + + test('creates sha256 hmac signature using crypto', () => { + const signature = signatureFor({ timestamp: 1, method: 'get' }) + deepStrictEqual(signature, createHmac('sha256', Buffer.from(String.prototype, 'base64')) + .update(`1GET`) + .digest('base64')) + }) + + test('takes an optional secret argument', () => { + const signature = signatureFor({ timestamp: 1, method: 'get' }, { npm_config_coinbase_pro_api_secret: String.prototype }) + deepStrictEqual(signature, createHmac('sha256', Buffer.from(String.prototype, 'base64')) + .update(`1GET`) + .digest('base64')) + }) + }) +}) diff --git a/test/client.js b/test/client.js new file mode 100644 index 0000000..cca88a0 --- /dev/null +++ b/test/client.js @@ -0,0 +1,107 @@ +/* eslint-disable camelcase */ + +test('coinbase-pro-api/client', () => { + const { ok, deepStrictEqual } = require('assert') + + test('.request', () => { + const { request } = require('../client') + + test('is callable', () => { + deepStrictEqual(typeof request, 'function') + }) + + test('wraps https.request with a promise', () => { + const promise = request({ method: 'get' }, undefined, { + request (_, resolve) { + return { + on (_, reject) { + return this + }, + end () { + return this + } + } + } + }) + deepStrictEqual(promise.constructor.name, 'Promise') + }) + }) + + test('.hostnameFor', () => { + const { hostnameFor } = require('../client') + + test('is callable', () => { + deepStrictEqual(typeof hostnameFor, 'function') + }) + + test('points to production environment when no configuration is provided', () => { + const hostname = hostnameFor(undefined) + deepStrictEqual(hostname, 'api.pro.coinbase.com') + }) + + test('enviroment can be overidden via npm configuration', () => { + const hostname = hostnameFor({ npm_config_coinbase_pro_api_hostname: 'my.proxy' }) + deepStrictEqual(hostname, 'my.proxy') + }) + + test('sandbox enviroment can be enabled via boolean npm configuration', () => { + const booleanSandbox = hostnameFor({ npm_config_coinbase_pro_api_sandbox: 'true' }) + deepStrictEqual(booleanSandbox, 'api-public.sandbox.pro.coinbase.com') + }) + + test('sandbox enviroment can be enabled via numeric npm configuration', () => { + const numericSandbox = hostnameFor({ npm_config_coinbase_pro_api_sandbox: '1' }) + deepStrictEqual(numericSandbox, 'api-public.sandbox.pro.coinbase.com') + }) + }) + + test('.headersFor', () => { + const { headersFor } = require('../client') + + test('is callable', () => { + deepStrictEqual(typeof headersFor, 'function') + }) + + test('adds mandatory headers', () => { + const headers = headersFor({ method: 'get' }) + + ok(headers.hasOwnProperty('User-Agent')) + ok(headers.hasOwnProperty('Accept')) + ok(headers.hasOwnProperty('Content-Type')) + ok(headers.hasOwnProperty('CB-ACCESS-KEY')) + ok(headers.hasOwnProperty('CB-ACCESS-PASSPHRASE')) + ok(headers.hasOwnProperty('CB-ACCESS-SIGN')) + ok(headers.hasOwnProperty('CB-ACCESS-TIMESTAMP')) + }) + }) + + test('.toString', () => { + const { Readable } = require('stream') + const { toString } = require('../client') + + test('is callable', () => { + deepStrictEqual(typeof toString, 'function') + }) + + test('returns promise', () => { + const promise = toString({ + on: Function.prototype + }) + deepStrictEqual(promise.constructor.name, 'Promise') + }) + + test('resolves readable stream into string', (done) => { + const stream = new Readable({ + read () { + this.push('foo') + this.push(null) + } + }) + + toString(stream) + .then(string => deepStrictEqual(string, 'foo')) + .then(done) + .catch(done) + }) + }) +}) diff --git a/test/lib/client.js b/test/lib/client.js deleted file mode 100644 index 8ad379d..0000000 --- a/test/lib/client.js +++ /dev/null @@ -1,77 +0,0 @@ -/* eslint-disable camelcase */ - -test('lib/client', () => { - const { ok, deepStrictEqual } = require('assert') - - test('.signatureFor', () => { - const { signatureFor } = require('../../lib/client') - - test('is callable', () => { - deepStrictEqual(typeof signatureFor, 'function') - }) - - test('contains mandatory headers', () => { - const signature = signatureFor(Object.prototype) - - ok(signature.hasOwnProperty('CB-ACCESS-KEY')) - ok(signature.hasOwnProperty('CB-ACCESS-PASSPHRASE')) - ok(signature.hasOwnProperty('CB-ACCESS-SIGN')) - ok(signature.hasOwnProperty('CB-ACCESS-TIMESTAMP')) - }) - - test('takes optional key, passphrase and secret', () => { - const npm_config_coinbase_pro_api_key = 'key' - const npm_config_coinbase_pro_api_passphrase = 'passphrase' - const npm_config_coinbase_pro_api_secret = Buffer.from('secret').toString('base64') - - const signature = signatureFor({}, { npm_config_coinbase_pro_api_key, npm_config_coinbase_pro_api_passphrase, npm_config_coinbase_pro_api_secret }, { createHmac }) - - deepStrictEqual(signature['CB-ACCESS-KEY'], npm_config_coinbase_pro_api_key) - deepStrictEqual(signature['CB-ACCESS-PASSPHRASE'], npm_config_coinbase_pro_api_passphrase) - deepStrictEqual(signature['CB-ACCESS-SIGN'], Buffer.from('secret')) - - function createHmac (_, secret) { - return { - update () { - return { - digest () { - return secret - } - } - } - } - } - }) - }) - - test('.headersFor', () => { - const { headersFor } = require('../../lib/client') - - test('is callable', () => { - deepStrictEqual(typeof headersFor, 'function') - }) - }) - - test('.hostnameFor', () => { - const { hostnameFor } = require('../../lib/client') - - test('is callable', () => { - deepStrictEqual(typeof hostnameFor, 'function') - }) - }) - - test('.requestFor', () => { - const { request: requestFor } = require('../../lib/client') - - test('is callable', () => { - deepStrictEqual(typeof requestFor, 'function') - }) - }) - - test('.toString', () => { - const { toString } = require('../../lib/client') - test('is callable', () => { - deepStrictEqual(typeof toString, 'function') - }) - }) -})