From 7b8588161a6886668ac1e2669a5e311cd1c0a9c8 Mon Sep 17 00:00:00 2001 From: Daniel Peng Date: Thu, 25 Sep 2025 12:54:46 -0400 Subject: [PATCH] feat(sdk-api): adding support for passing constants in BitGoAPI options to the sdk Ticket: WP-5538 --- modules/sdk-api/src/bitgoAPI.ts | 67 +++++++++++------ modules/sdk-api/src/types.ts | 8 +++ modules/sdk-api/test/unit/bitgoAPI.ts | 99 ++++++++++++++++++++++++++ modules/sdk-api/test/unit/v1/wallet.ts | 19 ++--- 4 files changed, 159 insertions(+), 34 deletions(-) diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 68adfd2149..339f89fc5a 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -55,6 +55,7 @@ import { CalculateRequestHeadersOptions, CalculateRequestHmacOptions, ChangePasswordOptions, + Constants, DeprecatedVerifyAddressOptions, EstimateFeeOptions, ExtendTokenOptions, @@ -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; @@ -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; + } } /** @@ -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 { + async fetchConstants(): Promise { 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]; } @@ -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); } @@ -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()] || {}); } /** diff --git a/modules/sdk-api/src/types.ts b/modules/sdk-api/src/types.ts index d47218a640..8afbec79ea 100644 --- a/modules/sdk-api/src/types.ts +++ b/modules/sdk-api/src/types.ts @@ -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; + const patchedRequestMethods = ['get', 'post', 'put', 'del', 'patch', 'options'] as const; export type RequestMethods = (typeof patchedRequestMethods)[number]; export type AdditionalHeadersCallback = ( @@ -23,6 +25,12 @@ export { export interface BitGoAPIOptions { accessToken?: string; authVersion?: 2 | 3; + clientConstants?: + | Record + | { + constants: Record; + ttl?: number; + }; customBitcoinNetwork?: V1Network; customRootURI?: string; customSigningAddress?: string; diff --git a/modules/sdk-api/test/unit/bitgoAPI.ts b/modules/sdk-api/test/unit/bitgoAPI.ts index 99a7dcfa4d..3ac85320d9 100644 --- a/modules/sdk-api/test/unit/bitgoAPI.ts +++ b/modules/sdk-api/test/unit/bitgoAPI.ts @@ -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 () { @@ -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(); + }); + }); }); diff --git a/modules/sdk-api/test/unit/v1/wallet.ts b/modules/sdk-api/test/unit/v1/wallet.ts index 044b615ff1..92ac1be455 100644 --- a/modules/sdk-api/test/unit/v1/wallet.ts +++ b/modules/sdk-api/test/unit/v1/wallet.ts @@ -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 = { @@ -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, { @@ -364,7 +355,7 @@ describe('Wallet Prototype Methods', function () { const { address, redeemScript, scriptPubKey } = await getFixture>( `${__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 } }, @@ -426,7 +417,7 @@ describe('Wallet Prototype Methods', function () { halfSignedTxHex, fullSignedTxHex, } = await getFixture>(`${__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 } }, @@ -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] } });