feat: Implementation of gelocation controller#8037
Conversation
NicolasMassart
left a comment
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
| 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'; |
There was a problem hiding this comment.
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
| lastFetchedAt: { | ||
| persist: false, | ||
| includeInDebugSnapshot: true, | ||
| includeInStateLogs: true, | ||
| usedInUi: true, | ||
| }, | ||
| error: { | ||
| persist: false, | ||
| includeInDebugSnapshot: true, | ||
| includeInStateLogs: true, | ||
| usedInUi: true, | ||
| }, |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| const promise = this.#performFetch(); | ||
| this.#fetchPromise = promise; |
There was a problem hiding this comment.
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)
cryptodev-2s
left a comment
There was a problem hiding this comment.
Could you also run update-readme-content to update the root readme
There was a problem hiding this comment.
The same change should be made to tsconfig.build.json


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.fetch()useEffecton mountsuccessfulFetch()checkEligibility()callsuccessfulFetch()getGeoRewardsMetadata()callfetch()getCardholder()callSolution
This PR introduces a new
@metamask/geolocation-controllerpackage that centralises geolocation fetching behind a singleGeolocationController. The controller extendsBaseControllerfrom@metamask/base-controllerand follows established controller conventions (matching the patterns inconnectivity-controller).Key design decisions:
getGeolocation()share a single in-flight promise, so multiple consumers calling simultaneously produce only one HTTP request.fetchfunction — the controller accepts afetchparameter (defaults toglobalThis.fetch), consistent with theRampsServicepattern. This makes the controller fully testable without global mocks.getGeolocationUrlcallback 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.persist: falseon all metadata fields). Geolocation is re-fetched on every app restart, which is appropriate since the data is IP-based and lightweight.What's included
New package:
packages/geolocation-controller/src/GeolocationController.ts— Controller implementation with state management, TTL cache, and promise deduplicationsrc/types.ts—GeolocationStatustype ('idle' | 'loading' | 'complete' | 'error')src/GeolocationController-method-action-types.ts— Messenger action types forgetGeolocationandrefreshGeolocationsrc/index.ts— Public exportssrc/GeolocationController.test.ts— 25 test cases covering cache behaviour, promise deduplication, fetch success/failure, refresh, messenger integration, metadata, and edge casespackage.json,tsconfig.json,tsconfig.build.json,jest.config.js,typedoc.json,README.md,CHANGELOG.md,LICENSEModified:
tsconfig.json— added project reference for the new package (alphabetical order, aftergator-permissions-controller)Public API
State:
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 byBaseController).Messenger events:
GeolocationController:stateChange— Fires on every state transition (provided byBaseController).References
decisions/decisions/core/0019-geolocation-controller.mddecisions/decisions/core/0001-messaging-non-controllers.mdpackages/connectivity-controller/Checklist
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-controllerpackage that centralizes geolocation fetching behind aBaseController, exposinggetGeolocation(TTL-cached + in-flight request deduplication) andrefreshGeolocation(bypass cache) via messenger actions and emitting standardstateChangeevents.The controller maintains in-memory-only state (
location,status,lastFetchedAt,error), supports injectablefetchand environment-providedgetGeolocationUrl, 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.