-
-
Notifications
You must be signed in to change notification settings - Fork 201
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
Changes from all commits
950a2eb
12a233a
c9b707c
b80a2da
0c15e21
27e2d6c
1890789
c07cadc
0afee9f
9d6d4ec
eca2402
84a029c
b9192af
614d072
2d31d3c
425d18a
c8e3065
e59a99b
890d317
7223308
21059a9
8d28a3c
7cc6bc0
4556f0a
d069a64
9e30e90
e1a582c
c3f273d
a1e11fb
3178c25
73fa7bf
bb93981
00ea290
243de73
7612528
daed2ea
9e67716
a49ed0d
c548615
115e329
41ccd3b
017c657
fe5d9ec
ae590d8
b355d11
9365597
582699f
84157f0
2f0bfb8
ef8b0f5
f5d1cea
afe5bd2
985aed5
80f4288
920699e
bcaee6c
852b238
d71ad47
1807e3c
b8a3c6d
9d3797d
fca2211
cfe20be
e29bd2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
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 | ||
}); | ||
|
||
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think no, since we are using a There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
); | ||
} | ||
} |
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.
In an ideal world, we will expose these methods onto the client, so that polling loops can be started/stopped in UI based way.
Meaning that we only start polling loops on components that need Multichain balances (like the asset list). However, if a user pops open the extension to sign a transaction, we don't need to start this polling loop, to minimize requests and improve perf.
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.
Let me get that working on a follow up PR