diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index b6aed9d175..d5c24593e3 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -35,7 +35,7 @@ import { BaseCoin, CoinFeature, DynamicCoin } from './base'; import { AmsNetworkConfigMap, AmsTokenConfig, TrimmedAmsTokenConfig } from './tokenConfig'; import { CoinMap } from './map'; import { BaseNetwork, getNetwork, getNetworksMap, NetworkType } from './networks'; -import { getNetworkFeatures } from './networkFeatureMapForTokens'; +import { getNetworkFeatures, registerErc20Families } from './networkFeatureMapForTokens'; import { ofcErc20Coins, tOfcErc20Coins } from './coins/ofcErc20Coins'; import { ofcHoodethTokens } from './coins/ofcHoodethTokens'; import { ofcCoins } from './coins/ofcCoins'; @@ -54,6 +54,8 @@ export const coins = CoinMap.fromCoins([ // Build a map of ERC20-supporting chain family names to their mainnet coin names // Maps family -> coin name (e.g., 'ip' -> 'ip') const erc20ChainToNameMap: Record = {}; +// Tracks whether each ERC20-supporting family's base coin also supports EIP1559 (e.g. xdc does not). +const erc20FamilySupportsEip1559 = new Map(); allCoinsAndTokens.forEach((coin) => { if ( @@ -62,6 +64,7 @@ allCoinsAndTokens.forEach((coin) => { !coin.isToken ) { erc20ChainToNameMap[coin.family] = coin.name; + erc20FamilySupportsEip1559.set(coin.family, coin.features.includes(CoinFeature.EIP1559)); } }); @@ -76,6 +79,11 @@ allCoinsAndTokens.forEach((coin) => { } }); +// Backfill networkFeatureMapForTokens with EVM_TOKEN_FEATURES for any family whose base coin +// supports ERC20 (see erc20ChainToNameMap above, built from the same statics data), so AMS token +// onboarding doesn't require hand-maintaining that map for every new EVM family. +registerErc20Families(erc20FamilySupportsEip1559); + export function createToken(token: AmsTokenConfig): Readonly | undefined { if (!token.isToken) { try { diff --git a/modules/statics/src/index.ts b/modules/statics/src/index.ts index 3394ab8a39..3b198d807d 100644 --- a/modules/statics/src/index.ts +++ b/modules/statics/src/index.ts @@ -53,8 +53,11 @@ export { CoinMap } from './map'; export { networkFeatureMapForTokens, registerNetworkFeatures, + registerErc20Families, getNetworkFeatures, getTokenFeatures, + EVM_TOKEN_FEATURES, + EVM_TOKEN_FEATURES_NON_EIP1559, } from './networkFeatureMapForTokens'; export { generateErc20Coin, diff --git a/modules/statics/src/networkFeatureMapForTokens.ts b/modules/statics/src/networkFeatureMapForTokens.ts index 9ea4ad163c..749e12f6aa 100644 --- a/modules/statics/src/networkFeatureMapForTokens.ts +++ b/modules/statics/src/networkFeatureMapForTokens.ts @@ -21,10 +21,44 @@ export function registerNetworkFeatures(family: string, features: CoinFeature[]) dynamicNetworkFeaturesMap.set(family, features); } +/** Default token feature set shared by "plain" EVM-compatible chain families (no bespoke features). */ +export const EVM_TOKEN_FEATURES: CoinFeature[] = [ + ...EVM_FEATURES, + CoinFeature.SHARED_EVM_SIGNING, + CoinFeature.SHARED_EVM_SDK, + CoinFeature.EVM_COMPATIBLE_IMS, + CoinFeature.EVM_COMPATIBLE_UI, + CoinFeature.EVM_COMPATIBLE_WP, + CoinFeature.SUPPORTS_ERC20, +]; + +/** Same as EVM_TOKEN_FEATURES, minus EIP1559, for EVM-compatible families that don't support it (e.g. xdc). */ +export const EVM_TOKEN_FEATURES_NON_EIP1559: CoinFeature[] = EVM_TOKEN_FEATURES.filter( + (feature) => feature !== CoinFeature.EIP1559 +); + +/** + * Populate networkFeatureMapForTokens for every family whose base coin carries + * CoinFeature.SUPPORTS_ERC20 and isn't already explicitly listed below, using EVM_TOKEN_FEATURES + * (or its non-EIP1559 variant, mirroring the base coin's own EIP1559 support). Called once from + * coins.ts (which has access to the full coin map) so this module doesn't need to import it + * directly (that would create a circular import: coins.ts -> networkFeatureMapForTokens.ts -> + * allCoinsAndTokens.ts -> coins/botTokens.ts -> networkFeatureMapForTokens.ts). + */ +export function registerErc20Families(families: Iterable<[family: string, supportsEip1559: boolean]>): void { + for (const [family, supportsEip1559] of families) { + if (!(family in networkFeatureMapForTokens)) { + networkFeatureMapForTokens[family as CoinFamily] = supportsEip1559 + ? EVM_TOKEN_FEATURES + : EVM_TOKEN_FEATURES_NON_EIP1559; + } + } +} + /** * Look up token features for a family. - * Checks static map first, then falls back to dynamic map. - * Returns undefined if the family is not registered in either map. + * Checks the static map first (including entries backfilled by registerErc20Families), then the + * dynamic map. Returns undefined if the family isn't recognized by either. */ export function getNetworkFeatures(family: string): CoinFeature[] | undefined { return networkFeatureMapForTokens[family as CoinFamily] ?? dynamicNetworkFeaturesMap.get(family); @@ -56,36 +90,12 @@ export const networkFeatureMapForTokens: Partial { }); }); +describe('getNetworkFeatures EVM fallback (drift guard)', () => { + it('should return EVM_TOKEN_FEATURES for every mainnet family that supports ERC20 but has no explicit entry in networkFeatureMapForTokens', () => { + const erc20Families = new Set( + allCoinsAndTokens + .filter( + (coin) => + !coin.isToken && + coin.network.type === NetworkType.MAINNET && + coin.features.includes(CoinFeature.SUPPORTS_ERC20) + ) + .map((coin) => coin.family) + ); + + erc20Families.forEach((family) => { + const features = getNetworkFeatures(family); + features?.should.not.be.undefined(); + }); + + // baseeth is the concrete gap this fallback closes: it has no hand-written entry in + // networkFeatureMapForTokens, but its base coin supports ERC20. + getNetworkFeatures('baseeth')?.should.deepEqual(EVM_TOKEN_FEATURES); + }); +}); + +describe('AMS token feature composition for EVM fallback families (drift guard)', () => { + function trimmedConfigFor(family: string, networkName: string): TrimmedAmsTokenConfig { + return { + id: 'f1a6f7d2-5c1e-4b9a-8f0d-1e2a3b4c5d6f', + fullName: `${family} Faketoken`, + name: `t${family}:faketoken`, + prefix: '', + suffix: `T${family.toUpperCase()}:FAKETOKEN`, + baseUnit: 'wei', + kind: 'crypto', + family, + isToken: true, + decimalPlaces: 18, + asset: `t${family}:faketoken`, + primaryKeyCurve: 'secp256k1', + contractAddress: '0x1234567890abcdef1234567890abcdef12345678', + network: { name: networkName }, + additionalFeatures: [CoinFeature.STAKING], + excludedFeatures: [CoinFeature.SHARED_EVM_SDK], + }; + } + + it('should compose EVM_TOKEN_FEATURES + additionalFeatures - excludedFeatures for baseeth (EIP1559-supporting fallback family)', () => { + const token = createTokenUsingTrimmedConfigDetails(trimmedConfigFor('baseeth', 'BaseChainTestnet')); + token?.should.not.be.undefined(); + + const expectedFeatures = new Set(EVM_TOKEN_FEATURES); + expectedFeatures.add(CoinFeature.STAKING); + expectedFeatures.delete(CoinFeature.SHARED_EVM_SDK); + + token?.features.should.have.length(expectedFeatures.size); + expectedFeatures.forEach((feature) => token?.features.should.containEql(feature)); + token?.features.should.not.containEql(CoinFeature.SHARED_EVM_SDK); + }); + + it('should compose EVM_TOKEN_FEATURES_NON_EIP1559 + additionalFeatures - excludedFeatures for prividiumeth (non-EIP1559 fallback family)', () => { + const token = createTokenUsingTrimmedConfigDetails(trimmedConfigFor('prividiumeth', 'Prividium Ethereum Testnet')); + token?.should.not.be.undefined(); + + const expectedFeatures = new Set(EVM_TOKEN_FEATURES_NON_EIP1559); + expectedFeatures.add(CoinFeature.STAKING); + expectedFeatures.delete(CoinFeature.SHARED_EVM_SDK); + + token?.features.should.have.length(expectedFeatures.size); + expectedFeatures.forEach((feature) => token?.features.should.containEql(feature)); + token?.features.should.not.containEql(CoinFeature.EIP1559); + token?.features.should.not.containEql(CoinFeature.SHARED_EVM_SDK); + }); +}); + describe('create token map contract address de-duplication', () => { function firstStaticErc20(): Readonly { for (const [, coin] of coins) { diff --git a/modules/statics/test/unit/resources/amsTokenConfig.ts b/modules/statics/test/unit/resources/amsTokenConfig.ts index 12de379b45..06b5d637e4 100644 --- a/modules/statics/test/unit/resources/amsTokenConfig.ts +++ b/modules/statics/test/unit/resources/amsTokenConfig.ts @@ -1232,4 +1232,28 @@ export const reducedTokenConfigForAllChains = { excludedFeatures: [], }, ], + // 'baseeth' has no explicit entry in networkFeatureMapForTokens; this exercises the + // SUPPORTS_ERC20-derived EVM_TOKEN_FEATURES fallback in getNetworkFeatures(). + 'tbaseeth:faketoken': [ + { + id: 'b3a6f7d2-5c1e-4b9a-8f0d-1e2a3b4c5d6e', + fullName: 'Base Testnet Faketoken', + name: 'tbaseeth:faketoken', + prefix: '', + suffix: 'TBASEETH:FAKETOKEN', + baseUnit: 'wei', + kind: 'crypto', + family: 'baseeth', + isToken: true, + decimalPlaces: 18, + asset: 'tbaseeth:faketoken', + primaryKeyCurve: 'secp256k1', + contractAddress: '0x1234567890abcdef1234567890abcdef12345678', + network: { + name: 'BaseChainTestnet', + }, + additionalFeatures: [], + excludedFeatures: [], + }, + ], }; diff --git a/modules/statics/test/unit/tokenConfigTests.ts b/modules/statics/test/unit/tokenConfigTests.ts index 590629bb34..0edbb572ec 100644 --- a/modules/statics/test/unit/tokenConfigTests.ts +++ b/modules/statics/test/unit/tokenConfigTests.ts @@ -22,6 +22,8 @@ import { BaseContractAddressConfig, } from '../../src/tokenConfig'; import { EthLikeERC20Token } from '../../src/account'; +import { allCoinsAndTokens } from '../../src/allCoinsAndTokens'; +import { NetworkType } from '../../src/networks'; describe('EthLike Token Config Functions', function () { describe('getEthLikeTokenConfig', function () { @@ -762,3 +764,35 @@ describe('EthLike Token Config Functions', function () { }); }); }); + +describe('getFormattedTokensByNetwork EVM family coverage (drift guard)', () => { + it('should emit a bucket for every mainnet family that supports ERC20, even without a hand-written entry', () => { + const erc20Families = new Set( + allCoinsAndTokens + .filter( + (coin) => + !coin.isToken && + coin.network.type === NetworkType.MAINNET && + coin.features.includes(CoinFeature.SUPPORTS_ERC20) + ) + .map((coin) => coin.family) + ); + + const formattedTokens = getFormattedTokens(); + // TokenNetwork only declares explicit keys for known families; cast to a dynamic + // lookup here since this test intentionally probes it with an arbitrary family string. + const bitcoinTokensByFamily = formattedTokens.bitcoin as unknown as Record< + string, + { tokens: unknown[] } | undefined + >; + + erc20Families.forEach((family) => { + should(bitcoinTokensByFamily[family]).not.be.undefined(); + should(bitcoinTokensByFamily[family]?.tokens).be.an.Array(); + }); + + // baseeth is the concrete gap this closes: it has no hand-written entry in + // getFormattedTokensByNetwork's returned object, but its base coin supports ERC20. + should(formattedTokens.bitcoin.baseeth).not.be.undefined(); + }); +});