Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ec0a27f
feat(perps-controller): integrate Terminal API as primary market data…
michalconsensys Jun 16, 2026
2e6e684
fix(perps-controller): apply params filtering to Terminal API getMark…
michalconsensys Jun 16, 2026
3478db9
fix(perps-controller): fetch terminal metadata in parallel with provi…
michalconsensys Jun 16, 2026
48d5c51
fix(perps-controller): make terminalApiBaseUrl optional on PerpsPlatf…
michalconsensys Jun 16, 2026
122c8ba
fix(perps-controller): validate marketType from Terminal API against …
michalconsensys Jun 16, 2026
ffb4791
fix(perps-controller): add runtime schema validation for Terminal API…
michalconsensys Jun 16, 2026
deb3e34
Revert "fix(perps-controller): apply params filtering to Terminal API…
michalconsensys Jun 16, 2026
a2116df
Revert "fix(perps-controller): fetch terminal metadata in parallel wi…
michalconsensys Jun 16, 2026
e7a60b7
Revert "fix(perps-controller): make terminalApiBaseUrl optional on Pe…
michalconsensys Jun 16, 2026
1f25950
Revert "fix(perps-controller): validate marketType from Terminal API …
michalconsensys Jun 16, 2026
8838cb6
fix(perps-controller): replace restricted `in` operator with hasOwnPr…
michalconsensys Jun 16, 2026
3b647a8
test(perps-controller): verify Terminal API parsing tolerates extra p…
michalconsensys Jun 17, 2026
138a209
test(perps-controller): verify marketType accepts any non-empty strin…
michalconsensys Jun 17, 2026
90d0310
fix(perps-controller): align @metamask/superstruct version to ^3.1.0 …
michalconsensys Jun 17, 2026
1538343
docs(perps-controller): update changelog PR links to #9137
michalconsensys Jun 17, 2026
ac5a959
fix(perps-controller): resolve lint violations in tests and remove de…
michalconsensys Jun 17, 2026
091b501
style(perps-controller): fix prettier formatting
michalconsensys Jun 17, 2026
e4b456c
style(perps-controller): fix oxfmt formatting in marketDataTransform
michalconsensys Jun 17, 2026
e5533b3
refactor(perps-controller): replace remote feature flag with explicit…
michalconsensys Jun 18, 2026
c928c7a
fix(perps-controller): route standalone getMarkets through MarketData…
michalconsensys Jun 18, 2026
4311219
fix(perps-controller): use case-insensitive symbol matching for Termi…
michalconsensys Jun 18, 2026
e98819f
Merge branch 'main' into feat/perps-terminal-api-markets
michalconsensys Jun 18, 2026
a5f381a
style(perps-controller): fix prettier formatting in MarketDataService
michalconsensys Jun 18, 2026
c761cb2
fix(perps-controller): address PR review feedback for Terminal API in…
michalconsensys Jun 18, 2026
98b9873
style(perps-controller): fix prettier formatting in TerminalMarketSer…
michalconsensys Jun 18, 2026
a3242fe
fix(perps-controller): remove hardcoded Terminal API endpoint URLs fr…
michalconsensys Jun 18, 2026
5cf1fd2
fix(perps-controller): address PR review — Terminal path filtering, c…
michalconsensys Jun 18, 2026
66beaa2
Merge branch 'main' into feat/perps-terminal-api-markets
michalconsensys Jun 18, 2026
e0e8fd6
Merge branch 'main' into feat/perps-terminal-api-markets
michalconsensys Jun 18, 2026
9aa0d1d
style(perps-controller): fix prettier formatting after merge
michalconsensys Jun 18, 2026
d903cb0
fix(perps-controller): use top-level type-only import in TerminalMark…
michalconsensys Jun 19, 2026
50a0a6e
Merge branch 'main' into feat/perps-terminal-api-markets
michalconsensys Jun 19, 2026
9d28acb
fix(perps-controller): move PR #9137 changelog entries to Unreleased
michalconsensys Jun 19, 2026
731371e
refactor(perps-controller): move TerminalMarketService into PerpsPlat…
michalconsensys Jun 19, 2026
846a8b4
style(perps-controller): fix prettier formatting in MarketDataService
michalconsensys Jun 19, 2026
56b62ce
Merge branch 'main' into feat/perps-terminal-api-markets
michalconsensys Jun 19, 2026
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
16 changes: 16 additions & 0 deletions packages/perps-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +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, controlled via `useTerminalApi` parameter on `GetMarketsParams` / `GetMarketDataWithPricesParams` ([#9137](https://github.com/MetaMask/core/pull/9137))
- `TerminalMarketService` fetches structured market metadata from the injected `terminalApiUrl` with a 5-minute cache TTL.
- When enabled, `getMarkets()` attempts the Terminal API first; on failure or empty response, falls back silently to HyperLiquid. Terminal results respect the same allowlist/blocklist filtering as the provider path.
- `getMarketDataWithPrices()` enriches provider data with Terminal API metadata (name, keywords, tags, categories).
- `PerpsPlatformDependencies` gains an optional `terminalApiUrl?: string` field and an optional `terminalMarketService?: PerpsTerminalMarketService` field; clients can inject a pre-built service instance or let the controller create one from the URL.
- `PerpsMarketData` gains optional `keywords`, `tags`, and `categories` fields.
- 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` ([#9137](https://github.com/MetaMask/core/pull/9137))
- Each item in the Terminal API response is now individually validated; items that fail validation are filtered out and logged instead of silently accepted.

## [8.2.0]

### Added
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.1.0",
"@metamask/utils": "^11.11.0",
"@nktkas/hyperliquid": "^0.32.2",
"bignumber.js": "^9.1.2",
Expand Down
72 changes: 64 additions & 8 deletions 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 @@ -126,6 +127,9 @@ import {
} from './types/transactionTypes';
import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils';
import { ensureError } from './utils/errorUtils';
import { parseAssetName } from './utils/hyperLiquidAdapter';
import { compileMarketPattern, shouldIncludeMarket } from './utils/marketUtils';
import type { CompiledMarketPattern } from './utils/marketUtils';
import {
hydrateFromDiskSync,
persistMarketEntriesToDisk,
Expand Down Expand Up @@ -950,7 +954,14 @@ 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.#marketDataService = new MarketDataService({
...infrastructure,
terminalMarketService:
infrastructure.terminalMarketService ??
(infrastructure.terminalApiUrl
? new TerminalMarketService(infrastructure)
: undefined),
});
this.#accountService = new AccountService(infrastructure, messenger);
this.#eligibilityService = new EligibilityService(infrastructure);
this.#dataLakeService = new DataLakeService(infrastructure, messenger);
Expand Down Expand Up @@ -1905,6 +1916,50 @@ export class PerpsController extends BaseController<
return this.state as unknown as PerpsControllerState;
}

/**
* Build a filter function that mirrors the provider's allowlist/blocklist
* logic so that Terminal API results are filtered identically.
*
* @returns Filter predicate accepting a market symbol.
*/
#buildMarketAllowedFilter(): (symbol: string) => boolean {
const hip3Enabled = this.#hip3Enabled;
const compiledAllowlist = this.#compilePatternsSafely(
this.#hip3AllowlistMarkets,
);
const compiledBlocklist = this.#compilePatternsSafely(
this.#hip3BlocklistMarkets,
);
return (symbol: string) => {
const { dex } = parseAssetName(symbol);
return shouldIncludeMarket(
symbol,
dex,
hip3Enabled,
compiledAllowlist,
compiledBlocklist,
);
};
}

/**
* Compile market patterns safely, skipping any that fail validation.
*
* @param patterns - Raw pattern strings from config.
* @returns Compiled patterns (invalid entries silently skipped).
*/
#compilePatternsSafely(patterns: string[]): CompiledMarketPattern[] {
const compiled: CompiledMarketPattern[] = [];
for (const pattern of patterns) {
try {
compiled.push({ pattern, matcher: compileMarketPattern(pattern) });
} catch {
// Invalid patterns silently skipped — logged at provider level.
}
}
return compiled;
}

/**
* Create a ServiceContext for dependency injection into services
* Provides all orchestration dependencies (tracing, analytics, state management)
Expand Down Expand Up @@ -2917,21 +2972,24 @@ export class PerpsController extends BaseController<
* @returns Array of available markets matching the filter criteria.
*/
async getMarkets(params?: GetMarketsParams): Promise<MarketInfo[]> {
// For standalone mode, access provider directly without initialization check
// This allows discovery use cases (checking if market exists) without full perps setup
const isMarketAllowed = this.#buildMarketAllowedFilter();
if (params?.standalone) {
// Use activeProviderInstance if available (respects provider abstraction)
// Fallback to cached standalone provider for pre-initialization discovery
const provider =
this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider();
return provider.getMarkets(params);
return this.#marketDataService.getMarkets({
provider,
params,
context: this.#createServiceContext('getMarkets'),
isMarketAllowed,
});
}

const provider = this.getActiveProvider();
return this.#marketDataService.getMarkets({
provider,
params,
context: this.#createServiceContext('getMarkets'),
isMarketAllowed,
});
}

Expand All @@ -2954,8 +3012,6 @@ export class PerpsController extends BaseController<
params?: GetMarketDataWithPricesParams,
): Promise<PerpsMarketData[]> {
if (params?.standalone) {
// Use activeProviderInstance if available (respects provider abstraction)
// Fallback to cached standalone provider for pre-initialization discovery
const provider =
this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider();
return this.#marketDataService.getMarketDataWithPrices({
Expand Down
10 changes: 10 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,16 @@ export const DATA_LAKE_API_CONFIG = {
OrdersEndpoint: 'https://perps.api.cx.metamask.io/api/v1/orders',
} as const;

/**
* Terminal API configuration.
* The full endpoint URL is injected at runtime via
* `PerpsPlatformDependencies.terminalApiUrl` from each client build
* (dev/uat/prd); only cache settings live here.
*/
export const TERMINAL_API_CONFIG = {
CacheTtlMs: 5 * 60 * 1000, // 5 minutes
} as const;

/**
* Decimal precision configuration
* Controls maximum decimal places for price and input validation
Expand Down
2 changes: 2 additions & 0 deletions packages/perps-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ export type {
PerpsInternalAccount,
PerpsRemoteFeatureFlagState,
PerpsPlatformDependencies,
PerpsTerminalMarketService,
TerminalAssetMetadata,
PerpsCacheType,
InvalidateCacheParams,
PerpsCacheInvalidator,
Expand Down
132 changes: 127 additions & 5 deletions packages/perps-controller/src/services/MarketDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
AssetRoute,
PerpsPlatformDependencies,
PerpsMarketData,
TerminalAssetMetadata,
} from '../types';
import type { CandleData } from '../types/perps-types';
import { coalescePerpsRestRequest } from '../utils/coalescePerpsRestRequest';
Expand Down Expand Up @@ -711,20 +712,28 @@ export class MarketDataService {

/**
* Get available markets
* Handles full orchestration: tracing, error logging, state management, and provider delegation
* Handles full orchestration: tracing, error logging, state management, and provider delegation.
* When `useTerminalApi` is true, attempts the Terminal API first; on failure or empty
* response, falls back silently to the HyperLiquid provider path.
*
* @param options - The configuration options.
* @param options.provider - The perps provider instance.
* @param options.params - The operation parameters.
* @param options.context - The service context for dependencies.
* @param options.isMarketAllowed - Optional filter callback applied to
* Terminal API results so that allowlist/blocklist rules from the provider
* layer are enforced even when the provider is bypassed. Skipped when
* `params.skipFilters` is true.
* @returns The result of the operation.
*/
async getMarkets(options: {
provider: PerpsProvider;
params?: GetMarketsParams;
context: ServiceContext;
isMarketAllowed?: (symbol: string) => boolean;
}): Promise<MarketInfo[]> {
const { provider, params, context } = options;
const { provider, params, context, isMarketAllowed } = options;
const useTerminalApi = params?.useTerminalApi;
const traceId = uuidv4();
let traceData: { success: boolean; error?: string } | undefined;

Expand All @@ -740,12 +749,65 @@ export class MarketDataService {
symbolCount: String(params.symbols.length),
}),
...(params?.dex !== undefined && { dex: params.dex }),
...(useTerminalApi !== undefined && {
useTerminalApi: String(useTerminalApi),
}),
},
});

// Terminal API path: attempt first when flag is enabled
if (useTerminalApi && this.#deps.terminalMarketService) {
try {
const { markets: terminalMarkets } =
await this.#deps.terminalMarketService.fetchMarkets();
if (terminalMarkets.length > 0) {
let filtered = terminalMarkets;

// Apply allowlist/blocklist filtering (same as provider path)
if (!params?.skipFilters && isMarketAllowed) {
filtered = filtered.filter((market) =>
isMarketAllowed(market.name),
);
}

// Filter by specific DEX when requested
if (params?.dex !== undefined) {
const dexPrefix = params.dex ? `${params.dex}:` : '';
filtered = filtered.filter((market) =>
dexPrefix
? market.name.startsWith(dexPrefix)
: !market.name.includes(':'),
);
}

// Filter by symbols when requested
if (params?.symbols?.length) {
filtered = filtered.filter((market) =>
(params.symbols as string[]).some(
(sym) => market.name.toLowerCase() === sym.toLowerCase(),
),
);
}

if (context.stateManager) {
context.stateManager.update((state) => {
state.lastError = null;
state.lastUpdateTimestamp = Date.now();
});
}
traceData = { success: true };
return filtered;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMarkets() currently falls back only when the Terminal request fails or returns an empty response. The PR description also says static fallback data remains available for assets absent from Terminal, but when Terminal returns a non-empty list that lacks the requested symbol or DEX, the filtered result is returned as [] and the HyperLiquid fallback is skipped.

Please fall back to the provider when a constrained Terminal query (symbols or dex) produces no matches, so Terminal partial coverage does not make provider-backed markets undiscoverable

}
} catch (terminalError) {
this.#deps.terminalMarketService.logError(
terminalError,
'getMarkets',
);
}
}

const markets = await provider.getMarkets(params);

// Clear any previous errors on successful call (if stateManager is provided)
if (context.stateManager) {
context.stateManager.update((state) => {
state.lastError = null;
Expand Down Expand Up @@ -779,7 +841,6 @@ export class MarketDataService {
},
);

// Update error state (if stateManager is provided)
if (context.stateManager) {
context.stateManager.update((state) => {
state.lastError = errorMessage;
Expand All @@ -805,6 +866,8 @@ export class MarketDataService {
/**
* Get market data with prices (includes price, volume, 24h change).
* Applies optional category filtering, sorting, and limit after fetching.
* When `useTerminalApi` is true, enriches provider data with Terminal API metadata
* (name, keywords, tags, categories). On Terminal API failure, falls back silently.
*
* @param options - The configuration options.
* @param options.provider - The perps provider instance.
Expand All @@ -818,6 +881,7 @@ export class MarketDataService {
context: ServiceContext;
}): Promise<PerpsMarketData[]> {
const { provider, params, context } = options;
const useTerminalApi = params?.useTerminalApi;
const traceId = uuidv4();
let traceData: { success: boolean; error?: string } | undefined;

Expand All @@ -832,11 +896,38 @@ export class MarketDataService {
...(params?.categories && {
categoryCount: String(params.categories.length),
}),
...(useTerminalApi !== undefined && {
useTerminalApi: String(useTerminalApi),
}),
},
});

// Fetch Terminal API metadata before provider data when enabled.
// Terminal metadata enriches the provider result (name, keywords, tags,
// categories) but never replaces live pricing / funding data.
let terminalMetadata: Map<string, TerminalAssetMetadata> | undefined;
if (useTerminalApi && this.#deps.terminalMarketService) {
try {
const result = await this.#deps.terminalMarketService.fetchMarkets();
if (result.metadata.size > 0) {
terminalMetadata = result.metadata;
}
} catch (terminalError) {
this.#deps.terminalMarketService.logError(
terminalError,
'getMarketDataWithPrices',
);
}
}

const markets = await provider.getMarketDataWithPrices();
const filtered = applyMarketFilters(markets, params);

// Enrich with terminal metadata when available
const enriched = terminalMetadata
? this.#enrichWithTerminalMetadata(markets, terminalMetadata)
: markets;

const filtered = applyMarketFilters(enriched, params);

traceData = { success: true };
return filtered;
Expand Down Expand Up @@ -1252,4 +1343,35 @@ export class MarketDataService {
const { provider, address } = options;
return provider.getBlockExplorerUrl(address);
}

/**
* Merge Terminal API metadata into provider-sourced PerpsMarketData.
* For each market, if the terminal metadata map contains an entry for its
* symbol, override name/marketType and attach keywords/tags/categories.
* Unmatched markets keep their provider-sourced values.
*
* @param markets - Markets from the provider.
* @param metadata - Per-symbol metadata from the Terminal API.
* @returns Enriched market data array.
*/
#enrichWithTerminalMetadata(
markets: PerpsMarketData[],
metadata: Map<string, TerminalAssetMetadata>,
): PerpsMarketData[] {
return markets.map((market) => {
const meta = metadata.get(market.symbol);
if (!meta) {
return market;
}

return {
...market,
name: meta.name,
...(meta.marketType !== undefined && { marketType: meta.marketType }),
...(meta.keywords !== undefined && { keywords: meta.keywords }),
...(meta.tags !== undefined && { tags: meta.tags }),
...(meta.categories !== undefined && { categories: meta.categories }),
};
});
}
}
Loading
Loading