Skip to content
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

refactor: modular controller init #28948

Open
wants to merge 24 commits into
base: main
Choose a base branch
from

Conversation

matthewwalsh0
Copy link
Member

@matthewwalsh0 matthewwalsh0 commented Dec 4, 2024

Description

Architecture to support modular controller initialisation to avoid the current monolithic metamask-controller.js.

Includes:

  • PPOMController as a simple example.
  • TransactionController as perhaps the most complex example.

Overview

Uses isolated class files (in app/scripts/controller-init/) extending a ControllerInit base class, each of which defines:

  • Required init method to return the controller instance given a ControllerInitRequest.
  • Optional getControllerMessengerCallback override to specify callback to generate controller messenger.
  • Optional getInitMessengerCallback override to specify callback to generate initialisation controller messenger.
  • Optional getApi override to return object of background API functions.
  • Optional getPersistedStateKey to override persisted state key, or disable.
  • Optional getMemStateKey to override mem store state key, or disable.

Dependent controllers can be retrieved via getController function in ControllerInitRequest to provide standard error message if controller is not yet initialised.

Messengers are intentionally defined in separate isolated files to allow alternate code ownership, and not over-power the controller logic and introduce bugs.

Initialisation logic is encapsulated in app/scripts/controller-init/utils.ts, which iterates over the ControllerInit instances to construct the controller instances, plus generate the API and store properties.

Advantages

  • Abstract class based so:
    • Easy to extend with additional configuration and default behaviour.
    • Enforces a readable and consistent template for each controller.
    • Allows iterating over controllers during initialisation with multiple functions to model the lifecycle.
  • Backwards compatible with almost no changes to legacy controller initialisation.
  • Facilitates an iterative approach meaning teams can refactor individual controllers ad-hoc.
  • Facilitates iterative Typescript adoption of controller initialisation code.
  • Supports modular unit testing of controller / messenger event handlers.

Open in GitHub Codespaces

Related issues

Manual testing steps

Screenshots/Recordings

Before

After

Pre-merge author checklist

Pre-merge reviewer checklist

  • I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed).
  • I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.

@matthewwalsh0 matthewwalsh0 added the team-confirmations Push issues to confirmations team label Dec 5, 2024
@metamaskbot
Copy link
Collaborator

Builds ready [a41b2b1]
Page Load Metrics (1809 ± 62 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint38520741747334160
domContentLoaded16262030177911354
load16412127180912962
domInteractive257334126
backgroundConnect10100322613
firstReactRender159023168
getState712851304923
initialActions01000
loadScripts12371587138410550
setupStore783162210
uiStartup190026202146226109
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 4.87 KiB (0.09%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [57a0608]
Page Load Metrics (1867 ± 58 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint17072132187312359
domContentLoaded16952091184411254
load17002101186712158
domInteractive2596382010
backgroundConnect97122189
firstReactRender1698333014
getState943911698340
initialActions01000
loadScripts12611682142711254
setupStore77512157
uiStartup194629102262277133
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 5.27 KiB (0.10%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@matthewwalsh0 matthewwalsh0 marked this pull request as ready for review December 5, 2024 09:06
@matthewwalsh0 matthewwalsh0 requested review from a team as code owners December 5, 2024 09:06
@matthewwalsh0 matthewwalsh0 marked this pull request as draft December 5, 2024 09:09
@metamaskbot
Copy link
Collaborator

Builds ready [dede03f]
Page Load Metrics (2194 ± 69 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint27223841824736353
domContentLoaded19192424215914067
load19602443219414369
domInteractive287245147
backgroundConnect781302412
firstReactRender17173383818
getState1243781795928
initialActions01000
loadScripts15021871166811857
setupStore712911
uiStartup222830472638225108
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 5.19 KiB (0.10%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [3ccc1a8]
Page Load Metrics (1789 ± 34 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint1639190717907436
domContentLoaded1630187217606833
load1639190717897234
domInteractive249934178
backgroundConnect896332512
firstReactRender159122168
getState1003801507436
initialActions00000
loadScripts1243146013476029
setupStore623942
uiStartup18942622211018488
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 5.91 KiB (0.11%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [69479a8]
Page Load Metrics (1870 ± 67 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint27222291793379182
domContentLoaded16362148183913665
load16802167187014067
domInteractive24533184
backgroundConnect877312311
firstReactRender158623157
getState1013811465527
initialActions01000
loadScripts12171660141412058
setupStore611821
uiStartup189729192171229110
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 8.67 KiB (0.17%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [b1ffbd5]
Page Load Metrics (1939 ± 102 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint31425931865419201
domContentLoaded17092525191620799
load173525391939212102
domInteractive256335115
backgroundConnect68323199
firstReactRender15382273
getState831871242010
initialActions00000
loadScripts12881988150717484
setupStore7381173
uiStartup195430332224261125
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 8.04 KiB (0.15%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@matthewwalsh0 matthewwalsh0 marked this pull request as ready for review December 9, 2024 14:41
@matthewwalsh0 matthewwalsh0 requested a review from a team as a code owner December 9, 2024 14:41
@metamaskbot
Copy link
Collaborator

Builds ready [c94d6e0]
Page Load Metrics (1990 ± 83 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint25324071901416200
domContentLoaded17462342195416278
load17602415199017283
domInteractive246839115
backgroundConnect1082362512
firstReactRender16175343919
getState733081627536
initialActions01000
loadScripts13261798151511455
setupStore783162211
uiStartup198433622392385185
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 9.2 KiB (0.18%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [55da18e]
Page Load Metrics (2072 ± 113 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint25826791896548263
domContentLoaded174926632053234112
load176326802072236113
domInteractive276636105
backgroundConnect95823168
firstReactRender179325168
getState1002951423718
initialActions01000
loadScripts13162127159920699
setupStore718921
uiStartup204531022406311149
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 9.19 KiB (0.18%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [dd59867]
Page Load Metrics (2036 ± 102 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint167326622037218105
domContentLoaded16622591200320699
load167126442036211102
domInteractive259041199
backgroundConnect983332211
firstReactRender169925178
getState944921518038
initialActions01000
loadScripts12522106157018087
setupStore7131021
uiStartup191835832378341164
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 9.18 KiB (0.17%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [fc39319]
Page Load Metrics (2036 ± 99 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint16632405204020398
domContentLoaded16492356200820196
load16632401203620799
domInteractive237736126
backgroundConnect96127178
firstReactRender1592282110
getState823701517435
initialActions00000
loadScripts12471832155916981
setupStore7201042
uiStartup193230422395291140
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 9.18 KiB (0.17%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@metamaskbot
Copy link
Collaborator

Builds ready [8d2801c]
Page Load Metrics (1980 ± 74 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint17512252197815575
domContentLoaded17342190194115172
load17562248198015574
domInteractive246737115
backgroundConnect1278362211
firstReactRender168724157
getState742801335526
initialActions00000
loadScripts13201779151813263
setupStore68012168
uiStartup20232708232118991
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 9.18 KiB (0.17%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@dbrans
Copy link
Contributor

dbrans commented Dec 12, 2024

Hey Matt, love this idea—breaking up metamask-controller.js and adding types to the controller initialization would be a huge win. Thanks for suggesting it!

@metamaskbot
Copy link
Collaborator

Builds ready [cf22570]
Page Load Metrics (1817 ± 233 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint142835451827496238
domContentLoaded139034001794475228
load142734751817486233
domInteractive24312596129
backgroundConnect887262010
firstReactRender15101442713
getState45416168
initialActions01000
loadScripts102327941348414199
setupStore676222110
uiStartup160339612119563270

@metamaskbot
Copy link
Collaborator

Builds ready [584c15b]
Page Load Metrics (1721 ± 128 ms)
PlatformPageMetricMin (ms)Max (ms)Average (ms)StandardDeviation (ms)MarginOfError (ms)
ChromeHomefirstPaint22026911640419201
domContentLoaded140526751698268129
load145726981721267128
domInteractive23257566029
backgroundConnect105722136
firstReactRender1695442713
getState56618199
initialActions00000
loadScripts103920331279212102
setupStore65214157
uiStartup164929961945285137
Bundle size diffs [🚨 Warning! Bundle size has increased!]
  • background: 9.18 KiB (0.16%)
  • ui: 0 Bytes (0.00%)
  • common: 0 Bytes (0.00%)

@HowardBraham
Copy link
Contributor

@davidmurdoch I just did a circular dependencies analysis on this PR.

These circular deps were removed:

  • app/scripts/controllers/swaps/swaps.constants.ts > app/scripts/controllers/swaps/swaps.types.ts

These circular deps were added:

  • app/scripts/controllers/swaps/swaps.types.ts > app/scripts/controllers/swaps/index.ts
  • app/scripts/controllers/swaps/swaps.types.ts > app/scripts/controllers/swaps/index.ts > app/scripts/controllers/swaps/swaps.utils.ts

Though I'm a little confused how this happened at all, when none of these files were changed 🤷

Copy link
Member

@Gudahtt Gudahtt left a comment

Choose a reason for hiding this comment

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

I like the overall structure we're using here, and a lot of the ideas for how to organize the initialization code. I left a lot of comments about the "TypeScript migration" parts of this, but my last comment on what each of these abstractions are for is probably the most relevant one for the overall plan here.

Have you considered writing an ADR about this? An ADR might be a good place to explain the overall layout/structure independently of the controller-specific details. This may help not only in explaining the pattern, but in helping others replicate it for other controllers (and especially for mobile). There are so many details in these examples, it's a bit hard to see the big picture.

Though it's great that we have such a complex example to work from, surely this hits most complexities we'll encounter in other controllers.

storageBackend: new IndexedDBPPOMStorage('PPOMDB', 1),
provider: getProvider(),
ppomProvider: {
// @ts-expect-error Mismatched types
Copy link
Member

Choose a reason for hiding this comment

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

Any idea why there is a type mismatch here?

PPOM: PPOMModule.PPOM,
ppomInit: () => PPOMModule.default(process.env.PPOM_URI),
},
state: persistedState.PPOMController as PPOMController['state'],
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Hmm, this isn't ideal. How can we ensure the persisted state is correctly typed?

It would be nice to avoid casting, as this is contrary to our TypeScript best practices. At the very least we should explain in a comment why we're casting here when we normally wouldn't.

Unfortunately it would be an overly large amount of work to avoid type safety problems altogether, as we don't validate it at any point. That would be beyond the scope of this PR. But a great deal of the handling of this state is still in JavaScript, so it should be easily avoided. At this point in the initialization process, it's fair to say that we expect the type to be { [ControllerName]: Partial<ControllerState> }, so maybe we can type it as that for now, and figure out how to maintain type safety before here as a later problem.

chainId: getGlobalChainId(),
securityAlertsEnabled:
preferencesController().state.securityAlertsEnabled,
// @ts-expect-error Mismatched types
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Here is a bit more detail on the type issue here:

Suggested change
// @ts-expect-error Mismatched types
// @ts-expect-error `onPreferencesChange` type signature is incorrect in `PPOMController`
// TODO: Use messenger directly in `PPOMController` rather than callback

initMessenger,
'PreferencesController:stateChange',
),
cdnBaseUrl: process.env.BLOCKAID_FILE_CDN as string,
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Here is more detail on why we're casting here:

Suggested change
cdnBaseUrl: process.env.BLOCKAID_FILE_CDN as string,
// Both of these are given default values in `builds.yml`, so they should always be set to something
cdnBaseUrl: process.env.BLOCKAID_FILE_CDN as string,

Copy link
Member

Choose a reason for hiding this comment

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

Hmm. Not sure what to expect from tests at this layer. I see that every constructor parameter isn't tested for example, and for the messenger callbacks we're not testing what capabilities they have. But maybe that's OK.

What do you see as the goals of these tests?

Comment on lines +96 to +98
(initObject as InitInstance).init
? (initObject as InitInstance)
: new LegacyControllerInit(initObject as InitFunction),
Copy link
Member

Choose a reason for hiding this comment

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

Rather than using unsafe type assertions here, we can use a type guard:

Suggested change
(initObject as InitInstance).init
? (initObject as InitInstance)
: new LegacyControllerInit(initObject as InitFunction),
isInitInstance(initObject)
? initObject
: new LegacyControllerInit(initObject),

Using a type guard like this:

/**
 * Type guard to distinguish the two types of `InitObject`.
 *
 * @param initObject - The object to check
 * @returns Returns `true` if it's an `InitInstance`, false otherwise.
 */
function isInitInstance(initObject: InitObject): initObject is InitInstance {
  return 'init' in initObject;
}

/**
* Adapter class to handle legacy controllers that are returned from a function.
*/
class LegacyControllerInit extends ControllerInit<
Copy link
Member

Choose a reason for hiding this comment

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

Perhaps there's a better term for these 🤔. We've been talking about "legacy controllers" for a while, but in this case it's a "legacy pattern for constructing controllers" I guess.

e.g. maybe we could call this ControllerFunctionInit - init based on a controller function.

@@ -5704,6 +5562,7 @@ export default class MetamaskController extends EventEmitter {
handleUpdate();
},
getStatePatches: () => patchStore.flushPendingPatches(),
...this.controllerApi,
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Perhaps we could move this to before the patch methods, so they can't be accidentally overwritten? Not that that seems especially likely, but it's certainly not something we ever want to be possible.

* Includes reducer properties.
* For example: `{ metamask: { transactions: [] } }`.
*/
getStateUI: () => unknown & { metamask: unknown };
Copy link
Member

Choose a reason for hiding this comment

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

This is misleading because we're not really getting the UI state here. We're getting the flattened state nested in an object under the metamask key.

Maybe we could build this on-the-fly using getFlatState, and drop this from here? Just to simplify the initial request object.

* Request to initialize and return a controller instance.
* Includes standard data and methods not coupled to any specific controller.
*/
export type ControllerInitRequest<
Copy link
Member

@Gudahtt Gudahtt Jan 13, 2025

Choose a reason for hiding this comment

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

This proposal is abstraction-heavy; we're introducing a bunch of new constructs to facilitate this pattern of splitting up controller initialization. I'm wondering if we could cut any of them out and make this process simpler to understand.

If I'm understanding correctly, the current process is roughly:

  • Controllers are initialized by #initControllers in metamask-controller.js
  • #initControllers calls the initControllers function, passing in relevant state that only metamask-controller.js has (persisted state, functions, etc.)
  • initControllers will, for each controller:
    • Create an "Init instance"
      • This instance doesn't have access to any global capabilities. It receives them when it's initialized, and uses them to complete controller-specific initialization
    • Create an "init request", and use it to initialize each "init object", which in turn initializes the controller.
      • This request passes in a custom "init messenger" and "messenger" for each controller, but otherwise is responsible for passing in the same global functions to all controllers.
    • Create a "API request" with the controller intance and an even more limited set of global functions (just one right now), and use this to get the "controller API" functions for each controller
    • Use the return value of getApi and various other bits of information from the "init object" to construct a collection of all controller API methods, memstate, persisted state, and a map of controllers by name.
  • Then finally, initControllers returns these four collections to metamask-controller.js to finalize initialization.

The first two steps I understand, those seem necessary (#initControllers for handling state, initControllers for performing initialization with state). But after that point I'm not sure.

I can see the benefits of having a custom "init request" per controller, in that it enables this "init messenger" pattern. We could do without an "init messenger", but it would require us to pass around controller instances to every initializer, which is not ideal for auditability (it'd make refactors of controllers much more painful). In theory we shouldn't need any special capabilities to initialize a controller, controllers can request capabilities from the messenger directly, but in practice we're a long way off from every controller behaving that way. The "init messenger" seems like a good attempt at improving the situation in the short-term, even if it does come at some cost in requiring a custom "init request" per controller.

The benefits of "init instance" and "API request" are less clear to me. Could we have an "initialize" function per controller instead, which returns the controller alongside a collection of API methods, memstate, and persisted state? What benefit does a separate "init instance" and "API request" have? The "API request" part seems especially strange given that the functions it's passed (the controller instance and the getFlatState function) are already accessible from the "init instance", which is where getApi is defined.

If it works just as well as a function, then this would be a good opportunity to reduce cognitive load by reducing the number of abstractions we're using here. But perhaps there is some benefit I am not seeing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
team-confirmations Push issues to confirmations team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants