Skip to content

feat: Implementation of gelocation controller#8037

Open
MarioAslau wants to merge 4 commits intomainfrom
feat/geolocation-cotroller
Open

feat: Implementation of gelocation controller#8037
MarioAslau wants to merge 4 commits intomainfrom
feat/geolocation-cotroller

Conversation

@MarioAslau
Copy link
Contributor

@MarioAslau MarioAslau commented Feb 25, 2026

Explanation

Problem

On mobile, four separate features (Ramp, Perps, Rewards, Card) independently call the same geolocation API endpoint (https://on-ramp.api.cx.metamask.io/geolocation), each with its own implementation, URL constants, caching strategy, and trigger mechanism. This results in 2–4 redundant HTTP calls on every app load (~1.8–3.1 seconds each), inconsistent environment URL handling across features, and no shared cache.

Feature Fetch Method Caching Trigger
Ramp fetch() None useEffect on mount
Perps successfulFetch() 5-min TTL + promise dedup checkEligibility() call
Rewards successfulFetch() Controller-level in-memory getGeoRewardsMetadata() call
Card fetch() None getCardholder() call

Solution

This PR introduces a new @metamask/geolocation-controller package that centralises geolocation fetching behind a single GeolocationController. The controller extends BaseController from @metamask/base-controller and follows established controller conventions (matching the patterns in connectivity-controller).

Key design decisions:

  • TTL-based caching (configurable, default 5 minutes) — prevents redundant network calls when the cached value is still fresh.
  • Promise coalescing — concurrent calls to getGeolocation() share a single in-flight promise, so multiple consumers calling simultaneously produce only one HTTP request.
  • Injectable fetch function — the controller accepts a fetch parameter (defaults to globalThis.fetch), consistent with the RampsService pattern. This makes the controller fully testable without global mocks.
  • Environment-aware URL resolution — a getGeolocationUrl callback is provided at construction time, allowing the host application to resolve the correct API URL (prod vs. dev/UAT) without the controller having to know about environment configuration.
  • No persistence — state is in-memory only (persist: false on all metadata fields). Geolocation is re-fetched on every app restart, which is appropriate since the data is IP-based and lightweight.
  • Platform-agnostic — no dependency on mobile or extension-specific code. The package is reusable across all MetaMask clients.

What's included

New package: packages/geolocation-controller/

  • src/GeolocationController.ts — Controller implementation with state management, TTL cache, and promise deduplication
  • src/types.tsGeolocationStatus type ('idle' | 'loading' | 'complete' | 'error')
  • src/GeolocationController-method-action-types.ts — Messenger action types for getGeolocation and refreshGeolocation
  • src/index.ts — Public exports
  • src/GeolocationController.test.ts — 25 test cases covering cache behaviour, promise deduplication, fetch success/failure, refresh, messenger integration, metadata, and edge cases
  • Package scaffolding: package.json, tsconfig.json, tsconfig.build.json, jest.config.js, typedoc.json, README.md, CHANGELOG.md, LICENSE

Modified: tsconfig.json — added project reference for the new package (alphabetical order, after gator-permissions-controller)

Public API

State:

type GeolocationControllerState = {
  location: string;            // ISO country code, e.g. "US", "GB", or "UNKNOWN"
  status: GeolocationStatus;   // 'idle' | 'loading' | 'complete' | 'error'
  lastFetchedAt: number | null; // Epoch ms of last successful fetch
  error: string | null;         // Last error message
};

Messenger actions:

  • GeolocationController:getGeolocation — Returns the cached location if fresh, otherwise fetches and returns it. Concurrent calls are deduplicated.
  • GeolocationController:refreshGeolocation — Forces a fresh fetch, bypassing the cache.
  • GeolocationController:getState — Returns the current controller state (provided by BaseController).

Messenger events:

  • GeolocationController:stateChange — Fires on every state transition (provided by BaseController).

References

  • Jira Ticket: https://consensyssoftware.atlassian.net/browse/MCWP-350
  • Related ADR: decisions/decisions/core/0019-geolocation-controller.md
  • Messenger pattern ADR: decisions/decisions/core/0001-messaging-non-controllers.md
  • Reference controller used as template: packages/connectivity-controller/
  • Follow-up ticket: Mobile integration and consumer migration (register in Engine, migrate Ramp/Perps/Rewards/Card, remove duplicate implementations)

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note: No breaking changes — this is an entirely new package with no existing consumers.


Note

Medium Risk
New shared controller introduces caching and concurrency/race-handling logic around network fetches; while well-tested, incorrect integration or TTL semantics could affect feature eligibility decisions across clients.

Overview
Introduces a new @metamask/geolocation-controller package that centralizes geolocation fetching behind a BaseController, exposing getGeolocation (TTL-cached + in-flight request deduplication) and refreshGeolocation (bypass cache) via messenger actions and emitting standard stateChange events.

The controller maintains in-memory-only state (location, status, lastFetchedAt, error), supports injectable fetch and environment-provided getGeolocationUrl, and includes extensive Jest coverage for caching, concurrency, refresh race handling, and error/edge cases. Monorepo wiring is updated by adding a TS project reference and yarn workspace lock entry for the new package.

Written by Cursor Bugbot for commit 9e74c1c. This will update automatically on new commits. Configure here.

Copy link
Contributor

@NicolasMassart NicolasMassart left a comment

Choose a reason for hiding this comment

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

A few suggestions to fix and questions, after that, will look good.

throw new Error(`Geolocation fetch failed: ${response.status}`);
}

const location = (await response.text()).trim() || 'UNKNOWN';
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (blocking): No validation of API response body — arbitrary strings stored as "location"

If the API returns a 200 with an HTML error page, a JSON blob, or any unexpected content, the controller stores it verbatim as location.

Downstream consumers checking state.location === 'US' would silently get false negatives while the state appears 'complete'. A lightweight ISO 3166-1 alpha-2 guard prevents storing garbage data.

Suggested change
const location = (await response.text()).trim() || 'UNKNOWN';
const raw = (await response.text()).trim();
const location = /^[A-Z]{2}$/.test(raw) ? raw : 'UNKNOWN';

throw new Error(`Geolocation fetch failed: ${response.status}`);
}

const location = (await response.text()).trim() || 'UNKNOWN';
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (blocking): Extract 'UNKNOWN' to a named constant. The string is hardcoded in three places

try:

export const UNKNOWN_LOCATION = 'UNKNOWN';

and use UNKNOWN_LOCATION in the 3 places

Comment on lines +51 to +62
lastFetchedAt: {
persist: false,
includeInDebugSnapshot: true,
includeInStateLogs: true,
usedInUi: true,
},
error: {
persist: false,
includeInDebugSnapshot: true,
includeInStateLogs: true,
usedInUi: true,
},
Copy link
Contributor

Choose a reason for hiding this comment

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

question (non-blocking): Is usedInUi: true accurate for lastFetchedAt and error?

All four state fields have usedInUi: true. The location and status fields are clearly UI-relevant, but lastFetchedAt (epoch ms of last fetch) and error (raw error message string) may not be surfaced in any UI. If they aren't, setting usedInUi: false reduces the data pushed to the UI layer. If they are planned for UI use, this is fine — just want to confirm intent.

if (generation === this.#fetchGeneration) {
this.update((draft) => {
draft.location = location;
draft.status = 'complete';
Copy link
Contributor

Choose a reason for hiding this comment

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

question (non-blocking): Empty 200 response yields status: 'complete' — is that intended?

When the API returns HTTP 200 with an empty body, the controller stores location: 'UNKNOWN' with status: 'complete'. This signals "fetch succeeded" even though no useful data was obtained. If consumers branch on status === 'complete' to show location-gated features, they'd proceed with UNKNOWN. Consider whether this case should set status: 'error' instead, or if the current behavior is acceptable given consumers are expected to check the location value directly.

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

}

const promise = this.#performFetch();
this.#fetchPromise = promise;
Copy link

Choose a reason for hiding this comment

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

Promise deduplication bypassed by re-entrant state change listeners

Medium Severity

#fetchPromise is assigned on line 228 after #performFetch() has already executed its synchronous body, which includes this.update() on line 270. Since BaseController.update() fires the stateChange event synchronously, any subscriber that calls getGeolocation() during that event will see #fetchPromise as null and start a second #performFetch(), defeating the promise deduplication. The original assignment on line 228 then overwrites whatever the re-entrant call stored.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

@cryptodev-2s cryptodev-2s left a comment

Choose a reason for hiding this comment

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

Could you also run update-readme-content to update the root readme

Copy link
Contributor

Choose a reason for hiding this comment

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

The same change should be made to tsconfig.build.json

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants