Skip to content
Draft
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
7 changes: 7 additions & 0 deletions packages/multichain-transactions-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Pending universal transaction state on `MultichainTransactionsController` ([#9115](https://github.com/MetaMask/core/pull/9115))
- New non-persisted `pendingTransactions` state keyed by approval ID.
- New `addPendingTransaction`, `updatePendingTransaction`, and `removePendingTransaction` messenger actions.
- New `PendingMultichainTransaction` type for protocol-agnostic pending confirmation display data.

### Changed

- Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.1` ([#8755](https://github.com/MetaMask/core/pull/8755), [#8774](https://github.com/MetaMask/core/pull/8774))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@

import type { MultichainTransactionsController } from './MultichainTransactionsController';

/**
* Adds or replaces a pending multichain transaction by approval ID.
*
* @param entry - Pending transaction entry to add.
*/
export type MultichainTransactionsControllerAddPendingTransactionAction = {
type: `MultichainTransactionsController:addPendingTransaction`;
handler: MultichainTransactionsController['addPendingTransaction'];
};

/**
* Updates a pending multichain transaction by approval ID.
*
* @param approvalId - Approval ID for the pending transaction.
* @param patch - Shallow patch to apply to the pending transaction.
*/
export type MultichainTransactionsControllerUpdatePendingTransactionAction = {
type: `MultichainTransactionsController:updatePendingTransaction`;
handler: MultichainTransactionsController['updatePendingTransaction'];
};

/**
* Removes a pending multichain transaction by approval ID.
*
* @param approvalId - Approval ID for the pending transaction.
*/
export type MultichainTransactionsControllerRemovePendingTransactionAction = {
type: `MultichainTransactionsController:removePendingTransaction`;
handler: MultichainTransactionsController['removePendingTransaction'];
};

/**
* Updates transactions for a specific account. This is used for the initial fetch
* when an account is first added.
Expand All @@ -21,4 +52,7 @@ export type MultichainTransactionsControllerUpdateTransactionsForAccountAction =
* Union of all MultichainTransactionsController action types.
*/
export type MultichainTransactionsControllerMethodActions =
MultichainTransactionsControllerUpdateTransactionsForAccountAction;
| MultichainTransactionsControllerAddPendingTransactionAction
| MultichainTransactionsControllerUpdatePendingTransactionAction
| MultichainTransactionsControllerRemovePendingTransactionAction
| MultichainTransactionsControllerUpdateTransactionsForAccountAction;
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import type {
MultichainTransactionsControllerState,
MultichainTransactionsControllerMessenger,
PendingMultichainTransaction,
} from './MultichainTransactionsController';

const mockBtcAccount = {
Expand Down Expand Up @@ -255,11 +256,26 @@ async function waitForAllPromises(): Promise<void> {

const NEW_ACCOUNT_ID = 'new-account-id';
const TEST_ACCOUNT_ID = 'test-account-id';
const MOCK_PENDING_TRANSACTION: PendingMultichainTransaction = {
approvalId: 'approval-id',
chainId: MultichainNetwork.Solana,
accountId: TEST_ACCOUNT_ID,
to: 'to-address',
amount: '1000000',
fee: {
amount: '5000',
},
origin: 'mock-snap',
createdAt: 123,
};

describe('MultichainTransactionsController', () => {
it('initialize with default state', () => {
const { controller } = setupController({});
expect(controller.state).toStrictEqual({ nonEvmTransactions: {} });
expect(controller.state).toStrictEqual({
nonEvmTransactions: {},
pendingTransactions: {},
});
});

it('updates transactions when "AccountsController:accountAdded" is fired', async () => {
Expand Down Expand Up @@ -322,7 +338,51 @@ describe('MultichainTransactionsController', () => {

expect(controller.state).toStrictEqual({
nonEvmTransactions: {},
pendingTransactions: {},
});
});

it('adds, updates, and removes pending multichain transactions', () => {
const { controller } = setupController();

controller.addPendingTransaction(MOCK_PENDING_TRANSACTION);

expect(
controller.state.pendingTransactions[MOCK_PENDING_TRANSACTION.approvalId],
).toStrictEqual(MOCK_PENDING_TRANSACTION);

controller.updatePendingTransaction(MOCK_PENDING_TRANSACTION.approvalId, {
amount: '2000000',
});

expect(
controller.state.pendingTransactions[MOCK_PENDING_TRANSACTION.approvalId],
).toStrictEqual({
...MOCK_PENDING_TRANSACTION,
amount: '2000000',
});

controller.removePendingTransaction(MOCK_PENDING_TRANSACTION.approvalId);

expect(
controller.state.pendingTransactions[MOCK_PENDING_TRANSACTION.approvalId],
).toBeUndefined();
});

it('warns when updating or removing a missing pending multichain transaction', () => {
const { controller } = setupController();
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();

controller.updatePendingTransaction('missing-approval-id', { amount: '1' });
controller.removePendingTransaction('missing-approval-id');

expect(warnSpy).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledWith(
'Pending multichain transaction not found for approvalId',
'missing-approval-id',
);

warnSpy.mockRestore();
});

it('updates transactions for a specific account', async () => {
Expand Down Expand Up @@ -564,6 +624,7 @@ describe('MultichainTransactionsController', () => {
},
},
},
pendingTransactions: {},
},
});

Expand Down Expand Up @@ -597,6 +658,7 @@ describe('MultichainTransactionsController', () => {
},
},
},
pendingTransactions: {},
},
});

Expand All @@ -621,6 +683,7 @@ describe('MultichainTransactionsController', () => {
const { controller, rootMessenger } = setupController({
state: {
nonEvmTransactions: {},
pendingTransactions: {},
},
});

Expand Down Expand Up @@ -653,6 +716,7 @@ describe('MultichainTransactionsController', () => {
},
},
},
pendingTransactions: {},
},
mocks: {
listMultichainAccounts: [],
Expand Down Expand Up @@ -707,6 +771,7 @@ describe('MultichainTransactionsController', () => {
},
},
},
pendingTransactions: {},
},
});

Expand Down Expand Up @@ -755,6 +820,7 @@ describe('MultichainTransactionsController', () => {
},
},
},
pendingTransactions: {},
},
});

Expand Down Expand Up @@ -844,6 +910,7 @@ describe('MultichainTransactionsController', () => {
},
},
},
pendingTransactions: {},
},
});

Expand Down Expand Up @@ -1027,6 +1094,7 @@ describe('MultichainTransactionsController', () => {
).toMatchInlineSnapshot(`
{
"nonEvmTransactions": {},
"pendingTransactions": {},
}
`);
});
Expand Down Expand Up @@ -1059,6 +1127,7 @@ describe('MultichainTransactionsController', () => {
).toMatchInlineSnapshot(`
{
"nonEvmTransactions": {},
"pendingTransactions": {},
}
`);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,26 @@
import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers';
import type { SnapId } from '@metamask/snaps-sdk';
import { HandlerType } from '@metamask/snaps-utils';
import type { CaipChainId, Json, JsonRpcRequest } from '@metamask/utils';
import type {
CaipAssetType,
CaipChainId,
Json,
JsonRpcRequest,
} from '@metamask/utils';
import type { Draft } from 'immer';

import type { MultichainTransactionsControllerMethodActions } from './MultichainTransactionsController-method-action-types';

const controllerName = 'MultichainTransactionsController';

const MESSENGER_EXPOSED_METHODS = ['updateTransactionsForAccount'] as const;
const MESSENGER_EXPOSED_METHODS = [
'addPendingTransaction',
'removePendingTransaction',
'updatePendingTransaction',
'updateTransactionsForAccount',
] as const;
const MISSING_PENDING_TRANSACTION_MESSAGE =
'Pending multichain transaction not found for approvalId';

/**
* PaginationOptions
Expand All @@ -42,6 +54,24 @@
next?: string | null;
};

export type PendingMultichainTransaction<
CustomData extends Record<string, Json> = Record<string, Json>,
> = {
approvalId: string;
chainId: CaipChainId;
accountId: string;
to: string;
amount: string;
assetId?: CaipAssetType;
fee?: {
amount: string;
assetId?: CaipAssetType;
};
custom?: CustomData;
origin?: string;
createdAt: number;
};

/**
* State used by the {@link MultichainTransactionsController} to cache account transactions.
*/
Expand All @@ -51,6 +81,7 @@
[chain: CaipChainId]: TransactionStateEntry;
};
};
pendingTransactions: Record<string, PendingMultichainTransaction>;
};

/**
Expand All @@ -61,6 +92,7 @@
export function getDefaultMultichainTransactionsControllerState(): MultichainTransactionsControllerState {
return {
nonEvmTransactions: {},
pendingTransactions: {},
};
}

Expand Down Expand Up @@ -152,6 +184,13 @@
includeInDebugSnapshot: false,
usedInUi: true,
},
pendingTransactions: {
includeInStateLogs: true,
persist: false,
includeInDebugSnapshot: false,
usedInUi: true,
anonymous: false,
},
};

/**
Expand Down Expand Up @@ -219,6 +258,55 @@
);
}

/**
* Adds or replaces a pending multichain transaction by approval ID.
*
* @param entry - Pending transaction entry to add.
*/
addPendingTransaction(entry: PendingMultichainTransaction): void {
this.update((state: Draft<MultichainTransactionsControllerState>) => {
state.pendingTransactions[entry.approvalId] = entry;
});
}

/**
* Updates a pending multichain transaction by approval ID.
*
* @param approvalId - Approval ID for the pending transaction.
* @param patch - Shallow patch to apply to the pending transaction.
*/
updatePendingTransaction(
approvalId: string,
patch: Partial<PendingMultichainTransaction>,
): void {
this.update((state: Draft<MultichainTransactionsControllerState>) => {
const pendingTransaction = state.pendingTransactions[approvalId];

if (!pendingTransaction) {
console.warn(MISSING_PENDING_TRANSACTION_MESSAGE, approvalId);
return;
}

Object.assign(pendingTransaction, patch);

Check warning

Code scanning / CodeQL

Prototype-polluting assignment Medium

This assignment may alter Object.prototype if a malicious '__proto__' string is injected from
library input
.
});
}

/**
* Removes a pending multichain transaction by approval ID.
*
* @param approvalId - Approval ID for the pending transaction.
*/
removePendingTransaction(approvalId: string): void {
this.update((state: Draft<MultichainTransactionsControllerState>) => {
if (!state.pendingTransactions[approvalId]) {
console.warn(MISSING_PENDING_TRANSACTION_MESSAGE, approvalId);
return;
}

delete state.pendingTransactions[approvalId];
});
}

/**
* Lists the multichain accounts coming from the `AccountsController`.
*
Expand Down
1 change: 1 addition & 0 deletions packages/multichain-transactions-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export { MultichainTransactionsController } from './MultichainTransactionsContro
export type {
MultichainTransactionsControllerState,
PaginationOptions,
PendingMultichainTransaction,
TransactionStateEntry,
MultichainTransactionsControllerStateChange,
MultichainTransactionsControllerGetStateAction,
Expand Down
Loading