UNPKG

@lunch-money/coinbase-to-lunch-money

Version:

A wrapper around the coinbase API for enabling Lunch Money to gather information about a user's account.

239 lines 10.9 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.coinbaseEndpoints = exports.coinbaseAPIBaseUrl = exports.CoinbaseClient = void 0; const axios_1 = __importDefault(require("axios")); const jsonwebtoken_1 = require("jsonwebtoken"); const crypto_1 = require("crypto"); const url_1 = require("url"); const BASE_URL = 'https://api.coinbase.com'; exports.coinbaseAPIBaseUrl = BASE_URL; const ENDPOINTS = { accounts: 'api/v3/brokerage/accounts', }; exports.coinbaseEndpoints = ENDPOINTS; const QUERY_PARAMS = { accounts: { limit: 100 }, }; // PKCS#8 DER prefix for Ed25519 (16 fixed bytes preceding the 32-byte seed) const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); function base64urlEncode(buf) { return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } /** * Coinbase Client * * Coinbase doesn't have an official node client, so a basic one is provided. * * There are two authentication methods: API key and OAuth2. Coinbase * discourages the use of API Keys except when writing your own software, so * OAuth2 is preferred. */ class CoinbaseClient { /** * Create the client instance with baseUrl and scopes */ constructor(config) { this.config = config; } /** * Execute a request and handle the response */ request(method, path, query = {}, data = '') { return __awaiter(this, void 0, void 0, function* () { const url = new url_1.URL(path, BASE_URL).href; const sJWT = this.generateSignedJwt(method, url); const requestConfig = { url, params: query, method, data, headers: { Authorization: `Bearer ${sJWT}`, }, }; // Make the request let response; try { response = yield (0, axios_1.default)(requestConfig); } catch (err) { // re-throw normal errors if (!axios_1.default.isAxiosError(err)) { throw err; } // return axios errors // as endpoints do return content even when triggering status errors response = err.response; } // Process response if (!response) { throw new Error('Invalid response'); } if (response.status >= 400) { const body = response.data; const detail = (body === null || body === void 0 ? void 0 : body.message) || (body === null || body === void 0 ? void 0 : body.error) || `HTTP ${response.status}`; console.log(`Coinbase API rejected request (${response.status}): ${detail}`); throw new Error(`Coinbase API error (${response.status}): ${detail}`); } if (typeof response.data === 'undefined') { throw new Error(`Coinbase API responded with no data`); } const result = response.data; if (typeof result === 'undefined') { throw new Error(`Coinbase API responded with no data`); } // Process results based on the type of request if (path == ENDPOINTS.accounts) { // Loop through pagination to fetch all results // @see https://docs.cdp.coinbase.com/advanced-trade/reference/retailbrokerageapi_getaccounts if (typeof result.has_next && result.cursor) { // If there is another page of resources after this one, request it and // append to our results. This will act recursively until all pages have // been returned. const nextResult = yield this.request(method, ENDPOINTS.accounts, Object.assign(Object.assign({}, query), { cursor: result.cursor })); result.accounts = result.accounts.concat(nextResult || []); } return result.accounts; } else { throw new Error(`Invalid path: ${path}. Path must match one of the defined endpoints.`); } }); } /** * Generate a JWT for the current request. * * Detects the key type from the PEM header when present. For raw base64 keys * (no header), attempts Ed25519 first (the newer Coinbase default) then falls * back to ECDSA. */ generateSignedJwt(method, url) { if (this.config.mockApiResponseTest) { return ''; } const key_name = this.config.name; const key_secret = this.config.privateKey; const strippedUrl = url.replace(/^https?:\/\//, ''); const uri = `${method} ${strippedUrl}`; const payload = { iss: 'cdp', nbf: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 120, sub: key_name, uri, }; try { const { pem, algorithm } = this.prepareKey(key_secret); if (algorithm === 'EdDSA') { return this.signJwtEdDSA(payload, pem, key_name); } const options = { algorithm: 'ES256', header: { kid: key_name, alg: 'ES256' }, }; return (0, jsonwebtoken_1.sign)(payload, pem, options); } catch (e) { const message = e.message; console.log(`Failed to get signed token with API credentials: ${message}`); throw new Error(`Unable to access Coinbase API with supplied credentials: ${message}`); } } /** * Determine key type and return a PEM-formatted key ready for signing. * * Priority: * 1. PEM header present → use it directly (ECDSA or Ed25519) * 2. No header + ≤64 bytes → must be raw Ed25519 material; construct PKCS#8 PEM * 3. No header + >64 bytes → ECDSA P-256 DER content; re-wrap with EC PEM headers */ prepareKey(rawKey) { if (rawKey.includes('-----BEGIN EC PRIVATE KEY-----')) { return { pem: rawKey, algorithm: 'ES256' }; } if (rawKey.includes('-----BEGIN PRIVATE KEY-----')) { return { pem: rawKey, algorithm: 'EdDSA' }; } // No PEM header — use byte length to distinguish key types: // Ed25519 raw keys are exactly 32 bytes (seed) or 64 bytes (extended seed + public key). // ECDSA P-256 DER content is ~120 bytes, so anything larger must be ECDSA. const rawBytes = Buffer.from(rawKey, 'base64'); if (rawBytes.length <= 64) { // Keys this small must be raw Ed25519 material (32-byte seed or 64-byte extended key). // ECDSA P-256 DER content is ~120 bytes so it can never appear here. const seed = rawBytes.slice(0, 32); const pkcs8Der = Buffer.concat([PKCS8_ED25519_PREFIX, seed]); const pem = `-----BEGIN PRIVATE KEY-----\n${pkcs8Der.toString('base64')}\n-----END PRIVATE KEY-----\n`; (0, crypto_1.createPrivateKey)(pem); // throws a meaningful error if the bytes are not valid Ed25519 return { pem, algorithm: 'EdDSA' }; } // Bytes longer than 64 are ECDSA P-256 DER content — re-wrap with EC PEM headers const pem = `-----BEGIN EC PRIVATE KEY-----\n${rawKey}\n-----END EC PRIVATE KEY-----\n`; return { pem, algorithm: 'ES256' }; } /** * Sign a JWT using Ed25519 via Node's built-in crypto module. * jsonwebtoken's jws sub-dependency does not support EdDSA, so we sign * the token directly here. */ signJwtEdDSA(payload, privateKeyPem, keyName) { const header = { alg: 'EdDSA', typ: 'JWT', kid: keyName }; const headerB64 = base64urlEncode(Buffer.from(JSON.stringify(header))); const payloadB64 = base64urlEncode(Buffer.from(JSON.stringify(payload))); const signingInput = `${headerB64}.${payloadB64}`; const key = (0, crypto_1.createPrivateKey)(privateKeyPem); const signature = (0, crypto_1.sign)(null, Buffer.from(signingInput), key); return `${signingInput}.${base64urlEncode(signature)}`; } /** * Returns current coinbase accounts * * @see https://docs.cdp.coinbase.com/advanced-trade/reference/retailbrokerageapi_getaccounts */ getAccounts() { return __awaiter(this, void 0, void 0, function* () { const query = this.config.testPagination ? Object.assign(Object.assign({}, QUERY_PARAMS.accounts), { limit: 1 }) : QUERY_PARAMS.accounts; const accounts = yield this.request('GET', ENDPOINTS.accounts, query); if (!accounts) { throw new Error('Could not fetch accounts data'); } return accounts; }); } /** * Returns current coinbase holdings */ getBalances() { return __awaiter(this, void 0, void 0, function* () { const accounts = yield this.getAccounts(); const balances = accounts // The original implementation of this connector filtered out zero balances // but we WANT these otherwise Lunch Money will inaccurately reflect the last // non-zero balance for currencies that were completely sold off // .filter((account: { available_balance: { value: string; currency: string } }) => { // return parseFloat(account.available_balance.value) > 0; // }) .map((account) => { return { asset: account.available_balance.currency, amount: account.available_balance.value, }; }); return balances; }); } } exports.CoinbaseClient = CoinbaseClient; //# sourceMappingURL=CoinbaseClient.js.map