-
-
Notifications
You must be signed in to change notification settings - Fork 254
Ramps Controller Foundation #7091
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
| console.error('Error fetching geolocation:', error); | ||
| throw error; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Unsafe geolocation: non-string API data breaks URLs
The #getGeolocation method returns the API response data without validating it's actually a string. If the API returns a non-string value (like an object or number), it will be used directly in URL construction and stored in state.region.id, causing malformed URLs and incorrect state. Runtime validation should confirm the response is a string before returning it.
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
mcmire
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hello, I took at this controller and had a few notes. Let me know what you think.
In particular, I don't know if you have seen this, but we have a sample-controllers repo that serves as our source of truth for how controllers ought to look (for example, here is an example controller and here are its tests). You might go through this and make sure you're doing things in a similar way.
| messenger, | ||
| name: controllerName, | ||
| metadata: rampsControllerMetadata, | ||
| state: { ...defaultState, ...state } as RampsControllerState, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason why you are using a type assertion here? There should be no need for this, you should have a complete state object here.
| */ | ||
| export type RampsControllerState = { | ||
| // Environment tells us which API urls to us | ||
| metamaskEnvironment: SdkEnvironment; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is the metamaskEnvironment and context in state? Do we expect to be able to change these at runtime?
context also doesn't seem to be getting used anywhere.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Will move context and environment out of state.
Context will be used for future calls. I will add it to the axios object as a default param.
| } | ||
|
|
||
| this.update((state) => { | ||
| state.region = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you explain how you intend to use this controller? Do you expect to call getCountries() somewhere and listen for changes to region somewhere else? Why not call it immediately and get the results?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True, we can do both. Return it but update the state. We will want to access it later without calling the function so keeping it in state was intentional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a getter method which will init the state if not initialized. Let me know if this makes sense or if the patter is to access the state in the controller directly instead.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I don't see this, is it possible you forgot to push those changes?
| async #getGeolocation(): Promise<string> { | ||
| try { | ||
| const url = this.#getApiUrl(); | ||
| const response = await fetch(new URL('geolocation', url).toString()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of making HTTP requests in your controller, we recommend extracting an API client from your controller that represents the "on ramp" API. It could serve as a direct layer on top of the layer and could be used independent of the controller anywhere in the stack.
We have a pattern called "data services" that would be useful here. In this case perhaps you want to:
- Create a separate file, perhaps
src/OnRampService.ts. - Define an
OnRampServiceclass in it. The constructor should take two arguments:messengerandpolicyOptions. It might also be best to takeenvironmentand/orcontextoptions based on what you're already doing in the controller (theenvironmentcan be used to construct the base URL). - Add
getGeolocationandgetCountriesmethods to the class, similar to how you have here. Each method should make afetchand thatfetchshould be rapped in apolicy.executecall. - Add a
messengeroption to the constructor. Create aOnRampServiceMessengertype and expose those methods using the messenger (see other comment on how to do this in a more automatic way) - Add the service messenger actions to the RampsController's messenger, and then call the service methods through the messenger (e.g.
await messenger.call('OnRampService:getGeolocation', ...).
We have an example data service here: https://github.com/MetaMask/core/blob/e144cc58d7b82c0d75c685de85adc3de510a1b3e/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts. And you can see how it's used in the controller here: https://github.com/MetaMask/core/blob/e144cc58d7b82c0d75c685de85adc3de510a1b3e/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.ts
Let me know your thoughts on this!
| }, | ||
| }; | ||
|
|
||
| const defaultState: RampsControllerState = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you read our controller guidelines? This states that the default state should be wrapped in a function: https://github.com/MetaMask/core/blob/main/docs/controller-guidelines.md#provide-a-default-representation-of-state
| state.region = null; | ||
| }); | ||
| // Or rethrow if you want the caller to handle it: | ||
| // throw error; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally in controllers and services we don't recommend swallowing errors like this unless there is a good reason to do so. But it depends on the use case — can you provide an example of how this would be used in the client?
| state: { ...defaultState, ...state } as RampsControllerState, | ||
| }); | ||
|
|
||
| this.#registerMessageHandlers(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A little while ago we added a way to automatically expose methods as actions. This way you don't need to think about and maintain the types so much whenever you add new methods in the future. Here are some instructions:
- Add
MESSENGER_EXPOSED_METHODSsomewhere in your controller file, e.g.:const MESSENGER_EXPOSED_METHODS = ['updateGasPrices'] as const; - Run
yarn generate-method-action-types. This will generate a new file,AnalyticsController-method-action-types.ts. - Import
AnalyticsControllerMethodActionsfrom this file. - Add this type to
AnalyticsControllerActions, e.g.:| SampleGasPricesControllerMethodActions; - Call
this.messenger.registerMethodActionHandlersin your controller constructor:this.messenger.registerMethodActionHandlers(
| let mockConsoleError: jest.SpyInstance; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mocks get automatically reset between tests in this repo, so you don't need this:
| jest.clearAllMocks(); |
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| mockFetch = jest.spyOn(global, 'fetch') as jest.MockedFunction< |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't recommend mocking the fetch function directly. Nock is already set in this repo and that offers a more realistic way of mocking requests. You can see an example here:
core/packages/sample-controllers/src/sample-gas-prices-service/sample-gas-prices-service.test.ts
Line 29 in e144cc5
| nock('https://api.example.com') |
| afterEach(() => { | ||
| jest.restoreAllMocks(); | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to comment above, this already happens by default.
| afterEach(() => { | |
| jest.restoreAllMocks(); | |
| }); |
| metamaskEnvironment: SdkEnvironment.Staging, | ||
| context: Context.Browser, | ||
| region: null, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Enforce Controller defaultState Guidelines (Bugbot Rules)
The defaultState constant violates controller guidelines. According to the repository's controller guidelines and PR feedback, default state must be wrapped in a function called getDefaultRampsControllerState() and exported. This prevents accidental mutations in tests and ensures a fresh object reference each time.
| }: { | ||
| messenger: RampsControllerMessenger; | ||
| state: Partial<RampsControllerState>; | ||
| }) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Flexible State Instantiation (Bugbot Rules)
The state constructor parameter is required but should be optional per controller guidelines. The type should be state?: Partial<RampsControllerState> with a default value of {}, allowing the controller to be instantiated without providing initial state.
Explanation
Current State:
Ramps currently depends on an sdk to handle all connections to the RAMPS api. We now have 2 different offerings which each use their own SDK. All of this routing logic is done on the mobile app. If this logic were to be extended to mobile or extension it would require rework on those codebases.
Why we want to change things:
Many other teams already use this controller design pattern. By moving towards this we can handle all business logic in 1 controller. It lets us expose Ramps data and apis to other experiences without those experiences requiring any knowledge of business logic. Lastly it would speed up any Ramps api integrations on extension and mobile in the future.
Changes introduced:
This is the foundation for the Ramps Controller. It contains 1 function which updates its internal state which reflects eligibility for a ramps feature on a certain region.
References
Checklist
Note
Introduce
@metamask/ramps-controllerwith agetCountriesaction that resolves API base by environment, fetches geolocation and region eligibility, and updates controller state.packages/ramps-controllerRampsControllerwith state (metamaskEnvironment,context,region) and metadata exposure.getCountriesfetchesgeolocationthenregions/countries/<code>using env-specific base URL; validates response and updatesstate.region; registers messenger actionRampsController:getCountries.SdkEnvironment,Context) viasrc/index.ts.@metamask/ramps-controllertoREADME.mdpackage list and dependency graph.tsconfig.jsonandtsconfig.build.jsonreferences..github/CODEOWNERSto add Ramp team ownership and release file ownership entries.yarn.lock.Written by Cursor Bugbot for commit 9efa238. This will update automatically on new commits. Configure here.