-
-
Notifications
You must be signed in to change notification settings - Fork 187
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
[assets-contract-controller] Apply messenger pattern and remove inheritance from BaseControllerV1
#4397
Conversation
e019088
to
6ea5485
Compare
9d30de1
to
ad69fb3
Compare
64095c7
to
a1d5fa9
Compare
5da97d5
to
871960d
Compare
ee38d05
to
e9f1916
Compare
AssetsContractController
from BaseControllerV1
to service class utilizing messenger patternAssetsContractController
] Remove inheritance from BaseControllerV1
and convert to use messenger pattern
AssetsContractController
] Remove inheritance from BaseControllerV1
and convert to use messenger patternBaseControllerV1
and convert to use messenger pattern
4c6b87d
to
b54d0bb
Compare
81fcaf2
to
57a71ae
Compare
774741b
to
99fc5b9
Compare
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.
Notes for reviewers - AssetsContractController
:
} | ||
}; | ||
|
||
const name = 'AssetsContractController'; |
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.
Left as name
instead of updating to controllerName
in anticipation of the class being renamed as a non-controller class.
get ipfsGateway() { | ||
return this.#ipfsGateway; | ||
} | ||
|
||
get chainId() { | ||
return this.#chainId; | ||
} |
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.
Added getters for use in tests. Since these class fields are now private and have no setters, they are not externally mutable.
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, why not make the properties public instead of adding getters?
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 want these fields to be immutable from the outside (since they affect the result of methods e.g. #getCorrect{ChainId,Provider}
), but freely assignable from inside of the same class. Private + getter seems to be the only straightforward way of achieving this.
Making them public and readonly
doesn't work because it restricts assignments from internal class methods (i.e. this.chainId = chainId
doesn't work if this.chainId
is readonly).
#getCorrectProvider(networkClientId?: NetworkClientId): Web3Provider { | ||
const provider = networkClientId | ||
? this.getNetworkClientById(networkClientId).provider | ||
: this._provider; | ||
? this.messagingSystem.call( | ||
`NetworkController:getNetworkClientById`, | ||
networkClientId, | ||
).provider | ||
: this.messagingSystem.call('NetworkController:getSelectedNetworkClient') | ||
?.provider ?? this.#provider; |
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.
Retrieves the currently selected provider instance even if the networkClientId
argument is not provided (fixes breaking tests).
#getCorrectChainId(networkClientId?: NetworkClientId): Hex { | ||
if (networkClientId) { | ||
const networkClientConfiguration = this.messagingSystem.call( | ||
'NetworkController:getNetworkConfigurationByNetworkClientId', | ||
networkClientId, | ||
); | ||
if (networkClientConfiguration) { | ||
return networkClientConfiguration.chainId; | ||
} | ||
} | ||
const { selectedNetworkClientId } = this.messagingSystem.call( | ||
'NetworkController:getState', | ||
); | ||
const networkClient = this.messagingSystem.call( | ||
'NetworkController:getNetworkClientById', | ||
selectedNetworkClientId, | ||
); | ||
return networkClient.configuration?.chainId ?? this.#chainId; |
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.
Retrieves the currently selected chainId even if the networkClientId
argument is not provided (fixes breaking tests).
@@ -25,7 +30,7 @@ import { ERC721Standard } from './Standards/NftStandards/ERC721/ERC721Standard'; | |||
* @param chainId - ChainID of network | |||
* @returns Whether the current network supports token detection | |||
*/ | |||
export const SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID: Record<Hex, string> = { | |||
export const SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID = { |
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.
Narrows type to const-asserted literal properties. See changelog "Changed" section for details.
if ( | ||
!((id): id is keyof typeof SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID => | ||
id in SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID)(chainId) | ||
) { |
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.
Type guard narrows chainId
to the index signature of SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID
. Without this, attempting to use chainId
as a property accessor for SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID
will result in the following error:
Element implicitly has an 'any' type because expression of type '`0x${string}`' can't be used to index type '{ readonly "0x1": "0xb1f8e55c7f64d203c1400b9d8555d050f94adf39"; readonly "0x38": "0x2352c63A83f9Fd126af8676146721Fa00924d7e4"; readonly "0x89": "0x2352c63A83f9Fd126af8676146721Fa00924d7e4"; ... 14 more ...; readonly "0x505": "0x6aa75276052d96696134252587894ef5ffa520af"; }'.ts(7053)
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. The hasProperty
function from @metamask/utils
is supposed to get around this. Strange.
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.
It looks like hasProperty
only serves as a type guard for objectToCheck
, and not the property key. The adhoc type guard here narrows chainId
, not SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID
.
hasProperty(SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID, chainId)
does not is
-assert that chainId
is any narrower than Hex
.
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.
Er, right. That makes sense.
I feel like there must be a more concise way to have TypeScript infer that id
is keyof typeof SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID
. But my brain isn't working right now, and this seems readable enough.
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.
Yes it seems like just the in
check should be sufficient to narrow chainId
down to keyof
.
It looks like this update in v5.5 microsoft/TypeScript#57847 might be related.
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! Nice, I'll follow that PR.
* | ||
* TODO: Replace this wth a method. | ||
* | ||
* @property provider - Provider used to create a new underlying Web3 instance | ||
* @param provider - Provider used to create a new underlying Web3 instance | ||
*/ | ||
set provider(provider: Provider) { | ||
this._provider = provider; | ||
setProvider(provider: Provider | undefined) { | ||
this.#provider = provider; | ||
} | ||
|
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.
Replaces provider
setter. Allows undefined
literal to be passed in and assigned, which is a frequent pattern in the tests.
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.
Notes for reviewers - tests:
@@ -56,31 +61,39 @@ const TEST_ACCOUNT_PUBLIC_ADDRESS = | |||
*/ | |||
async function setupAssetContractControllers({ | |||
options, | |||
useNetworkControllerProvider, | |||
useNetworkControllerProvider = false, |
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.
Previously, the controller was relying on useNetworkControllerProvider
being evaluated as a falsy value when set to undefined
.
options?: Partial< | ||
Omit<ConstructorParameters<typeof AssetsContractController>[0], 'messenger'> | ||
>; |
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.
All constructor options except for messenger
, which is defined by this function.
const controllerMessenger = new ControllerMessenger< | ||
| NetworkControllerActions | ||
| AssetsContractControllerActions | ||
| AssetsContractAllowedActions, | ||
| NetworkControllerEvents | ||
| AssetsContractControllerEvents | ||
| AssetsContractAllowedEvents | ||
>(); |
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.
The function is updated to use a shared, unrestricted controller messenger for communications between NetworkController and AssetsContractController.
).rejects.toThrow( | ||
`No custom network client was found with the ID "invalidNetworkClientId".`, | ||
); | ||
messenger.clearEventSubscriptions('NetworkController:networkDidChange'); |
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.
Fix outdated reference to NetworkController:stateChange
.
const callActionSpy = jest | ||
.spyOn(messenger, 'call') | ||
// 1. `AccountsController:getAccount` | ||
.mockReturnValueOnce(OWNER_ACCOUNT) | ||
// 2. `AssetsContractController:getERC721OwnerOf` | ||
.mockResolvedValueOnce(OWNER_ADDRESS) | ||
// 3. `AssetsContractController:getERC721TokenURI` | ||
.mockResolvedValueOnce('https://testtokenuri.com') | ||
// 4. `ApprovalController:addRequest` | ||
.mockResolvedValueOnce({}) | ||
.mockReturnValueOnce(OWNER_ACCOUNT); | ||
// 5. `AccountsController:getAccount` | ||
.mockReturnValueOnce(OWNER_ACCOUNT) | ||
// 6. `AssetsContractController:getERC721AssetName` | ||
.mockResolvedValueOnce('testERC721Name') | ||
// 7. `AssetsContractController:getERC721AssetSymbol` | ||
.mockResolvedValueOnce('testERC721Symbol'); | ||
|
||
await nftController.watchNft(ERC721_NFT, ERC721, 'https://test-dapp.com'); | ||
// First call is getInternalAccount. Second call is the approval request. | ||
expect(callActionSpy).toHaveBeenCalledTimes(3); | ||
expect(callActionSpy).toHaveBeenCalledTimes(7); | ||
expect(callActionSpy).toHaveBeenNthCalledWith( | ||
2, | ||
4, | ||
'ApprovalController:addRequest', |
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.
This is the flow of NftController method calls and messenger call invocations triggered by a watchNft
call on a ERC721 asset.
- `#getAddressOrSelectedAddress`
1. `AccountsController:getAccount`
- `#validateWatchNft` > `isNftOwner`
2. `AssetsContractController:getERC721OwnerOf`
- `#getNftInformation` > `#getNftInformationFromTokenURI` > `#getNftURIAndStandard`
3. `AssetsContractController:getERC721TokenURI`
- `_requestApproval`
4. `ApprovalController:addRequest`
- `addNft` > `#getAddressOrSelectedAddress`
5. `AccountsController:getAccount`
- `#addNftContract` > `#getNftContractInformation` > `#getNftContractInformationFromContract`
6. `AssetsContractController:getERC721AssetName`
7. `AssetsContractController:getERC721AssetSymbol`
const callActionSpy = jest | ||
.spyOn(messenger, 'call') | ||
// 1. `AccountsController:getAccount` | ||
.mockReturnValueOnce(OWNER_ACCOUNT) | ||
// 2. `AssetsContractController:getERC721OwnerOf` | ||
.mockRejectedValueOnce(new Error('Not an ERC721 contract')) | ||
// 3. `AssetsContractController:getERC1155BalanceOf` | ||
.mockResolvedValueOnce(new BN(1)) | ||
// 4. `AssetsContractController:getERC721TokenURI` | ||
.mockRejectedValueOnce(new Error('Not an ERC721 contract')) | ||
// 5. `AssetsContractController:getERC1155TokenURI` | ||
.mockResolvedValueOnce('https://testtokenuri.com') | ||
// 6. `ApprovalController:addRequest` | ||
.mockResolvedValueOnce({}) | ||
.mockReturnValueOnce(OWNER_ACCOUNT); | ||
// 7. `AccountsController:getAccount` | ||
.mockReturnValueOnce(OWNER_ACCOUNT) | ||
// 8. `AssetsContractController:getERC1155AssetName` | ||
.mockResolvedValueOnce('testERC1155Name') | ||
// 9. `AssetsContractController:getERC1155AssetSymbol` | ||
.mockResolvedValueOnce('testERC1155Symbol'); | ||
|
||
await nftController.watchNft( | ||
ERC1155_NFT, | ||
ERC1155, | ||
'https://etherscan.io', | ||
); | ||
// First call is getInternalAccount. Second call is the approval request. | ||
expect(callActionSpy).toHaveBeenCalledTimes(3); | ||
expect(callActionSpy).toHaveBeenCalledTimes(9); | ||
expect(callActionSpy).toHaveBeenNthCalledWith( | ||
2, | ||
6, | ||
'ApprovalController:addRequest', |
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.
This is the flow of NftController method calls and messenger call invocations triggered by a watchNft
call on a ERC1155 asset.
- `#getAddressOrSelectedAddress`
1. `AccountsController:getAccount`
- `#validateWatchNft` > `isNftOwner`
2. `AssetsContractController:getERC721OwnerOf` (rejects)
3. `AssetsContractController:getERC1155BalanceOf`
- `#getNftInformation` > `#getNftInformationFromTokenURI` > `#getNftURIAndStandard`
4. `AssetsContractController:getERC721TokenURI` (rejects)
5. `AssetsContractController:getERC1155TokenURI`
- `_requestApproval`
6. `ApprovalController:addRequest`
- `addNft` > `#getAddressOrSelectedAddress`
7. `AccountsController:getAccount`
- `#addNftContract` > `#getNftContractInformation` > `#getNftContractInformationFromContract`
8. `AssetsContractController:getERC721AssetName` (rejects)
9. `AssetsContractController:getERC721AssetSymbol` (rejects)
description: 'testERC721Description', | ||
image: 'testERC721Image', | ||
name: 'testERC721Name', |
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.
The result for this test changed with all messenger calls being fully mocked. Are we perhaps now getting the correct result, or is this unexpected behavior?
getERC721TokenURI: jest | ||
.fn() | ||
.mockImplementation(() => 'https://testtokenuri.com'), | ||
getERC721OwnerOf: jest.fn().mockImplementation(() => OWNER_ADDRESS), |
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.
These mocks are redundant with the return and resolved values being fully mocked for the callActionSpy
. Should we rely on fully mocking the action handlers instead?
- Restore usage of `Object.getPrototypeOf` - add `setProvider` to exceptions
/** | ||
* A utility type that derives the public method names of a given messenger consumer class, | ||
* and uses it to generate the class's internal messenger action types. | ||
* @template Controller - A messenger consumer class. | ||
*/ | ||
// TODO: Figure out generic constraint and move to base-controller | ||
type ControllerActionsMap<Controller> = { | ||
[ClassMethod in keyof Controller as Controller[ClassMethod] extends ActionConstraint['handler'] | ||
? ClassMethod | ||
: never]: { | ||
type: `${typeof name}:${ClassMethod & string}`; | ||
handler: Controller[ClassMethod]; | ||
}; | ||
}; | ||
|
||
type AssetsContractControllerActionsMap = | ||
ControllerActionsMap<AssetsContractController>; | ||
|
||
/** | ||
* The union of all public class method names of {@link AssetsContractController}. | ||
*/ | ||
type AssetsContractControllerMethodName = | ||
keyof AssetsContractControllerActionsMap; | ||
|
||
/** | ||
* The union of all internal messenger actions available to the {@link AssetsContractControllerMessenger}. | ||
*/ | ||
export type AssetsContractControllerActions = | ||
AssetsContractControllerActionsMap[AssetsContractControllerMethodName]; |
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.
Proof of concept for new base-controller utility type that should be useful when creating new controllers, or adding a large number of new actions/methods.
// TODO: Expand into base-controller utility function that batch registers action handlers. | ||
#registerActionHandlers() { | ||
const nonMethodClassProperties = [ | ||
'constructor', | ||
'messagingSystem', | ||
'setProvider', | ||
'provider', | ||
'ipfsGateway', | ||
'chainId', | ||
]; | ||
|
||
getKnownPropertyNames<keyof this>(Object.getPrototypeOf(this)).forEach( | ||
(method) => { | ||
if ( | ||
((key: keyof this): key is AssetsContractControllerMethodName => | ||
!nonMethodClassProperties.find((e) => e === key) && | ||
typeof this[key] === 'function')(method) | ||
) { | ||
this.messagingSystem.registerActionHandler( | ||
`${name}:${method}`, | ||
// TODO: Write a generic for-loop implementation that iterates over an input union type in tandem with the input array. | ||
// @ts-expect-error Both assigned argument and assignee parameter are using the entire union type for `method` instead of the type for the current element | ||
this[method].bind(this), | ||
); | ||
} | ||
}, | ||
); | ||
} |
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.
Proof of concept for new base-controller utility function that can batch register action handlers to a given messenger.
fd1e1d1
to
b758d93
Compare
96db928
to
161b7ce
Compare
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.
Makes sense overall. Just had some comments/suggestions.
}; | ||
}; | ||
|
||
type AssetsContractControllerActionsMap = |
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.
Food for thought: I'm curious whether in the future we should deprecate separate types for actions in favor of one type that holds all actions, like this. For cases like this it seems like it would save time for both the author (because we wouldn't have to repeat the name of the action in the name of the type) and the maintainer (because we wouldn't have to check that names of types have the correct form).
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.
One overkill solution we could try is to use the TypeScript compiler API. We could define a function that would auto-generate the individual types by manipulating the AST directly, and then output a module containing those types.
But I agree that consolidating the type exports into lookup tables would be just as effective and much simpler.
const nonMethodClassProperties = [ | ||
'constructor', | ||
'messagingSystem', | ||
'setProvider', | ||
'provider', | ||
'ipfsGateway', | ||
'chainId', | ||
]; | ||
|
||
getKnownPropertyNames<keyof this>(Object.getPrototypeOf(this)).forEach( | ||
(method) => { | ||
if ( | ||
((key: keyof this): key is AssetsContractControllerMethodName => | ||
!nonMethodClassProperties.find((e) => e === key) && |
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.
Doesn't the typeof this[key] === 'function'
check already exclude non-methods? Would it be enough to just exclude setProvider
here?
const nonMethodClassProperties = [ | |
'constructor', | |
'messagingSystem', | |
'setProvider', | |
'provider', | |
'ipfsGateway', | |
'chainId', | |
]; | |
getKnownPropertyNames<keyof this>(Object.getPrototypeOf(this)).forEach( | |
(method) => { | |
if ( | |
((key: keyof this): key is AssetsContractControllerMethodName => | |
!nonMethodClassProperties.find((e) => e === key) && | |
const methodsExcludedFromMessenger = [ | |
'setProvider', | |
]; | |
getKnownPropertyNames<keyof this>(Object.getPrototypeOf(this)).forEach( | |
(method) => { | |
if ( | |
((key: keyof this): key is AssetsContractControllerMethodName => | |
!methodsExcludedFromMessenger.find((e) => e === key) && |
Alternatively, why exclude setProvider
? Would it be easier to assume that all methods will become actions?
const nonMethodClassProperties = [ | |
'constructor', | |
'messagingSystem', | |
'setProvider', | |
'provider', | |
'ipfsGateway', | |
'chainId', | |
]; | |
getKnownPropertyNames<keyof this>(Object.getPrototypeOf(this)).forEach( | |
(method) => { | |
if ( | |
((key: keyof this): key is AssetsContractControllerMethodName => | |
!nonMethodClassProperties.find((e) => e === key) && | |
getKnownPropertyNames<keyof this>(Object.getPrototypeOf(this)).forEach( | |
(method) => { | |
if ( | |
((key: keyof this): key is AssetsContractControllerMethodName => |
Also why use Object.getPrototypeOf(this)
, why not use this
directly?
const nonMethodClassProperties = [ | |
'constructor', | |
'messagingSystem', | |
'setProvider', | |
'provider', | |
'ipfsGateway', | |
'chainId', | |
]; | |
getKnownPropertyNames<keyof this>(Object.getPrototypeOf(this)).forEach( | |
(method) => { | |
if ( | |
((key: keyof this): key is AssetsContractControllerMethodName => | |
!nonMethodClassProperties.find((e) => e === key) && | |
getKnownPropertyNames<keyof this>(this).forEach( | |
(method) => { | |
if ( | |
((key: keyof this): key is AssetsContractControllerMethodName => |
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.
Doesn't the typeof this[key] === 'function' check already exclude non-methods? Would it be enough to just exclude setProvider here?
All public methods that are not actions should be explicitly excluded here. Otherwise, the forEach loop will register actions like AssetsContractController:constructor
. While in TypeScript environments our types will prevent these from being used, in downstream JavaScript environments, these actions will be accessible at runtime.
This is both an unintended and undesirable extension of the messenger's API, as well as a potential security risk in the form of a ready-made dynamic property access vulnerability (see #4041 -- especially constructor
key as attack vector), since the action handler this['constructor'].bind(this)
is bound to the class instance in use.
That said, the variable name methodsExcludedFromMessenger
is much clearer. Renamed here: f50e5f4
Also why use Object.getPrototypeOf(this), why not use this directly?
This results in errors (e.g "A handler for AssetsContractController:getERC1155BalanceOf has not been registered"
) showing that none of the methods are being registered as action handlers.
This is because public class methods are defined as sub-properties of the class's prototype
property, not top-level properties of the class itself, meaning we need to iterate over the prototype object here, not this
.
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.
This is because public class methods are defined as sub-properties of the class's
prototype
property, not top-level properties of the class itself, meaning we need to iterate over the prototype object, notthis
.
Oh, I forgot that getKnownPropertyNames
doesn't loop over all properties of an object, it only loops over owned properties. Okay, that makes sense now.
get ipfsGateway() { | ||
return this.#ipfsGateway; | ||
} | ||
|
||
get chainId() { | ||
return this.#chainId; | ||
} |
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, why not make the properties public instead of adding getters?
/** | ||
* Get the relevant provider instance. | ||
* | ||
* @param networkClientId - Network Client ID. | ||
* @returns Web3Provider instance. | ||
*/ | ||
getProvider(networkClientId?: NetworkClientId): Web3Provider { |
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 it necessary to make this method private and rename it in this PR? It's weird that it exists, but it seems separate from making this class a non-controller.
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.
Since we're introducing the messagingSystem into this class, it seems reasonable to enforce a norm that public methods should be messenger actions in principle (except for constructor
, getters/setters etc.). The implementation of ControllerActionsMap
and #registerActionHandlers
(at least in their current, simple form) also rely on this norm being in place.
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.
Gotcha, makes sense.
if ( | ||
!((id): id is keyof typeof SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID => | ||
id in SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID)(chainId) | ||
) { |
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. The hasProperty
function from @metamask/utils
is supposed to get around this. Strange.
await expect( | ||
assetsContract.getERC20BalanceOf( | ||
messenger.call( |
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 see that you've changed all of the instances where we are calling a method on the class to call the corresponding messenger action. That makes sense — but it does create some inconsistency with the guidance we have for writing tests. The describe
s for these tests use the name of the method, but here we are calling the messenger action. I worry that could be a bit confusing when reading these tests.
Do we want to change the describe
s, or how do you think is the best way to resolve this discrepancy?
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 a little confused about this. It looks like describe
is only used once at the top level, and the it
descriptions don't use method names directly in all affected test files except NftController.test.ts
? For NftController
it seems like the methods are being tested directly.
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.
Ah, you're right. We don't follow that describe
convention here anyway.
Then I guess I will ask a slightly different question. Fast-forwarding, if we were to automatically register messenger actions for all public methods in all controllers, how should we go about writing tests? Would we ensure that the tests that currently center around method now center around the messenger actions (i.e. instead of calling the method we now call the messenger action in the test)? Would we test both the method and the messenger action by wrapping existing tests for a public method in a loop so that we can easily run the same tests for that method and the messenger action without having to copy those tests?
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 wonder if we could omit method tests if a messenger action handler has a single corresponding class method because it is defined using this[method].bind(this)
?
If we're only testing one of the two, it should be safer to test the messenger action, since the constraints for setting up a messenger action call (e.g. instantiating messenger and enumerating available actions) is a superset of the same for invoking its corresponding class method.
Otherwise, the messenger action and the method should be equivalent, but to ensure that they don't become inconsistent due to code drift, we could add an expect
call to these tests verifying that the action handler is a bound class method with no additional logic.
For other cases where a method has no corresponding messenger action (e.g. NftController.watchNft
), or a messenger action handler is not directly derived from a class method (e.g. ApprovalController:addRequest
), we will need to test, respectively, only the method, or both the method and the messenger separately.
What are your thoughts on testing only the messenger actions where the action and the method are equivalent? I feel like I might be missing some of the implications here e.g. how would this affect coverage?
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.
Testing just the messenger in the cases where it's a bound method could work. The coverage tool would still be happy because we would effectively be killing two birds with one stone. The one thing I'm interested to know is whether it is truly possible to test that the messenger action handler is equivalent to the bound version of the method. I found a Stack Overflow answer here but it seems a bit too good to be true. But we could definitely try it out!
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.
Nice that's our exact use case and it looks like it's a simple solution. We should definitely try applying this.
Co-authored-by: Elliot Winkler <[email protected]>
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.
Had one more suggestion, and I still have an open question about the tests. But once we resolve those then I should be good.
I know we talked about this before but I'm a bit hesitant about the keeping the existing name "AssetsContractController". It would probably mess up the diff to rename it considering how many changes there are, so maybe that's something we can do in a separate PR. But my thought here is that if we have something that's not a controller, it seems confusing to name it with a "Controller" suffix. So, I'll think about some alternate names.
const controllerMessenger = new ControllerMessenger< | ||
| NetworkControllerActions | ||
| AssetsContractControllerActions | ||
| AssetsContractAllowedActions, | ||
| NetworkControllerEvents | ||
| AssetsContractControllerEvents | ||
| AssetsContractAllowedEvents | ||
>(); |
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 believe we can do this more easily without having to export AllowedActions
and AllowedEvents
now. A while back I added a couple of utility types for extracting the action and event types from a controller messenger type. So what if we say this instead:
const controllerMessenger = new ControllerMessenger< | |
| NetworkControllerActions | |
| AssetsContractControllerActions | |
| AssetsContractAllowedActions, | |
| NetworkControllerEvents | |
| AssetsContractControllerEvents | |
| AssetsContractAllowedEvents | |
>(); | |
const controllerMessenger = new ControllerMessenger< | |
| ExtractAvailableAction<AssetsContractControllerMessenger> | |
| NetworkControllerActions, | |
| ExtractAvailableEvent<AssetsContractControllerMessenger> | |
| NetworkControllerEvents | |
>(); |
ab6681e
to
d488ce8
Compare
Co-authored-by: Elliot Winkler <[email protected]>
d488ce8
to
49831d9
Compare
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.
This looks great. Very thorough!
Motivation
As part of the Wallet Framework Team's Q2 2024 OKRs (O3KR1: "100% completion of all core controllers to BaseControllerV2"), the
AssetsContractController
needs to be converted to a V2 controller.However, because
AssetsContractController
has an empty state object, it is a non-controller, which must be able to communicate with other controllers using amessagingSystem
, but should not inherit from theBaseControllerV2
class.Explanation
This commit introduces and applies the messenger pattern to the
AssetsContractController
class:AssetsContractControllerMessenger
and associated types.BaseControllerV1
.BaseControllerV2
.The newly defined messenger actions/events are applied downstream in
NftController
andTokenBalancesController
.References
Changelog
@metamask/assets-controllers
Added
TokenBalancesControllerMessenger
must allow theAssetsContractController:getERC20BalanceOf
action in addition to its previous allowed actions. (#4397)NftControllerMessenger
must allow the following actions in addition to its previous allowed actions:AssetsContractController:getERC721AssetName
,AssetsContractController:getERC721AssetSymbol
,AssetsContractController:getERC721TokenURI
,AssetsContractController:getERC721OwnerOf
,AssetsContractController:getERC1155BalanceOf
,AssetsContractController:getERC1155TokenURI
. (#4397)AssetsContractController
class: (#4397)messenger
.messagingSystem
.ipfsGateway
andchainId
. As corresponding setters have not been defined, these properties are not externally mutable.AssetsContractControllerMessenger
type (#4397)AssetsContractControllerMessenger
must allow the external actionsNetworkController:getNetworkClientById
,NetworkController:getNetworkConfigurationByNetworkClientId
,NetworkController:getSelectedNetworkClient
,NetworkController:getState
.AssetsContractControllerMessenger
must allow the external eventsPreferencesController:stateChange
,NetworkController:networkDidChange
.AssetsContractControllerActions
,AssetsContractControllerEvents
,AssetsContractControllerGetERC20StandardAction
,AssetsContractControllerGetERC721StandardAction
,AssetsContractControllerGetERC1155StandardAction
,AssetsContractControllerGetERC20BalanceOfAction
,AssetsContractControllerGetERC20TokenDecimalsAction
,AssetsContractControllerGetERC20TokenNameAction
,AssetsContractControllerGetERC721NftTokenIdAction
,AssetsContractControllerGetERC721TokenURIAction
,AssetsContractControllerGetERC721AssetNameAction
,AssetsContractControllerGetERC721AssetSymbolAction
,AssetsContractControllerGetERC721OwnerOfAction
,AssetsContractControllerGetERC1155TokenURIAction
,AssetsContractControllerGetERC1155BalanceOfAction
,AssetsContractControllerTransferSingleERC1155Action
,AssetsContractControllerGetTokenStandardAndDetailsAction
,AssetsContractControllerGetBalancesInSingleCallAction
. (#4397)setProvider
method toAssetsContractController
. (#4397)provider
setter method, and widens theprovider
function parameter type fromProvider
toProvider | undefined
.Changed
SINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID
is narrowed fromRecord<Hex, string>
to the const-asserted literal properties of theSINGLE_CALL_BALANCES_ADDRESS_BY_CHAINID
object. (#4397)SupportedTokenDetectionNetworks
.Record<Hex, string>
using thesatisfies
keyword.BalanceMap
type from aninterface
into a type alias. (#4397)string
by default, and are compatible with theStateConstraint
type defined in the@metamask/base-controller
package.Removed
AssetsContractController
class: (#4397)AssetsContractController
no longer inherits fromBaseControllerV1
.onPreferencesStateChange
,onNetworkDidChange
,getNetworkClientById
, and replace with corresponding messenger actions and events.name
,config
(along with its propertiesprovider
,ipfsGateway
,chainId
).getProvider
,getChainId
.provider
setter method.getERC20BalanceOf
constructor option callback from theTokenBalancesControllerOptions
type and theTokenBalancesController
constructor. (#4397)NftController
constructor option callbacks:getERC721AssetName
,getERC721AssetSymbol
,getERC721TokenURI
,getERC721OwnerOf
,getERC1155BalanceOf
,getERC1155TokenURI
. (#4397)AssetsContractConfig
type. (#4397)MISSING_PROVIDER_ERROR
. (#4397)Fixed
getERC721NftTokenId
method of theAssetsContractController
into an async function. (#4397)Checklist