Skip to content
10 changes: 9 additions & 1 deletion modules/statics/src/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string, string> = {};
// Tracks whether each ERC20-supporting family's base coin also supports EIP1559 (e.g. xdc does not).
const erc20FamilySupportsEip1559 = new Map<string, boolean>();

allCoinsAndTokens.forEach((coin) => {
if (
Expand All @@ -62,6 +64,7 @@ allCoinsAndTokens.forEach((coin) => {
!coin.isToken
) {
erc20ChainToNameMap[coin.family] = coin.name;
erc20FamilySupportsEip1559.set(coin.family, coin.features.includes(CoinFeature.EIP1559));
}
});

Expand All @@ -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<BaseCoin> | undefined {
if (!token.isToken) {
try {
Expand Down
3 changes: 3 additions & 0 deletions modules/statics/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 39 additions & 29 deletions modules/statics/src/networkFeatureMapForTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -56,36 +90,12 @@ export const networkFeatureMapForTokens: Partial<Record<CoinFamily, CoinFeature[
celo: AccountCoin.DEFAULT_FEATURES,
eth: AccountCoin.DEFAULT_FEATURES,
eos: AccountCoin.DEFAULT_FEATURES,
gasevm: [
...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,
],
gasevm: EVM_TOKEN_FEATURES,
hbar: AccountCoin.DEFAULT_FEATURES,
opeth: [...AccountCoin.DEFAULT_FEATURES, CoinFeature.EIP1559],
katanaeth: [
...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,
],
katanaeth: EVM_TOKEN_FEATURES,
polygon: POLYGON_TOKEN_FEATURES,
scrolleth: [
...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,
],
scrolleth: EVM_TOKEN_FEATURES,
sol: SOL_TOKEN_FEATURES,
stx: STX_TOKEN_FEATURES,
sui: SUI_TOKEN_FEATURES,
Expand Down
78 changes: 78 additions & 0 deletions modules/statics/test/unit/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import {
EosCoin,
Erc20Coin,
EthereumNetwork,
EVM_TOKEN_FEATURES,
EVM_TOKEN_FEATURES_NON_EIP1559,
getFormattedTokenConfigForCoin,
getFormattedTokens,
getNetworkFeatures,
HederaToken,
KeyCurve,
Networks,
Expand All @@ -24,6 +27,7 @@ import {
SolCoin,
SuiCoin,
tokens,
TrimmedAmsTokenConfig,
UnderlyingAsset,
UtxoCoin,
XrpCoin,
Expand Down Expand Up @@ -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<Erc20Coin> {
for (const [, coin] of coins) {
Expand Down
24 changes: 24 additions & 0 deletions modules/statics/test/unit/resources/amsTokenConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
},
],
};
34 changes: 34 additions & 0 deletions modules/statics/test/unit/tokenConfigTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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();
});
});
Loading