Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 47 additions & 20 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
CalculateRequestHeadersOptions,
CalculateRequestHmacOptions,
ChangePasswordOptions,
Constants,
DeprecatedVerifyAddressOptions,
EstimateFeeOptions,
ExtendTokenOptions,
Expand Down Expand Up @@ -275,6 +276,10 @@ export class BitGoAPI implements BitGoBase {
this._baseApiUrlV2 = this._baseUrl + '/api/v2';
this._baseApiUrlV3 = this._baseUrl + '/api/v3';
this._token = params.accessToken;

const clientConstants = params.clientConstants;
this._initializeClientConstants(clientConstants);

this._userAgent = params.userAgent || 'BitGoJS-api/' + this.version();
this._reqId = undefined;
this._refreshToken = params.refreshToken;
Expand Down Expand Up @@ -303,17 +308,34 @@ export class BitGoAPI implements BitGoBase {

this._customProxyAgent = params.customProxyAgent;

// capture outer stack so we have useful debug information if fetch constants fails
const e = new Error();
// Only fetch constants from constructor if clientConstants was not provided
if (!clientConstants) {
// capture outer stack so we have useful debug information if fetch constants fails
const e = new Error();

// Kick off first load of constants
this.fetchConstants().catch((err) => {
if (err) {
// make sure an error does not terminate the entire script
console.error('failed to fetch initial client constants from BitGo');
debug(e.stack);
}
});
}
}

// Kick off first load of constants
this.fetchConstants().catch((err) => {
if (err) {
// make sure an error does not terminate the entire script
console.error('failed to fetch initial client constants from BitGo');
debug(e.stack);
/**
* Initialize client constants if provided.
* @param clientConstants - The client constants from params
* @private
*/
private _initializeClientConstants(clientConstants: any): void {
if (clientConstants) {
if (!BitGoAPI._constants) {
BitGoAPI._constants = {};
}
});
BitGoAPI._constants[this.env] = 'constants' in clientConstants ? clientConstants.constants : clientConstants;
}
}

/**
Expand Down Expand Up @@ -531,17 +553,15 @@ export class BitGoAPI implements BitGoBase {
* but are unlikely to change during the lifetime of a BitGo object,
* so they can safely cached.
*/
async fetchConstants(): Promise<any> {
async fetchConstants(): Promise<Constants> {
const env = this.getEnv();

if (!BitGoAPI._constants) {
BitGoAPI._constants = {};
}
if (!BitGoAPI._constantsExpire) {
BitGoAPI._constantsExpire = {};
}

if (BitGoAPI._constants[env] && BitGoAPI._constantsExpire[env] && new Date() < BitGoAPI._constantsExpire[env]) {
// Check if we have cached constants that haven't expired
if (
BitGoAPI._constants &&
BitGoAPI._constants[env] &&
(!BitGoAPI._constantsExpire || !BitGoAPI._constantsExpire[env] || new Date() < BitGoAPI._constantsExpire[env])
) {
return BitGoAPI._constants[env];
}

Expand All @@ -560,9 +580,16 @@ export class BitGoAPI implements BitGoBase {
}
}
const result = await resultPromise;

if (!BitGoAPI._constants) {
BitGoAPI._constants = {};
}
BitGoAPI._constants[env] = result.body.constants;

if (result.body?.ttl && typeof result.body?.ttl === 'number') {
if (!BitGoAPI._constantsExpire) {
BitGoAPI._constantsExpire = {};
}
BitGoAPI._constantsExpire[env] = new Date(new Date().getTime() + (result.body.ttl as number) * 1000);
}

Expand Down Expand Up @@ -2116,8 +2143,8 @@ export class BitGoAPI implements BitGoBase {
}
});

// use defaultConstants as the backup for keys that are not set in this._constants
return _.merge({}, defaultConstants(this.getEnv()), BitGoAPI._constants[this.getEnv()]);
// use defaultConstants as the backup for keys that are not set in BitGoAPI._constants
return _.merge({}, defaultConstants(this.getEnv()), BitGoAPI._constants?.[this.getEnv()] || {});
}

/**
Expand Down
8 changes: 8 additions & 0 deletions modules/sdk-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { EnvironmentName, IRequestTracer, V1Network } from '@bitgo/sdk-core';
import { ECPairInterface } from '@bitgo/utxo-lib';
import { type Agent } from 'http';

export type Constants = Record<string, any>;

const patchedRequestMethods = ['get', 'post', 'put', 'del', 'patch', 'options'] as const;
export type RequestMethods = (typeof patchedRequestMethods)[number];
export type AdditionalHeadersCallback = (
Expand All @@ -23,6 +25,12 @@ export {
export interface BitGoAPIOptions {
accessToken?: string;
authVersion?: 2 | 3;
clientConstants?:
| Record<string, any>
| {
constants: Record<string, any>;
ttl?: number;
};
customBitcoinNetwork?: V1Network;
customRootURI?: string;
customSigningAddress?: string;
Expand Down
99 changes: 99 additions & 0 deletions modules/sdk-api/test/unit/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'should';
import { BitGoAPI } from '../../src/bitgoAPI';
import { ProxyAgent } from 'proxy-agent';
import * as sinon from 'sinon';
import nock from 'nock';

describe('Constructor', function () {
describe('cookiesPropagationEnabled argument', function () {
Expand Down Expand Up @@ -305,4 +306,102 @@ describe('Constructor', function () {
result.should.containDeep(['wallet-id-2', 'wallet-id-4']);
});
});

describe('constants parameter', function () {
it('should allow passing constants via options and expose via fetchConstants', async function () {
const bitgo = new BitGoAPI({
env: 'custom',
customRootURI: 'https://app.example.local',
clientConstants: { maxFeeRate: '123123123123123' },
});

const constants = await bitgo.fetchConstants();
constants.should.have.property('maxFeeRate', '123123123123123');
});

it('should refresh constants when cache has expired', async function () {
const bitgo = new BitGoAPI({
env: 'custom',
customRootURI: 'https://app.example.local',
});

// Set up cached constants with an expired cache
(BitGoAPI as any)._constants = (BitGoAPI as any)._constants || {};
(BitGoAPI as any)._constantsExpire = (BitGoAPI as any)._constantsExpire || {};
(BitGoAPI as any)._constants['custom'] = { maxFeeRate: 'old-value' };
(BitGoAPI as any)._constantsExpire['custom'] = new Date(Date.now() - 1000); // Expired 1 second ago

const scope = nock('https://app.example.local')
.get('/api/v1/client/constants')
.reply(200, {
constants: { maxFeeRate: 'new-value', newConstant: 'added' },
});

const constants = await bitgo.fetchConstants();

// Should return the new constants from the server
constants.should.have.property('maxFeeRate', 'new-value');
constants.should.have.property('newConstant', 'added');

scope.isDone().should.be.true();

nock.cleanAll();
});

it('should use cached constants when cache is still valid', async function () {
const bitgo = new BitGoAPI({
env: 'custom',
customRootURI: 'https://app.example.local',
});

// Set up cached constants with a future expiry
const cachedConstants = { maxFeeRate: 'cached-value', anotherSetting: 'cached-setting' };
(BitGoAPI as any)._constants = (BitGoAPI as any)._constants || {};
(BitGoAPI as any)._constantsExpire = (BitGoAPI as any)._constantsExpire || {};
(BitGoAPI as any)._constants['custom'] = cachedConstants;
(BitGoAPI as any)._constantsExpire['custom'] = new Date(Date.now() + 5 * 60 * 1000); // Valid for 5 more minutes

const scope = nock('https://app.example.local')
.get('/api/v1/client/constants')
.reply(200, { constants: { shouldNotBeUsed: true } });

const constants = await bitgo.fetchConstants();

// Should return the cached constants
constants.should.deepEqual(cachedConstants);

// Verify that no HTTP request was made (since cache was valid)
scope.isDone().should.be.false();

nock.cleanAll();
});

it('should use cached constants when no cache expiry is set', async function () {
const bitgo = new BitGoAPI({
env: 'custom',
customRootURI: 'https://app.example.local',
});

// Set up cached constants with no expiry
const cachedConstants = { maxFeeRate: 'no-expiry-value' };
(BitGoAPI as any)._constants = (BitGoAPI as any)._constants || {};
(BitGoAPI as any)._constantsExpire = (BitGoAPI as any)._constantsExpire || {};
(BitGoAPI as any)._constants['custom'] = cachedConstants;
(BitGoAPI as any)._constantsExpire['custom'] = undefined;

const scope = nock('https://app.example.local')
.get('/api/v1/client/constants')
.reply(200, { constants: { shouldNotBeUsed: true } });

const constants = await bitgo.fetchConstants();

// Should return the cached constants
constants.should.deepEqual(cachedConstants);

// Verify that no HTTP request was made (since no expiry means cache is always valid)
scope.isDone().should.be.false();

nock.cleanAll();
});
});
});
19 changes: 5 additions & 14 deletions modules/sdk-api/test/unit/v1/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,10 @@ nock.disableNetConnect();
const TestBitGo = {
TEST_WALLET1_PASSCODE: 'iVWeATjqLS1jJShrPpETti0b',
};
const originalFetchConstants = BitGoAPI.prototype.fetchConstants;
BitGoAPI.prototype.fetchConstants = function (this: any) {
nock(this._baseUrl).get('/api/v1/client/constants').reply(200, { ttl: 3600, constants: {} });

// force client constants reload
BitGoAPI['_constants'] = undefined;

return originalFetchConstants.apply(this, arguments as any);
};
describe('Wallet Prototype Methods', function () {
const fixtures = getFixtures();

let bitgo = new BitGoAPI({ env: 'test' });
let bitgo = new BitGoAPI({ env: 'test', clientConstants: { constants: {} } });
// bitgo.initializeTestVars();

const userKeypair = {
Expand Down Expand Up @@ -131,7 +122,7 @@ describe('Wallet Prototype Methods', function () {

before(function () {
nock.pendingMocks().should.be.empty();
const prodBitgo = new BitGoAPI({ env: 'prod' });
const prodBitgo = new BitGoAPI({ env: 'prod', clientConstants: { constants: {} } });
// prodBitgo.initializeTestVars();
bgUrl = common.Environments[prodBitgo.getEnv()].uri;
fakeProdWallet = new Wallet(prodBitgo, {
Expand Down Expand Up @@ -364,7 +355,7 @@ describe('Wallet Prototype Methods', function () {
const { address, redeemScript, scriptPubKey } = await getFixture<Record<string, unknown>>(
`${__dirname}/fixtures/sign-transaction.json`
);
const testBitgo = new BitGoAPI({ env: 'test' });
const testBitgo = new BitGoAPI({ env: 'test', clientConstants: { constants: {} } });
const fakeTestV1SafeWallet = new Wallet(testBitgo, {
id: address,
private: { safe: { redeemScript } },
Expand Down Expand Up @@ -426,7 +417,7 @@ describe('Wallet Prototype Methods', function () {
halfSignedTxHex,
fullSignedTxHex,
} = await getFixture<Record<string, unknown>>(`${__dirname}/fixtures/sign-transaction.json`);
const testBitgo = new BitGoAPI({ env: 'test' });
const testBitgo = new BitGoAPI({ env: 'test', clientConstants: { constants: {} } });
const fakeTestV1SafeWallet = new Wallet(testBitgo, {
id: address,
private: { safe: { redeemScript } },
Expand Down Expand Up @@ -745,7 +736,7 @@ describe('Wallet Prototype Methods', function () {
before(function accelerateTxMockedBefore() {
nock.pendingMocks().should.be.empty();

bitgo = new BitGoAPI({ env: 'mock' });
bitgo = new BitGoAPI({ env: 'mock', clientConstants: { constants: {} } });
// bitgo.initializeTestVars();
bitgo.setValidate(false);
wallet = new Wallet(bitgo, { id: walletId, private: { keychains: [userKeypair, backupKeypair, bitgoKey] } });
Expand Down