Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions modules/statics/src/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,107 @@ allCoinsAndTokens.forEach((coin) => {
}
});

const VALID_COIN_FEATURES = new Set<string>(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<BaseCoin> | undefined {
validateAmsTokenConfig(token);

if (!token.isToken) {
try {
return buildDynamicCoin(token);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -557,6 +667,8 @@ export function createTokenMapUsingTrimmedConfigDetails(
export function createTokenUsingTrimmedConfigDetails(
tokenConfig: TrimmedAmsTokenConfig
): Readonly<BaseCoin> | undefined {
validateTrimmedAmsTokenConfig(tokenConfig);

let fullTokenConfig: AmsTokenConfig | undefined;
const networkNameMap = getNetworksMap();
const network = networkNameMap.get(tokenConfig.network.name);
Expand Down
192 changes: 192 additions & 0 deletions modules/statics/test/unit/coins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
tokens,
UnderlyingAsset,
UtxoCoin,
validateAmsTokenConfig,
validateTrimmedAmsTokenConfig,
XrpCoin,
} from '../../src';
import { utxo } from '../../src/utxo';
Expand Down Expand Up @@ -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"/);
});
});
Loading