diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index 060b3902933..8526f2ffa09 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -31,7 +31,7 @@ import { erc721Token, } from './account'; import { ofcToken } from './ofc'; -import { BaseCoin, CoinFeature, DynamicCoin } from './base'; +import { BaseCoin, CoinFeature, DynamicCoin, UnderlyingAsset } from './base'; import { AmsNetworkConfigMap, AmsTokenConfig, TrimmedAmsTokenConfig } from './tokenConfig'; import { CoinMap } from './map'; import { BaseNetwork, getNetwork, getNetworksMap, NetworkType } from './networks'; @@ -160,20 +160,7 @@ function constructToken(token: AmsTokenConfig): Readonly | undefined { } } - // dynamically add erc20 token initializers for eth like chains to the initializer map - Object.keys(erc20ChainToNameMap).forEach((key) => { - initializerMap[key] = erc20Token; - }); - - Object.keys(erc721ChainToNameMap).forEach((key) => { - initializerMap[key] = erc721Token; - }); - const family = token.family; - const initializer = initializerMap[family] as (...args: unknown[]) => Readonly; - if (!initializer) { - return undefined; - } const commonArgs = [ token.id, @@ -188,28 +175,45 @@ function constructToken(token: AmsTokenConfig): Readonly | undefined { token.primaryKeyCurve, ]; + // A family can support both ERC20 and ERC721 tokens (e.g. hbarevm), so for + // families tracked by the ERC20/ERC721 chain maps the token's kind — not the + // family — picks the initializer and its argument layout. Dispatching on the + // family alone invoked erc721Token with the ERC20 layout for such families, + // handing it `token.asset` in place of `features`, which failed the coin's + // required account-model feature check and caused the token to be skipped. + if (erc20ChainToNameMap[family] || erc721ChainToNameMap[family]) { + const isErc721 = token.asset === UnderlyingAsset.ERC721; + const evmInitializer = (isErc721 ? erc721Token : erc20Token) as unknown as ( + ...args: unknown[] + ) => Readonly; + return isErc721 + ? evmInitializer( + ...commonArgs.slice(0, 3), // id, name, fullName + token.contractAddress, // contractAddress + token.network, + token.features, + token.prefix, + token.suffix, + token.primaryKeyCurve + ) + : evmInitializer( + ...commonArgs.slice(0, 4), // id, name, fullName, decimalPlaces + token.contractAddress || token.tokenAddress, // contractAddress + token.asset, + token.network, + token.features, + token.prefix, + token.suffix, + token.primaryKeyCurve + ); + } + + const initializer = initializerMap[family] as (...args: unknown[]) => Readonly; + if (!initializer) { + return undefined; + } + switch (family) { - case erc20ChainToNameMap[family]: - return initializer( - ...commonArgs.slice(0, 4), // id, name, fullName, decimalPlaces - token.contractAddress || token.tokenAddress, // contractAddress - token.asset, - token.network, - token.features, - token.prefix, - token.suffix, - token.primaryKeyCurve - ); - case erc721ChainToNameMap[family]: - return initializer( - ...commonArgs.slice(0, 3), // id, name, fullName - token.contractAddress, // contractAddress - token.network, - token.features, - token.prefix, - token.suffix, - token.primaryKeyCurve - ); case 'arbeth': case 'avaxc': case 'baseeth': diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index f234b255d26..649e0698c65 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -14,6 +14,7 @@ import { DynamicCoin, EosCoin, Erc20Coin, + EthLikeERC721Token, EthereumNetwork, EVM_TOKEN_FEATURES, EVM_TOKEN_FEATURES_NON_EIP1559, @@ -41,6 +42,7 @@ import { amsTokenWithUnsupportedNetwork, dynamicBaseChainFullConfig, dynamicTestNetwork, + hbarevmTokenConfigs, incorrectAmsTokenConfig, multipleTokensSameFamilyConfig, reducedAmsTokenConfig, @@ -50,6 +52,7 @@ import { import { EthLikeErc20Token } from '../../../sdk-coin-evm/src'; import { AccountCoin, + EthLikeERC20Token, getUnsupportedSolTokenExtensions, ProgramID, solToken, @@ -1874,6 +1877,43 @@ describe('create token map using config details', () => { } } }); + + it('should create hbarevm ERC721 and ERC20 AMS tokens instead of skipping them as malformed', () => { + // Regression for CECHO-2453: the hbarevm family supports both ERC20 and + // ERC721, and dispatching on the family alone invoked erc721Token with the + // ERC20 argument layout, so its `features` parameter received `token.asset` + // and the coin failed the required account-model feature check. Both token + // kinds were then skipped with a 'Skipping malformed token' warning. + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.join(' ')); + }; + try { + const coinMap = createTokenMapUsingTrimmedConfigDetails(hbarevmTokenConfigs); + + const nft = coinMap.get('hbarevm:freshnft'); + (nft instanceof EthLikeERC721Token).should.be.true(); + nft.decimalPlaces.should.eql(0); + nft.features.should.containEql(CoinFeature.ACCOUNT_MODEL); + (nft as unknown as { contractAddress: string }).contractAddress.should.eql( + '0x1111111111111111111111111111111111111111' + ); + + const erc20 = coinMap.get('hbarevm:fresherc20'); + (erc20 instanceof EthLikeERC20Token).should.be.true(); + erc20.decimalPlaces.should.eql(6); + erc20.features.should.containEql(CoinFeature.ACCOUNT_MODEL); + (erc20 as unknown as { contractAddress: string }).contractAddress.should.eql( + '0x2222222222222222222222222222222222222222' + ); + + warnings.filter((message) => message.includes('Skipping malformed token')).should.be.empty(); + } finally { + console.warn = originalWarn; + } + }); + describe('zkSync Era Features', function () { it('should have ZKSYNCERA_FEATURES for zksyncera', function () { const coin = coins.get('zksyncera'); diff --git a/modules/statics/test/unit/resources/amsTokenConfig.ts b/modules/statics/test/unit/resources/amsTokenConfig.ts index c4d4f495237..e79ac2facb8 100644 --- a/modules/statics/test/unit/resources/amsTokenConfig.ts +++ b/modules/statics/test/unit/resources/amsTokenConfig.ts @@ -1,4 +1,5 @@ import { DynamicNetwork } from '../../../src/networks'; +import { TrimmedAmsTokenConfig } from '../../../src/tokenConfig'; export const dynamicTestNetwork = new DynamicNetwork({ name: 'MyDynamicTestnet', @@ -1324,3 +1325,53 @@ export const reducedTokenConfigForAllChains = { }, ], }; + +// Fresh hbarevm-family tokens (names and UUIDs not present in the static coin +// map) covering both token kinds on a family whose base coin supports both +// ERC20 and ERC721. +export const hbarevmTokenConfigs: Record = { + 'hbarevm:freshnft': [ + { + id: 'b1f0a7e2-6c1d-4f3b-9a2e-0d5c4b3a2c11', + fullName: 'Fresh Hedera EVM NFT', + name: 'hbarevm:freshnft', + prefix: '', + suffix: 'HBAREVM:FRESHNFT', + baseUnit: 'wei', + kind: 'crypto', + family: 'hbarevm', + isToken: true, + decimalPlaces: 0, + asset: 'erc721', + primaryKeyCurve: 'secp256k1', + contractAddress: '0x1111111111111111111111111111111111111111', + network: { + name: 'Hedera EVM', + }, + additionalFeatures: [], + excludedFeatures: [], + }, + ], + 'hbarevm:fresherc20': [ + { + id: 'b1f0a7e2-6c1d-4f3b-9a2e-0d5c4b3a2c12', + fullName: 'Fresh Hedera EVM ERC20', + name: 'hbarevm:fresherc20', + prefix: '', + suffix: 'HBAREVM:FRESHERC20', + baseUnit: 'wei', + kind: 'crypto', + family: 'hbarevm', + isToken: true, + decimalPlaces: 6, + asset: 'hbarevm:fresherc20', + primaryKeyCurve: 'secp256k1', + contractAddress: '0x2222222222222222222222222222222222222222', + network: { + name: 'Hedera EVM', + }, + additionalFeatures: [], + excludedFeatures: [], + }, + ], +};