@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.
217 lines • 9.07 kB
JavaScript
import axios from 'axios';
import { sign } from 'jsonwebtoken';
import { createPrivateKey, sign as cryptoSign } from 'crypto';
import { URL } from 'url';
const BASE_URL = 'https://api.coinbase.com';
const ENDPOINTS = {
accounts: 'api/v3/brokerage/accounts',
};
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.
*/
export class CoinbaseClient {
config;
/**
* Create the client instance with baseUrl and scopes
*/
constructor(config) {
this.config = config;
}
/**
* Execute a request and handle the response
*/
async request(method, path, query = {}, data = '') {
const url = new 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 = await axios(requestConfig);
}
catch (err) {
// re-throw normal errors
if (!axios.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?.message || 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 = await this.request(method, ENDPOINTS.accounts, { ...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 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`;
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 = createPrivateKey(privateKeyPem);
const signature = cryptoSign(null, Buffer.from(signingInput), key);
return `${signingInput}.${base64urlEncode(signature)}`;
}
/**
* Returns current coinbase accounts
*
* @see https://docs.cdp.coinbase.com/advanced-trade/reference/retailbrokerageapi_getaccounts
*/
async getAccounts() {
const query = this.config.testPagination ? { ...QUERY_PARAMS.accounts, limit: 1 } : QUERY_PARAMS.accounts;
const accounts = await this.request('GET', ENDPOINTS.accounts, query);
if (!accounts) {
throw new Error('Could not fetch accounts data');
}
return accounts;
}
/**
* Returns current coinbase holdings
*/
async getBalances() {
const accounts = await 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;
}
}
export { BASE_URL as coinbaseAPIBaseUrl, ENDPOINTS as coinbaseEndpoints };
//# sourceMappingURL=CoinbaseClient.js.map