diff --git a/modules/bitgo/src/bitgo.ts b/modules/bitgo/src/bitgo.ts index dd6125f114..ed09c742ca 100644 --- a/modules/bitgo/src/bitgo.ts +++ b/modules/bitgo/src/bitgo.ts @@ -100,12 +100,12 @@ export class BitGo extends BitGoAPI { } /** - * Register a token in the coin factory - * @param tokenConfig - The token metadata from AMS + * Register a token in the coin factory by name, fetching metadata from AMS if needed. + * @param tokenName - The token name to register */ - async registerToken(tokenName: string): Promise { + async registerTokenByName(tokenName: string): Promise { if (!this._useAms) { - throw new Error('registerToken is only supported when useAms is set to true'); + throw new Error('registerTokenByName is only supported when useAms is set to true'); } //do not register a coin/token if it's already registered if (this._coinFactory.hasCoin(tokenName)) { diff --git a/modules/bitgo/test/v2/unit/ams/ams.ts b/modules/bitgo/test/v2/unit/ams/ams.ts index 6de7233b6f..1397480c3c 100644 --- a/modules/bitgo/test/v2/unit/ams/ams.ts +++ b/modules/bitgo/test/v2/unit/ams/ams.ts @@ -37,7 +37,7 @@ describe('Asset metadata service', () => { it('should be able to register a token in the coin factory', () => { const tokenName = 'hteth:faketoken'; - bitgo.registerToken(tokenName); + bitgo.registerTokenByName(tokenName); const coin = bitgo.coin(tokenName); should.exist(coin); coin.type.should.equal(tokenName); @@ -58,20 +58,20 @@ describe('Asset metadata service', () => { should.exist(coin); }); - describe('registerToken', () => { + describe('registerTokenByName', () => { it('should throw an error when useAms is false', async () => { const bitgoNoAms = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: false } as BitGoOptions); bitgoNoAms.initializeTestVars(); await bitgoNoAms - .registerToken('hteth:faketoken') - .should.be.rejectedWith('registerToken is only supported when useAms is set to true'); + .registerTokenByName('hteth:faketoken') + .should.be.rejectedWith('registerTokenByName is only supported when useAms is set to true'); }); it('should register a token from statics library if available', async () => { const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: true } as BitGoOptions); bitgo.initializeTestVars(); - await bitgo.registerToken('hteth:bgerchv2'); + await bitgo.registerTokenByName('hteth:bgerchv2'); const coin = bitgo.coin('hteth:bgerchv2'); should.exist(coin); }); @@ -85,7 +85,7 @@ describe('Asset metadata service', () => { // Setup nocks nock(microservicesUri).get(`/api/v1/assets/name/${tokenName}`).reply(200, reducedAmsTokenConfig[tokenName][0]); - await bitgo.registerToken(tokenName); + await bitgo.registerTokenByName(tokenName); const coin = bitgo.coin(tokenName); should.exist(coin); }); @@ -96,7 +96,7 @@ describe('Asset metadata service', () => { const tokenName = 'ofc'; - await bitgo.registerToken(tokenName); + await bitgo.registerTokenByName(tokenName); const coin = bitgo.coin(tokenName); should.exist(coin); }); @@ -104,7 +104,7 @@ describe('Asset metadata service', () => { it('should register a EVM coin token from statics library if available', async () => { const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: true } as BitGoOptions); bitgo.initializeTestVars(); - await bitgo.registerToken('tip:usdc'); + await bitgo.registerTokenByName('tip:usdc'); const coin = bitgo.coin('tip:usdc'); should.exist(coin); }); @@ -118,7 +118,7 @@ describe('Asset metadata service', () => { // Setup nocks for AMS API call nock(microservicesUri).get(`/api/v1/assets/name/${tokenName}`).reply(200, reducedAmsTokenConfig[tokenName][0]); - await bitgo.registerToken(tokenName); + await bitgo.registerTokenByName(tokenName); const coin = bitgo.coin(tokenName); should.exist(coin); coin.type.should.equal(tokenName); @@ -141,7 +141,7 @@ describe('Asset metadata service', () => { // Setup nocks for AMS API call nock(microservicesUri).get(`/api/v1/assets/name/${tokenName}`).reply(200, reducedAmsTokenConfig[tokenName][0]); - await bitgo.registerToken(tokenName); + await bitgo.registerTokenByName(tokenName); const coin = bitgo.coin(tokenName); should.exist(coin); coin.type.should.equal(tokenName); 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(); + }); + }); });