Skip to content

Commit a6a1ca0

Browse files
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
1 parent 4a311fc commit a6a1ca0

4 files changed

Lines changed: 106 additions & 14 deletions

File tree

modules/bitgo/src/bitgo.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,12 @@ export class BitGo extends BitGoAPI {
100100
}
101101

102102
/**
103-
* Register a token in the coin factory
104-
* @param tokenConfig - The token metadata from AMS
103+
* Register a token in the coin factory by name, fetching metadata from AMS if needed.
104+
* @param tokenName - The token name to register
105105
*/
106-
async registerToken(tokenName: string): Promise<void> {
106+
async registerTokenByName(tokenName: string): Promise<void> {
107107
if (!this._useAms) {
108-
throw new Error('registerToken is only supported when useAms is set to true');
108+
throw new Error('registerTokenByName is only supported when useAms is set to true');
109109
}
110110
//do not register a coin/token if it's already registered
111111
if (this._coinFactory.hasCoin(tokenName)) {

modules/bitgo/test/v2/unit/ams/ams.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('Asset metadata service', () => {
3737

3838
it('should be able to register a token in the coin factory', () => {
3939
const tokenName = 'hteth:faketoken';
40-
bitgo.registerToken(tokenName);
40+
bitgo.registerTokenByName(tokenName);
4141
const coin = bitgo.coin(tokenName);
4242
should.exist(coin);
4343
coin.type.should.equal(tokenName);
@@ -58,20 +58,20 @@ describe('Asset metadata service', () => {
5858
should.exist(coin);
5959
});
6060

61-
describe('registerToken', () => {
61+
describe('registerTokenByName', () => {
6262
it('should throw an error when useAms is false', async () => {
6363
const bitgoNoAms = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: false } as BitGoOptions);
6464
bitgoNoAms.initializeTestVars();
6565

6666
await bitgoNoAms
67-
.registerToken('hteth:faketoken')
68-
.should.be.rejectedWith('registerToken is only supported when useAms is set to true');
67+
.registerTokenByName('hteth:faketoken')
68+
.should.be.rejectedWith('registerTokenByName is only supported when useAms is set to true');
6969
});
7070

7171
it('should register a token from statics library if available', async () => {
7272
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: true } as BitGoOptions);
7373
bitgo.initializeTestVars();
74-
await bitgo.registerToken('hteth:bgerchv2');
74+
await bitgo.registerTokenByName('hteth:bgerchv2');
7575
const coin = bitgo.coin('hteth:bgerchv2');
7676
should.exist(coin);
7777
});
@@ -85,7 +85,7 @@ describe('Asset metadata service', () => {
8585
// Setup nocks
8686
nock(microservicesUri).get(`/api/v1/assets/name/${tokenName}`).reply(200, reducedAmsTokenConfig[tokenName][0]);
8787

88-
await bitgo.registerToken(tokenName);
88+
await bitgo.registerTokenByName(tokenName);
8989
const coin = bitgo.coin(tokenName);
9090
should.exist(coin);
9191
});
@@ -96,15 +96,15 @@ describe('Asset metadata service', () => {
9696

9797
const tokenName = 'ofc';
9898

99-
await bitgo.registerToken(tokenName);
99+
await bitgo.registerTokenByName(tokenName);
100100
const coin = bitgo.coin(tokenName);
101101
should.exist(coin);
102102
});
103103

104104
it('should register a EVM coin token from statics library if available', async () => {
105105
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: true } as BitGoOptions);
106106
bitgo.initializeTestVars();
107-
await bitgo.registerToken('tip:usdc');
107+
await bitgo.registerTokenByName('tip:usdc');
108108
const coin = bitgo.coin('tip:usdc');
109109
should.exist(coin);
110110
});
@@ -118,7 +118,7 @@ describe('Asset metadata service', () => {
118118
// Setup nocks for AMS API call
119119
nock(microservicesUri).get(`/api/v1/assets/name/${tokenName}`).reply(200, reducedAmsTokenConfig[tokenName][0]);
120120

121-
await bitgo.registerToken(tokenName);
121+
await bitgo.registerTokenByName(tokenName);
122122
const coin = bitgo.coin(tokenName);
123123
should.exist(coin);
124124
coin.type.should.equal(tokenName);
@@ -141,7 +141,7 @@ describe('Asset metadata service', () => {
141141
// Setup nocks for AMS API call
142142
nock(microservicesUri).get(`/api/v1/assets/name/${tokenName}`).reply(200, reducedAmsTokenConfig[tokenName][0]);
143143

144-
await bitgo.registerToken(tokenName);
144+
await bitgo.registerTokenByName(tokenName);
145145
const coin = bitgo.coin(tokenName);
146146
should.exist(coin);
147147
coin.type.should.equal(tokenName);

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
BitGoBase,
66
BitGoRequest,
77
CoinConstructor,
8+
CoinFactory,
89
common,
910
DecryptKeysOptions,
1011
DecryptOptions,
@@ -1559,6 +1560,21 @@ export class BitGoAPI implements BitGoBase {
15591560
GlobalCoinFactory.register(name, coin);
15601561
}
15611562

1563+
/**
1564+
* Registers a token into GlobalCoinFactory's coin map and constructor map.
1565+
* Enables runtime registration of tokens sourced from AMS (not in statics).
1566+
* Idempotent: re-registering the same token does not throw.
1567+
*
1568+
* @param coinConfig - The static coin/token config object
1569+
* @param coinConstructor - The constructor function for the token class
1570+
*/
1571+
public registerToken(
1572+
coinConfig: Parameters<CoinFactory['registerToken']>[0],
1573+
coinConstructor: CoinConstructor
1574+
): void {
1575+
GlobalCoinFactory.registerToken(coinConfig, coinConstructor);
1576+
}
1577+
15621578
/**
15631579
* Get bitcoin market data
15641580
*

modules/sdk-api/test/unit/bitgoAPI.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ProxyAgent } from 'proxy-agent';
44
import * as sinon from 'sinon';
55
import nock from 'nock';
66
import type { IHmacAuthStrategy } from '@bitgo/sdk-hmac';
7+
import { GlobalCoinFactory, CoinConstructor } from '@bitgo/sdk-core';
78

89
describe('Constructor', function () {
910
describe('cookiesPropagationEnabled argument', function () {
@@ -794,4 +795,79 @@ describe('Constructor', function () {
794795
nock.cleanAll();
795796
});
796797
});
798+
799+
describe('registerToken', function () {
800+
let bitgo: BitGoAPI;
801+
let sandbox: sinon.SinonSandbox;
802+
803+
beforeEach(function () {
804+
bitgo = new BitGoAPI({ env: 'custom', customRootURI: 'https://app.example.local' });
805+
sandbox = sinon.createSandbox();
806+
});
807+
808+
afterEach(function () {
809+
sandbox.restore();
810+
});
811+
812+
it('should call GlobalCoinFactory.registerToken and allow sdk.coin() to resolve the registered token', function () {
813+
const mockCoinInstance = { type: 'test-ams-dynamic-token' } as any;
814+
const mockConstructor = sandbox.stub().returns(mockCoinInstance) as unknown as CoinConstructor;
815+
const mockCoinConfig = { name: 'test-ams-dynamic-token', id: 'test-ams-dynamic-token-id' } as any;
816+
817+
sandbox.stub(GlobalCoinFactory, 'registerToken');
818+
sandbox.stub(GlobalCoinFactory, 'getInstance').callsFake((_bitgo, name) => {
819+
if (name === 'test-ams-dynamic-token') {
820+
return mockConstructor(_bitgo, mockCoinConfig);
821+
}
822+
throw new Error(`Unsupported coin: ${name}`);
823+
});
824+
825+
bitgo.registerToken(mockCoinConfig, mockConstructor);
826+
827+
const coin = bitgo.coin('test-ams-dynamic-token');
828+
coin.should.equal(mockCoinInstance);
829+
(GlobalCoinFactory.registerToken as sinon.SinonStub)
830+
.calledOnceWith(mockCoinConfig, mockConstructor)
831+
.should.be.true();
832+
});
833+
834+
it('should be idempotent — calling registerToken twice for same token does not throw', function () {
835+
const mockCoinConfig = { name: 'test-ams-idempotent-token', id: 'test-ams-idempotent-token-id' } as any;
836+
const mockConstructor = sandbox.stub() as unknown as CoinConstructor;
837+
sandbox.stub(GlobalCoinFactory, 'registerToken');
838+
839+
(() => {
840+
bitgo.registerToken(mockCoinConfig, mockConstructor);
841+
bitgo.registerToken(mockCoinConfig, mockConstructor);
842+
}).should.not.throw();
843+
844+
(GlobalCoinFactory.registerToken as sinon.SinonStub).callCount.should.equal(2);
845+
});
846+
847+
it('should not affect existing statics coins after registerToken calls', function () {
848+
const newCoinConfig = { name: 'test-ams-new-token', id: 'test-ams-new-token-id' } as any;
849+
const newConstructor = sandbox.stub() as unknown as CoinConstructor;
850+
const existingCoinInstance = { type: 'eth' } as any;
851+
const existingConstructor = sandbox.stub().returns(existingCoinInstance) as unknown as CoinConstructor;
852+
853+
const registerTokenStub = sandbox.stub(GlobalCoinFactory, 'registerToken');
854+
sandbox.stub(GlobalCoinFactory, 'getInstance').callsFake((_bitgo, name) => {
855+
if (name === 'eth') {
856+
return existingConstructor(_bitgo, undefined);
857+
}
858+
throw new Error(`Unsupported coin: ${name}`);
859+
});
860+
861+
// Register a new AMS token
862+
bitgo.registerToken(newCoinConfig, newConstructor);
863+
864+
// Existing statics coin should still resolve correctly
865+
const ethCoin = bitgo.coin('eth');
866+
ethCoin.should.equal(existingCoinInstance);
867+
868+
// registerToken was called only once (for the new token, not for eth)
869+
registerTokenStub.calledOnce.should.be.true();
870+
registerTokenStub.calledWith(newCoinConfig, newConstructor).should.be.true();
871+
});
872+
});
797873
});

0 commit comments

Comments
 (0)