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
3 changes: 2 additions & 1 deletion packages/transaction-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add chain-agnostic Pay source metadata using CAIP-10 account and CAIP-19 asset identifiers without changing legacy EVM Pay fields ([#10272](https://github.com/MetaMask/core/pull/10272))
- Add chain-agnostic Pay source metadata using CAIP-10 account and CAIP-19 asset identifiers, plus a phase-aware external Solana execution checkpoint, without changing legacy EVM Pay fields ([#10272](https://github.com/MetaMask/core/pull/10272), [#10273](https://github.com/MetaMask/core/pull/10273))
- Add externally handled publish results and lifecycle finalization for hashless non-EVM parent transactions ([#10273](https://github.com/MetaMask/core/pull/10273))

### Changed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,17 @@ export type TransactionControllerWipeTransactionsAction = {
handler: TransactionController['wipeTransactions'];
};

/**
* Marks an existing externally handled transaction as confirmed and emits
* the standard lifecycle events.
*
* @param transactionId - Existing transaction ID.
*/
export type TransactionControllerConfirmTransactionAction = {
type: `TransactionController:confirmTransaction`;
handler: TransactionController['confirmTransaction'];
};

/**
* Adds external provided transaction to state as confirmed transaction.
*
Expand Down Expand Up @@ -459,6 +470,7 @@ export type TransactionControllerMethodActions =
| TransactionControllerFailTransactionAction
| TransactionControllerUpdateSecurityAlertResponseAction
| TransactionControllerWipeTransactionsAction
| TransactionControllerConfirmTransactionAction
| TransactionControllerConfirmExternalTransactionAction
| TransactionControllerUpdateTransactionGasFeesAction
| TransactionControllerUpdatePreviousGasParamsAction
Expand Down
172 changes: 172 additions & 0 deletions packages/transaction-controller/src/TransactionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6408,6 +6408,116 @@ describe('TransactionController', () => {
expect(sendRawTransactionCalls).toHaveLength(1);
});

it.each(['submitted', 'ambiguous'] as const)(
'marks an externally handled %s publication submitted without EVM fallback',
async (outcome) => {
const { controller } = setupController({
options: {
hooks: {
publish: async () => ({ externallyHandled: true, outcome }),
},
},
messengerOptions: {
addTransactionApprovalRequest: {
state: 'approved',
},
},
});

const { result } = await controller.addTransaction(paramsMock, {
networkClientId: NETWORK_CLIENT_ID_MOCK,
});

await result;

expect(controller.state.transactions[0]).toMatchObject({
hash: undefined,
isExternalPublish: true,
status: TransactionStatus.submitted,
});
expect(
rpcRequestMock.mock.calls.filter(
([request]) => request.method === 'eth_sendRawTransaction',
),
).toHaveLength(0);
},
);

it('emits rejection lifecycle for an externally handled user rejection', async () => {
const { controller, messenger } = setupController({
options: {
hooks: {
publish: async () => ({
externallyHandled: true,
outcome: 'user-rejected',
}),
},
},
messengerOptions: {
addTransactionApprovalRequest: {
state: 'approved',
},
},
});
const listener = jest.fn();
messenger.subscribe(
'TransactionController:transactionRejected',
listener,
);

const { result } = await controller.addTransaction(paramsMock, {
networkClientId: NETWORK_CLIENT_ID_MOCK,
});
await result.catch(() => undefined);

expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
transactionMeta: expect.objectContaining({
status: TransactionStatus.rejected,
}),
}),
);
expect(controller.state.transactions).toHaveLength(0);
});

it('emits failure lifecycle for guaranteed external non-submission', async () => {
const { controller, messenger } = setupController({
options: {
hooks: {
publish: async () => ({
error: 'Snap preparation failed',
externallyHandled: true,
outcome: 'not-submitted',
}),
},
},
messengerOptions: {
addTransactionApprovalRequest: {
state: 'approved',
},
},
});
const listener = jest.fn();
messenger.subscribe('TransactionController:transactionFailed', listener);

const { result } = await controller.addTransaction(paramsMock, {
networkClientId: NETWORK_CLIENT_ID_MOCK,
});
await expect(result).rejects.toThrow('Snap preparation failed');

expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Snap preparation failed',
transactionMeta: expect.objectContaining({
status: TransactionStatus.failed,
}),
}),
);
expect(controller.state.transactions[0].status).toBe(
TransactionStatus.failed,
);
});

it('submits to publish hook with final transaction meta', async () => {
const publishHook = jest
.fn()
Expand Down Expand Up @@ -6550,6 +6660,68 @@ describe('TransactionController', () => {
});
});

describe('confirmTransaction', () => {
const submittedTransactionMock = {
...TRANSACTION_META_MOCK,
isExternalPublish: true,
status: TransactionStatus.submitted as const,
} as unknown as TransactionMeta;

it('marks an externally handled transaction confirmed and publishes lifecycle events', () => {
const confirmedListener = jest.fn();
const statusUpdatedListener = jest.fn();
const finishedListener = jest.fn();
const { controller, messenger } = setupController({
options: {
state: { transactions: [submittedTransactionMock] },
},
});
messenger.subscribe(
'TransactionController:transactionConfirmed',
confirmedListener,
);
messenger.subscribe(
'TransactionController:transactionStatusUpdated',
statusUpdatedListener,
);
messenger.subscribe(
'TransactionController:transactionFinished',
finishedListener,
);

controller.confirmTransaction(submittedTransactionMock.id);

expect(controller.state.transactions[0].status).toBe(
TransactionStatus.confirmed,
);
expect(confirmedListener).toHaveBeenCalledTimes(1);
expect(statusUpdatedListener).toHaveBeenCalledTimes(1);
expect(finishedListener).toHaveBeenCalledTimes(1);
});

it.each([
[TransactionStatus.submitted, false],
[TransactionStatus.confirmed, true],
[TransactionStatus.failed, true],
])(
'rejects status %s with external publication %s',
(status, isExternalPublish) => {
const transaction = {
...submittedTransactionMock,
isExternalPublish,
status,
};
const { controller } = setupController({
options: { state: { transactions: [transaction] } },
});

expect(() => controller.confirmTransaction(transaction.id)).toThrow(
'Only submitted externally published transactions can be confirmed',
);
},
);
});

describe('updateSecurityAlertResponse', () => {
it('add securityAlertResponse to transaction meta', async () => {
const transactionMeta = TRANSACTION_META_MOCK;
Expand Down
95 changes: 81 additions & 14 deletions packages/transaction-controller/src/TransactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ const MESSENGER_EXPOSED_METHODS = [
'approveTransactionsWithSameNonce',
'clearUnapprovedTransactions',
'confirmExternalTransaction',
'confirmTransaction',
'emulateNewTransaction',
'emulateTransactionUpdate',
'estimateGas',
Expand Down Expand Up @@ -1772,6 +1773,46 @@ export class TransactionController extends BaseController<
return this.#getTransactionOrThrow(transactionID);
}

/**
* Marks an existing externally handled transaction as confirmed and emits
* the standard lifecycle events.
*
* @param transactionId - Existing transaction ID.
*/
confirmTransaction(transactionId: string): void {
const transactionMeta = this.#getTransactionOrThrow(transactionId);

if (
transactionMeta.status !== TransactionStatus.submitted ||
!transactionMeta.isExternalPublish
) {
throw new Error(
'Only submitted externally published transactions can be confirmed',
);
}

const updatedTransactionMeta = this.#updateTransactionInternal(
{ transactionId },
(draftTransactionMeta) => {
draftTransactionMeta.status = TransactionStatus.confirmed;
},
);

this.#onTransactionStatusChange(updatedTransactionMeta);
this.messenger.publish(
`${controllerName}:transactionConfirmed`,
updatedTransactionMeta,
);
this.messenger.publish(
`${controllerName}:transactionFinished`,
updatedTransactionMeta,
);
this.#internalEvents.emit(
`${transactionId}:finished`,
updatedTransactionMeta,
);
}

/**
* Adds external provided transaction to state as confirmed transaction.
*
Expand Down Expand Up @@ -3057,6 +3098,12 @@ export class TransactionController extends BaseController<
resultCallbacks?.success();
return finalMeta.hash as string;

case TransactionStatus.rejected: {
const error = finalMeta.error as Error;
resultCallbacks?.error(error);
throw error;
}

default: {
const internalError = rpcErrors.internal(
`MetaMask Tx Signature: Unknown problem: ${JSON.stringify(
Expand Down Expand Up @@ -3206,10 +3253,25 @@ export class TransactionController extends BaseController<
publishHook = extraTransactionsPublishHook.getHook();
}

const { transactionHash: hash } = await publishHook(
transactionMeta,
rawTx ?? '0x',
);
const publishResult = await publishHook(transactionMeta, rawTx ?? '0x');
const { transactionHash: hash } = publishResult;

if (publishResult.externallyHandled) {
if (publishResult.outcome === 'user-rejected') {
this.#rejectTransaction(transactionId);
return ApprovalState.NotApproved;
}

if (publishResult.outcome === 'not-submitted') {
this.#failTransaction(
transactionMeta,
new Error(
publishResult.error ?? 'External transaction was not submitted',
),
);
return ApprovalState.NotApproved;
}
}

// eslint-disable-next-line require-atomic-updates
transactionMeta = this.#updateTransactionInternal(
Expand All @@ -3218,6 +3280,7 @@ export class TransactionController extends BaseController<
},
(draftTxMeta) => {
draftTxMeta.hash = hash;
draftTxMeta.isExternalPublish = publishResult.externallyHandled;
draftTxMeta.status = TransactionStatus.submitted;
draftTxMeta.submittedTime ??= new Date().getTime();
if (shouldUpdatePreTxBalance) {
Expand Down Expand Up @@ -4469,27 +4532,31 @@ export class TransactionController extends BaseController<
transactionMeta: TransactionMeta,
signedTx: string,
): Promise<PublishHookResult> {
let transactionHash: string | undefined;
let result: PublishHookResult = {};

await this.#trace(
{ name: 'Publish', parentContext: traceContext },
async () => {
const publishHook = publishHookOverride ?? this.#publish;

({ transactionHash } = await publishHook(transactionMeta, signedTx));
result = await publishHook(transactionMeta, signedTx);

// eslint-disable-next-line require-atomic-updates
transactionHash ??= await this.#publishTransaction({
...transactionMeta,
networkClientId,
rawTx: signedTx,
});
if (!result.externallyHandled && !result.transactionHash) {
// eslint-disable-next-line require-atomic-updates
result = {
transactionHash: await this.#publishTransaction({
...transactionMeta,
networkClientId,
rawTx: signedTx,
}),
};
}
},
);

log('Publish successful', transactionHash);
log('Publish successful', result);

return { transactionHash };
return result;
}

async #getGasFeeTokens(transaction: TransactionMeta): Promise<{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,20 @@ describe('PendingTransactionTracker', () => {
);
});

it('does not poll externally published transactions as EVM transactions', () => {
pendingTransactionTracker = new PendingTransactionTracker(options);
options.getTransactions.mockReturnValue(
freeze(
[{ ...TRANSACTION_SUBMITTED_MOCK, isExternalPublish: true }],
true,
),
);

pendingTransactionTracker.startIfPendingTransactions();

expect(transactionPoller.start).not.toHaveBeenCalled();
});

it('does nothing if listener already added', () => {
pendingTransactionTracker = new PendingTransactionTracker(options);

Expand Down
Loading
Loading