Skip to content

feat: Implementation of gelocation controller#8037

Merged
MarioAslau merged 18 commits intomainfrom
feat/geolocation-cotroller
Mar 2, 2026
Merged

feat: Implementation of gelocation controller#8037
MarioAslau merged 18 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
Adds a new network-facing geolocation service/controller with caching and retry/circuit-breaker behavior; integration risk is moderate due to new runtime HTTP calls and state lifecycle handling, though it is currently additive with no existing consumers.

Overview
Introduces a new @metamask/geolocation-controller package that centralizes country-code lookup behind a GeolocationController (UI-facing state machine) and a GeolocationApiService (HTTP fetch with TTL cache, in-flight promise deduplication, response validation, and service-policy retry/circuit-breaker hooks).

Updates monorepo wiring to include the new package in README.md, tsconfig references/build, yarn.lock, ownership mapping (CODEOWNERS, teams.json), and adds comprehensive Jest coverage plus package scaffolding (exports, typedoc, changelog/license).

Written by Cursor Bugbot for commit 11712bb. 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.

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

@MarioAslau MarioAslau requested a review from a team as a code owner February 26, 2026 16:25
Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Made a first pass and left some comments. I will take another look tomorrow.

@MarioAslau MarioAslau requested a review from mcmire February 27, 2026 16:01
Comment on lines +26 to +28
export type GeolocationControllerState = {
/** ISO 3166-1 alpha-2 country code, or "UNKNOWN" if not yet determined. */
location: string;
Copy link
Contributor

Choose a reason for hiding this comment

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

suggestion (non-blocking): location state uses a broad string type. Consider a branded type for consumer safety

Suggested change
export type GeolocationControllerState = {
/** ISO 3166-1 alpha-2 country code, or "UNKNOWN" if not yet determined. */
location: string;
export type ISOCountryCode = Uppercase<string> & { __brand: 'ISOCountryCode' };
export type GeolocationControllerState = {
/** ISO 3166-1 alpha-2 country code, or "UNKNOWN" if not yet determined. */
location: ISOCountryCode | typeof UNKNOWN_LOCATION;

async fetchGeolocation(options?: FetchGeolocationOptions): Promise<string> {
if (options?.bypassCache) {
this.#fetchGeneration += 1;
this.#fetchPromise = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

Should bypassCache also prevent request deduplication? It seems odd that it would — I would think request deduplication is something that we would always want to do. Can you explain further the intent here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The purpose of nulling #fetchPromise is to ensure the bypass result actually gets cached. If we reused the in-flight promise instead, the generation counter would correctly prevent the old request's result from being written to the cache, but since no new request was started, nothing populates the cache at all. The next caller would then trigger another HTTP request, so I think we would end up with more total requests, not fewer

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.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

@MarioAslau MarioAslau requested a review from mcmire February 28, 2026 03:08
Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

Looks a lot better, thanks for the changes! Just one more question on codeownership.

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

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

LGTM.

@MarioAslau MarioAslau added this pull request to the merge queue Mar 2, 2026
Merged via the queue into main with commit 50e0585 Mar 2, 2026
318 checks passed
@MarioAslau MarioAslau deleted the feat/geolocation-cotroller branch March 2, 2026 18:07
MarioAslau added a commit that referenced this pull request Mar 2, 2026
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.

4 participants