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

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
4 changes: 4 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.1",
"@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 @@ -82,6 +84,8 @@
"@metamask/keyring-controller": "^19.0.0",
"@metamask/network-controller": "^22.0.2",
"@metamask/preferences-controller": "^15.0.0",
"@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,135 @@
import { BtcAccountType, BtcMethod } from '@metamask/keyring-api';
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
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Poller } from './Poller';

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

const BALANCES_TRACKING_INTERVAL = 30 * 1000; // Every 30s 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().catch((error) => {
console.error('Error updating balances:', error);
});
}, 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 tracker, false otherwise.
*/
isTracked(accountId: string) {
return accountId in this.#balances;
}

/**
* 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];
const isOutdated = Date.now() - info.lastUpdated >= info.blockTime;
const hasNoBalanceYet = info.lastUpdated === 0;
if (hasNoBalanceYet || isOutdated) {
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);
}),
);
}
}
Loading