Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,20 @@
"packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.test.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 1
},
"no-restricted-syntax": {
"count": 1
}
},
"packages/assets-controllers/src/DeFiPositionsController/DeFiPositionsController.ts": {
"@typescript-eslint/explicit-function-return-type": {
"count": 2
},
"@typescript-eslint/no-misused-promises": {
"count": 2
"count": 3
},
"no-restricted-syntax": {
"count": 1
}
},
"packages/assets-controllers/src/DeFiPositionsController/group-defi-positions.ts": {
Expand Down
6 changes: 6 additions & 0 deletions packages/assets-controllers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/keyring-controller` from `^25.1.1` to `^25.2.0` ([#8363](https://github.com/MetaMask/core/pull/8363))
- Bump `@metamask/messenger` from `^1.0.0` to `^1.1.1` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373))

### Fixed

- `DeFiPositionsController` now refreshes DeFi positions on app launch for returning users ([#8433](https://github.com/MetaMask/core/pull/8433))
- Previously, `selectedAccountGroupChange` did not fire when the persisted selected group was unchanged, leaving positions stale until the next poll interval.
- Now subscribes to `AccountTreeController:stateChange`, which is guaranteed to fire when `AccountTreeController.init()` calls `this.update()`, triggering a position refresh once accounts are available.

## [103.1.1]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function setupController({
'KeyringController:lock',
'TransactionController:transactionConfirmed',
'AccountTreeController:selectedAccountGroupChange',
'AccountTreeController:stateChange',
],
});

Expand Down Expand Up @@ -156,11 +157,16 @@ function setupController({
);
};

const triggerAccountTreeStateChange = (): void => {
messenger.publish('AccountTreeController:stateChange', {} as never, []);
};

return {
controller,
triggerLock,
triggerTransactionConfirmed,
triggerAccountGroupChange,
triggerAccountTreeStateChange,
buildPositionsFetcherSpy,
updateSpy,
mockFetchPositions,
Expand Down Expand Up @@ -385,6 +391,71 @@ describe('DeFiPositionsController', () => {
expect(updateSpy).toHaveBeenCalledTimes(1);
});

it('fetches positions for the selected evm account when the account tree state changes', async () => {
const mockFetchPositions = jest.fn().mockResolvedValue('mock-fetch-data-1');
const mockGroupDeFiPositions = jest
.fn()
.mockReturnValue('mock-grouped-data-1');

const {
controller,
triggerAccountTreeStateChange,
buildPositionsFetcherSpy,
updateSpy,
} = setupController({
mockFetchPositions,
mockGroupDeFiPositions,
});

triggerAccountTreeStateChange();
await flushPromises();

expect(controller.state).toStrictEqual({
allDeFiPositions: {
[GROUP_ACCOUNTS[0].address]: 'mock-grouped-data-1',
},
allDeFiPositionsCount: {},
});

expect(buildPositionsFetcherSpy).toHaveBeenCalled();

expect(mockFetchPositions).toHaveBeenCalledWith(GROUP_ACCOUNTS[0].address);
expect(mockFetchPositions).toHaveBeenCalledTimes(1);

expect(mockGroupDeFiPositions).toHaveBeenCalledWith('mock-fetch-data-1');
expect(mockGroupDeFiPositions).toHaveBeenCalledTimes(1);

expect(updateSpy).toHaveBeenCalledTimes(1);
});

it('does not fetch positions when the account tree state changes and there is no evm account', async () => {
const {
controller,
triggerAccountTreeStateChange,
buildPositionsFetcherSpy,
updateSpy,
mockFetchPositions,
mockGroupDeFiPositions,
} = setupController({
mockGroupAccounts: GROUP_ACCOUNTS_NO_EVM,
});

triggerAccountTreeStateChange();
await flushPromises();

expect(controller.state).toStrictEqual(
getDefaultDefiPositionsControllerState(),
);

expect(buildPositionsFetcherSpy).toHaveBeenCalled();

expect(mockFetchPositions).not.toHaveBeenCalled();

expect(mockGroupDeFiPositions).not.toHaveBeenCalled();

expect(updateSpy).not.toHaveBeenCalled();
});

it('does not fetch positions when the account group changes and there is no evm account', async () => {
const {
controller,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AccountTreeControllerGetAccountsFromSelectedAccountGroupAction,
AccountTreeControllerSelectedAccountGroupChangeEvent,
AccountTreeControllerStateChangeEvent,
} from '@metamask/account-tree-controller';
import type {
ControllerGetStateAction,
Expand Down Expand Up @@ -114,7 +115,8 @@ export type AllowedActions =
export type AllowedEvents =
| KeyringControllerLockEvent
| TransactionControllerTransactionConfirmedEvent
| AccountTreeControllerSelectedAccountGroupChangeEvent;
| AccountTreeControllerSelectedAccountGroupChangeEvent
| AccountTreeControllerStateChangeEvent;

/**
* The messenger of the {@link DeFiPositionsController}.
Expand Down Expand Up @@ -203,6 +205,20 @@ export class DeFiPositionsController extends StaticIntervalPollingController()<
},
);

// `selectedAccountGroupChange` does not fire on returning users when the
// persisted selected group is unchanged. `:stateChange` is guaranteed to
// fire when `AccountTreeController.init()` calls `this.update()`, so we
// use it to catch the initial tree build and trigger a position refresh.
this.messenger.subscribe('AccountTreeController:stateChange', async () => {
const selectedAddress = this.#getSelectedEvmAdress();

if (!selectedAddress) {
return;
}

await this.#updateAccountPositions(selectedAddress);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unbounded stateChange subscription causes duplicate and unnecessary fetches

Medium Severity

The AccountTreeController:stateChange subscription fires on every state mutation in AccountTreeController (account add/remove, group selection, wallet status change, etc.), not just during init(). This causes two problems: (1) when a user changes account groups, both the stateChange and selectedAccountGroupChange handlers fire, triggering duplicate network fetches of DeFi positions; (2) unrelated state changes like adding/removing accounts or wallet status updates trigger unnecessary position fetches. The comment states the intent is to "catch the initial tree build," but unlike AssetsController's idempotent #updateActive(), #updateAccountPositions makes a non-idempotent network request every time.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit de89ca9. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is a temporary solution until the event AccountTreeController:selectedAccountTreeChanged will be fixed


this.#trackEvent = trackEvent;
}

Expand Down
Loading