Skip to content

Commit 0867eae

Browse files
feat: Add transaction emulation actions (#6935)
## Explanation These will be used indirectly by the `UserOperationController` to emulate adding and updating a transaction, in response to user operations. ## References This came up as a blocker to MetaMask/metamask-extension#31843 because currently the extension is directly publishing `TransactionController` events, which will no longer be allowed with the new messenger. ## 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 communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Add `emulateNewTransaction` and `emulateTransactionUpdate` actions to TransactionController, emitting relevant swap/update events and updating state via messenger. > > - **Transaction Controller** > - **New Actions**: Add `TransactionController:emulateNewTransaction` and `TransactionController:emulateTransactionUpdate`. > - `emulateNewTransaction`: Publishes `TransactionController:transactionNewSwap` or `TransactionController:transactionNewSwapApproval` based on the transaction type. > - `emulateTransactionUpdate`: Sets `txParams.from` to the selected account, adds transaction if missing, updates it, and publishes `TransactionController:transactionStatusUpdated`. > - **Messenger**: Register handlers for `emulateNewTransaction` and `emulateTransactionUpdate`. > - **Exports**: Export new action types from `src/index.ts`. > - **Tests**: Add unit tests covering new actions, event publishing, and state updates. > - **Changelog**: Note added actions under Unreleased. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 357add4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Pedro Figueiredo <pedro.figueiredo@consensys.net>
1 parent 7e387eb commit 0867eae

File tree

4 files changed

+357
-1
lines changed

4 files changed

+357
-1
lines changed

packages/transaction-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add actions `TransactionController:emulateNewTransaction` and `TransactionController:emulateTransactionUpdate` ([#6935](https://github.com/MetaMask/core/pull/6935))
13+
1014
### Changed
1115

1216
- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917))

packages/transaction-controller/src/TransactionController.test.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7970,6 +7970,262 @@ describe('TransactionController', () => {
79707970
});
79717971
});
79727972

7973+
describe('emulateNewTransaction', () => {
7974+
it('publishes swap event when transaction is a swap', () => {
7975+
const swapTransactionMeta = {
7976+
id: 'tx1',
7977+
chainId: CHAIN_ID_MOCK,
7978+
networkClientId: NETWORK_CLIENT_ID_MOCK,
7979+
status: TransactionStatus.approved,
7980+
type: TransactionType.swap,
7981+
txParams: {
7982+
from: ACCOUNT_MOCK,
7983+
to: ACCOUNT_2_MOCK,
7984+
},
7985+
} as TransactionMeta;
7986+
7987+
const { controller, messenger } = setupController({
7988+
options: {
7989+
state: {
7990+
transactions: [swapTransactionMeta],
7991+
},
7992+
},
7993+
});
7994+
7995+
expect(controller.state.transactions).toHaveLength(1);
7996+
expect(controller.state.transactions[0]).toMatchObject({
7997+
id: swapTransactionMeta.id,
7998+
type: TransactionType.swap,
7999+
});
8000+
8001+
const swapListener = jest.fn();
8002+
messenger.subscribe(
8003+
'TransactionController:transactionNewSwap',
8004+
swapListener,
8005+
);
8006+
8007+
controller.emulateNewTransaction(swapTransactionMeta.id);
8008+
8009+
expect(swapListener).toHaveBeenCalledTimes(1);
8010+
expect(swapListener).toHaveBeenCalledWith({
8011+
transactionMeta: expect.objectContaining({
8012+
id: swapTransactionMeta.id,
8013+
type: TransactionType.swap,
8014+
}),
8015+
});
8016+
});
8017+
8018+
it('publishes swap approval event when transaction is a swap approval', () => {
8019+
const swapApprovalTransactionMeta = {
8020+
id: 'tx2',
8021+
chainId: CHAIN_ID_MOCK,
8022+
networkClientId: NETWORK_CLIENT_ID_MOCK,
8023+
status: TransactionStatus.approved,
8024+
type: TransactionType.swapApproval,
8025+
txParams: {
8026+
from: ACCOUNT_MOCK,
8027+
to: ACCOUNT_2_MOCK,
8028+
},
8029+
} as TransactionMeta;
8030+
8031+
const { controller, messenger } = setupController({
8032+
options: {
8033+
state: {
8034+
transactions: [swapApprovalTransactionMeta],
8035+
},
8036+
},
8037+
});
8038+
8039+
expect(controller.state.transactions).toHaveLength(1);
8040+
expect(controller.state.transactions[0]).toMatchObject({
8041+
id: swapApprovalTransactionMeta.id,
8042+
type: TransactionType.swapApproval,
8043+
});
8044+
8045+
const swapApprovalListener = jest.fn();
8046+
messenger.subscribe(
8047+
'TransactionController:transactionNewSwapApproval',
8048+
swapApprovalListener,
8049+
);
8050+
8051+
controller.emulateNewTransaction(swapApprovalTransactionMeta.id);
8052+
8053+
expect(swapApprovalListener).toHaveBeenCalledTimes(1);
8054+
expect(swapApprovalListener).toHaveBeenCalledWith({
8055+
transactionMeta: expect.objectContaining({
8056+
id: swapApprovalTransactionMeta.id,
8057+
type: TransactionType.swapApproval,
8058+
}),
8059+
});
8060+
});
8061+
8062+
it('does not publish events when transaction is not a swap or swap approval', () => {
8063+
const { controller, messenger } = setupController({
8064+
options: {
8065+
state: {
8066+
transactions: [TRANSACTION_META_MOCK],
8067+
},
8068+
},
8069+
});
8070+
8071+
const swapListener = jest.fn();
8072+
const swapApprovalListener = jest.fn();
8073+
messenger.subscribe(
8074+
'TransactionController:transactionNewSwap',
8075+
swapListener,
8076+
);
8077+
messenger.subscribe(
8078+
'TransactionController:transactionNewSwapApproval',
8079+
swapApprovalListener,
8080+
);
8081+
8082+
controller.emulateNewTransaction(TRANSACTION_META_MOCK.id);
8083+
8084+
expect(swapListener).not.toHaveBeenCalled();
8085+
expect(swapApprovalListener).not.toHaveBeenCalled();
8086+
});
8087+
8088+
it('does not publish events when transaction does not exist', () => {
8089+
const { controller, messenger } = setupController();
8090+
8091+
const swapListener = jest.fn();
8092+
const swapApprovalListener = jest.fn();
8093+
messenger.subscribe(
8094+
'TransactionController:transactionNewSwap',
8095+
swapListener,
8096+
);
8097+
messenger.subscribe(
8098+
'TransactionController:transactionNewSwapApproval',
8099+
swapApprovalListener,
8100+
);
8101+
8102+
controller.emulateNewTransaction('missing-transaction-id');
8103+
8104+
expect(swapListener).not.toHaveBeenCalled();
8105+
expect(swapApprovalListener).not.toHaveBeenCalled();
8106+
});
8107+
});
8108+
8109+
describe('emulateTransactionUpdate', () => {
8110+
it('adds transaction to state and publishes update when it does not exist', () => {
8111+
const selectedAccount = {
8112+
...INTERNAL_ACCOUNT_MOCK,
8113+
address: ACCOUNT_2_MOCK,
8114+
};
8115+
const { controller, messenger, mockGetSelectedAccount } = setupController(
8116+
{ selectedAccount },
8117+
);
8118+
8119+
const transactionMeta = {
8120+
id: 'tx3',
8121+
chainId: CHAIN_ID_MOCK,
8122+
networkClientId: NETWORK_CLIENT_ID_MOCK,
8123+
status: TransactionStatus.unapproved,
8124+
txParams: {
8125+
from: ACCOUNT_MOCK,
8126+
to: ACCOUNT_2_MOCK,
8127+
},
8128+
} as TransactionMeta;
8129+
8130+
const statusUpdatedListener = jest.fn();
8131+
messenger.subscribe(
8132+
'TransactionController:transactionStatusUpdated',
8133+
statusUpdatedListener,
8134+
);
8135+
8136+
controller.emulateTransactionUpdate(transactionMeta);
8137+
8138+
const transactionMetaWithUpdatedSender = {
8139+
...transactionMeta,
8140+
txParams: {
8141+
...transactionMeta.txParams,
8142+
from: ACCOUNT_2_MOCK,
8143+
},
8144+
};
8145+
const transactionMetaWithUpdatedSenderAndNormalized = {
8146+
...transactionMetaWithUpdatedSender,
8147+
txParams: {
8148+
...transactionMetaWithUpdatedSender.txParams,
8149+
value: '0x0',
8150+
},
8151+
};
8152+
expect(controller.state.transactions).toHaveLength(1);
8153+
// State updated after updating sender and normalizing
8154+
expect(controller.state.transactions[0]).toStrictEqual(
8155+
transactionMetaWithUpdatedSenderAndNormalized,
8156+
);
8157+
expect(mockGetSelectedAccount).toHaveBeenCalledTimes(1);
8158+
expect(statusUpdatedListener).toHaveBeenCalledTimes(1);
8159+
// Status published after updating sender, but before normalization
8160+
expect(statusUpdatedListener).toHaveBeenCalledWith({
8161+
transactionMeta: transactionMetaWithUpdatedSender,
8162+
});
8163+
});
8164+
8165+
it('updates transaction when it already exists', () => {
8166+
const selectedAccount = {
8167+
...INTERNAL_ACCOUNT_MOCK,
8168+
address: ACCOUNT_2_MOCK,
8169+
};
8170+
const existingTransactionMeta = {
8171+
id: 'tx4',
8172+
chainId: CHAIN_ID_MOCK,
8173+
networkClientId: NETWORK_CLIENT_ID_MOCK,
8174+
status: TransactionStatus.unapproved,
8175+
txParams: {
8176+
from: ACCOUNT_MOCK,
8177+
to: ACCOUNT_2_MOCK,
8178+
},
8179+
} as TransactionMeta;
8180+
const newTransactionMeta = {
8181+
...existingTransactionMeta,
8182+
status: TransactionStatus.approved,
8183+
};
8184+
8185+
const { controller, messenger } = setupController({
8186+
options: {
8187+
state: {
8188+
transactions: [existingTransactionMeta],
8189+
},
8190+
},
8191+
selectedAccount,
8192+
});
8193+
8194+
const statusUpdatedListener = jest.fn();
8195+
messenger.subscribe(
8196+
'TransactionController:transactionStatusUpdated',
8197+
statusUpdatedListener,
8198+
);
8199+
8200+
controller.emulateTransactionUpdate(newTransactionMeta);
8201+
8202+
const transactionMetaWithUpdatedSender = {
8203+
...newTransactionMeta,
8204+
txParams: {
8205+
...newTransactionMeta.txParams,
8206+
from: ACCOUNT_2_MOCK,
8207+
},
8208+
};
8209+
const transactionMetaWithUpdatedSenderAndNormalized = {
8210+
...transactionMetaWithUpdatedSender,
8211+
txParams: {
8212+
...transactionMetaWithUpdatedSender.txParams,
8213+
value: '0x0',
8214+
},
8215+
};
8216+
expect(controller.state.transactions).toHaveLength(1);
8217+
// State updated after updating sender and normalizing
8218+
expect(controller.state.transactions[0]).toStrictEqual(
8219+
transactionMetaWithUpdatedSenderAndNormalized,
8220+
);
8221+
expect(statusUpdatedListener).toHaveBeenCalledTimes(1);
8222+
// Status published after updating sender, but before normalization
8223+
expect(statusUpdatedListener).toHaveBeenCalledWith({
8224+
transactionMeta: transactionMetaWithUpdatedSender,
8225+
});
8226+
});
8227+
});
8228+
79738229
describe('metadata', () => {
79748230
it('includes expected state in debug snapshots', () => {
79758231
const { controller } = setupController();

packages/transaction-controller/src/TransactionController.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,26 @@ export type TransactionControllerAddTransactionBatchAction = {
357357
handler: TransactionController['addTransactionBatch'];
358358
};
359359

360+
/**
361+
* Emulate a new transaction.
362+
*
363+
* @param transactionId - The transaction ID.
364+
*/
365+
export type TransactionControllerEmulateNewTransaction = {
366+
type: `${typeof controllerName}:emulateNewTransaction`;
367+
handler: TransactionController['emulateNewTransaction'];
368+
};
369+
370+
/**
371+
* Emmulate a transaction update.
372+
*
373+
* @param transactionMeta - Transaction metadata.
374+
*/
375+
export type TransactionControllerEmulateTransactionUpdate = {
376+
type: `${typeof controllerName}:emulateTransactionUpdate`;
377+
handler: TransactionController['emulateTransactionUpdate'];
378+
};
379+
360380
/**
361381
* The internal actions available to the TransactionController.
362382
*/
@@ -369,7 +389,9 @@ export type TransactionControllerActions =
369389
| TransactionControllerGetStateAction
370390
| TransactionControllerGetTransactionsAction
371391
| TransactionControllerUpdateCustodialTransactionAction
372-
| TransactionControllerUpdateTransactionAction;
392+
| TransactionControllerUpdateTransactionAction
393+
| TransactionControllerEmulateNewTransaction
394+
| TransactionControllerEmulateTransactionUpdate;
373395

374396
/**
375397
* Configuration options for the PendingTransactionTracker
@@ -2786,6 +2808,68 @@ export class TransactionController extends BaseController<
27862808
});
27872809
}
27882810

2811+
/**
2812+
* Emulate a new transaction.
2813+
*
2814+
* @param transactionId - The transaction ID.
2815+
*/
2816+
emulateNewTransaction(transactionId: string) {
2817+
const transactionMeta = this.state.transactions.find(
2818+
(tx) => tx.id === transactionId,
2819+
);
2820+
2821+
if (!transactionMeta) {
2822+
return;
2823+
}
2824+
2825+
if (transactionMeta.type === TransactionType.swap) {
2826+
this.messagingSystem.publish('TransactionController:transactionNewSwap', {
2827+
transactionMeta,
2828+
});
2829+
} else if (transactionMeta.type === TransactionType.swapApproval) {
2830+
this.messagingSystem.publish(
2831+
'TransactionController:transactionNewSwapApproval',
2832+
{ transactionMeta },
2833+
);
2834+
}
2835+
}
2836+
2837+
/**
2838+
* Emulate a transaction update.
2839+
*
2840+
* @param transactionMeta - Transaction metadata.
2841+
*/
2842+
emulateTransactionUpdate(transactionMeta: TransactionMeta) {
2843+
const updatedTransactionMeta = {
2844+
...transactionMeta,
2845+
txParams: {
2846+
...transactionMeta.txParams,
2847+
from: this.messagingSystem.call('AccountsController:getSelectedAccount')
2848+
.address,
2849+
},
2850+
};
2851+
2852+
const transactionExists = this.state.transactions.some(
2853+
(tx) => tx.id === updatedTransactionMeta.id,
2854+
);
2855+
2856+
if (!transactionExists) {
2857+
this.update((state) => {
2858+
state.transactions.push(updatedTransactionMeta);
2859+
});
2860+
}
2861+
2862+
this.updateTransaction(
2863+
updatedTransactionMeta,
2864+
'Generated from user operation',
2865+
);
2866+
2867+
this.messagingSystem.publish(
2868+
'TransactionController:transactionStatusUpdated',
2869+
{ transactionMeta: updatedTransactionMeta },
2870+
);
2871+
}
2872+
27892873
#addMetadata(transactionMeta: TransactionMeta) {
27902874
validateTxParams(transactionMeta.txParams);
27912875
this.update((state) => {
@@ -4392,6 +4476,16 @@ export class TransactionController extends BaseController<
43924476
`${controllerName}:updateTransaction`,
43934477
this.updateTransaction.bind(this),
43944478
);
4479+
4480+
this.messagingSystem.registerActionHandler(
4481+
`${controllerName}:emulateNewTransaction`,
4482+
this.emulateNewTransaction.bind(this),
4483+
);
4484+
4485+
this.messagingSystem.registerActionHandler(
4486+
`${controllerName}:emulateTransactionUpdate`,
4487+
this.emulateTransactionUpdate.bind(this),
4488+
);
43954489
}
43964490

43974491
#deleteTransaction(transactionId: string) {

0 commit comments

Comments
 (0)