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
14 changes: 14 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add Terminal API integration for market data behind `perpsTerminalApiMarkets` feature flag ([#TBD](https://github.com/MetaMask/core/pull/TBD))
- `TerminalMarketService` fetches structured market metadata from `{terminalApiBaseUrl}/perpetuals` with a 5-minute cache TTL.
- When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid.
- `getMarketDataWithPrices()` enriches provider data with Terminal API metadata (name, keywords, tags, categories).
- `PerpsPlatformDependencies` gains a required `terminalApiBaseUrl: string` field; clients must inject the correct environment URL.
- `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields.
- `transformMarketData()` accepts optional `terminalMetadata` parameter to override static name/category maps per symbol.
- Market search (`getMarketMatchRank`, `rankMarketsByQuery`) now indexes the `keywords` field for richer search results.
- `HYPERLIQUID_ASSET_NAMES` and `HIP3_ASSET_MARKET_TYPES` remain intact as fallback for assets absent from the Terminal API.

### Changed

- Replace unsafe `as` type cast with runtime schema validation (`@metamask/superstruct`) in `TerminalMarketService` ([#TBD](https://github.com/MetaMask/core/pull/TBD))
- Each item in the Terminal API response is now individually validated; items that fail validation are filtered out and logged instead of silently accepted.
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))

## [8.1.0]
Expand Down
1 change: 1 addition & 0 deletions packages/perps-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"@metamask/base-controller": "^9.1.0",
"@metamask/controller-utils": "^12.2.0",
"@metamask/messenger": "^1.2.0",
"@metamask/superstruct": "^3.2.1",
"@metamask/utils": "^11.11.0",
"@nktkas/hyperliquid": "^0.32.2",
"bignumber.js": "^9.1.2",
Expand Down
91 changes: 91 additions & 0 deletions packages/perps-controller/scripts/test-terminal-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/* eslint-disable no-console */
import { TerminalMarketService } from '../src/services/TerminalMarketService';
import type { PerpsPlatformDependencies } from '../src/types';

const ENDPOINTS: Record<string, string> = {
dev: 'https://terminal.dev-api.cx.metamask.io',
uat: 'https://terminal.uat-api.cx.metamask.io',
prd: 'https://terminal.api.cx.metamask.io',
};

const env = process.argv[2] || 'dev';
const baseUrl = ENDPOINTS[env];

if (!baseUrl) {
console.error(`Unknown environment "${env}". Use: dev, uat, or prd`);
process.exit(1);
}

const noop = (): void => undefined;

const deps = {
terminalApiBaseUrl: baseUrl,
logger: {
error: (err: Error, meta: unknown) => {
console.warn('[validation error]', err.message, meta);
},
},
debugLogger: { log: noop },
metrics: { trackEvent: noop },
performance: { now: () => performance.now() },
tracer: { trace: noop, endTrace: noop, setMeasurement: noop, addBreadcrumb: noop },
} as unknown as PerpsPlatformDependencies;

const service = new TerminalMarketService(deps);

async function main(): Promise<void> {
console.log(`Fetching from ${baseUrl} via TerminalMarketService...\n`);

const { markets, metadata } = await service.fetchMarkets();

console.log(`Total markets: ${markets.length}`);
console.log(`Metadata entries: ${metadata.size}\n`);

// Response shape from first market
if (markets.length > 0) {
console.log('=== MarketInfo shape (first item) ===');
console.log(JSON.stringify(markets[0], null, 2));
console.log();
}

// First 5 markets
console.log('=== First 5 MarketInfo items ===');
for (const m of markets.slice(0, 5)) {
console.log(JSON.stringify(m, null, 2));
console.log('---');
}

// All symbols
console.log('\n=== All symbols ===');
console.log(markets.map((m) => m.name).join(', '));

// Metadata enrichment stats
const withName = [...metadata.values()].filter((m) => m.name);
const withKeywords = [...metadata.values()].filter((m) => m.keywords?.length);
const withTags = [...metadata.values()].filter((m) => m.tags?.length);
const withCategories = [...metadata.values()].filter((m) => m.categories?.length);
const marketTypes = [
...new Set([...metadata.values()].map((m) => m.marketType).filter(Boolean)),
];

console.log('\n=== Metadata enrichment stats ===');
console.log(`Entries with name: ${withName.length} / ${metadata.size}`);
console.log(`Entries with keywords: ${withKeywords.length} / ${metadata.size}`);
console.log(`Entries with tags: ${withTags.length} / ${metadata.size}`);
console.log(`Entries with categories: ${withCategories.length} / ${metadata.size}`);
console.log(`Distinct marketTypes: ${marketTypes.join(', ') || '(none)'}`);

// Sample fully-enriched metadata entry
const enrichedEntry = [...metadata.entries()].find(
([, m]) => m.keywords?.length && m.marketType,
);
if (enrichedEntry) {
console.log(`\n=== Sample enriched metadata (${enrichedEntry[0]}) ===`);
console.log(JSON.stringify(enrichedEntry[1], null, 2));
}
}

main().catch((err) => {
console.error('Service error:', (err as Error).message);
process.exit(1);
});
32 changes: 31 additions & 1 deletion packages/perps-controller/src/PerpsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { FeatureFlagConfigurationService } from './services/FeatureFlagConfigura
import { MarketDataService } from './services/MarketDataService';
import { RewardsIntegrationService } from './services/RewardsIntegrationService';
import type { ServiceContext } from './services/ServiceContext';
import { TerminalMarketService } from './services/TerminalMarketService';
import { TradingService } from './services/TradingService';
// PerpsStreamChannelKey removed: using string for channel keys (PerpsStreamManager.pauseChannel takes string)
import {
Expand Down Expand Up @@ -881,6 +882,26 @@ export class PerpsController extends BaseController<
}
}

/**
* Whether the Terminal API should be used as the primary market data source.
* Reads the `perpsTerminalApiMarkets` remote feature flag on every call
* (per-fetch, not cached at init) so toggling the flag takes effect immediately.
*
* @returns True when the flag is enabled.
*/
#isTerminalApiEnabled(): boolean {
try {
const remoteState = this.messenger.call(
'RemoteFeatureFlagController:getState',
);
return (
remoteState.remoteFeatureFlags?.perpsTerminalApiMarkets === true
);
} catch {
return false;
}
}

/**
* Active provider instance for routing operations.
* When activeProvider is 'hyperliquid' or 'myx': points to specific provider directly
Expand Down Expand Up @@ -911,6 +932,8 @@ export class PerpsController extends BaseController<

readonly #marketDataService: MarketDataService;

readonly #terminalMarketService: TerminalMarketService;

readonly #accountService: AccountService;

readonly #eligibilityService: EligibilityService;
Expand Down Expand Up @@ -950,7 +973,11 @@ export class PerpsController extends BaseController<
// Instantiate services with platform dependencies
// Services that need cross-controller access receive the messenger
this.#tradingService = new TradingService(infrastructure);
this.#marketDataService = new MarketDataService(infrastructure);
this.#terminalMarketService = new TerminalMarketService(infrastructure);
this.#marketDataService = new MarketDataService(
infrastructure,
this.#terminalMarketService,
);
this.#accountService = new AccountService(infrastructure, messenger);
this.#eligibilityService = new EligibilityService(infrastructure);
this.#dataLakeService = new DataLakeService(infrastructure, messenger);
Expand Down Expand Up @@ -2932,6 +2959,7 @@ export class PerpsController extends BaseController<
provider,
params,
context: this.#createServiceContext('getMarkets'),
useTerminalApi: this.#isTerminalApiEnabled(),
});
}

Expand Down Expand Up @@ -2962,6 +2990,7 @@ export class PerpsController extends BaseController<
provider,
params,
context: this.#createServiceContext('getMarketDataWithPrices'),
useTerminalApi: this.#isTerminalApiEnabled(),
});
}

Expand All @@ -2970,6 +2999,7 @@ export class PerpsController extends BaseController<
provider,
params,
context: this.#createServiceContext('getMarketDataWithPrices'),
useTerminalApi: this.#isTerminalApiEnabled(),
});
}

Expand Down
16 changes: 16 additions & 0 deletions packages/perps-controller/src/constants/perpsConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,22 @@ export const DATA_LAKE_API_CONFIG = {
OrdersEndpoint: 'https://perps.api.cx.metamask.io/api/v1/orders',
} as const;

/**
* Terminal API configuration
* Endpoints for fetching structured market metadata from the MetaMask Terminal backend.
* The active URL at runtime comes from PerpsPlatformDependencies.terminalApiBaseUrl,
* not these constants (they are reference-only for each environment).
*/
export const TERMINAL_API_CONFIG = {
Endpoints: {
dev: 'https://terminal.dev-api.cx.metamask.io',
uat: 'https://terminal.uat-api.cx.metamask.io',
prd: 'https://terminal.api.cx.metamask.io',
},
PerpetualPath: '/v1/perpetuals',
CacheTtlMs: 5 * 60 * 1000, // 5 minutes
} as const;

/**
* Decimal precision configuration
* Controls maximum decimal places for price and input validation
Expand Down
Loading
Loading