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

feat: add MultichainBalancesController #4965

Open
wants to merge 63 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 51 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
950a2eb
chore: add dependencies
gantunesr Nov 21, 2024
12a233a
feat: add MultichainBalancesController, BalancesTracker, and Poller
gantunesr Nov 21, 2024
c9b707c
test: add unit test to MultichainBalancesController, BalancesTracker,…
gantunesr Nov 22, 2024
b80a2da
Merge branch 'main' of https://github.com/MetaMask/core into feat/add…
gantunesr Nov 22, 2024
0c15e21
fix: dependencies version consistency
gantunesr Nov 25, 2024
27e2d6c
test: update BtcMethod
gantunesr Nov 25, 2024
1890789
test: update BtcMethod
gantunesr Nov 25, 2024
c07cadc
chore: add utils and constants
gantunesr Nov 27, 2024
0afee9f
refactor: construction and accounts tracking
gantunesr Nov 27, 2024
9d6d4ec
chore: add comments
gantunesr Dec 1, 2024
eca2402
Merge branch 'main' of https://github.com/MetaMask/core into feat/add…
gantunesr Dec 1, 2024
84a029c
chore: small refactor to trackAccount
gantunesr Dec 2, 2024
b9192af
test: fix address validation mock
gantunesr Dec 2, 2024
614d072
test: add utils unit test
gantunesr Dec 2, 2024
2d31d3c
test: improve BalancesTracker unit test
gantunesr Dec 2, 2024
425d18a
test: improve Poller unit test
gantunesr Dec 2, 2024
c8e3065
test: improve MultichainBalancesController unit test
gantunesr Dec 2, 2024
e59a99b
refactor: BalancesTracker constructor
gantunesr Dec 2, 2024
890d317
test: add test to updateBalance
gantunesr Dec 2, 2024
7223308
Merge branch 'main' into feat/add-multichain-balances
gantunesr Dec 3, 2024
21059a9
chore: add logs
gantunesr Dec 3, 2024
8d28a3c
Merge branch 'feat/add-multichain-balances' of https://github.com/Met…
gantunesr Dec 3, 2024
7cc6bc0
Revert "chore: add logs"
gantunesr Dec 3, 2024
4556f0a
chore: update exports
gantunesr Dec 3, 2024
d069a64
chore: update exports
gantunesr Dec 3, 2024
9e30e90
chore: update imports
gantunesr Dec 3, 2024
e1a582c
chore: add console.error to Poller
gantunesr Dec 4, 2024
c3f273d
chore: state update
gantunesr Dec 5, 2024
a1e11fb
chore: add logs
gantunesr Dec 5, 2024
3178c25
chore: add more logs
gantunesr Dec 5, 2024
73fa7bf
chore: add more logs
gantunesr Dec 5, 2024
bb93981
chore: update logs
gantunesr Dec 6, 2024
00ea290
Merge branch 'main' into feat/add-multichain-balances
gantunesr Dec 6, 2024
243de73
chore: revert changes
gantunesr Dec 6, 2024
7612528
test: fix unit test issues
gantunesr Dec 6, 2024
daed2ea
Merge branch 'feat/add-multichain-balances' of https://github.com/Met…
gantunesr Dec 6, 2024
9e67716
fix: lint
gantunesr Dec 6, 2024
a49ed0d
chore: BalancesTracker method
gantunesr Dec 9, 2024
c548615
chore: update constant
gantunesr Dec 9, 2024
115e329
chore: nit changes
gantunesr Dec 10, 2024
41ccd3b
refactor: updateBalance method
gantunesr Dec 10, 2024
017c657
refactor: BITCOIN_AVG_BLOCK_TIME var name
gantunesr Dec 10, 2024
fe5d9ec
chore: add error classes
gantunesr Dec 10, 2024
ae590d8
refactor: listAccounts
gantunesr Dec 10, 2024
b355d11
refactor: Poller class to use PollerError
gantunesr Dec 10, 2024
9365597
chore: add JSDocs to isBalanceOutdated method
gantunesr Dec 10, 2024
582699f
i tpusjMerge branch 'feat/add-multichain-balances' of https://github.…
gantunesr Dec 10, 2024
84157f0
fix: lint issue with isTracked
gantunesr Dec 10, 2024
2f0bfb8
Merge branch 'main' into feat/add-multichain-balances
gantunesr Dec 10, 2024
ef8b0f5
fix: remove unused vars
gantunesr Dec 10, 2024
f5d1cea
Merge branch 'main' of https://github.com/MetaMask/core into feat/add…
gantunesr Dec 11, 2024
afe5bd2
Merge branch 'main' of https://github.com/MetaMask/core into feat/add…
gantunesr Dec 13, 2024
985aed5
test: fix import for MultichainBalancesController.test
gantunesr Dec 13, 2024
80f4288
chore: fix import in utils file
gantunesr Dec 13, 2024
920699e
Merge branch 'main' into feat/add-multichain-balances
gantunesr Dec 18, 2024
bcaee6c
refactor: apply review suggestions
gantunesr Dec 18, 2024
852b238
test: update unit tests
gantunesr Dec 18, 2024
d71ad47
test: improve coverage
gantunesr Dec 18, 2024
1807e3c
Merge branch 'main' of https://github.com/MetaMask/core into feat/add…
gantunesr Dec 19, 2024
b8a3c6d
Merge branch 'main' of https://github.com/MetaMask/core into feat/add…
gantunesr Jan 6, 2025
9d3797d
Merge branch 'main' of https://github.com/MetaMask/core into feat/add…
gantunesr Jan 9, 2025
fca2211
chore: bump @metamask/keyring-api
gantunesr Jan 9, 2025
cfe20be
test: update mocks to have account scopes
gantunesr Jan 10, 2025
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
5 changes: 5 additions & 0 deletions packages/assets-controllers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,12 @@
"@metamask/metamask-eth-abis": "^3.1.1",
"@metamask/polling-controller": "^12.0.2",
"@metamask/rpc-errors": "^7.0.1",
"@metamask/snaps-utils": "^8.3.0",
"@metamask/utils": "^10.0.0",
"@types/bn.js": "^5.1.5",
"@types/uuid": "^8.3.0",
"async-mutex": "^0.5.0",
"bitcoin-address-validation": "^2.2.3",
"bn.js": "^5.2.1",
"cockatiel": "^3.1.2",
"immer": "^9.0.6",
Expand All @@ -83,6 +85,9 @@
"@metamask/keyring-internal-api": "^1.0.0",
"@metamask/network-controller": "^22.1.1",
"@metamask/preferences-controller": "^15.0.1",
"@metamask/providers": "^18.1.1",
"@metamask/snaps-controllers": "^9.10.0",
"@metamask/snaps-sdk": "^6.7.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.191",
"@types/node": "^16.18.54",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { BtcAccountType, BtcMethod } from '@metamask/keyring-api';

Check failure on line 1 in packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

'@metamask/keyring-api' should be listed in the project's dependencies. Run 'npm i -S @metamask/keyring-api' to add it

Check failure on line 1 in packages/assets-controllers/src/MultichainBalancesController/BalancesTracker.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (20.x)

"@metamask/keyring-api" is extraneous
import { KeyringTypes } from '@metamask/keyring-controller';
import { v4 as uuidv4 } from 'uuid';

import { BalancesTracker } from './BalancesTracker';
import { Poller } from './Poller';

const MOCK_TIMESTAMP = 1709983353;

const mockBtcAccount = {
address: '',
id: uuidv4(),
metadata: {
name: 'Bitcoin Account 1',
importTime: Date.now(),
keyring: {
type: KeyringTypes.snap,
},
snap: {
id: 'mock-btc-snap',
name: 'mock-btc-snap',
enabled: true,
},
lastSelected: 0,
},
options: {},
methods: [BtcMethod.SendBitcoin],
type: BtcAccountType.P2wpkh,
};

/**
* Sets up a BalancesTracker instance for testing.
* @returns The BalancesTracker instance and a mock update balance function.
*/
function setupTracker() {
const mockUpdateBalance = jest.fn();
const tracker = new BalancesTracker(mockUpdateBalance);

return {
tracker,
mockUpdateBalance,
};
}

describe('BalancesTracker', () => {
it('starts polling when calling start', async () => {
const { tracker } = setupTracker();
const spyPoller = jest.spyOn(Poller.prototype, 'start');

tracker.start();
expect(spyPoller).toHaveBeenCalledTimes(1);
});

it('stops polling when calling stop', async () => {
const { tracker } = setupTracker();
const spyPoller = jest.spyOn(Poller.prototype, 'stop');

tracker.start();
tracker.stop();
expect(spyPoller).toHaveBeenCalledTimes(1);
});

it('is not tracking if none accounts have been registered', async () => {
const { tracker, mockUpdateBalance } = setupTracker();

tracker.start();
await tracker.updateBalances();

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

it('tracks account balances', async () => {
const { tracker, mockUpdateBalance } = setupTracker();

tracker.start();
// We must track account IDs explicitly
tracker.track(mockBtcAccount.id, 0);
// Trigger balances refresh (not waiting for the Poller here)
await tracker.updateBalances();

expect(mockUpdateBalance).toHaveBeenCalledWith(mockBtcAccount.id);
});

it('untracks account balances', async () => {
const { tracker, mockUpdateBalance } = setupTracker();

tracker.start();
tracker.track(mockBtcAccount.id, 0);
await tracker.updateBalances();
expect(mockUpdateBalance).toHaveBeenCalledWith(mockBtcAccount.id);

tracker.untrack(mockBtcAccount.id);
await tracker.updateBalances();
expect(mockUpdateBalance).toHaveBeenCalledTimes(1); // No second call after untracking
});

it('tracks account after being registered', async () => {
const { tracker } = setupTracker();

tracker.start();
tracker.track(mockBtcAccount.id, 0);
expect(tracker.isTracked(mockBtcAccount.id)).toBe(true);
});

it('does not track account if not registered', async () => {
const { tracker } = setupTracker();

tracker.start();
expect(tracker.isTracked(mockBtcAccount.id)).toBe(false);
});

it('does not refresh balance if they are considered up-to-date', async () => {
const { tracker, mockUpdateBalance } = setupTracker();

const blockTime = 10 * 60 * 1000; // 10 minutes in milliseconds.
jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date(MOCK_TIMESTAMP).getTime());

tracker.start();
tracker.track(mockBtcAccount.id, blockTime);
await tracker.updateBalances();
expect(mockUpdateBalance).toHaveBeenCalledTimes(1);

await tracker.updateBalances();
expect(mockUpdateBalance).toHaveBeenCalledTimes(1); // No second call since the balances is already still up-to-date

jest
.spyOn(global.Date, 'now')
.mockImplementation(() => new Date(MOCK_TIMESTAMP + blockTime).getTime());

await tracker.updateBalances();
expect(mockUpdateBalance).toHaveBeenCalledTimes(2); // Now the balance will update
});

it('throws an error if trying to update balance of an untracked account', async () => {
const { tracker } = setupTracker();

await expect(tracker.updateBalance(mockBtcAccount.id)).rejects.toThrow(
`Account is not being tracked: ${mockBtcAccount.id}`,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { Poller } from './Poller';

type BalanceInfo = {
lastUpdated: number;
blockTime: number;
};

const BALANCES_TRACKING_INTERVAL = 5000; // Every 5s in milliseconds.

export class BalancesTracker {
#poller: Poller;

#updateBalance: (accountId: string) => Promise<void>;

#balances: Record<string, BalanceInfo> = {};

constructor(updateBalanceCallback: (accountId: string) => Promise<void>) {
this.#updateBalance = updateBalanceCallback;

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

/**
* Starts the tracking process.
*/
start(): void {
this.#poller.start();
}

/**
* Stops the tracking process.
*/
stop(): void {
this.#poller.stop();
}

/**
* Checks if an account ID is being tracked.
*
* @param accountId - The account ID.
* @returns True if the account is being tracked, false otherwise.
*/
isTracked(accountId: string) {
return Object.prototype.hasOwnProperty.call(this.#balances, accountId);
}

/**
* Asserts that an account ID is being tracked.
*
* @param accountId - The account ID.
* @throws If the account ID is not being tracked.
*/
assertBeingTracked(accountId: string) {
if (!this.isTracked(accountId)) {
throw new Error(`Account is not being tracked: ${accountId}`);
}
}

/**
* Starts tracking a new account ID. This method has no effect on already tracked
* accounts.
*
* @param accountId - The account ID.
* @param blockTime - The block time (used when refreshing the account balances).
*/
track(accountId: string, blockTime: number) {
// Do not overwrite current info if already being tracked!
if (!this.isTracked(accountId)) {
this.#balances[accountId] = {
lastUpdated: 0,
blockTime,
};
}
}

/**
* Stops tracking a tracked account ID.
*
* @param accountId - The account ID.
* @throws If the account ID is not being tracked.
*/
untrack(accountId: string) {
this.assertBeingTracked(accountId);
delete this.#balances[accountId];
}

/**
* Update the balances for a tracked account ID.
*
* @param accountId - The account ID.
* @throws If the account ID is not being tracked.
*/
async updateBalance(accountId: string) {
this.assertBeingTracked(accountId);

// We check if the balance is outdated (by comparing to the block time associated
// with this kind of account).
//
// This might not be super accurate, but we could probably compute this differently
// and try to sync with the "real block time"!
const info = this.#balances[accountId];
if (this.#isBalanceOutdated(info)) {
await this.#updateBalance(accountId);
this.#balances[accountId].lastUpdated = Date.now();
}
}

/**
* Update the balances of all tracked accounts (only if the balances
* is considered outdated).
*/
async updateBalances() {
await Promise.allSettled(
Object.keys(this.#balances).map(async (accountId) => {
await this.updateBalance(accountId);
Copy link
Contributor

Choose a reason for hiding this comment

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

@gantunesr @owencraston we could try/catch here and log and the error here. We could re-use the pattern I suggested with PollerError maybe?

Copy link
Member Author

Choose a reason for hiding this comment

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

But wouldn't the Poller class error log already catch any error from this method?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think no, since we are using a Promise.allSettled here, some promises will be marked as "rejected", but no exceptions will be raised. So we would not see any logs IMO.

Copy link
Contributor

@owencraston owencraston Dec 13, 2024

Choose a reason for hiding this comment

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

@ccharly is correct, we would need to filter on the promises for fulfilled | rejected to log the state of a promise.

}),
);
}

/**
* Checks if the balance is outdated according to the provided data.
*
* @param param - The balance info.
* @param param.lastUpdated - The last updated timestamp.
* @param param.blockTime - The block time.
* @returns True if the balance is outdated, false otherwise.
*/
#isBalanceOutdated({ lastUpdated, blockTime }: BalanceInfo): boolean {
return (
// Never been updated:
lastUpdated === 0 ||
// Outdated:
Date.now() - lastUpdated >= blockTime
);
}
}
Loading
Loading