From 533ac0e271eaa4f2dd812f8768597175bfd82e08 Mon Sep 17 00:00:00 2001 From: ArunBala-Bitgo Date: Mon, 1 Dec 2025 12:27:50 +0530 Subject: [PATCH] feat: onboard thypeevm:usdc Ticket: WIN-8098 --- modules/bitgo/src/v2/coinFactory.ts | 5 +- .../bitgo/test/v2/resources/amsTokenConfig.ts | 22 +++++ modules/bitgo/test/v2/unit/ams/ams.ts | 23 +++++ modules/statics/src/allCoinsAndTokens.ts | 14 +++- modules/statics/src/base.ts | 3 + modules/statics/src/coins.ts | 4 +- modules/statics/src/tokenConfig.ts | 2 +- modules/statics/test/unit/tokenConfigTests.ts | 84 ++++++++++++++++++- 8 files changed, 147 insertions(+), 10 deletions(-) diff --git a/modules/bitgo/src/v2/coinFactory.ts b/modules/bitgo/src/v2/coinFactory.ts index ad00aa679c..671b32caa8 100644 --- a/modules/bitgo/src/v2/coinFactory.ts +++ b/modules/bitgo/src/v2/coinFactory.ts @@ -911,9 +911,9 @@ export const buildEthLikeChainToTestnetMap = (): { const testnetToMainnetMap: Record = {}; const mainnetToTestnetMap: Record = {}; - const enabledEvmCoins = ['ip']; + const enabledEvmCoins = ['ip', 'hypeevm']; - // TODO: remove ip coin here and remove other evm coins from switch block, once changes are tested (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) + // TODO: remove ip and hypeeevm coins here and remove other evm coins from switch block, once changes are tested (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) coins.forEach((coin) => { if (coin.network.type === NetworkType.TESTNET && !coin.isToken && enabledEvmCoins.includes(coin.family)) { if (coins.get(coin.family)?.features.includes(CoinFeature.SUPPORTS_ERC20)) { @@ -926,7 +926,6 @@ export const buildEthLikeChainToTestnetMap = (): { return { mainnetToTestnetMap, testnetToMainnetMap }; }; -// TODO: add IP token here and test changes (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) const { mainnetToTestnetMap, testnetToMainnetMap } = buildEthLikeChainToTestnetMap(); export function getTokenConstructor(tokenConfig: TokenConfig): CoinConstructor | undefined { diff --git a/modules/bitgo/test/v2/resources/amsTokenConfig.ts b/modules/bitgo/test/v2/resources/amsTokenConfig.ts index 86a28306cc..aa9c5dc494 100644 --- a/modules/bitgo/test/v2/resources/amsTokenConfig.ts +++ b/modules/bitgo/test/v2/resources/amsTokenConfig.ts @@ -43,4 +43,26 @@ export const reducedAmsTokenConfig = { contractAddress: '0x1234567890123456789012345678901234567890', }, ], + 'thypeevm:faketoken': [ + { + id: 'b2c3d4e5-f6a7-4890-9bcd-ef012345678a', + fullName: 'Hyperliquid EVM Testnet Faketoken', + name: 'thypeevm:faketoken', + prefix: '', + suffix: 'THYPEEVM:FAKETOKEN', + baseUnit: 'wei', + kind: 'crypto', + family: 'hypeevm', + isToken: true, + additionalFeatures: [], + excludedFeatures: [], + decimalPlaces: 18, + asset: 'thypeevm:faketoken', + network: { + name: 'HyperliquidTestnet', + }, + primaryKeyCurve: 'secp256k1', + contractAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + }, + ], }; diff --git a/modules/bitgo/test/v2/unit/ams/ams.ts b/modules/bitgo/test/v2/unit/ams/ams.ts index 764f91d3ce..6de7233b6f 100644 --- a/modules/bitgo/test/v2/unit/ams/ams.ts +++ b/modules/bitgo/test/v2/unit/ams/ams.ts @@ -131,5 +131,28 @@ describe('Asset metadata service', () => { } staticsCoin.family.should.equal('ip'); }); + + it('should register a thypeevm EVM coin token from AMS', async () => { + const bitgo = TestBitGo.decorate(BitGo, { env: 'mock', microservicesUri, useAms: true } as BitGoOptions); + bitgo.initializeTestVars(); + + const tokenName = 'thypeevm:faketoken'; + + // Setup nocks for AMS API call + nock(microservicesUri).get(`/api/v1/assets/name/${tokenName}`).reply(200, reducedAmsTokenConfig[tokenName][0]); + + await bitgo.registerToken(tokenName); + const coin = bitgo.coin(tokenName); + should.exist(coin); + coin.type.should.equal(tokenName); + const staticsCoin = coin.getConfig(); + staticsCoin.name.should.equal('thypeevm'); + staticsCoin.decimalPlaces.should.equal(18); + // For EVM tokens, contractAddress is available on the statics coin + if ('contractAddress' in staticsCoin && staticsCoin.contractAddress) { + (staticsCoin.contractAddress as string).should.equal('0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'); + } + staticsCoin.family.should.equal('hypeevm'); + }); }); }); diff --git a/modules/statics/src/allCoinsAndTokens.ts b/modules/statics/src/allCoinsAndTokens.ts index e0ee835f43..a6da909a49 100644 --- a/modules/statics/src/allCoinsAndTokens.ts +++ b/modules/statics/src/allCoinsAndTokens.ts @@ -1634,6 +1634,7 @@ export const allCoinsAndTokens = [ CoinFeature.EVM_COMPATIBLE_UI, CoinFeature.EVM_NON_BITGO_RECOVERY, CoinFeature.EVM_UNSIGNED_SWEEP_RECOVERY, + CoinFeature.SUPPORTS_ERC20, ] ), account( @@ -2762,6 +2763,17 @@ export const allCoinsAndTokens = [ Networks.main.mon ), + // hypeeevm testnet tokens + erc20Token( + '2460e83c-e819-42c3-83c9-3974e08a45c8', + 'thypeevm:usdc', + 'Testnet HypeEVM USDC', + 6, + '0x421cdf5e890070c28db0fd8e4bf87deac0cd0ffc', + UnderlyingAsset['thypeevm:usdc'], + Networks.test.hypeevm + ), + // Story testnet tokens erc20Token( 'f9a9c36f-8938-4206-bf0d-5016a861c58f', @@ -2775,7 +2787,7 @@ export const allCoinsAndTokens = [ // Story mainnet tokens erc20Token( - '2460e83c-e819-42c3-83c9-3974e08a45c8', + 'a2460e83-e819-42c3-83c9-3974e08a45c9', 'ip:aria', 'Aria', 18, diff --git a/modules/statics/src/base.ts b/modules/statics/src/base.ts index 146d0afc8f..795e2cb9cf 100644 --- a/modules/statics/src/base.ts +++ b/modules/statics/src/base.ts @@ -2909,6 +2909,9 @@ export enum UnderlyingAsset { 'xdc:srx' = 'xdc:srx', 'xdc:weth' = 'xdc:weth', + // hypeeevm testnet tokens + 'thypeevm:usdc' = 'thypeevm:usdc', + // Story testnet tokens 'tip:usdc' = 'tip:usdc', diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index 36b869b6c5..41d049b602 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -51,8 +51,8 @@ export const coins = CoinMap.fromCoins([ // Maps family -> coin name (e.g., 'ip' -> 'ip') const erc20ChainToNameMap: Record = {}; -// TODO: remove ip coin here and remove other evm coins from switch block, once changes are tested (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) -const enabledEvmCoins = ['ip']; +// TODO: remove ip and hypeeevm coins here and remove other evm coins from switch block, once changes are tested (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) +const enabledEvmCoins = ['ip', 'hypeevm']; allCoinsAndTokens.forEach((coin) => { if ( coin.features.includes(CoinFeature.SUPPORTS_ERC20) && diff --git a/modules/statics/src/tokenConfig.ts b/modules/statics/src/tokenConfig.ts index 5a8cbccc56..d5cde4e6fb 100644 --- a/modules/statics/src/tokenConfig.ts +++ b/modules/statics/src/tokenConfig.ts @@ -1097,7 +1097,7 @@ export const getEthLikeTokens = (network: 'Mainnet' | 'Testnet'): EthLikeTokenMa const networkTokens = getFormattedEthLikeTokenConfig().filter((token) => token.network === network); const ethLikeTokenMap = {} as EthLikeTokenMap; // TODO: add IP token here and test changes (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) - const enabledChains = ['ip'] as string[]; + const enabledChains = ['ip', 'hypeevm'] as string[]; coins.forEach((coin) => { // TODO: remove enabled chains once changes are done (Ticket: https://bitgoinc.atlassian.net/browse/WIN-7835) diff --git a/modules/statics/test/unit/tokenConfigTests.ts b/modules/statics/test/unit/tokenConfigTests.ts index 50486ec48e..0119c8d04d 100644 --- a/modules/statics/test/unit/tokenConfigTests.ts +++ b/modules/statics/test/unit/tokenConfigTests.ts @@ -123,6 +123,64 @@ describe('EthLike Token Config Functions', function () { config.coin.should.equal('ip'); config.type.should.equal('ip:usdc'); }); + + it('should convert an EthLikeERC20Token to EthLikeTokenConfig for hypeevm mainnet', function () { + // Create a mock mainnet EthLikeERC20Token for hypeevm + const mockMainnetToken = new EthLikeERC20Token({ + id: 'a1234567-1234-4234-8234-123456789012', + name: 'hypeevm:testtoken', + fullName: 'HypeEVM Test Token', + network: Networks.main.hypeevm, + contractAddress: '0x9876543210987654321098765432109876543210', + decimalPlaces: 18, + asset: UnderlyingAsset.HYPEEVM, + features: [...AccountCoin.DEFAULT_FEATURES, CoinFeature.EIP1559], + prefix: '', + suffix: 'TESTTOKEN', + primaryKeyCurve: KeyCurve.Secp256k1, + isToken: true, + baseUnit: BaseUnit.ETH, + }); + + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockMainnetToken]))[0]; + + config.should.not.be.undefined(); + config.type.should.equal('hypeevm:testtoken'); + config.coin.should.equal('hypeevm'); + config.network.should.equal('Mainnet'); + config.name.should.equal('HypeEVM Test Token'); + config.tokenContractAddress.should.equal('0x9876543210987654321098765432109876543210'); + config.decimalPlaces.should.equal(18); + }); + + it('should convert an EthLikeERC20Token to EthLikeTokenConfig for thypeevm testnet', function () { + // Create a mock testnet EthLikeERC20Token for thypeevm + const mockTestnetToken = new EthLikeERC20Token({ + id: 'b2234567-2234-4234-9234-223456789012', + name: 'thypeevm:testtoken', + fullName: 'HypeEVM Test Token Testnet', + network: Networks.test.hypeevm, + contractAddress: '0xfedcba0987654321fedcba0987654321fedcba09', + decimalPlaces: 18, + asset: UnderlyingAsset.HYPEEVM, + features: [...AccountCoin.DEFAULT_FEATURES, CoinFeature.EIP1559], + prefix: '', + suffix: 'TESTTOKEN', + primaryKeyCurve: KeyCurve.Secp256k1, + isToken: true, + baseUnit: BaseUnit.ETH, + }); + + const config = getFormattedEthLikeTokenConfig(CoinMap.fromCoins([mockTestnetToken]))[0]; + + config.should.not.be.undefined(); + config.type.should.equal('thypeevm:testtoken'); + config.coin.should.equal('thypeevm'); + config.network.should.equal('Testnet'); + config.name.should.equal('HypeEVM Test Token Testnet'); + config.tokenContractAddress.should.equal('0xfedcba0987654321fedcba0987654321fedcba09'); + config.decimalPlaces.should.equal(18); + }); }); describe('getFormattedEthLikeTokenConfig', function () { @@ -254,11 +312,15 @@ describe('EthLike Token Config Functions', function () { const result = getEthLikeTokens('Mainnet'); result.should.be.an.Object(); - // The function filters by enabledChains which currently includes 'ip' + // The function filters by enabledChains which currently includes 'ip' and 'hypeevm' if (result.ip) { result.ip.should.have.property('tokens'); result.ip.tokens.should.be.an.Array(); } + if (result.hypeevm) { + result.hypeevm.should.have.property('tokens'); + result.hypeevm.tokens.should.be.an.Array(); + } }); it('should filter mainnet tokens correctly', function () { @@ -289,6 +351,11 @@ describe('EthLike Token Config Functions', function () { token.coin.should.equal('tip'); }); } + if (result.hypeevm && result.hypeevm.tokens.length > 0) { + result.hypeevm.tokens.forEach((token) => { + token.coin.should.equal('thypeevm'); + }); + } }); it('should not prepend "t" to coin name for mainnet tokens', function () { @@ -299,6 +366,11 @@ describe('EthLike Token Config Functions', function () { token.coin.should.equal('ip'); }); } + if (result.hypeevm && result.hypeevm.tokens.length > 0) { + result.hypeevm.tokens.forEach((token) => { + token.coin.should.equal('hypeevm'); + }); + } }); it('should only include tokens from chains with SUPPORTS_ERC20 feature', function () { @@ -318,8 +390,8 @@ describe('EthLike Token Config Functions', function () { const mainnetResult = getEthLikeTokens('Mainnet'); const testnetResult = getEthLikeTokens('Testnet'); - // Current implementation only enables 'ip' chain - const enabledChains = ['ip']; + // Current implementation enables 'ip' and 'hypeevm' chains + const enabledChains = ['ip', 'hypeevm']; Object.keys(mainnetResult).forEach((family) => { enabledChains.should.containEql(family); @@ -348,6 +420,12 @@ describe('EthLike Token Config Functions', function () { token.coin.should.equal('ip'); }); } + if (result.hypeevm && result.hypeevm.tokens.length > 0) { + result.hypeevm.tokens.forEach((token) => { + // All tokens in hypeevm group should have coin 'hypeevm' + token.coin.should.equal('hypeevm'); + }); + } }); it('should return tokens with correct structure', function () {