Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add actions `TransactionController:emulateNewTransaction` and `TransactionController:emulateTransactionUpdate` ([#6935](https://github.com/MetaMask/core/pull/6935))

### Changed

- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917))
Expand Down
256 changes: 256 additions & 0 deletions packages/transaction-controller/src/TransactionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7970,6 +7970,262 @@ describe('TransactionController', () => {
});
});

describe('emulateNewTransaction', () => {
it('publishes swap event when transaction is a swap', () => {
const swapTransactionMeta = {
id: 'tx1',
chainId: CHAIN_ID_MOCK,
networkClientId: NETWORK_CLIENT_ID_MOCK,
status: TransactionStatus.approved,
type: TransactionType.swap,
txParams: {
from: ACCOUNT_MOCK,
to: ACCOUNT_2_MOCK,
},
} as TransactionMeta;

const { controller, messenger } = setupController({
options: {
state: {
transactions: [swapTransactionMeta],
},
},
});

expect(controller.state.transactions).toHaveLength(1);
expect(controller.state.transactions[0]).toMatchObject({
id: swapTransactionMeta.id,
type: TransactionType.swap,
});

const swapListener = jest.fn();
messenger.subscribe(
'TransactionController:transactionNewSwap',
swapListener,
);

controller.emulateNewTransaction(swapTransactionMeta.id);

expect(swapListener).toHaveBeenCalledTimes(1);
expect(swapListener).toHaveBeenCalledWith({
transactionMeta: expect.objectContaining({
id: swapTransactionMeta.id,
type: TransactionType.swap,
}),
});
});

it('publishes swap approval event when transaction is a swap approval', () => {
const swapApprovalTransactionMeta = {
id: 'tx2',
chainId: CHAIN_ID_MOCK,
networkClientId: NETWORK_CLIENT_ID_MOCK,
status: TransactionStatus.approved,
type: TransactionType.swapApproval,
txParams: {
from: ACCOUNT_MOCK,
to: ACCOUNT_2_MOCK,
},
} as TransactionMeta;

const { controller, messenger } = setupController({
options: {
state: {
transactions: [swapApprovalTransactionMeta],
},
},
});

expect(controller.state.transactions).toHaveLength(1);
expect(controller.state.transactions[0]).toMatchObject({
id: swapApprovalTransactionMeta.id,
type: TransactionType.swapApproval,
});

const swapApprovalListener = jest.fn();
messenger.subscribe(
'TransactionController:transactionNewSwapApproval',
swapApprovalListener,
);

controller.emulateNewTransaction(swapApprovalTransactionMeta.id);

expect(swapApprovalListener).toHaveBeenCalledTimes(1);
expect(swapApprovalListener).toHaveBeenCalledWith({
transactionMeta: expect.objectContaining({
id: swapApprovalTransactionMeta.id,
type: TransactionType.swapApproval,
}),
});
});

it('does not publish events when transaction is not a swap or swap approval', () => {
const { controller, messenger } = setupController({
options: {
state: {
transactions: [TRANSACTION_META_MOCK],
},
},
});

const swapListener = jest.fn();
const swapApprovalListener = jest.fn();
messenger.subscribe(
'TransactionController:transactionNewSwap',
swapListener,
);
messenger.subscribe(
'TransactionController:transactionNewSwapApproval',
swapApprovalListener,
);

controller.emulateNewTransaction(TRANSACTION_META_MOCK.id);

expect(swapListener).not.toHaveBeenCalled();
expect(swapApprovalListener).not.toHaveBeenCalled();
});

it('does not publish events when transaction does not exist', () => {
const { controller, messenger } = setupController();

const swapListener = jest.fn();
const swapApprovalListener = jest.fn();
messenger.subscribe(
'TransactionController:transactionNewSwap',
swapListener,
);
messenger.subscribe(
'TransactionController:transactionNewSwapApproval',
swapApprovalListener,
);

controller.emulateNewTransaction('missing-transaction-id');

expect(swapListener).not.toHaveBeenCalled();
expect(swapApprovalListener).not.toHaveBeenCalled();
});
});

describe('emulateTransactionUpdate', () => {
it('adds transaction to state and publishes update when it does not exist', () => {
const selectedAccount = {
...INTERNAL_ACCOUNT_MOCK,
address: ACCOUNT_2_MOCK,
};
const { controller, messenger, mockGetSelectedAccount } = setupController(
{ selectedAccount },
);

const transactionMeta = {
id: 'tx3',
chainId: CHAIN_ID_MOCK,
networkClientId: NETWORK_CLIENT_ID_MOCK,
status: TransactionStatus.unapproved,
txParams: {
from: ACCOUNT_MOCK,
to: ACCOUNT_2_MOCK,
},
} as TransactionMeta;

const statusUpdatedListener = jest.fn();
messenger.subscribe(
'TransactionController:transactionStatusUpdated',
statusUpdatedListener,
);

controller.emulateTransactionUpdate(transactionMeta);

const transactionMetaWithUpdatedSender = {
...transactionMeta,
txParams: {
...transactionMeta.txParams,
from: ACCOUNT_2_MOCK,
},
};
const transactionMetaWithUpdatedSenderAndNormalized = {
...transactionMetaWithUpdatedSender,
txParams: {
...transactionMetaWithUpdatedSender.txParams,
value: '0x0',
},
};
expect(controller.state.transactions).toHaveLength(1);
// State updated after updating sender and normalizing
expect(controller.state.transactions[0]).toStrictEqual(
transactionMetaWithUpdatedSenderAndNormalized,
);
expect(mockGetSelectedAccount).toHaveBeenCalledTimes(1);
expect(statusUpdatedListener).toHaveBeenCalledTimes(1);
// Status published after updating sender, but before normalization
expect(statusUpdatedListener).toHaveBeenCalledWith({
transactionMeta: transactionMetaWithUpdatedSender,
});
});

it('updates transaction when it already exists', () => {
const selectedAccount = {
...INTERNAL_ACCOUNT_MOCK,
address: ACCOUNT_2_MOCK,
};
const existingTransactionMeta = {
id: 'tx4',
chainId: CHAIN_ID_MOCK,
networkClientId: NETWORK_CLIENT_ID_MOCK,
status: TransactionStatus.unapproved,
txParams: {
from: ACCOUNT_MOCK,
to: ACCOUNT_2_MOCK,
},
} as TransactionMeta;
const newTransactionMeta = {
...existingTransactionMeta,
status: TransactionStatus.approved,
};

const { controller, messenger } = setupController({
options: {
state: {
transactions: [existingTransactionMeta],
},
},
selectedAccount,
});

const statusUpdatedListener = jest.fn();
messenger.subscribe(
'TransactionController:transactionStatusUpdated',
statusUpdatedListener,
);

controller.emulateTransactionUpdate(newTransactionMeta);

const transactionMetaWithUpdatedSender = {
...newTransactionMeta,
txParams: {
...newTransactionMeta.txParams,
from: ACCOUNT_2_MOCK,
},
};
const transactionMetaWithUpdatedSenderAndNormalized = {
...transactionMetaWithUpdatedSender,
txParams: {
...transactionMetaWithUpdatedSender.txParams,
value: '0x0',
},
};
expect(controller.state.transactions).toHaveLength(1);
// State updated after updating sender and normalizing
expect(controller.state.transactions[0]).toStrictEqual(
transactionMetaWithUpdatedSenderAndNormalized,
);
expect(statusUpdatedListener).toHaveBeenCalledTimes(1);
// Status published after updating sender, but before normalization
expect(statusUpdatedListener).toHaveBeenCalledWith({
transactionMeta: transactionMetaWithUpdatedSender,
});
});
});

describe('metadata', () => {
it('includes expected state in debug snapshots', () => {
const { controller } = setupController();
Expand Down
96 changes: 95 additions & 1 deletion packages/transaction-controller/src/TransactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,26 @@ export type TransactionControllerAddTransactionBatchAction = {
handler: TransactionController['addTransactionBatch'];
};

/**
* Emulate a new transaction.
*
* @param transactionId - The transaction ID.
*/
export type TransactionControllerEmulateNewTransaction = {
type: `${typeof controllerName}:emulateNewTransaction`;
handler: TransactionController['emulateNewTransaction'];
};

/**
* Emmulate a transaction update.
*
* @param transactionMeta - Transaction metadata.
*/
export type TransactionControllerEmulateTransactionUpdate = {
type: `${typeof controllerName}:emulateTransactionUpdate`;
handler: TransactionController['emulateTransactionUpdate'];
};

/**
* The internal actions available to the TransactionController.
*/
Expand All @@ -369,7 +389,9 @@ export type TransactionControllerActions =
| TransactionControllerGetStateAction
| TransactionControllerGetTransactionsAction
| TransactionControllerUpdateCustodialTransactionAction
| TransactionControllerUpdateTransactionAction;
| TransactionControllerUpdateTransactionAction
| TransactionControllerEmulateNewTransaction
| TransactionControllerEmulateTransactionUpdate;

/**
* Configuration options for the PendingTransactionTracker
Expand Down Expand Up @@ -2786,6 +2808,68 @@ export class TransactionController extends BaseController<
});
}

/**
* Emulate a new transaction.
*
* @param transactionId - The transaction ID.
*/
emulateNewTransaction(transactionId: string) {
const transactionMeta = this.state.transactions.find(
(tx) => tx.id === transactionId,
);

if (!transactionMeta) {
return;
}

if (transactionMeta.type === TransactionType.swap) {
this.messagingSystem.publish('TransactionController:transactionNewSwap', {
transactionMeta,
});
} else if (transactionMeta.type === TransactionType.swapApproval) {
this.messagingSystem.publish(
'TransactionController:transactionNewSwapApproval',
{ transactionMeta },
);
}
}

/**
* Emulate a transaction update.
*
* @param transactionMeta - Transaction metadata.
*/
emulateTransactionUpdate(transactionMeta: TransactionMeta) {
const updatedTransactionMeta = {
...transactionMeta,
txParams: {
...transactionMeta.txParams,
from: this.messagingSystem.call('AccountsController:getSelectedAccount')
.address,
},
};

const transactionExists = this.state.transactions.some(
(tx) => tx.id === updatedTransactionMeta.id,
);

if (!transactionExists) {
this.update((state) => {
state.transactions.push(updatedTransactionMeta);
});
}
Copy link

Choose a reason for hiding this comment

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

Bug: Transaction History Limit Bypass

When adding a transaction that doesn't exist, the code directly pushes updatedTransactionMeta to state without using #trimTransactionsForState(). This bypasses the transaction history limit enforcement that is used everywhere else in the controller (e.g., in #addMetadata at line 2876-2880). This could cause the state to exceed the configured transactionHistoryLimit, leading to unbounded memory growth.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

Again, this does seem like a legitimate bug, but it's a pre-existing problem

Copy link
Member

Choose a reason for hiding this comment

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

Likely not used at all currently so very very low risk.


this.updateTransaction(
updatedTransactionMeta,
'Generated from user operation',
);

this.messagingSystem.publish(
'TransactionController:transactionStatusUpdated',
{ transactionMeta: updatedTransactionMeta },
);
}
Copy link

Choose a reason for hiding this comment

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

Bug: Event Publishes Incomplete Transaction Data

In emulateTransactionUpdate, the transactionStatusUpdated event publishes transaction metadata before it's fully normalized by updateTransaction. This means event listeners receive data (e.g., missing value: '0x0') that differs from what's ultimately stored in the controller's state, leading to inconsistency.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

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

This does seem kinda broken, but, this is the pre-existing behavior

Copy link
Member

Choose a reason for hiding this comment

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

Again, likely not used, so low risk we can address in a future PR.


#addMetadata(transactionMeta: TransactionMeta) {
validateTxParams(transactionMeta.txParams);
this.update((state) => {
Expand Down Expand Up @@ -4392,6 +4476,16 @@ export class TransactionController extends BaseController<
`${controllerName}:updateTransaction`,
this.updateTransaction.bind(this),
);

this.messagingSystem.registerActionHandler(
`${controllerName}:emulateNewTransaction`,
this.emulateNewTransaction.bind(this),
);

this.messagingSystem.registerActionHandler(
`${controllerName}:emulateTransactionUpdate`,
this.emulateTransactionUpdate.bind(this),
);
}

#deleteTransaction(transactionId: string) {
Expand Down
Loading
Loading