Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
eb2666b
fix: added cancellation and proper timeout logic to coverage result p…
lwin-kyaw Oct 15, 2025
0ce87d5
test: added tests for coverage
lwin-kyaw Oct 15, 2025
7b18fd5
fix: abort/cancel coverage result requests before logging.
lwin-kyaw Oct 15, 2025
2fe24ea
feat: removed 'personalSign' check from signature coverage and some c…
lwin-kyaw Oct 16, 2025
a2a9f78
doc: updated changelog
lwin-kyaw Oct 16, 2025
9225267
feat: created map of abort-controllers to track the multiple tx/sig r…
lwin-kyaw Oct 16, 2025
9119e4a
apply abort.signal to result request
lwin-kyaw Oct 16, 2025
bfa117c
chore: rename some methods/vars and removed unnecessary mocks from test
lwin-kyaw Oct 17, 2025
affbbcf
chore: refactor backend.test.ts
lwin-kyaw Oct 17, 2025
565c42c
refactor: refactor polling logic and clean up tests
lwin-kyaw Oct 17, 2025
a1d580c
feat: updated log methods with abortPendingRequests
lwin-kyaw Oct 17, 2025
19e8a3e
chore: added error reason, timeout or cancelled in polling
lwin-kyaw Oct 17, 2025
588dd38
chore: update comments
lwin-kyaw Oct 17, 2025
7ed65c0
chore: resolved conflicts
lwin-kyaw Oct 17, 2025
a5295ab
fix: fixed ChangeLog
lwin-kyaw Oct 17, 2025
27a1acb
feat: cancel polling delay as request aborted
lwin-kyaw Oct 20, 2025
332b0b7
Merge remote-tracking branch 'origin/main' into fix/shield-result-pol…
lwin-kyaw Oct 20, 2025
9ad6b0d
fix: wait for abortPendingRequest before inserting new request entry
lwin-kyaw Oct 21, 2025
54b7354
fix: rejects if AbortSignal triggers in delay method
lwin-kyaw Oct 21, 2025
b078925
fix: fixed AbortPendingRequest
lwin-kyaw Oct 21, 2025
25c79c7
trying Cockatiel Policy
lwin-kyaw Oct 21, 2025
67ee8dd
Merge remote-tracking branch 'origin/main' into fix/shield-result-pol…
lwin-kyaw Oct 21, 2025
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: 6 additions & 0 deletions packages/shield-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Bump `@metamask/transaction-controller` from `^60.7.0` to `^60.8.0` ([#6883](https://github.com/MetaMask/core/pull/6883))
- Updated internal coverage result polling and log logic. ([#6847](https://github.com/MetaMask/core/pull/6847))
- Added cancellation logic to the polling.
- Updated implementation of timeout.
- Cancel any pending requests before starting new polling or logging.
- Updated TransactionMeta comparison in `TransactionController:stateChange` subscriber to avoid triggering multiple check coverage result unnecessarily. ([#6847](https://github.com/MetaMask/core/pull/6847))
- Removed `Personal Sign` check from the check signature coverage result. ([#6847](https://github.com/MetaMask/core/pull/6847))

## [0.3.2]

Expand Down
4 changes: 3 additions & 1 deletion packages/shield-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
},
"dependencies": {
"@metamask/base-controller": "^8.4.1",
"@metamask/utils": "^11.8.1"
"@metamask/controller-utils": "^11.14.1",
"@metamask/utils": "^11.8.1",
"cockatiel": "^3.1.2"
},
"devDependencies": {
"@babel/runtime": "^7.23.9",
Expand Down
42 changes: 42 additions & 0 deletions packages/shield-controller/src/ShieldController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@metamask/transaction-controller';

import { ShieldController } from './ShieldController';
import { TX_META_SIMULATION_DATA_MOCKS } from '../tests/data';
import { createMockBackend, MOCK_COVERAGE_ID } from '../tests/mocks/backend';
import { createMockMessenger } from '../tests/mocks/messenger';
import {
Expand Down Expand Up @@ -169,6 +170,47 @@ describe('ShieldController', () => {
});
});

TX_META_SIMULATION_DATA_MOCKS.forEach(
({ description, previousSimulationData, newSimulationData }) => {
it(`should check coverage when ${description}`, async () => {
const { baseMessenger, backend } = setup();
const previousTxMeta = {
...generateMockTxMeta(),
simulationData: previousSimulationData,
};
const coverageResultReceived =
setupCoverageResultReceived(baseMessenger);

// Add transaction.
baseMessenger.publish(
'TransactionController:stateChange',
{ transactions: [previousTxMeta] } as TransactionControllerState,
undefined as never,
);
expect(await coverageResultReceived).toBeUndefined();
expect(backend.checkCoverage).toHaveBeenCalledWith({
txMeta: previousTxMeta,
});

// Simulate transaction.
const txMeta2 = { ...previousTxMeta };
txMeta2.simulationData = newSimulationData;
const coverageResultReceived2 =
setupCoverageResultReceived(baseMessenger);
baseMessenger.publish(
'TransactionController:stateChange',
{ transactions: [txMeta2] } as TransactionControllerState,
undefined as never,
);
expect(await coverageResultReceived2).toBeUndefined();
expect(backend.checkCoverage).toHaveBeenCalledWith({
coverageId: MOCK_COVERAGE_ID,
txMeta: txMeta2,
});
});
},
);

it('throws an error when the coverage ID has changed', async () => {
const { controller, backend } = setup();
backend.checkCoverage.mockResolvedValueOnce({
Expand Down
75 changes: 64 additions & 11 deletions packages/shield-controller/src/ShieldController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type {
} from '@metamask/base-controller';
import {
SignatureRequestStatus,
SignatureRequestType,
type SignatureRequest,
type SignatureStateChange,
} from '@metamask/signature-controller';
Expand Down Expand Up @@ -236,10 +235,7 @@ export class ShieldController extends BaseController<

// Check coverage if the signature request is new and has type
// `personal_sign`.
if (
!previousSignatureRequest &&
signatureRequest.type === SignatureRequestType.PersonalSign
) {
if (!previousSignatureRequest) {
this.checkSignatureCoverage(signatureRequest).catch(
// istanbul ignore next
(error) => log('Error checking coverage:', error),
Expand Down Expand Up @@ -268,15 +264,15 @@ export class ShieldController extends BaseController<
);
for (const transaction of transactions) {
const previousTransaction = previousTransactionsById.get(transaction.id);
// Check if the simulation data has changed.
const simulationDataChanged = this.#compareTransactionSimulationData(
transaction.simulationData,
previousTransaction?.simulationData,
);
Copy link

Choose a reason for hiding this comment

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

Bug: Transaction Simulation Data Comparison Reversed

The call to #compareTransactionSimulationData passes previousTransaction?.simulationData as the current simulationData and transaction.simulationData as the previous previousSimulationData. This reverses the comparison logic, leading to incorrect change detection for transaction simulation data and potentially triggering unnecessary coverage checks.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

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

Bot must have had a bad day. That's not the case, as far as I can see.


// Check coverage if the transaction is new or if the simulation data has
// changed.
if (
!previousTransaction ||
// Checking reference equality is sufficient because this object is
// replaced if the simulation data has changed.
previousTransaction.simulationData !== transaction.simulationData
) {
if (!previousTransaction || simulationDataChanged) {
this.checkCoverage(transaction).catch(
// istanbul ignore next
(error) => log('Error checking coverage:', error),
Expand Down Expand Up @@ -443,4 +439,61 @@ export class ShieldController extends BaseController<
#getLatestCoverageId(itemId: string): string | undefined {
return this.state.coverageResults[itemId]?.results[0]?.coverageId;
}

/**
* Compares the simulation data of a transaction.
*
* @param simulationData - The simulation data of the transaction.
* @param previousSimulationData - The previous simulation data of the transaction.
* @returns Whether the simulation data has changed.
*/
#compareTransactionSimulationData(
simulationData?: TransactionMeta['simulationData'],
previousSimulationData?: TransactionMeta['simulationData'],
) {
if (!simulationData && !previousSimulationData) {
return false;
}

// check the simulation error
if (
simulationData?.error?.code !== previousSimulationData?.error?.code ||
simulationData?.error?.message !== previousSimulationData?.error?.message
) {
return true;
}

// check the native balance change
if (
simulationData?.nativeBalanceChange?.difference !==
previousSimulationData?.nativeBalanceChange?.difference ||
simulationData?.nativeBalanceChange?.newBalance !==
previousSimulationData?.nativeBalanceChange?.newBalance ||
simulationData?.nativeBalanceChange?.previousBalance !==
previousSimulationData?.nativeBalanceChange?.previousBalance ||
simulationData?.nativeBalanceChange?.isDecrease !==
previousSimulationData?.nativeBalanceChange?.isDecrease
) {
return true;
}

// check the token balance changes
if (
simulationData?.tokenBalanceChanges?.length !==
previousSimulationData?.tokenBalanceChanges?.length ||
simulationData?.tokenBalanceChanges?.some(
(tokenBalanceChange, index) =>
tokenBalanceChange.difference !==
previousSimulationData?.tokenBalanceChanges?.[index]?.difference,
)
) {
return true;
}

Copy link

Choose a reason for hiding this comment

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

Bug: Undefined Token Balances Cause TypeError

The tokenBalanceChanges comparison in #compareTransactionSimulationData can cause a TypeError. If either simulationData?.tokenBalanceChanges or previousSimulationData?.tokenBalanceChanges is undefined, the code attempts to call .some() on an undefined value. This happens because the length comparison doesn't fully guard against undefined values before the .some() call.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

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

tokenBalanceChanges is a required filed in SimulationData.

// check the isUpdatedAfterSecurityCheck
return (
simulationData?.isUpdatedAfterSecurityCheck !==
previousSimulationData?.isUpdatedAfterSecurityCheck
);
}
Copy link

Choose a reason for hiding this comment

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

Bug: Transaction Simulation Data Comparison Fails

The #compareTransactionSimulationData method doesn't correctly detect changes when one simulationData parameter is undefined and the other is defined (e.g., an empty object). This can lead to coverage checks not being triggered when the simulation data has effectively changed.

Fix in Cursor Fix in Web

}
7 changes: 6 additions & 1 deletion packages/shield-controller/src/backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ function setup({
}

describe('ShieldRemoteBackend', () => {
afterEach(() => {
// Clean up mocks after each test
jest.clearAllMocks();
});

it('should check coverage', async () => {
const { backend, fetchMock, getAccessToken } = setup();

Expand Down Expand Up @@ -143,7 +148,7 @@ describe('ShieldRemoteBackend', () => {

const txMeta = generateMockTxMeta();
await expect(backend.checkCoverage({ txMeta })).rejects.toThrow(
'Timeout waiting for coverage result',
'getCoverageResult: Request timed out',
);

// Waiting here ensures coverage of the unexpected error and lets us know
Expand Down
85 changes: 42 additions & 43 deletions packages/shield-controller/src/backend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SignatureRequest } from '@metamask/signature-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';

import { PollingWithTimeoutAndAbort } from './polling-with-timeout-abort';
import type {
CheckCoverageRequest,
CheckSignatureCoverageRequest,
Expand Down Expand Up @@ -58,6 +59,8 @@ export class ShieldRemoteBackend implements ShieldBackend {

readonly #fetch: typeof globalThis.fetch;

readonly #pollingWithTimeout: PollingWithTimeoutAndAbort;

constructor({
getAccessToken,
getCoverageResultTimeout = 5000, // milliseconds
Expand All @@ -76,6 +79,10 @@ export class ShieldRemoteBackend implements ShieldBackend {
this.#getCoverageResultPollInterval = getCoverageResultPollInterval;
this.#baseUrl = baseUrl;
this.#fetch = fetchFn;
this.#pollingWithTimeout = new PollingWithTimeoutAndAbort({
timeout: getCoverageResultTimeout,
pollInterval: getCoverageResultPollInterval,
});
}

async checkCoverage(req: CheckCoverageRequest): Promise<CoverageResult> {
Expand All @@ -90,6 +97,7 @@ export class ShieldRemoteBackend implements ShieldBackend {

const txCoverageResultUrl = `${this.#baseUrl}/v1/transaction/coverage/result`;
const coverageResult = await this.#getCoverageResult(coverageId, {
requestId: req.txMeta.id,
coverageResultUrl: txCoverageResultUrl,
});
return {
Expand All @@ -114,6 +122,7 @@ export class ShieldRemoteBackend implements ShieldBackend {

const signatureCoverageResultUrl = `${this.#baseUrl}/v1/signature/coverage/result`;
const coverageResult = await this.#getCoverageResult(coverageId, {
requestId: req.signatureRequest.id,
coverageResultUrl: signatureCoverageResultUrl,
});
return {
Expand All @@ -132,6 +141,9 @@ export class ShieldRemoteBackend implements ShieldBackend {
...initBody,
};

// clean up the pending coverage result polling
await this.#pollingWithTimeout.abortPendingRequest(req.signatureRequest.id);

const res = await this.#fetch(
`${this.#baseUrl}/v1/signature/coverage/log`,
{
Expand All @@ -153,6 +165,9 @@ export class ShieldRemoteBackend implements ShieldBackend {
...initBody,
};

// clean up the pending coverage result polling
await this.#pollingWithTimeout.abortPendingRequest(req.txMeta.id);

const res = await this.#fetch(
`${this.#baseUrl}/v1/transaction/coverage/log`,
{
Expand Down Expand Up @@ -183,7 +198,8 @@ export class ShieldRemoteBackend implements ShieldBackend {

async #getCoverageResult(
coverageId: string,
configs: {
config: {
requestId: string;
coverageResultUrl: string;
timeout?: number;
pollInterval?: number;
Expand All @@ -192,41 +208,34 @@ export class ShieldRemoteBackend implements ShieldBackend {
const reqBody: GetCoverageResultRequest = {
coverageId,
};

const timeout = configs?.timeout ?? this.#getCoverageResultTimeout;
const pollInterval =
configs?.pollInterval ?? this.#getCoverageResultPollInterval;

const pollingOptions = {
timeout: config.timeout ?? this.#getCoverageResultTimeout,
pollInterval: config.pollInterval ?? this.#getCoverageResultPollInterval,
fnName: 'getCoverageResult',
};
const headers = await this.#createHeaders();
return await new Promise((resolve, reject) => {
let timeoutReached = false;
setTimeout(() => {
timeoutReached = true;
reject(new Error('Timeout waiting for coverage result'));
}, timeout);

const poll = async (): Promise<GetCoverageResultResponse> => {
// The timeoutReached variable is modified in the timeout callback.
// eslint-disable-next-line no-unmodified-loop-condition
while (!timeoutReached) {
const startTime = Date.now();
const res = await this.#fetch(configs.coverageResultUrl, {
method: 'POST',
headers,
body: JSON.stringify(reqBody),
});
if (res.status === 200) {
return (await res.json()) as GetCoverageResultResponse;
}
await sleep(pollInterval - (Date.now() - startTime));
}
// The following line will not have an effect as the upper level promise
// will already be rejected by now.
throw new Error('unexpected error');
};
const requestCoverageFn = async (
signal: AbortSignal,
): Promise<GetCoverageResultResponse> => {
const res = await this.#fetch(config.coverageResultUrl, {
method: 'POST',
headers,
body: JSON.stringify(reqBody),
signal,
});
if (res.status === 200) {
return (await res.json()) as GetCoverageResultResponse;
}
throw new Error(`Failed to get coverage result: ${res.status}`);
};

poll().then(resolve).catch(reject);
});
const coverageResult = await this.#pollingWithTimeout.pollRequest(
config.requestId,
requestCoverageFn,
pollingOptions,
);
return coverageResult;
}

async #createHeaders() {
Expand All @@ -238,16 +247,6 @@ export class ShieldRemoteBackend implements ShieldBackend {
}
}

/**
* Sleep for a specified amount of time.
*
* @param ms - The number of milliseconds to sleep.
* @returns A promise that resolves after the specified amount of time.
*/
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
* Make the body for the init coverage check request.
*
Expand Down
Loading
Loading