Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add optional `trackMetaMetricsEvent` callback to measure and report first init/fetch historical time (duration in ms) to MetaMetrics when the initial asset fetch completes after unlock or app open ([#7871](https://github.com/MetaMask/core/pull/7871))
- Add `AccountsApiDataSourceConfig` and `PriceDataSourceConfig` types; add `accountsApiDataSourceConfig` and `priceDataSourceConfig` options to `AssetsControllerOptions` for per-data-source configuration (pollInterval, tokenDetectionEnabled, etc.). When `tokenDetectionEnabled` is false, `AccountsApiDataSource` only returns balances for tokens already in state and does not add new tokens ([#7926](https://github.com/MetaMask/core/pull/7926))
- Add `useExternalService` option to `TokenDetector`, `TokenDetectionOptions`, `RpcDataSourceConfig`, and `RpcDataSourceOptions`. Token detection runs only when both `tokenDetectionEnabled` and `useExternalService` are true and stops when either is false ([#7924](https://github.com/MetaMask/core/pull/7924))
- Add basic functionality toggle: `isBasicFunctionality` (getter `() => boolean`); no value is stored in the controller. When the getter returns true (matches UI "Basic functionality" ON), token and price APIs are used; when false, only RPC is used. Optional `subscribeToBasicFunctionalityChange(onChange)` lets the consumer register for toggle changes (e.g. extension subscribes to PreferencesController:stateChange, mobile uses its own mechanism); may return an unsubscribe function for controller destroy ([#7904](https://github.com/MetaMask/core/pull/7904))
Expand Down
66 changes: 62 additions & 4 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getDefaultAssetsControllerState,
} from './AssetsController';
import type {
AssetsControllerFirstInitFetchMetaMetricsPayload,
AssetsControllerMessenger,
AssetsControllerState,
} from './AssetsController';
Expand Down Expand Up @@ -56,6 +57,12 @@ function createMockInternalAccount(
type WithControllerOptions = {
state?: Partial<AssetsControllerState>;
isBasicFunctionality?: () => boolean;
/** Extra options passed to AssetsController constructor (e.g. trackMetaMetricsEvent). */
controllerOptions?: Partial<{
trackMetaMetricsEvent: (
payload: AssetsControllerFirstInitFetchMetaMetricsPayload,
) => void;
}>;
};

type WithControllerCallback<ReturnValue> = ({
Expand All @@ -78,10 +85,15 @@ async function withController<ReturnValue>(
| [WithControllerOptions, WithControllerCallback<ReturnValue>]
| [WithControllerCallback<ReturnValue>]
): Promise<ReturnValue> {
const [{ state = {}, isBasicFunctionality = (): boolean => true }, fn]: [
WithControllerOptions,
WithControllerCallback<ReturnValue>,
] = args.length === 2 ? args : [{}, args[0]];
const [
{
state = {},
isBasicFunctionality = (): boolean => true,
controllerOptions = {},
},
fn,
]: [WithControllerOptions, WithControllerCallback<ReturnValue>] =
args.length === 2 ? args : [{}, args[0]];

// Use root messenger (MOCK_ANY_NAMESPACE) so data sources can register their actions.
const messenger: RootMessenger = new Messenger({
Expand Down Expand Up @@ -138,6 +150,7 @@ async function withController<ReturnValue>(
subscribeToBasicFunctionalityChange: (): void => {
/* no-op for tests */
},
...controllerOptions,
});

return fn({ controller, messenger });
Expand Down Expand Up @@ -794,6 +807,51 @@ describe('AssetsController', () => {
expect(true).toBe(true);
});
});

it('invokes trackMetaMetricsEvent with first init fetch duration on unlock', async () => {
const trackMetaMetricsEvent = jest.fn();

await withController(
{ controllerOptions: { trackMetaMetricsEvent } },
async ({ messenger }) => {
messenger.publish('KeyringController:unlock');

// Allow #start() -> getAssets() to resolve so the callback runs
await new Promise((resolve) => setTimeout(resolve, 100));

expect(trackMetaMetricsEvent).toHaveBeenCalledTimes(1);
expect(trackMetaMetricsEvent).toHaveBeenCalledWith(
expect.objectContaining({
durationMs: expect.any(Number),
chainIds: expect.any(Array),
durationByDataSource: expect.any(Object),
}),
);
const payload = trackMetaMetricsEvent.mock
.calls[0][0] as AssetsControllerFirstInitFetchMetaMetricsPayload;
expect(payload.durationMs).toBeGreaterThanOrEqual(0);
expect(Array.isArray(payload.chainIds)).toBe(true);
expect(typeof payload.durationByDataSource).toBe('object');
},
);
});

it('invokes trackMetaMetricsEvent only once per session until lock', async () => {
const trackMetaMetricsEvent = jest.fn();

await withController(
{ controllerOptions: { trackMetaMetricsEvent } },
async ({ messenger }) => {
messenger.publish('KeyringController:unlock');
await new Promise((resolve) => setTimeout(resolve, 100));

messenger.publish('KeyringController:unlock');
await new Promise((resolve) => setTimeout(resolve, 100));

expect(trackMetaMetricsEvent).toHaveBeenCalledTimes(1);
},
);
});
});

describe('subscribeAssetsPrice', () => {
Expand Down
143 changes: 110 additions & 33 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import type {
DataType,
DataRequest,
DataResponse,
FetchContext,
FetchNextFunction,
NextFunction,
Middleware,
SubscriptionResponse,
Expand Down Expand Up @@ -202,6 +204,23 @@ export type AssetsControllerMessenger = Messenger<
// CONTROLLER OPTIONS
// ============================================================================

/**
* Payload for the first init/fetch MetaMetrics event.
* Passed to the optional trackMetaMetricsEvent callback when the initial
* asset fetch completes after unlock or app open.
*/
export type AssetsControllerFirstInitFetchMetaMetricsPayload = {
/** Duration of the first init fetch in milliseconds (wall-clock). */
durationMs: number;
Copy link
Contributor

Choose a reason for hiding this comment

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

is this a duration for all fetch we do ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes

/** Chain IDs requested in the fetch (e.g. ['eip155:1', 'eip155:137']). */
chainIds: string[];
/**
* Exclusive latency in ms per data source (time spent in that source only).
* Sum of values approximates durationMs. Order: same as middleware chain.
*/
durationByDataSource: Record<string, number>;
Copy link
Contributor

Choose a reason for hiding this comment

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

and i guess this is duration per data source

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes

};

export type AssetsControllerOptions = {
messenger: AssetsControllerMessenger;
state?: Partial<AssetsControllerState>;
Expand Down Expand Up @@ -235,6 +254,13 @@ export type AssetsControllerOptions = {
queryApiClient: ApiPlatformClient;
/** Optional configuration for RpcDataSource. */
rpcDataSourceConfig?: RpcDataSourceConfig;
/**
* Optional callback invoked when the first init/fetch completes (e.g. after unlock).
* Use this to track first init fetch duration in MetaMetrics.
*/
trackMetaMetricsEvent?: (
payload: AssetsControllerFirstInitFetchMetaMetricsPayload,
) => void;
/** Optional configuration for AccountsApiDataSource. */
accountsApiDataSourceConfig?: AccountsApiDataSourceConfig;
/** Optional configuration for PriceDataSource. */
Expand Down Expand Up @@ -390,6 +416,14 @@ export class AssetsController extends BaseController<
/** Default update interval hint passed to data sources */
readonly #defaultUpdateInterval: number;

/** Optional callback for first init/fetch MetaMetrics (duration). */
readonly #trackMetaMetricsEvent?: (
payload: AssetsControllerFirstInitFetchMetaMetricsPayload,
) => void;

/** Whether we have already reported first init fetch for this session (reset on #stop). */
#firstInitFetchReported = false;
Copy link
Contributor

Choose a reason for hiding this comment

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

what this means ? is this reset after each open/close of the app or when the background stop and restart ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yes


readonly #controllerMutex = new Mutex();

/**
Expand Down Expand Up @@ -461,6 +495,7 @@ export class AssetsController extends BaseController<
subscribeToBasicFunctionalityChange,
queryApiClient,
rpcDataSourceConfig,
trackMetaMetricsEvent,
accountsApiDataSourceConfig,
priceDataSourceConfig,
}: AssetsControllerOptions) {
Expand All @@ -477,7 +512,7 @@ export class AssetsController extends BaseController<
this.#isEnabled = isEnabled();
this.#isBasicFunctionality = isBasicFunctionality ?? ((): boolean => true);
this.#defaultUpdateInterval = defaultUpdateInterval;

this.#trackMetaMetricsEvent = trackMetaMetricsEvent;
const rpcConfig = rpcDataSourceConfig ?? {};

const onActiveChainsUpdated = (
Expand Down Expand Up @@ -701,18 +736,44 @@ export class AssetsController extends BaseController<

/**
* Execute middlewares with request/response context.
* Returns response and exclusive duration per source (sum ≈ wall time).
*
* @param middlewares - Middlewares to execute in order.
* @param sources - Data sources or middlewares with getName() and assetsMiddleware (executed in order).
* @param request - The data request.
* @param initialResponse - Optional initial response (for enriching existing data).
* @returns The final DataResponse after all middlewares have processed.
* @returns Response and durationByDataSource (exclusive ms per source name).
*/
async #executeMiddlewares(
middlewares: Middleware[],
sources: { getName(): string; assetsMiddleware: Middleware }[],
request: DataRequest,
initialResponse: DataResponse = {},
): Promise<DataResponse> {
const chain = middlewares.reduceRight<NextFunction>(
): Promise<{
response: DataResponse;
durationByDataSource: Record<string, number>;
}> {
const names = sources.map((source) => source.getName());
const middlewares = sources.map((source) => source.assetsMiddleware);
const inclusive: number[] = [];
const wrapped = middlewares.map(
(middleware, i) =>
(async (
ctx: FetchContext,
next: FetchNextFunction,
): Promise<{
request: DataRequest;
response: DataResponse;
getAssetsState: () => AssetsControllerStateInternal;
}> => {
const start = Date.now();
try {
return await middleware(ctx, next);
} finally {
inclusive[i] = Date.now() - start;
}
}) as Middleware,
);

const chain = wrapped.reduceRight<NextFunction>(
(next, middleware) =>
async (
ctx,
Expand All @@ -736,7 +797,17 @@ export class AssetsController extends BaseController<
response: initialResponse,
getAssetsState: () => this.state as AssetsControllerStateInternal,
});
return result.response;

const durationByDataSource: Record<string, number> = {};
for (let i = 0; i < inclusive.length; i++) {
const nextInc = i + 1 < inclusive.length ? (inclusive[i + 1] ?? 0) : 0;
const exclusive = Math.max(0, (inclusive[i] ?? 0) - nextInc);
const name = names[i];
if (name !== undefined) {
durationByDataSource[name] = exclusive;
}
}
return { response: result.response, durationByDataSource };
}

// ============================================================================
Expand Down Expand Up @@ -764,27 +835,37 @@ export class AssetsController extends BaseController<
}

if (options?.forceUpdate) {
const startTime = Date.now();
const request = this.#buildDataRequest(accounts, chainIds, {
assetTypes,
dataTypes,
customAssets: customAssets.length > 0 ? customAssets : undefined,
forceUpdate: true,
});
const middlewares = this.#isBasicFunctionality()
const sources = this.#isBasicFunctionality()
? [
this.#accountsApiDataSource.assetsMiddleware,
this.#snapDataSource.assetsMiddleware,
this.#rpcDataSource.assetsMiddleware,
this.#detectionMiddleware.assetsMiddleware,
this.#tokenDataSource.assetsMiddleware,
this.#priceDataSource.assetsMiddleware,
this.#accountsApiDataSource,
this.#snapDataSource,
this.#rpcDataSource,
this.#detectionMiddleware,
this.#tokenDataSource,
this.#priceDataSource,
]
: [
this.#rpcDataSource.assetsMiddleware,
this.#detectionMiddleware.assetsMiddleware,
];
const response = await this.#executeMiddlewares(middlewares, request);
: [this.#rpcDataSource, this.#detectionMiddleware];
const { response, durationByDataSource } = await this.#executeMiddlewares(
sources,
request,
);
await this.#updateState(response);
if (this.#trackMetaMetricsEvent && !this.#firstInitFetchReported) {
this.#firstInitFetchReported = true;
const durationMs = Date.now() - startTime;
this.#trackMetaMetricsEvent({
durationMs,
chainIds,
durationByDataSource,
});
Copy link

Choose a reason for hiding this comment

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

Stale fetch can misreport session metric

Medium Severity

trackMetaMetricsEvent is emitted based only on #firstInitFetchReported, with no guard that the fetch still belongs to the active session. If #stop() runs while a prior getAssets(..., forceUpdate) is still in flight, that stale fetch can report after stop and set the flag, skewing session attribution and suppressing the next real init metric.

Additional Locations (1)

Fix in Cursor Fix in Web

}
}

return this.#getAssetsFromState(accounts, chainIds, assetTypes);
Expand Down Expand Up @@ -1335,14 +1416,12 @@ export class AssetsController extends BaseController<
});

this.#subscribeAssets();
if (this.#selectedAccounts.length > 0) {
this.getAssets(this.#selectedAccounts, {
chainIds: [...this.#enabledChains],
forceUpdate: true,
}).catch((error) => {
log('Failed to fetch assets', error);
});
}
this.getAssets(this.#selectedAccounts, {
chainIds: [...this.#enabledChains],
forceUpdate: true,
}).catch((error) => {
log('Failed to fetch assets', error);
});
}

/**
Expand All @@ -1355,6 +1434,8 @@ export class AssetsController extends BaseController<
hasPriceSubscription: this.#activeSubscriptions.has('ds:PriceDataSource'),
});

this.#firstInitFetchReported = false;
Copy link

Choose a reason for hiding this comment

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

Session metric resets on preference toggle

Medium Severity

#firstInitFetchReported is reset inside #stop(), but #stop() is also called by handleBasicFunctionalityChange(). That allows trackMetaMetricsEvent to fire again without a lock/unlock cycle, so the “first init fetch once per session” guarantee in AssetsController can be violated.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is not a problem


// Stop price subscription first (uses direct messenger call)
this.unsubscribeAssetsPrice();

Expand Down Expand Up @@ -1726,12 +1807,8 @@ export class AssetsController extends BaseController<

// Run through enrichment middlewares (Event Stack: Detection → Token → Price)
// Include 'metadata' in dataTypes so TokenDataSource runs to enrich detected assets
const enrichedResponse = await this.#executeMiddlewares(
[
this.#detectionMiddleware.assetsMiddleware,
this.#tokenDataSource.assetsMiddleware,
this.#priceDataSource.assetsMiddleware,
],
const { response: enrichedResponse } = await this.#executeMiddlewares(
[this.#detectionMiddleware, this.#tokenDataSource, this.#priceDataSource],
request ?? {
accountsWithSupportedChains: [],
chainIds: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ describe('PriceDataSource', () => {

it('initializes with correct name', () => {
const { controller } = setupController();
expect(controller.name).toBe('PriceDataSource');
expect(controller.getName()).toBe('PriceDataSource');
controller.destroy();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ function isValidMarketData(data: unknown): data is SpotPriceMarketData {
* Usage: Create with queryApiClient; subscribe() requires getAssetsState in the request for balance-based pricing.
*/
export class PriceDataSource {
readonly name = CONTROLLER_NAME;
static readonly controllerName = CONTROLLER_NAME;

getName(): string {
return PriceDataSource.controllerName;
}

readonly #currency: SupportedCurrency;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ function transformV3AssetResponseToMetadata(
export class TokenDataSource {
readonly name = CONTROLLER_NAME;

getName(): string {
return this.name;
}

/** ApiPlatformClient for cached API calls */
readonly #apiClient: ApiPlatformClient;

Expand Down
1 change: 1 addition & 0 deletions packages/assets-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
AssetsControllerState,
AssetsControllerMessenger,
AssetsControllerOptions,
AssetsControllerFirstInitFetchMetaMetricsPayload,
AssetsControllerGetStateAction,
AssetsControllerActions,
AssetsControllerStateChangeEvent,
Expand Down
Loading
Loading