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>` covering + * every other family so that any EVM family not listed here (current or future, e.g. + * gasevm/katanaeth/scrolleth/zksyncera/mantle/...) still type-checks — `getFormattedTokensByNetwork` + * already populates a bucket for every family whose base coin carries `CoinFeature.SUPPORTS_ERC20`/ + * `SUPPORTS_ERC721` via `getEthLikeTokens`, so the type should not require hand-enumerating every such + * family either. + */ +export type TokenNetwork = { eth: { tokens: Erc20TokenConfig[]; nfts: EthLikeTokenConfig[]; @@ -221,33 +257,14 @@ export interface TokenNetwork { celo: { tokens: CeloTokenConfig[] }; eos: { tokens: EosTokenConfig[] }; avaxc: { tokens: AvaxcTokenConfig[] }; - polygon: { tokens: EthLikeTokenConfig[] }; - soneium: { tokens: EthLikeTokenConfig[] }; - bsc: { tokens: EthLikeTokenConfig[] }; - arbeth: { tokens: EthLikeTokenConfig[] }; - opeth: { tokens: EthLikeTokenConfig[] }; - baseeth: { tokens: EthLikeTokenConfig[] }; - og: { tokens: EthLikeTokenConfig[] }; - flow: { tokens: EthLikeTokenConfig[] }; - lineaeth: { tokens: EthLikeTokenConfig[] }; - seievm: { tokens: EthLikeTokenConfig[] }; - coredao: { tokens: EthLikeTokenConfig[] }; - world: { tokens: EthLikeTokenConfig[] }; - flr: { tokens: EthLikeTokenConfig[] }; sol: { tokens: SolTokenConfig[] }; hbar: { tokens: HbarTokenConfig[] }; ada: { tokens: AdaTokenConfig[] }; trx: { tokens: TrxTokenConfig[] }; xrp: { tokens: XrpTokenConfig[]; mptTokens: XrpMptTokenConfig[] }; - zketh: { tokens: EthLikeTokenConfig[] }; sui: { tokens: SuiTokenConfig[] }; tao: { tokens: TaoTokenConfig[] }; polyx: { tokens: PolyxTokenConfig[] }; - bera: { tokens: EthLikeTokenConfig[] }; - mon: { tokens: EthLikeTokenConfig[] }; - xdc: { tokens: EthLikeTokenConfig[] }; - hypeevm: { tokens: EthLikeTokenConfig[] }; - ip: { tokens: EthLikeTokenConfig[] }; apt: { tokens: AptTokenConfig[]; nftCollections: AptNFTCollectionConfig[]; @@ -262,7 +279,7 @@ export interface TokenNetwork { ton: { tokens: JettonTokenConfig[] }; tempo: { tokens: Tip20TokenConfig[] }; canton: { tokens: CantonTokenConfig[] }; -} +} & Partial, EvmTokenBucket>>; export interface Tokens { bitcoin: TokenNetwork; diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index f0d558daa6..e185819296 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -14,8 +14,11 @@ import { EosCoin, Erc20Coin, EthereumNetwork, + EVM_TOKEN_FEATURES, + EVM_TOKEN_FEATURES_NON_EIP1559, getFormattedTokenConfigForCoin, getFormattedTokens, + getNetworkFeatures, HederaToken, KeyCurve, Networks, @@ -24,6 +27,7 @@ import { SolCoin, SuiCoin, tokens, + TrimmedAmsTokenConfig, UnderlyingAsset, UtxoCoin, XrpCoin, @@ -1668,6 +1672,80 @@ describe('create token map using config details', () => { }); }); +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..383e65df63 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,29 @@ 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(); + + erc20Families.forEach((family) => { + should(formattedTokens.bitcoin[family]).not.be.undefined(); + should(formattedTokens.bitcoin[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(); + }); +});