diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index f2d687a13a5..6a7ff30f0a3 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -5310,7 +5310,7 @@ describe('TransactionController', () => { options: { hooks: { afterSign: () => false, - beforePublish: () => false, + beforePublish: () => Promise.resolve(false), getAdditionalSignArguments: () => [metadataMock], }, }, @@ -5352,7 +5352,7 @@ describe('TransactionController', () => { options: { hooks: { afterSign: () => false, - beforePublish: () => false, + beforePublish: () => Promise.resolve(false), getAdditionalSignArguments: () => [metadataMock], }, // @ts-expect-error sign intentionally returns undefined @@ -5682,7 +5682,6 @@ describe('TransactionController', () => { }; transactionMeta = { ...baseTransaction, - custodyId: '123', history: [{ ...baseTransaction }], }; }); @@ -5710,7 +5709,8 @@ describe('TransactionController', () => { updateToInitialState: true, }); - controller.updateCustodialTransaction(transactionId, { + controller.updateCustodialTransaction({ + transactionId, status: newStatus, errorMessage, }); @@ -5746,7 +5746,8 @@ describe('TransactionController', () => { finishedEventListener, ); - controller.updateCustodialTransaction(transactionId, { + controller.updateCustodialTransaction({ + transactionId, status: newStatus, errorMessage, }); @@ -5765,7 +5766,7 @@ describe('TransactionController', () => { ); it('updates transaction hash', async () => { - const newHash = '1234'; + const newHash = '0x1234'; const { controller } = setupController({ options: { state: { @@ -5775,7 +5776,8 @@ describe('TransactionController', () => { updateToInitialState: true, }); - controller.updateCustodialTransaction(transactionId, { + controller.updateCustodialTransaction({ + transactionId, hash: newHash, }); @@ -5784,40 +5786,166 @@ describe('TransactionController', () => { expect(updatedTransaction.hash).toStrictEqual(newHash); }); - it('throws if custodial transaction does not exists', async () => { - const nonExistentId = 'nonExistentId'; - const newStatus = TransactionStatus.approved as const; - const { controller } = setupController(); + it('updates gasLimit', async () => { + const newGasLimit = '0x1234'; + const { controller } = setupController({ + options: { + state: { transactions: [transactionMeta] }, + }, + updateToInitialState: true, + }); - expect(() => - controller.updateCustodialTransaction(nonExistentId, { - status: newStatus, - }), - ).toThrow( - 'Cannot update custodial transaction as no transaction metadata found', + controller.updateCustodialTransaction({ + transactionId, + gasLimit: newGasLimit, + }); + + const updatedTransaction = controller.state.transactions[0]; + + expect(updatedTransaction.txParams.gasLimit).toStrictEqual(newGasLimit); + }); + + it('updates gasPrice', async () => { + const newGasPrice = '0x1234'; + const { controller } = setupController({ + options: { + state: { transactions: [transactionMeta] }, + }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + gasPrice: newGasPrice, + }); + + const updatedTransaction = controller.state.transactions[0]; + + expect(updatedTransaction.txParams.gasPrice).toStrictEqual(newGasPrice); + }); + + it('updates maxFeePerGas', async () => { + const newMaxFeePerGas = '0x1234'; + const { controller } = setupController({ + options: { + state: { transactions: [transactionMeta] }, + }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + maxFeePerGas: newMaxFeePerGas, + }); + + const updatedTransaction = controller.state.transactions[0]; + + expect(updatedTransaction.txParams.maxFeePerGas).toStrictEqual( + newMaxFeePerGas, ); }); - it('throws if transaction is not a custodial transaction', async () => { - const nonCustodialTransaction: TransactionMeta = { - ...baseTransaction, - history: [{ ...baseTransaction }], - }; - const newStatus = TransactionStatus.approved as const; + it('updates maxPriorityFeePerGas', async () => { + const newMaxPriorityFeePerGas = '0x1234'; + const { controller } = setupController({ + options: { + state: { transactions: [transactionMeta] }, + }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + maxPriorityFeePerGas: newMaxPriorityFeePerGas, + }); + + const updatedTransaction = controller.state.transactions[0]; + + expect(updatedTransaction.txParams.maxPriorityFeePerGas).toStrictEqual( + newMaxPriorityFeePerGas, + ); + }); + + it('updates nonce', async () => { + const newNonce = '0x1234'; + const { controller } = setupController({ + options: { + state: { transactions: [transactionMeta] }, + }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + nonce: newNonce, + }); + + const updatedTransaction = controller.state.transactions[0]; + + expect(updatedTransaction.txParams.nonce).toStrictEqual(newNonce); + }); + + it('updates type from legacy to feeMarket', async () => { + const newType = TransactionEnvelopeType.feeMarket; + const { controller } = setupController({ + options: { state: { transactions: [transactionMeta] } }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + type: newType, + }); + + const updatedTransaction = controller.state.transactions[0]; + + expect(updatedTransaction.txParams.type).toStrictEqual(newType); + }); + + it('updates type from feeMarket to legacy', async () => { + const newType = TransactionEnvelopeType.legacy; const { controller } = setupController({ options: { state: { - transactions: [nonCustodialTransaction], + transactions: [ + { + ...transactionMeta, + txParams: { + ...transactionMeta.txParams, + maxFeePerGas: '0x1234', + maxPriorityFeePerGas: '0x1234', + }, + }, + ], }, }, updateToInitialState: true, }); + controller.updateCustodialTransaction({ + transactionId, + type: newType, + }); + + const updatedTransaction = controller.state.transactions[0]; + + expect(updatedTransaction.txParams.maxFeePerGas).toBeUndefined(); + expect(updatedTransaction.txParams.maxPriorityFeePerGas).toBeUndefined(); + }); + + it('throws if custodial transaction does not exists', async () => { + const nonExistentId = 'nonExistentId'; + const newStatus = TransactionStatus.approved as const; + const { controller } = setupController(); + expect(() => - controller.updateCustodialTransaction(nonCustodialTransaction.id, { + controller.updateCustodialTransaction({ + transactionId: nonExistentId, status: newStatus, }), - ).toThrow('Transaction must be a custodian transaction'); + ).toThrow( + 'Cannot update custodial transaction as no transaction metadata found', + ); }); it('throws if status is invalid', async () => { @@ -5832,7 +5960,8 @@ describe('TransactionController', () => { }); expect(() => - controller.updateCustodialTransaction(transactionMeta.id, { + controller.updateCustodialTransaction({ + transactionId: transactionMeta.id, status: newStatus, }), ).toThrow( @@ -5850,13 +5979,124 @@ describe('TransactionController', () => { updateToInitialState: true, }); - controller.updateCustodialTransaction(transactionId, {}); + controller.updateCustodialTransaction({ + transactionId, + ...{}, + }); const updatedTransaction = controller.state.transactions[0]; expect(updatedTransaction.status).toStrictEqual(transactionMeta.status); expect(updatedTransaction.hash).toStrictEqual(transactionMeta.hash); }); + + it.each([ + { + paramName: 'hash', + newValue: '0x1234', + expectedPath: 'hash', + }, + { + paramName: 'gasLimit', + newValue: '0x1234', + expectedPath: 'txParams.gasLimit', + }, + { + paramName: 'gasPrice', + newValue: '0x1234', + expectedPath: 'txParams.gasPrice', + }, + { + paramName: 'maxFeePerGas', + newValue: '0x1234', + expectedPath: 'txParams.maxFeePerGas', + }, + { + paramName: 'maxPriorityFeePerGas', + newValue: '0x1234', + expectedPath: 'txParams.maxPriorityFeePerGas', + }, + { + paramName: 'nonce', + newValue: '0x1234', + expectedPath: 'txParams.nonce', + }, + ])('updates $paramName', async ({ paramName, newValue, expectedPath }) => { + const { controller } = setupController({ + options: { + state: { transactions: [transactionMeta] }, + }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + [paramName]: newValue, + }); + + const updatedTransaction = controller.state.transactions[0]; + const pathParts = expectedPath.split('.'); + let actualValue = updatedTransaction; + + for (const key of pathParts) { + // Type assertion needed since we're accessing dynamic properties + actualValue = actualValue[ + key as keyof typeof actualValue + ] as typeof actualValue; + } + + expect(actualValue).toStrictEqual(newValue); + }); + + describe('type updates', () => { + it('updates from legacy to feeMarket', async () => { + const newType = TransactionEnvelopeType.feeMarket; + const { controller } = setupController({ + options: { state: { transactions: [transactionMeta] } }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + type: newType, + }); + + const updatedTransaction = controller.state.transactions[0]; + expect(updatedTransaction.txParams.type).toStrictEqual(newType); + }); + + it('updates from feeMarket to legacy', async () => { + const newType = TransactionEnvelopeType.legacy; + const { controller } = setupController({ + options: { + state: { + transactions: [ + { + ...transactionMeta, + txParams: { + ...transactionMeta.txParams, + maxFeePerGas: '0x1234', + maxPriorityFeePerGas: '0x1234', + }, + }, + ], + }, + }, + updateToInitialState: true, + }); + + controller.updateCustodialTransaction({ + transactionId, + type: newType, + }); + + const updatedTransaction = controller.state.transactions[0]; + expect(updatedTransaction.txParams.maxFeePerGas).toBeUndefined(); + expect( + updatedTransaction.txParams.maxPriorityFeePerGas, + ).toBeUndefined(); + }); + }); }); describe('getTransactions', () => { diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index a513ee5c327..1360d369b9e 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -99,6 +99,7 @@ import type { TransactionBatchRequest, TransactionBatchResult, BatchTransactionParams, + UpdateCustodialTransactionRequest, PublishHook, PublishBatchHook, GasFeeToken, @@ -255,10 +256,20 @@ export type TransactionControllerGetStateAction = ControllerGetStateAction< TransactionControllerState >; +/** + * Represents the `TransactionController:updateCustodialTransaction` action. + */ +export type TransactionControllerUpdateCustodialTransactionAction = { + type: `${typeof controllerName}:updateCustodialTransaction`; + handler: TransactionController['updateCustodialTransaction']; +}; + /** * The internal actions available to the TransactionController. */ -export type TransactionControllerActions = TransactionControllerGetStateAction; +export type TransactionControllerActions = + | TransactionControllerGetStateAction + | TransactionControllerUpdateCustodialTransactionAction; /** * Configuration options for the PendingTransactionTracker @@ -365,13 +376,13 @@ export type TransactionControllerOptions = { */ beforeCheckPendingTransaction?: ( transactionMeta: TransactionMeta, - ) => boolean; + ) => Promise<boolean>; /** * Additional logic to execute before publishing a transaction. * Return false to prevent the broadcast of the transaction. */ - beforePublish?: (transactionMeta: TransactionMeta) => boolean; + beforePublish?: (transactionMeta: TransactionMeta) => Promise<boolean>; /** Returns additional arguments required to sign a transaction. */ getAdditionalSignArguments?: ( @@ -712,9 +723,11 @@ export class TransactionController extends BaseController< private readonly beforeCheckPendingTransaction: ( transactionMeta: TransactionMeta, - ) => boolean; + ) => Promise<boolean>; - private readonly beforePublish: (transactionMeta: TransactionMeta) => boolean; + private readonly beforePublish: ( + transactionMeta: TransactionMeta, + ) => Promise<boolean>; private readonly publish: ( transactionMeta: TransactionMeta, @@ -864,10 +877,9 @@ export class TransactionController extends BaseController< this.afterSign = hooks?.afterSign ?? (() => true); this.beforeCheckPendingTransaction = - hooks?.beforeCheckPendingTransaction ?? /* istanbul ignore next */ - (() => true); - this.beforePublish = hooks?.beforePublish ?? (() => true); + hooks?.beforeCheckPendingTransaction ?? (() => Promise.resolve(true)); + this.beforePublish = hooks?.beforePublish ?? (() => Promise.resolve(true)); this.getAdditionalSignArguments = hooks?.getAdditionalSignArguments ?? (() => []); this.publish = @@ -986,6 +998,7 @@ export class TransactionController extends BaseController< this.onBootCleanup(); this.#checkForPendingTransactionAndStartPolling(); + this.#registerActionHandlers(); } /** @@ -1687,6 +1700,7 @@ export class TransactionController extends BaseController< // Intentional given potential duration of process. this.updatePostBalance(updatedTransactionMeta).catch((error) => { + /* istanbul ignore next */ log('Error while updating post balance', error); throw error; }); @@ -2097,24 +2111,24 @@ export class TransactionController extends BaseController< /** * Update a custodial transaction. * - * @param transactionId - The ID of the transaction to update. - * @param options - The custodial transaction options to update. - * @param options.errorMessage - The error message to be assigned in case transaction status update to failed. - * @param options.hash - The new hash value to be assigned. - * @param options.status - The new status value to be assigned. + * @param request - The custodial transaction update request. + * + * @returns The updated transaction metadata. */ - updateCustodialTransaction( - transactionId: string, - { + updateCustodialTransaction(request: UpdateCustodialTransactionRequest) { + const { + transactionId, errorMessage, hash, status, - }: { - errorMessage?: string; - hash?: string; - status?: TransactionStatus; - }, - ) { + gasLimit, + gasPrice, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + type, + } = request; + const transactionMeta = this.getTransaction(transactionId); if (!transactionMeta) { @@ -2123,10 +2137,6 @@ export class TransactionController extends BaseController< ); } - if (!transactionMeta.custodyId) { - throw new Error('Transaction must be a custodian transaction'); - } - if ( status && ![ @@ -2139,7 +2149,6 @@ export class TransactionController extends BaseController< `Cannot update custodial transaction with status: ${status}`, ); } - const updatedTransactionMeta = merge( {}, transactionMeta, @@ -2154,12 +2163,33 @@ export class TransactionController extends BaseController< updatedTransactionMeta.error = normalizeTxError(new Error(errorMessage)); } + // Update txParams properties with a single pickBy operation + updatedTransactionMeta.txParams = merge( + {}, + updatedTransactionMeta.txParams, + pickBy({ + gasLimit, + gasPrice, + maxFeePerGas, + maxPriorityFeePerGas, + nonce, + type, + }), + ); + + // Special case for type change to legacy + if (type === TransactionEnvelopeType.legacy) { + delete updatedTransactionMeta.txParams.maxFeePerGas; + delete updatedTransactionMeta.txParams.maxPriorityFeePerGas; + } + this.updateTransaction( updatedTransactionMeta, `${controllerName}:updateCustodialTransaction - Custodial transaction updated`, ); if ( + status && [TransactionStatus.submitted, TransactionStatus.failed].includes( status as TransactionStatus, ) @@ -2173,6 +2203,8 @@ export class TransactionController extends BaseController< updatedTransactionMeta, ); } + + return updatedTransactionMeta; } /** @@ -2835,7 +2867,7 @@ export class TransactionController extends BaseController< () => this.signTransaction(transactionMeta), ); - if (!this.beforePublish(transactionMeta)) { + if (!(await this.beforePublish(transactionMeta))) { log('Skipping publishing transaction based on hook'); this.messagingSystem.publish( `${controllerName}:transactionPublishingSkipped`, @@ -3645,7 +3677,6 @@ export class TransactionController extends BaseController< hooks: { beforeCheckPendingTransaction: this.beforeCheckPendingTransaction.bind(this), - beforePublish: this.beforePublish.bind(this), }, }); @@ -4116,6 +4147,13 @@ export class TransactionController extends BaseController< }); } + #registerActionHandlers(): void { + this.messagingSystem.registerActionHandler( + `${controllerName}:updateCustodialTransaction`, + this.updateCustodialTransaction.bind(this), + ); + } + #deleteTransaction(transactionId: string) { this.update((state) => { const transactions = state.transactions.filter( diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts index 149a701a1c0..335006ac6cb 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.test.ts @@ -394,8 +394,7 @@ describe('PendingTransactionTracker', () => { ...options, getTransactions: () => freeze([transactionMetaMock], true), hooks: { - beforeCheckPendingTransaction: () => false, - beforePublish: () => false, + beforeCheckPendingTransaction: () => Promise.resolve(false), }, }); @@ -744,7 +743,7 @@ describe('PendingTransactionTracker', () => { ); }); - it('if beforePublish returns false, does not resubmit the transaction', async () => { + it('if beforeCheckPendingTransaction returns false, does not resubmit the transaction', async () => { const transaction = { ...TRANSACTION_SUBMITTED_MOCK }; const getTransactions = jest .fn() @@ -754,8 +753,7 @@ describe('PendingTransactionTracker', () => { ...options, getTransactions, hooks: { - beforeCheckPendingTransaction: () => false, - beforePublish: () => false, + beforeCheckPendingTransaction: () => Promise.resolve(false), }, }); diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index b2fffc8147a..325fc9806e0 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -95,9 +95,7 @@ export class PendingTransactionTracker { readonly #beforeCheckPendingTransaction: ( transactionMeta: TransactionMeta, - ) => boolean; - - readonly #beforePublish: (transactionMeta: TransactionMeta) => boolean; + ) => Promise<boolean>; constructor({ blockTracker, @@ -125,8 +123,7 @@ export class PendingTransactionTracker { hooks?: { beforeCheckPendingTransaction?: ( transactionMeta: TransactionMeta, - ) => boolean; - beforePublish?: (transactionMeta: TransactionMeta) => boolean; + ) => Promise<boolean>; }; messenger: TransactionControllerMessenger; }) { @@ -142,14 +139,17 @@ export class PendingTransactionTracker { this.#getGlobalLock = getGlobalLock; this.#publishTransaction = publishTransaction; this.#running = false; + this.#transactionPoller = new TransactionPoller({ blockTracker, chainId: getChainId(), messenger, }); - this.#beforePublish = hooks?.beforePublish ?? (() => true); + this.#beforeCheckPendingTransaction = - hooks?.beforeCheckPendingTransaction ?? (() => true); + hooks?.beforeCheckPendingTransaction ?? + /* istanbul ignore next */ + (() => Promise.resolve(true)); this.#log = createModuleLogger( log, @@ -308,7 +308,7 @@ export class PendingTransactionTracker { return; } - if (!this.#beforePublish(txMeta)) { + if (!(await this.#beforeCheckPendingTransaction(txMeta))) { return; } @@ -356,7 +356,7 @@ export class PendingTransactionTracker { async #checkTransaction(txMeta: TransactionMeta) { const { hash, id } = txMeta; - if (!hash && this.#beforeCheckPendingTransaction(txMeta)) { + if (!hash && (await this.#beforeCheckPendingTransaction(txMeta))) { const error = new Error( 'We had an error while submitting this transaction, please try again.', ); diff --git a/packages/transaction-controller/src/index.ts b/packages/transaction-controller/src/index.ts index 6536e329306..d0d824cdcb9 100644 --- a/packages/transaction-controller/src/index.ts +++ b/packages/transaction-controller/src/index.ts @@ -21,6 +21,7 @@ export type { TransactionControllerTransactionStatusUpdatedEvent, TransactionControllerTransactionSubmittedEvent, TransactionControllerUnapprovedTransactionAddedEvent, + TransactionControllerUpdateCustodialTransactionAction, TransactionControllerMessenger, TransactionControllerOptions, } from './TransactionController'; diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index b132c55a5e7..97a9eaedc47 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -79,16 +79,6 @@ export type TransactionMeta = { */ currentTokenBalance?: string; - /** - * Unique ID for custodian transaction. - */ - custodyId?: string; - - /** - * Custodian transaction status. - */ - custodyStatus?: string; - /** The optional custom nonce override as a decimal string. */ customNonceValue?: string; @@ -1576,6 +1566,41 @@ export type TransactionBatchResult = { batchId: Hex; }; +/** + * Request parameters for updating a custodial transaction. + */ +export type UpdateCustodialTransactionRequest = { + /** The ID of the transaction to update. */ + transactionId: string; + + /** The error message to be assigned in case transaction status update to failed. */ + errorMessage?: string; + + /** The new hash value to be assigned. */ + hash?: string; + + /** The new status value to be assigned. */ + status?: TransactionStatus; + + /** The new gas limit value to be assigned. */ + gasLimit?: string; + + /** The new gas price value to be assigned. */ + gasPrice?: string; + + /** The new max fee per gas value to be assigned. */ + maxFeePerGas?: string; + + /** The new max priority fee per gas value to be assigned. */ + maxPriorityFeePerGas?: string; + + /** The new nonce value to be assigned. */ + nonce?: string; + + /** The new transaction type (hardfork) to be assigned. */ + type?: TransactionEnvelopeType; +}; + /** * Data returned from custom logic to publish a transaction. */