From a1fbbcf86e4612b0a8dfbc15e8d2def3ae48efa4 Mon Sep 17 00:00:00 2001 From: Manas Ladha Date: Sat, 28 Mar 2026 16:58:17 +0530 Subject: [PATCH] feat(sdk-api): add registerToken method to BitGoAPI Adds a `registerToken(coinConfig, coinConstructor)` method to `BitGoAPI` that delegates to `GlobalCoinFactory.registerToken`. Enables runtime registration of AMS-sourced tokens without requiring statics library updates. Method is idempotent and does not affect existing statics coins. Closes CSHLD-89 / AMS-15 Co-Authored-By: Paperclip --- modules/sdk-api/src/bitgoAPI.ts | 16 ++++++ modules/sdk-api/test/unit/bitgoAPI.ts | 76 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 669373b94a..73ff7568dc 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -5,6 +5,7 @@ import { BitGoBase, BitGoRequest, CoinConstructor, + CoinFactory, common, DecryptKeysOptions, DecryptOptions, @@ -1559,6 +1560,21 @@ export class BitGoAPI implements BitGoBase { GlobalCoinFactory.register(name, coin); } + /** + * Registers a token into GlobalCoinFactory's coin map and constructor map. + * Enables runtime registration of tokens sourced from AMS (not in statics). + * Idempotent: re-registering the same token does not throw. + * + * @param coinConfig - The static coin/token config object + * @param coinConstructor - The constructor function for the token class + */ + public registerToken( + coinConfig: Parameters[0], + coinConstructor: CoinConstructor + ): void { + GlobalCoinFactory.registerToken(coinConfig, coinConstructor); + } + /** * Get bitcoin market data * diff --git a/modules/sdk-api/test/unit/bitgoAPI.ts b/modules/sdk-api/test/unit/bitgoAPI.ts index e2fed6eed2..86868bc787 100644 --- a/modules/sdk-api/test/unit/bitgoAPI.ts +++ b/modules/sdk-api/test/unit/bitgoAPI.ts @@ -4,6 +4,7 @@ import { ProxyAgent } from 'proxy-agent'; import * as sinon from 'sinon'; import nock from 'nock'; import type { IHmacAuthStrategy } from '@bitgo/sdk-hmac'; +import { GlobalCoinFactory, CoinConstructor } from '@bitgo/sdk-core'; describe('Constructor', function () { describe('cookiesPropagationEnabled argument', function () { @@ -794,4 +795,79 @@ describe('Constructor', function () { nock.cleanAll(); }); }); + + describe('registerToken', function () { + let bitgo: BitGoAPI; + let sandbox: sinon.SinonSandbox; + + beforeEach(function () { + bitgo = new BitGoAPI({ env: 'custom', customRootURI: 'https://app.example.local' }); + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('should call GlobalCoinFactory.registerToken and allow sdk.coin() to resolve the registered token', function () { + const mockCoinInstance = { type: 'test-ams-dynamic-token' } as any; + const mockConstructor = sandbox.stub().returns(mockCoinInstance) as unknown as CoinConstructor; + const mockCoinConfig = { name: 'test-ams-dynamic-token', id: 'test-ams-dynamic-token-id' } as any; + + sandbox.stub(GlobalCoinFactory, 'registerToken'); + sandbox.stub(GlobalCoinFactory, 'getInstance').callsFake((_bitgo, name) => { + if (name === 'test-ams-dynamic-token') { + return mockConstructor(_bitgo, mockCoinConfig); + } + throw new Error(`Unsupported coin: ${name}`); + }); + + bitgo.registerToken(mockCoinConfig, mockConstructor); + + const coin = bitgo.coin('test-ams-dynamic-token'); + coin.should.equal(mockCoinInstance); + (GlobalCoinFactory.registerToken as sinon.SinonStub) + .calledOnceWith(mockCoinConfig, mockConstructor) + .should.be.true(); + }); + + it('should be idempotent — calling registerToken twice for same token does not throw', function () { + const mockCoinConfig = { name: 'test-ams-idempotent-token', id: 'test-ams-idempotent-token-id' } as any; + const mockConstructor = sandbox.stub() as unknown as CoinConstructor; + sandbox.stub(GlobalCoinFactory, 'registerToken'); + + (() => { + bitgo.registerToken(mockCoinConfig, mockConstructor); + bitgo.registerToken(mockCoinConfig, mockConstructor); + }).should.not.throw(); + + (GlobalCoinFactory.registerToken as sinon.SinonStub).callCount.should.equal(2); + }); + + it('should not affect existing statics coins after registerToken calls', function () { + const newCoinConfig = { name: 'test-ams-new-token', id: 'test-ams-new-token-id' } as any; + const newConstructor = sandbox.stub() as unknown as CoinConstructor; + const existingCoinInstance = { type: 'eth' } as any; + const existingConstructor = sandbox.stub().returns(existingCoinInstance) as unknown as CoinConstructor; + + const registerTokenStub = sandbox.stub(GlobalCoinFactory, 'registerToken'); + sandbox.stub(GlobalCoinFactory, 'getInstance').callsFake((_bitgo, name) => { + if (name === 'eth') { + return existingConstructor(_bitgo, undefined); + } + throw new Error(`Unsupported coin: ${name}`); + }); + + // Register a new AMS token + bitgo.registerToken(newCoinConfig, newConstructor); + + // Existing statics coin should still resolve correctly + const ethCoin = bitgo.coin('eth'); + ethCoin.should.equal(existingCoinInstance); + + // registerToken was called only once (for the new token, not for eth) + registerTokenStub.calledOnce.should.be.true(); + registerTokenStub.calledWith(newCoinConfig, newConstructor).should.be.true(); + }); + }); });