From f98d813e349f60cc60fad15f2cca9380e8717730 Mon Sep 17 00:00:00 2001 From: Neha Kumari Date: Wed, 3 Jun 2026 06:08:34 +0000 Subject: [PATCH] feat(statics): add runtime validation for AMS token metadata Add validateAmsTokenConfig and validateTrimmedAmsTokenConfig functions that verify required fields, decimalPlaces (finite non-negative integer), and feature arrays (only known CoinFeature values) before AMS token metadata is used to construct coin objects. - decimalPlaces: guards against NaN, Infinity, negatives, non-integers that would corrupt Math.pow(10, decimalPlaces) in getBaseFactor() - features / additionalFeatures / excludedFeatures: guards against unknown string values that could bypass contract address checks - Required string fields: guards against missing or empty values Validation is called in createToken(), createTokenUsingTrimmedConfigDetails(), and createTokenMapUsingTrimmedConfigDetails() (where invalid tokens are skipped with a console.warn rather than throwing). 21 new unit tests cover valid configs, each invalid case, and the integration with createToken / createTokenMapUsingTrimmedConfigDetails. Ticket: CGD-748 Session-Id: 8c327ce7-3f5a-40aa-9516-1aa73939d1e1 Task-Id: 5aa45fd0-f207-4868-8327-1d13d7a1e234 --- modules/statics/src/coins.ts | 112 +++++++++++++++++ modules/statics/test/unit/coins.ts | 192 +++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) diff --git a/modules/statics/src/coins.ts b/modules/statics/src/coins.ts index c7f49e2d30..f813dde0d8 100644 --- a/modules/statics/src/coins.ts +++ b/modules/statics/src/coins.ts @@ -73,7 +73,107 @@ allCoinsAndTokens.forEach((coin) => { } }); +const VALID_COIN_FEATURES = new Set(Object.values(CoinFeature)); + +/** + * Validates AMS token metadata before it is used to construct coin objects. + * + * Throws an error describing the first invalid field encountered. Callers that + * want to skip malformed tokens rather than throw should catch and log. + * + * Specific guards: + * - Required string fields must be non-empty strings. + * - `decimalPlaces` must be a finite, non-negative integer so that + * downstream `Math.pow(10, decimalPlaces)` cannot produce NaN, Infinity, + * or corrupt amounts via a negative exponent. + * - `features` / `additionalFeatures`, when present, must contain only + * recognised CoinFeature values, preventing injection of values such as + * `"genericToken"` that bypass contract-address format checks. + */ +export function validateAmsTokenConfig(token: AmsTokenConfig): void { + const requiredStrings: (keyof AmsTokenConfig)[] = ['id', 'name', 'fullName', 'family', 'asset']; + for (const field of requiredStrings) { + const value = token[field]; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`AMS token config has invalid required field "${field}": ${JSON.stringify(value)}`); + } + } + + if (typeof token.isToken !== 'boolean') { + throw new Error(`AMS token config has invalid required field "isToken": ${JSON.stringify(token.isToken)}`); + } + + const dp = token.decimalPlaces; + if (typeof dp !== 'number' || !Number.isFinite(dp) || !Number.isInteger(dp) || dp < 0) { + throw new Error( + `AMS token config has invalid "decimalPlaces": ${JSON.stringify(dp)}. ` + + `Must be a non-negative integer.` + ); + } + + const features = token.features; + if (features !== undefined) { + if (!Array.isArray(features)) { + throw new Error( + `AMS token config has invalid field "features": expected string array, got ${typeof features}` + ); + } + for (const feature of features) { + if (!VALID_COIN_FEATURES.has(feature)) { + throw new Error(`AMS token config contains unrecognised feature "${feature}" in field "features"`); + } + } + } +} + +/** + * Validates a TrimmedAmsTokenConfig before features are merged and a full + * AmsTokenConfig is assembled. Focuses on the fields unique to the trimmed + * format (additionalFeatures, excludedFeatures) and the subset of base fields + * that are always present regardless of the trim step. + */ +export function validateTrimmedAmsTokenConfig(token: TrimmedAmsTokenConfig): void { + const requiredStrings: (keyof TrimmedAmsTokenConfig)[] = ['id', 'name', 'fullName', 'family', 'asset']; + for (const field of requiredStrings) { + const value = token[field]; + if (typeof value !== 'string' || (value as string).trim() === '') { + throw new Error(`AMS token config has invalid required field "${field}": ${JSON.stringify(value)}`); + } + } + + if (typeof token.isToken !== 'boolean') { + throw new Error(`AMS token config has invalid required field "isToken": ${JSON.stringify(token.isToken)}`); + } + + const dp = token.decimalPlaces; + if (typeof dp !== 'number' || !Number.isFinite(dp) || !Number.isInteger(dp) || dp < 0) { + throw new Error( + `AMS token config has invalid "decimalPlaces": ${JSON.stringify(dp)}. ` + + `Must be a non-negative integer.` + ); + } + + for (const featureField of ['additionalFeatures', 'excludedFeatures'] as const) { + const featureList = token[featureField]; + if (featureList === undefined) continue; + if (!Array.isArray(featureList)) { + throw new Error( + `AMS token config has invalid field "${featureField}": expected string array, got ${typeof featureList}` + ); + } + for (const feature of featureList) { + if (!VALID_COIN_FEATURES.has(feature)) { + throw new Error( + `AMS token config contains unrecognised feature "${feature}" in field "${featureField}"` + ); + } + } + } +} + export function createToken(token: AmsTokenConfig): Readonly | undefined { + validateAmsTokenConfig(token); + if (!token.isToken) { try { return buildDynamicCoin(token); @@ -530,6 +630,16 @@ export function createTokenMapUsingTrimmedConfigDetails( for (const tokenConfigs of Object.values(reducedTokenConfigMap)) { if (!tokenConfigs.length) continue; const tokenConfig = tokenConfigs[0]; + + try { + validateTrimmedAmsTokenConfig(tokenConfig); + } catch (e) { + console.warn( + `Skipping malformed token: name="${tokenConfig.name}" id="${tokenConfig.id}" error=${(e as Error).message}` + ); + continue; + } + const network = networkNameMap.get(tokenConfig.network.name); if (isCoinPresentInCoinMap({ ...tokenConfig })) continue; @@ -557,6 +667,8 @@ export function createTokenMapUsingTrimmedConfigDetails( export function createTokenUsingTrimmedConfigDetails( tokenConfig: TrimmedAmsTokenConfig ): Readonly | undefined { + validateTrimmedAmsTokenConfig(tokenConfig); + let fullTokenConfig: AmsTokenConfig | undefined; const networkNameMap = getNetworksMap(); const network = networkNameMap.get(tokenConfig.network.name); diff --git a/modules/statics/test/unit/coins.ts b/modules/statics/test/unit/coins.ts index 8ebbda4fa1..99dcd20b5b 100644 --- a/modules/statics/test/unit/coins.ts +++ b/modules/statics/test/unit/coins.ts @@ -25,6 +25,8 @@ import { tokens, UnderlyingAsset, UtxoCoin, + validateAmsTokenConfig, + validateTrimmedAmsTokenConfig, XrpCoin, } from '../../src'; import { utxo } from '../../src/utxo'; @@ -1524,3 +1526,193 @@ describe('DynamicCoin and dynamic base chain support', function () { }); }); }); + +describe('validateAmsTokenConfig', function () { + const validBase = { + id: 'test-id-001', + name: 'eth:testtoken', + fullName: 'Test Token', + family: 'eth', + isToken: true, + decimalPlaces: 18, + asset: 'eth:testtoken', + features: [CoinFeature.ACCOUNT_MODEL, CoinFeature.REQUIRES_BIG_NUMBER], + }; + + it('should not throw for a valid config', function () { + (() => validateAmsTokenConfig(validBase as any)).should.not.throw(); + }); + + it('should throw when a required string field is missing', function () { + (() => validateAmsTokenConfig({ ...validBase, id: '' } as any)).should.throw( + /invalid required field "id"/ + ); + (() => validateAmsTokenConfig({ ...validBase, name: undefined } as any)).should.throw( + /invalid required field "name"/ + ); + (() => validateAmsTokenConfig({ ...validBase, fullName: ' ' } as any)).should.throw( + /invalid required field "fullName"/ + ); + (() => validateAmsTokenConfig({ ...validBase, family: 42 } as any)).should.throw( + /invalid required field "family"/ + ); + (() => validateAmsTokenConfig({ ...validBase, asset: '' } as any)).should.throw( + /invalid required field "asset"/ + ); + }); + + it('should throw when isToken is not a boolean', function () { + (() => validateAmsTokenConfig({ ...validBase, isToken: 'true' } as any)).should.throw( + /invalid required field "isToken"/ + ); + }); + + it('should throw when decimalPlaces is NaN', function () { + (() => validateAmsTokenConfig({ ...validBase, decimalPlaces: NaN } as any)).should.throw( + /invalid "decimalPlaces"/ + ); + }); + + it('should throw when decimalPlaces is Infinity', function () { + (() => validateAmsTokenConfig({ ...validBase, decimalPlaces: Infinity } as any)).should.throw( + /invalid "decimalPlaces"/ + ); + }); + + it('should throw when decimalPlaces is negative', function () { + (() => validateAmsTokenConfig({ ...validBase, decimalPlaces: -1 } as any)).should.throw( + /invalid "decimalPlaces"/ + ); + }); + + it('should throw when decimalPlaces is a non-integer', function () { + (() => validateAmsTokenConfig({ ...validBase, decimalPlaces: 6.5 } as any)).should.throw( + /invalid "decimalPlaces"/ + ); + }); + + it('should throw when decimalPlaces is a string', function () { + (() => validateAmsTokenConfig({ ...validBase, decimalPlaces: '18' } as any)).should.throw( + /invalid "decimalPlaces"/ + ); + }); + + it('should allow decimalPlaces of zero', function () { + (() => validateAmsTokenConfig({ ...validBase, decimalPlaces: 0 } as any)).should.not.throw(); + }); + + it('should throw when features contains an unrecognised value', function () { + (() => + validateAmsTokenConfig({ + ...validBase, + features: [CoinFeature.ACCOUNT_MODEL, 'totally-fake-feature'], + } as any) + ).should.throw(/unrecognised feature "totally-fake-feature"/); + }); + + it('should throw when features is not an array', function () { + (() => validateAmsTokenConfig({ ...validBase, features: 'account-model' } as any)).should.throw( + /invalid field "features"/ + ); + }); + + it('should accept a config with no optional feature fields', function () { + const { features: _f, ...noFeatures } = validBase; + (() => validateAmsTokenConfig(noFeatures as any)).should.not.throw(); + }); + + it('should throw when createToken is given a config with invalid decimalPlaces', function () { + (() => createToken({ ...validBase, decimalPlaces: -5 } as any)).should.throw(/invalid "decimalPlaces"/); + }); + + it('should throw when createToken is given a config with an unknown feature', function () { + (() => + createToken({ + ...validBase, + network: coins.get('eth').network, + features: [CoinFeature.ACCOUNT_MODEL, 'injected-bad-feature'], + contractAddress: '0x' + 'a'.repeat(40), + } as any) + ).should.throw(/unrecognised feature/); + }); +}); + +describe('validateTrimmedAmsTokenConfig', function () { + const validTrimmedBase = { + id: 'trimmed-id-001', + name: 'eth:trimtoken', + fullName: 'Trimmed Token', + family: 'eth', + isToken: true, + decimalPlaces: 6, + asset: 'eth:trimtoken', + network: { name: 'Ethereum Mainnet' }, + }; + + it('should not throw for a valid trimmed config', function () { + (() => validateTrimmedAmsTokenConfig(validTrimmedBase as any)).should.not.throw(); + }); + + it('should throw when decimalPlaces is invalid', function () { + (() => validateTrimmedAmsTokenConfig({ ...validTrimmedBase, decimalPlaces: NaN } as any)).should.throw( + /invalid "decimalPlaces"/ + ); + (() => validateTrimmedAmsTokenConfig({ ...validTrimmedBase, decimalPlaces: -2 } as any)).should.throw( + /invalid "decimalPlaces"/ + ); + }); + + it('should throw when additionalFeatures contains an unknown value', function () { + (() => + validateTrimmedAmsTokenConfig({ + ...validTrimmedBase, + additionalFeatures: ['fake-feature-xyz'], + } as any) + ).should.throw(/unrecognised feature "fake-feature-xyz"/); + }); + + it('should throw when excludedFeatures contains an unknown value', function () { + (() => + validateTrimmedAmsTokenConfig({ + ...validTrimmedBase, + excludedFeatures: ['not-valid'], + } as any) + ).should.throw(/unrecognised feature "not-valid"/); + }); + + it('should accept valid additionalFeatures and excludedFeatures', function () { + (() => + validateTrimmedAmsTokenConfig({ + ...validTrimmedBase, + additionalFeatures: [CoinFeature.BULK_TRANSACTION], + excludedFeatures: [CoinFeature.STAKING], + } as any) + ).should.not.throw(); + }); + + it('should skip invalid tokens in createTokenMapUsingTrimmedConfigDetails', function () { + const badConfig = { + 'eth:badtoken': [ + { + ...validTrimmedBase, + name: 'eth:badtoken', + id: 'bad-id-001', + decimalPlaces: NaN, + network: { name: 'Ethereum Mainnet' }, + }, + ], + }; + (() => createTokenMapUsingTrimmedConfigDetails(badConfig as any)).should.not.throw(); + const result = createTokenMapUsingTrimmedConfigDetails(badConfig as any); + result.has('eth:badtoken').should.be.false(); + }); + + it('should throw in createTokenUsingTrimmedConfigDetails when config is invalid', function () { + (() => + createTokenUsingTrimmedConfigDetails({ + ...validTrimmedBase, + decimalPlaces: -1, + } as any) + ).should.throw(/invalid "decimalPlaces"/); + }); +});