Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Add simulation retrigger #4792

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 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
6 changes: 3 additions & 3 deletions packages/transaction-controller/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ module.exports = merge(baseConfig, {
coverageThreshold: {
global: {
branches: 94.42,
functions: 97.46,
lines: 98.44,
statements: 98.46,
functions: 97.22,
lines: 98.38,
statements: 98.39,
},
},

Expand Down
226 changes: 226 additions & 0 deletions packages/transaction-controller/src/TransactionController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { errorCodes, providerErrors, rpcErrors } from '@metamask/rpc-errors';
import type { Hex } from '@metamask/utils';
import { createDeferredPromise } from '@metamask/utils';
import assert from 'assert';
import { merge } from 'lodash';
import * as uuidModule from 'uuid';

import { FakeBlockTracker } from '../../../tests/fake-block-tracker';
Expand Down Expand Up @@ -73,6 +74,7 @@ import type {
SubmitHistoryEntry,
} from './types';
import {
BlockaidResultType,
GasFeeEstimateType,
SimulationErrorCode,
SimulationTokenStandard,
Expand Down Expand Up @@ -5030,6 +5032,230 @@ describe('TransactionController', () => {
'Cannot update security alert response as no transaction metadata found',
);
});

describe('should not trigger simulation data update', () => {
it('when simulationData is not available', async () => {
const transactionMeta = TRANSACTION_META_MOCK;

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

controller.updateSecurityAlertResponse(transactionMeta.id, {
reason: 'NA',
// This is API specific hence naming convention is not followed.
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: BlockaidResultType.Malicious,
});

expect(getSimulationDataMock).toHaveBeenCalledTimes(0);
});

it('when security alert response type is not malicious', async () => {
const transactionMetaWithSimulationData = merge(
{},
TRANSACTION_META_MOCK,
{
txParams: {
value: '0x0',
},
simulationData: {
tokenBalanceChanges: [],
nativeBalanceChange: {
difference: '0x0',
},
},
},
);

getSimulationDataMock.mockResolvedValueOnce(SIMULATION_DATA_MOCK);

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

controller.updateSecurityAlertResponse(
transactionMetaWithSimulationData.id,
{
reason: 'NA',
// This is API specific hence naming convention is not followed.
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: BlockaidResultType.Warning,
},
);

expect(getSimulationDataMock).toHaveBeenCalledTimes(0);
});

it('when transaction value is already different than simulated value', async () => {
const transactionMetaWithSimulationData = merge(
{},
TRANSACTION_META_MOCK,
{
txParams: {
value: '0x12345678',
},
simulationData: {
tokenBalanceChanges: [],
nativeBalanceChange: {
difference: '0x0',
},
},
},
);

getSimulationDataMock.mockResolvedValueOnce(SIMULATION_DATA_MOCK);

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

controller.updateSecurityAlertResponse(
transactionMetaWithSimulationData.id,
{
reason: 'NA',
// This is API specific hence naming convention is not followed.
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: BlockaidResultType.Malicious,
},
);

expect(getSimulationDataMock).toHaveBeenCalledTimes(0);
});
});

it('should retrigger simulation data update when simulationData available and if result_type is malicious', async () => {
const transactionMetaWithSimulationData = merge(
{},
TRANSACTION_META_MOCK,
{
txParams: {
value: '0x0',
},
simulationData: {
tokenBalanceChanges: [],
nativeBalanceChange: {
difference: '0x0',
},
},
},
);

getSimulationDataMock.mockResolvedValueOnce(SIMULATION_DATA_MOCK);

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

controller.updateSecurityAlertResponse(
transactionMetaWithSimulationData.id,
{
reason: 'NA',
// This is API specific hence naming convention is not followed.
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: BlockaidResultType.Malicious,
},
);

await flushPromises();

expect(getSimulationDataMock).toHaveBeenCalledTimes(1);
});

describe('should mark simulationDataChanged after security alert update', () => {
it('as true if simulationData updated', async () => {
// Assume that previous simulationData tells that transaction has no token balance changes
const transactionMetaWithSimulationData = merge(
{},
TRANSACTION_META_MOCK,
{
txParams: {
value: '0x0',
},
simulationData: {
tokenBalanceChanges: [],
nativeBalanceChange: {
difference: '0x0',
},
},
},
);

getSimulationDataMock.mockResolvedValueOnce(SIMULATION_DATA_MOCK);

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

controller.updateSecurityAlertResponse(
transactionMetaWithSimulationData.id,
{
reason: 'NA',
// This is API specific hence naming convention is not followed.
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: BlockaidResultType.Malicious,
},
);

await flushPromises();

expect(
controller.state.transactions[0].simulationData
?.changeInSimulationData,
).toBe(true);
});

it('as undefined if current and new simulationData equality check is false', async () => {
// Assume that previous simulationData tells that transaction has some token balance changes
const transactionMeta = merge({}, TRANSACTION_META_MOCK, {
simulationData: SIMULATION_DATA_MOCK,
});

getSimulationDataMock.mockResolvedValueOnce(SIMULATION_DATA_MOCK);

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

controller.updateSecurityAlertResponse(transactionMeta.id, {
reason: 'NA',
// This is API specific hence naming convention is not followed.
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: BlockaidResultType.Malicious,
});

await flushPromises();

expect(
controller.state.transactions[0].simulationData
?.changeInSimulationData,
).toBeUndefined();
});
});
});

describe('updateCustodialTransaction', () => {
Expand Down
48 changes: 47 additions & 1 deletion packages/transaction-controller/src/TransactionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import type {
SubmitHistoryEntry,
} from './types';
import {
BlockaidResultType,
TransactionEnvelopeType,
TransactionType,
TransactionStatus,
Expand Down Expand Up @@ -3599,9 +3600,42 @@ export class TransactionController extends BaseController<
);
}

if (this.#checkIfSimulationRetriggerNeeded(transactionMeta)) {
Copy link
Member

Choose a reason for hiding this comment

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

We still have the condition above and onTransactionParamsUpdated that also re-triggers a simulation, is it worth combining those for clarity?

Copy link
Member Author

Choose a reason for hiding this comment

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

I've refactored that part a bit more, feedbacks appreciated!

this.#updateSimulationData(transactionMeta).catch((error) => {
log('Error updating simulation data', error);
throw error;
});
}

return transactionMeta;
}

#checkIfSimulationRetriggerNeeded(transactionMeta: TransactionMeta) {
Copy link
Member

Choose a reason for hiding this comment

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

Won't this keep firing unless we also pass the previousTransactionMeta and also check if any of the relevant inputs have changed?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good spot Matthew, this is now fixed, comparing before and after

const {
securityAlertResponse,
simulationData,
txParams: { value },
} = transactionMeta;

const isSimulationDataAvailable = Boolean(simulationData);
const isMaliciousTransfer =
securityAlertResponse?.result_type === BlockaidResultType.Malicious;
const simulationNativeBalanceDifference =
simulationData?.nativeBalanceChange?.difference;
const isBalanceDifferenceEqual =
Copy link
Member

Choose a reason for hiding this comment

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

Are we definitely looking for an exact comparison rather than a percentage difference?

Copy link
Member Author

Choose a reason for hiding this comment

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

What do we think about the value on this? Would it make sense to make it 5%?

simulationNativeBalanceDifference === value;

if (
!isSimulationDataAvailable ||
!isMaliciousTransfer ||
!isBalanceDifferenceEqual
) {
return false;
}

return true;
Copy link
Member

Choose a reason for hiding this comment

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

Minor, can we simplify to a single return since it's a boolean?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

}

#checkIfTransactionParamsUpdated(newTransactionMeta: TransactionMeta) {
const { id: transactionId, txParams: newParams } = newTransactionMeta;

Expand Down Expand Up @@ -3649,7 +3683,12 @@ export class TransactionController extends BaseController<
transactionMeta: TransactionMeta,
{ traceContext }: { traceContext?: TraceContext } = {},
) {
const { id: transactionId, chainId, txParams } = transactionMeta;
const {
id: transactionId,
chainId,
txParams,
simulationData: prevSimulationData,
} = transactionMeta;
const { from, to, value, data } = txParams;

let simulationData: SimulationData = {
Expand Down Expand Up @@ -3679,6 +3718,13 @@ export class TransactionController extends BaseController<
data: data as Hex,
}),
);

if (prevSimulationData && !isEqual(simulationData, prevSimulationData)) {
simulationData = {
...simulationData,
changeInSimulationData: true,
};
}
}

const finalTransactionMeta = this.getTransaction(transactionId);
Expand Down
18 changes: 16 additions & 2 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1119,9 +1119,9 @@ export type TransactionError = {
export type SecurityAlertResponse = {
reason: string;
features?: string[];
// TODO: Either fix this lint violation or explain why it's necessary to ignore.
// This is API specific hence naming convention is not followed.
// eslint-disable-next-line @typescript-eslint/naming-convention
result_type: string;
Copy link
Member

Choose a reason for hiding this comment

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

Since string encompasses all the BlockaidResultType options, should we just leave it as string to minimise coupling to Blockaid?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

result_type: BlockaidResultType | string;
providerRequestsCount?: Record<string, number>;
};

Expand Down Expand Up @@ -1307,6 +1307,9 @@ export type SimulationError = {

/** Simulation data for a transaction. */
export type SimulationData = {
/** Set to true if simulationData changed after updating security alert */
changeInSimulationData?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

What is the purpose of this property?

So the client can trigger an additional alert if the simulation isn't consistent?

If so, how do we differentiate between expected updates if the user changes the value or data?

Is this needed at all?

Copy link
Member Author

@OGPoyraz OGPoyraz Oct 15, 2024

Choose a reason for hiding this comment

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

According to the designs, UI need to know if that re-simulation done because of the security alert. I thought this will be extra overhead in the UI to old and new values, so I've put it because of that.

Also I didn't have a chance to test this one out yet, so please consider this as draft for now.


/** Error data if the simulation failed or the transaction reverted. */
error?: SimulationError;

Expand Down Expand Up @@ -1368,3 +1371,14 @@ export type SubmitHistoryEntry = {
/** The transaction parameters that were submitted. */
transaction: TransactionParams;
};

export enum BlockaidResultType {
Copy link
Member

Choose a reason for hiding this comment

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

Rather than defining a full enum, could we just define a local constant such as BLOCKAID_RESULT_TYPE_MALICIOUS?

Copy link
Member Author

Choose a reason for hiding this comment

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

Done

Malicious = 'Malicious',
Warning = 'Warning',
Benign = 'Benign',
Errored = 'Error',

// MetaMask defined result types
NotApplicable = 'NotApplicable',
Loading = 'loading',
}
Loading