Skip to content

Commit 02a504d

Browse files
zone-liveccharly
andauthored
chore: updates handleOnAccountTransactionsUpdated (#5339)
## Explanation We were assuming that controller would get all transactions for an account in the update event, but we just get new/updated transactions, therefore we need to take that into account in the method. The key improvements are: - Handles the update of transactions by using a Map to deduplicate them by Id (newer versions replace older ones) - Preserves existing transactions - Sorts transactions by timestamp (newest first) ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Related to https://consensyssoftware.atlassian.net/browse/SOL-156 --> ## Changelog <!-- If you're making any consumer-facing changes, list those changes here as if you were updating a changelog, using the template below as a guide. (CATEGORY is one of BREAKING, ADDED, CHANGED, DEPRECATED, REMOVED, or FIXED. For security-related issues, follow the Security Advisory process.) Please take care to name the exact pieces of the API you've added or changed (e.g. types, interfaces, functions, or methods). If there are any breaking changes, make sure to offer a solution for consumers to follow once they upgrade to the changes. Finally, if you're only making changes to development scripts or tests, you may replace the template below with "None". --> ### `@metamask/multichain-transactions-controller` - **ADDED**: Updates account transactions with new and updated ones while preserving existing ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Charly Chevalier <[email protected]>
1 parent 342332e commit 02a504d

File tree

2 files changed

+250
-20
lines changed

2 files changed

+250
-20
lines changed

packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts

Lines changed: 210 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Messenger } from '@metamask/base-controller';
2-
import type { CaipAssetType, Transaction } from '@metamask/keyring-api';
2+
import type {
3+
AccountTransactionsUpdatedEventPayload,
4+
CaipAssetType,
5+
TransactionsPage,
6+
} from '@metamask/keyring-api';
37
import {
48
BtcAccountType,
59
BtcMethod,
@@ -130,7 +134,7 @@ const setupController = ({
130134
state?: MultichainTransactionsControllerState;
131135
mocks?: {
132136
listMultichainAccounts?: InternalAccount[];
133-
handleRequestReturnValue?: Record<CaipAssetType, Transaction>;
137+
handleRequestReturnValue?: TransactionsPage;
134138
};
135139
} = {}) => {
136140
const messenger = new Messenger<AllowedActions, AllowedEvents>();
@@ -192,6 +196,9 @@ async function waitForAllPromises(): Promise<void> {
192196
await new Promise(process.nextTick);
193197
}
194198

199+
const NEW_ACCOUNT_ID = 'new-account-id';
200+
const TEST_ACCOUNT_ID = 'test-account-id';
201+
195202
describe('MultichainTransactionsController', () => {
196203
it('initialize with default state', () => {
197204
const { controller } = setupController({});
@@ -433,36 +440,225 @@ describe('MultichainTransactionsController', () => {
433440
});
434441

435442
it('updates transactions when receiving "AccountsController:accountTransactionsUpdated" event', async () => {
443+
const mockSolAccountWithId = {
444+
...mockSolAccount,
445+
id: TEST_ACCOUNT_ID,
446+
};
447+
448+
const existingTransaction = {
449+
...mockTransactionResult.data[0],
450+
id: '123',
451+
status: 'confirmed' as const,
452+
};
453+
454+
const newTransaction = {
455+
...mockTransactionResult.data[0],
456+
id: '456',
457+
status: 'submitted' as const,
458+
};
459+
460+
const updatedExistingTransaction = {
461+
...mockTransactionResult.data[0],
462+
id: '123',
463+
status: 'failed' as const,
464+
};
465+
466+
const { controller, messenger } = setupController({
467+
state: {
468+
nonEvmTransactions: {
469+
[mockSolAccountWithId.id]: {
470+
transactions: [existingTransaction],
471+
next: null,
472+
lastUpdated: Date.now(),
473+
},
474+
},
475+
},
476+
});
477+
478+
messenger.publish('AccountsController:accountTransactionsUpdated', {
479+
transactions: {
480+
[mockSolAccountWithId.id]: [updatedExistingTransaction, newTransaction],
481+
},
482+
});
483+
484+
await waitForAllPromises();
485+
486+
const finalTransactions =
487+
controller.state.nonEvmTransactions[mockSolAccountWithId.id].transactions;
488+
expect(finalTransactions).toStrictEqual([
489+
updatedExistingTransaction,
490+
newTransaction,
491+
]);
492+
});
493+
494+
it('handles empty transaction updates gracefully', async () => {
436495
const { controller, messenger } = setupController({
437496
state: {
438497
nonEvmTransactions: {
439-
[mockBtcAccount.id]: {
498+
[TEST_ACCOUNT_ID]: {
440499
transactions: [],
441500
next: null,
442501
lastUpdated: Date.now(),
443502
},
444503
},
445504
},
446505
});
447-
const transactionUpdate = {
506+
507+
messenger.publish('AccountsController:accountTransactionsUpdated', {
508+
transactions: {},
509+
});
510+
511+
await waitForAllPromises();
512+
513+
expect(controller.state.nonEvmTransactions[TEST_ACCOUNT_ID]).toStrictEqual({
514+
transactions: [],
515+
next: null,
516+
lastUpdated: expect.any(Number),
517+
});
518+
});
519+
520+
it('initializes new accounts with empty transactions array when receiving updates', async () => {
521+
const { controller, messenger } = setupController({
522+
state: {
523+
nonEvmTransactions: {},
524+
},
525+
});
526+
527+
messenger.publish('AccountsController:accountTransactionsUpdated', {
448528
transactions: {
449-
[mockBtcAccount.id]: mockTransactionResult.data,
529+
[NEW_ACCOUNT_ID]: mockTransactionResult.data,
530+
},
531+
});
532+
533+
await waitForAllPromises();
534+
535+
expect(controller.state.nonEvmTransactions[NEW_ACCOUNT_ID]).toStrictEqual({
536+
transactions: mockTransactionResult.data,
537+
lastUpdated: expect.any(Number),
538+
});
539+
});
540+
541+
it('handles undefined transactions in update payload', async () => {
542+
const { controller, messenger } = setupController({
543+
state: {
544+
nonEvmTransactions: {
545+
[TEST_ACCOUNT_ID]: {
546+
transactions: [],
547+
next: null,
548+
lastUpdated: Date.now(),
549+
},
550+
},
551+
},
552+
mocks: {
553+
listMultichainAccounts: [],
554+
handleRequestReturnValue: {
555+
data: [],
556+
next: null,
557+
},
558+
},
559+
});
560+
561+
const initialStateSnapshot = {
562+
[TEST_ACCOUNT_ID]: {
563+
...controller.state.nonEvmTransactions[TEST_ACCOUNT_ID],
564+
lastUpdated: expect.any(Number),
450565
},
451566
};
452567

453-
messenger.publish(
454-
'AccountsController:accountTransactionsUpdated',
455-
transactionUpdate,
568+
messenger.publish('AccountsController:accountTransactionsUpdated', {
569+
transactions: undefined,
570+
} as unknown as AccountTransactionsUpdatedEventPayload);
571+
572+
await waitForAllPromises();
573+
574+
expect(controller.state.nonEvmTransactions).toStrictEqual(
575+
initialStateSnapshot,
456576
);
577+
});
578+
579+
it('sorts transactions by timestamp (newest first)', async () => {
580+
const olderTransaction = {
581+
...mockTransactionResult.data[0],
582+
id: '123',
583+
timestamp: 1000,
584+
};
585+
const newerTransaction = {
586+
...mockTransactionResult.data[0],
587+
id: '456',
588+
timestamp: 2000,
589+
};
590+
591+
const { controller, messenger } = setupController({
592+
state: {
593+
nonEvmTransactions: {
594+
[TEST_ACCOUNT_ID]: {
595+
transactions: [olderTransaction],
596+
next: null,
597+
lastUpdated: Date.now(),
598+
},
599+
},
600+
},
601+
});
602+
603+
messenger.publish('AccountsController:accountTransactionsUpdated', {
604+
transactions: {
605+
[TEST_ACCOUNT_ID]: [newerTransaction],
606+
},
607+
});
457608

458609
await waitForAllPromises();
459610

460-
expect(
461-
controller.state.nonEvmTransactions[mockBtcAccount.id],
462-
).toStrictEqual({
463-
transactions: mockTransactionResult.data,
464-
next: null,
465-
lastUpdated: expect.any(Number),
611+
const finalTransactions =
612+
controller.state.nonEvmTransactions[TEST_ACCOUNT_ID].transactions;
613+
expect(finalTransactions).toStrictEqual([
614+
newerTransaction,
615+
olderTransaction,
616+
]);
617+
});
618+
619+
it('sorts transactions by timestamp and handles null timestamps', async () => {
620+
const nullTimestampTx1 = {
621+
...mockTransactionResult.data[0],
622+
id: '123',
623+
timestamp: null,
624+
};
625+
const nullTimestampTx2 = {
626+
...mockTransactionResult.data[0],
627+
id: '456',
628+
timestamp: null,
629+
};
630+
const withTimestampTx = {
631+
...mockTransactionResult.data[0],
632+
id: '789',
633+
timestamp: 1000,
634+
};
635+
636+
const { controller, messenger } = setupController({
637+
state: {
638+
nonEvmTransactions: {
639+
[TEST_ACCOUNT_ID]: {
640+
transactions: [nullTimestampTx1],
641+
next: null,
642+
lastUpdated: Date.now(),
643+
},
644+
},
645+
},
466646
});
647+
648+
messenger.publish('AccountsController:accountTransactionsUpdated', {
649+
transactions: {
650+
[TEST_ACCOUNT_ID]: [withTimestampTx, nullTimestampTx2],
651+
},
652+
});
653+
654+
await waitForAllPromises();
655+
656+
const finalTransactions =
657+
controller.state.nonEvmTransactions[TEST_ACCOUNT_ID].transactions;
658+
expect(finalTransactions).toStrictEqual([
659+
withTimestampTx,
660+
nullTimestampTx1,
661+
nullTimestampTx2,
662+
]);
467663
});
468664
});

packages/multichain-transactions-controller/src/MultichainTransactionsController.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,47 @@ export class MultichainTransactionsController extends BaseController<
337337
#handleOnAccountTransactionsUpdated(
338338
transactionsUpdate: AccountTransactionsUpdatedEventPayload,
339339
): void {
340-
this.update((state: Draft<MultichainTransactionsControllerState>) => {
341-
Object.entries(transactionsUpdate.transactions).forEach(
340+
const updatedTransactions: Record<string, Transaction[]> = {};
341+
342+
if (!transactionsUpdate?.transactions) {
343+
return;
344+
}
345+
346+
Object.entries(transactionsUpdate.transactions).forEach(
347+
([accountId, newTransactions]) => {
348+
// Account might not have any transactions yet, so use `[]` in that case.
349+
const oldTransactions =
350+
this.state.nonEvmTransactions[accountId]?.transactions ?? [];
351+
352+
// Uses a `Map` to deduplicate transactions by ID, ensuring we keep the latest version
353+
// of each transaction while preserving older transactions and transactions from other accounts.
354+
// Transactions are sorted by timestamp (newest first).
355+
const transactions = new Map();
356+
357+
oldTransactions.forEach((tx) => {
358+
transactions.set(tx.id, tx);
359+
});
360+
361+
newTransactions.forEach((tx) => {
362+
transactions.set(tx.id, tx);
363+
});
364+
365+
// Sorted by timestamp (newest first). If the timestamp is not provided, those
366+
// transactions will be put in the end of this list.
367+
updatedTransactions[accountId] = Array.from(transactions.values()).sort(
368+
(a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0),
369+
);
370+
},
371+
);
372+
373+
this.update((state) => {
374+
Object.entries(updatedTransactions).forEach(
342375
([accountId, transactions]) => {
343-
if (accountId in state.nonEvmTransactions) {
344-
state.nonEvmTransactions[accountId].transactions = transactions;
345-
state.nonEvmTransactions[accountId].lastUpdated = Date.now();
346-
}
376+
state.nonEvmTransactions[accountId] = {
377+
...state.nonEvmTransactions[accountId],
378+
transactions,
379+
lastUpdated: Date.now(),
380+
};
347381
},
348382
);
349383
});

0 commit comments

Comments
 (0)