diff --git a/packages/multichain-transactions-controller/CHANGELOG.md b/packages/multichain-transactions-controller/CHANGELOG.md index 6143bafd60..393aa39ac6 100644 --- a/packages/multichain-transactions-controller/CHANGELOG.md +++ b/packages/multichain-transactions-controller/CHANGELOG.md @@ -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)) diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController-method-action-types.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController-method-action-types.ts index 6a825efeec..7c5a0523fb 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController-method-action-types.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController-method-action-types.ts @@ -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. @@ -21,4 +52,7 @@ export type MultichainTransactionsControllerUpdateTransactionsForAccountAction = * Union of all MultichainTransactionsController action types. */ export type MultichainTransactionsControllerMethodActions = - MultichainTransactionsControllerUpdateTransactionsForAccountAction; + | MultichainTransactionsControllerAddPendingTransactionAction + | MultichainTransactionsControllerUpdatePendingTransactionAction + | MultichainTransactionsControllerRemovePendingTransactionAction + | MultichainTransactionsControllerUpdateTransactionsForAccountAction; diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts index cf1ac2319d..04ea438546 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.test.ts @@ -32,6 +32,7 @@ import { import type { MultichainTransactionsControllerState, MultichainTransactionsControllerMessenger, + PendingMultichainTransaction, } from './MultichainTransactionsController'; const mockBtcAccount = { @@ -255,11 +256,26 @@ async function waitForAllPromises(): Promise { 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 () => { @@ -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 () => { @@ -564,6 +624,7 @@ describe('MultichainTransactionsController', () => { }, }, }, + pendingTransactions: {}, }, }); @@ -597,6 +658,7 @@ describe('MultichainTransactionsController', () => { }, }, }, + pendingTransactions: {}, }, }); @@ -621,6 +683,7 @@ describe('MultichainTransactionsController', () => { const { controller, rootMessenger } = setupController({ state: { nonEvmTransactions: {}, + pendingTransactions: {}, }, }); @@ -653,6 +716,7 @@ describe('MultichainTransactionsController', () => { }, }, }, + pendingTransactions: {}, }, mocks: { listMultichainAccounts: [], @@ -707,6 +771,7 @@ describe('MultichainTransactionsController', () => { }, }, }, + pendingTransactions: {}, }, }); @@ -755,6 +820,7 @@ describe('MultichainTransactionsController', () => { }, }, }, + pendingTransactions: {}, }, }); @@ -844,6 +910,7 @@ describe('MultichainTransactionsController', () => { }, }, }, + pendingTransactions: {}, }, }); @@ -1027,6 +1094,7 @@ describe('MultichainTransactionsController', () => { ).toMatchInlineSnapshot(` { "nonEvmTransactions": {}, + "pendingTransactions": {}, } `); }); @@ -1059,6 +1127,7 @@ describe('MultichainTransactionsController', () => { ).toMatchInlineSnapshot(` { "nonEvmTransactions": {}, + "pendingTransactions": {}, } `); }); diff --git a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts index d51575beb8..8507f8a6fd 100644 --- a/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts +++ b/packages/multichain-transactions-controller/src/MultichainTransactionsController.ts @@ -21,14 +21,26 @@ import type { Messenger } from '@metamask/messenger'; 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 @@ -42,6 +54,24 @@ export type PaginationOptions = { next?: string | null; }; +export type PendingMultichainTransaction< + CustomData extends Record = Record, +> = { + 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. */ @@ -51,6 +81,7 @@ export type MultichainTransactionsControllerState = { [chain: CaipChainId]: TransactionStateEntry; }; }; + pendingTransactions: Record; }; /** @@ -61,6 +92,7 @@ export type MultichainTransactionsControllerState = { export function getDefaultMultichainTransactionsControllerState(): MultichainTransactionsControllerState { return { nonEvmTransactions: {}, + pendingTransactions: {}, }; } @@ -152,6 +184,13 @@ const multichainTransactionsControllerMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + pendingTransactions: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + anonymous: false, + }, }; /** @@ -219,6 +258,55 @@ export class MultichainTransactionsController extends BaseController< ); } + /** + * 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) => { + 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, + ): void { + this.update((state: Draft) => { + const pendingTransaction = state.pendingTransactions[approvalId]; + + if (!pendingTransaction) { + console.warn(MISSING_PENDING_TRANSACTION_MESSAGE, approvalId); + return; + } + + Object.assign(pendingTransaction, patch); + }); + } + + /** + * Removes a pending multichain transaction by approval ID. + * + * @param approvalId - Approval ID for the pending transaction. + */ + removePendingTransaction(approvalId: string): void { + this.update((state: Draft) => { + if (!state.pendingTransactions[approvalId]) { + console.warn(MISSING_PENDING_TRANSACTION_MESSAGE, approvalId); + return; + } + + delete state.pendingTransactions[approvalId]; + }); + } + /** * Lists the multichain accounts coming from the `AccountsController`. * diff --git a/packages/multichain-transactions-controller/src/index.ts b/packages/multichain-transactions-controller/src/index.ts index ed5c590d95..c295a72de6 100644 --- a/packages/multichain-transactions-controller/src/index.ts +++ b/packages/multichain-transactions-controller/src/index.ts @@ -2,6 +2,7 @@ export { MultichainTransactionsController } from './MultichainTransactionsContro export type { MultichainTransactionsControllerState, PaginationOptions, + PendingMultichainTransaction, TransactionStateEntry, MultichainTransactionsControllerStateChange, MultichainTransactionsControllerGetStateAction,