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

fix(multichain): use accounts{Added,Removed} to fetch/clear balances #25884

Merged
merged 19 commits into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
262616b
fix(multichain): use accounts{Added,Removed} to fetch/clear balances
ccharly Jul 17, 2024
9e959df
feat(multichain): now tracks non-EVM balances to relax excessive fetches
ccharly Jul 17, 2024
17a2e96
test(multichain): fix + add more tests for BalancesController
ccharly Jul 17, 2024
00cc6be
test(multichain): add tests for BalancesTracker
ccharly Jul 17, 2024
d9c01bb
chore: lint
ccharly Jul 17, 2024
bd3dda9
refactor(multichain): use action rather than AccountsController.listM…
ccharly Jul 18, 2024
ad712af
refactor(multichain): add public BalancesController.updateBalance method
ccharly Jul 18, 2024
c8e21df
fix(multichain): only tracks non-EVM accounts when constructing Balan…
ccharly Jul 18, 2024
7008a68
test(multichain): fix metamask-controller.test.js with new BalancesCo…
ccharly Jul 18, 2024
06c946b
fix(multichain): do not subscribe to AccountsController:stateChange w…
ccharly Jul 18, 2024
502872e
feat(multichain): force fetch balance upon account creation (in Creat…
ccharly Jul 18, 2024
f973430
feat(multichain): update fetch info when fetching single account bala…
ccharly Jul 18, 2024
a3bd765
fix(multichain): fix BalancesController initial state
ccharly Jul 18, 2024
1e805b2
fix(multichain): fix btc balances tests
ccharly Jul 18, 2024
ecd1b6a
feat(multichain): add BALANCES_UPDATE_TIME (lower than average block …
ccharly Jul 19, 2024
175abe5
refactor(multichain): use Promise.allSettled when fecthing all balances
ccharly Jul 19, 2024
3dba8f7
refactor(multichain): use named function for BalancesTracker's callback
ccharly Jul 19, 2024
5be1e46
test(multichain): remove use of it.only
ccharly Jul 19, 2024
9637c8e
refactor(btc): prevent error from bubbling up when fetching BTC balan…
ccharly Jul 19, 2024
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
84 changes: 64 additions & 20 deletions app/scripts/lib/accounts/BalancesController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
defaultState,
BalancesControllerMessenger,
} from './BalancesController';
import { Poller } from './Poller';
import { BalancesTracker } from './BalancesTracker';

const mockBtcAccount = createMockInternalAccount({
address: '',
Expand Down Expand Up @@ -54,8 +54,14 @@ const setupController = ({
const balancesControllerMessenger: BalancesControllerMessenger =
controllerMessenger.getRestricted({
name: 'BalancesController',
allowedActions: ['SnapController:handleRequest'],
allowedEvents: ['AccountsController:stateChange'],
allowedActions: [
'SnapController:handleRequest',
'AccountsController:listMultichainAccounts',
],
allowedEvents: [
'AccountsController:accountAdded',
'AccountsController:accountRemoved',
],
});

const mockSnapHandleRequest = jest.fn();
Expand All @@ -66,20 +72,22 @@ const setupController = ({
),
);

// TODO: remove when listMultichainAccounts action is available
const mockListMultichainAccounts = jest
.fn()
.mockReturnValue(mocks?.listMultichainAccounts ?? [mockBtcAccount]);
const mockListMultichainAccounts = jest.fn();
controllerMessenger.registerActionHandler(
'AccountsController:listMultichainAccounts',
mockListMultichainAccounts.mockReturnValue(
mocks?.listMultichainAccounts ?? [mockBtcAccount],
),
);

const controller = new BalancesController({
messenger: balancesControllerMessenger,
state,
// TODO: remove when listMultichainAccounts action is available
listMultichainAccounts: mockListMultichainAccounts,
});

return {
controller,
messenger: controllerMessenger,
mockSnapHandleRequest,
mockListMultichainAccounts,
};
Expand All @@ -91,19 +99,19 @@ describe('BalancesController', () => {
expect(controller.state).toEqual({ balances: {} });
});

it('starts polling when calling start', async () => {
const spyPoller = jest.spyOn(Poller.prototype, 'start');
it('starts tracking when calling start', async () => {
const spyTracker = jest.spyOn(BalancesTracker.prototype, 'start');
const { controller } = setupController();
await controller.start();
expect(spyPoller).toHaveBeenCalledTimes(1);
expect(spyTracker).toHaveBeenCalledTimes(1);
});

it('stops polling when calling stop', async () => {
const spyPoller = jest.spyOn(Poller.prototype, 'stop');
it('stops tracking when calling stop', async () => {
const spyTracker = jest.spyOn(BalancesTracker.prototype, 'stop');
const { controller } = setupController();
await controller.start();
await controller.stop();
expect(spyPoller).toHaveBeenCalledTimes(1);
expect(spyTracker).toHaveBeenCalledTimes(1);
});

it('update balances when calling updateBalances', async () => {
Expand All @@ -113,13 +121,49 @@ describe('BalancesController', () => {

expect(controller.state).toEqual({
balances: {
[mockBtcAccount.id]: {
'bip122:000000000933ea01ad0ee984209779ba/slip44:0': {
amount: '0.00000000',
unit: 'BTC',
},
[mockBtcAccount.id]: mockBalanceResult,
},
});
});

it.only('update balances when "AccountsController:accountAdded" is fired', async () => {
const { controller, messenger, mockListMultichainAccounts } =
setupController({
mocks: {
listMultichainAccounts: [],
},
});

controller.start();
mockListMultichainAccounts.mockReturnValue([mockBtcAccount]);
messenger.publish('AccountsController:accountAdded', mockBtcAccount);
await controller.updateBalances();

expect(controller.state).toEqual({
balances: {
[mockBtcAccount.id]: mockBalanceResult,
},
});
});

it.only('update balances when "AccountsController:accountRemoved" is fired', async () => {
const { controller, messenger, mockListMultichainAccounts } =
setupController();

controller.start();
await controller.updateBalances();
expect(controller.state).toEqual({
balances: {
[mockBtcAccount.id]: mockBalanceResult,
},
});

messenger.publish('AccountsController:accountRemoved', mockBtcAccount.id);
mockListMultichainAccounts.mockReturnValue([]);
await controller.updateBalances();

expect(controller.state).toEqual({
balances: {},
});
});
});
175 changes: 131 additions & 44 deletions app/scripts/lib/accounts/BalancesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import type { SnapId } from '@metamask/snaps-sdk';
import { HandlerType } from '@metamask/snaps-utils';
import type { Draft } from 'immer';
import type {
AccountsControllerChangeEvent,
AccountsControllerState,
AccountsControllerAccountAddedEvent,
AccountsControllerAccountRemovedEvent,
AccountsControllerListMultichainAccountsAction,
} from '@metamask/accounts-controller';
import { isBtcMainnetAddress } from '../../../../shared/lib/multichain';
import { Poller } from './Poller';
import { BalancesTracker } from './BalancesTracker';

const controllerName = 'BalancesController';

Expand Down Expand Up @@ -85,12 +86,16 @@ export type BalancesControllerEvents = BalancesControllerStateChange;
/**
* Actions that this controller is allowed to call.
*/
export type AllowedActions = HandleSnapRequest;
export type AllowedActions =
| HandleSnapRequest
| AccountsControllerListMultichainAccountsAction;

/**
* Events that this controller is allowed to subscribe.
*/
export type AllowedEvents = AccountsControllerChangeEvent;
export type AllowedEvents =
| AccountsControllerAccountAddedEvent
| AccountsControllerAccountRemovedEvent;

/**
* Messenger type for the BalancesController.
Expand Down Expand Up @@ -119,7 +124,7 @@ const balancesControllerMetadata = {

const BTC_TESTNET_ASSETS = ['bip122:000000000933ea01ad0ee984209779ba/slip44:0'];
const BTC_MAINNET_ASSETS = ['bip122:000000000019d6689c085ae165831e93/slip44:0'];
export const BTC_AVG_BLOCK_TIME = 600000; // 10 minutes in milliseconds
export const BTC_AVG_BLOCK_TIME = 10 * 60 * 1000; // 10 minutes in milliseconds
ccharly marked this conversation as resolved.
Show resolved Hide resolved

/**
* The BalancesController is responsible for fetching and caching account
Expand All @@ -130,19 +135,14 @@ export class BalancesController extends BaseController<
BalancesControllerState,
BalancesControllerMessenger
> {
#poller: Poller;

// TODO: remove once action is implemented
#listMultichainAccounts: () => InternalAccount[];
#tracker: BalancesTracker;

constructor({
messenger,
state,
listMultichainAccounts,
}: {
messenger: BalancesControllerMessenger;
state: BalancesControllerState;
listMultichainAccounts: () => InternalAccount[];
}) {
super({
messenger,
Expand All @@ -154,27 +154,62 @@ export class BalancesController extends BaseController<
},
});

this.#tracker = new BalancesTracker(async (accountId: string) => {
// The BalancesTracker only uses account IDs, so we have to get the associated account first
const account = this.#listMultichainAccounts().find(
(multichainAccount) => multichainAccount.id === accountId,
);
if (!account) {
throw new Error(`Unknown account: ${accountId}`);
}
// Just to double-check, we make sure we are using a non-EVM account
if (!this.#isNonEvmAccount(account)) {
throw new Error(`Account is not a non-EVM account: ${accountId}`);
}

await this.#updateBalance(account);
ccharly marked this conversation as resolved.
Show resolved Hide resolved
});

// Register all non-EVM accounts into the tracker
for (const account of this.#listAccounts()) {
if (this.#isNonEvmAccount(account)) {
this.#tracker.track(account.id, BTC_AVG_BLOCK_TIME);
}
}

this.messagingSystem.subscribe(
'AccountsController:stateChange',
(newState) => this.#handleOnAccountsControllerChange(newState),
'AccountsController:accountAdded',
(account) => this.#handleOnAccountAdded(account),
);
this.messagingSystem.subscribe(
'AccountsController:accountRemoved',
(account) => this.#handleOnAccountRemoved(account),
);

this.#listMultichainAccounts = listMultichainAccounts;
this.#poller = new Poller(() => this.updateBalances(), BTC_AVG_BLOCK_TIME);
}

/**
* Starts the polling process.
*/
async start(): Promise<void> {
this.#poller.start();
this.#tracker.start();
}

/**
* Stops the polling process.
*/
async stop(): Promise<void> {
this.#poller.stop();
this.#tracker.stop();
}

/**
* Lists the multichain accounts coming from the `AccountsController`.
*
* @returns A list of multichain accounts.
*/
#listMultichainAccounts(): InternalAccount[] {
return this.messagingSystem.call(
'AccountsController:listMultichainAccounts',
);
}

/**
Expand All @@ -185,50 +220,102 @@ export class BalancesController extends BaseController<
*
* @returns A list of accounts that we should get balances for.
*/
async #listAccounts(): Promise<InternalAccount[]> {
#listAccounts(): InternalAccount[] {
const accounts = this.#listMultichainAccounts();

return accounts.filter((account) => account.type === BtcAccountType.P2wpkh);
}

/**
* Updates the balances of all supported accounts. This method doesn't return
* Updates the balances of one account. This method doesn't return
* anything, but it updates the state of the controller.
*
* **NOTE**: This method assumes the given account is a non-EVM account
* associated with a non-EVM Snap.
*
* @param account - The account.
*/
async updateBalances() {
const accounts = await this.#listAccounts();
async #updateBalance(account: InternalAccount) {
const partialState: BalancesControllerState = { balances: {} };

for (const account of accounts) {
if (account.metadata.snap) {
partialState.balances[account.id] = await this.#getBalances(
account.id,
account.metadata.snap.id,
isBtcMainnetAddress(account.address)
? BTC_MAINNET_ASSETS
: BTC_TESTNET_ASSETS,
);
}
}
partialState.balances[account.id] = await this.#getBalances(
account.id,
account.metadata.snap.id,
isBtcMainnetAddress(account.address)
? BTC_MAINNET_ASSETS
: BTC_TESTNET_ASSETS,
);

this.update((state: Draft<BalancesControllerState>) => ({
...state,
...partialState,
balances: {
...state.balances,
...partialState.balances,
},
}));
}

/**
* Handles changes in the accounts state, specifically when new non-EVM accounts are added.
* Updates the balances of one account. This method doesn't return
* anything, but it updates the state of the controller.
*
* @param accountId - The account ID.
*/
async updateBalance(accountId: string) {
await this.#tracker.updateBalance(accountId);
}

/**
* Updates the balances of all supported accounts. This method doesn't return
* anything, but it updates the state of the controller.
*/
async updateBalances() {
await this.#tracker.updateBalances();
}

/**
* Checks for non-EVM accounts.
*
* @param newState - The new state of the accounts controller.
* @param account - The new account to be checked.
* @returns True if the account is a non-EVM account, false otherwise.
*/
#handleOnAccountsControllerChange(newState: AccountsControllerState) {
// If we have any new non-EVM accounts, we just update non-EVM balances
const newNonEvmAccounts = Object.values(
newState.internalAccounts.accounts,
).filter((account) => !isEvmAccountType(account.type));
if (newNonEvmAccounts.length) {
this.updateBalances();
#isNonEvmAccount(account: InternalAccount): boolean {
return (
!isEvmAccountType(account.type) &&
// Non-EVM accounts are backed by a Snap for now
account.metadata.snap
);
}

/**
* Handles changes when a new account has been added.
*
* @param account - The new account being added.
*/
async #handleOnAccountAdded(account: InternalAccount) {
if (!this.#isNonEvmAccount(account)) {
// Nothing to do here for EVM accounts
return;
}

this.#tracker.track(account.id, BTC_AVG_BLOCK_TIME);
}

/**
* Handles changes when a new account has been removed.
*
* @param accountId - The account ID being removed.
*/
async #handleOnAccountRemoved(accountId: string) {
if (this.#tracker.isTracked(accountId)) {
this.#tracker.untrack(accountId);
}

if (accountId in this.state.balances) {
this.update((state: Draft<BalancesControllerState>) => {
delete state.balances[accountId];
return state;
});
}
}

Expand Down
Loading