Skip to content

Commit

Permalink
feat: enable STX by default with migration and notification (#28854)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

This PR enables Smart Transactions (STX) by default through migration
number 135 for users who have either opted out or haven't interacted
with the STX toggle, provided they have no recorded STX activity.

How it works:

- Upon Migration 135, alert displays on transaction confirmations:
 - Legacy transaction flow
 - New transaction flow (experimental)
 - Swaps confirmation flow
 - Contract deployment
 - Contract interactions (minting, etc.)

In the case a user migrates from a previous version of the extension and
the migration runs and sets STX toggle "ON" in `Settings > Advanced >
Smart Transactions`, they will receive an STX Banner Alert on
transaction confirmation screens until dismissed through a close button,
or by clicking on the "Higher success rates" link within the alert that
goes to: [What is 'Smart
Transactions'?](https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/)
for more information.

Edge Cases: 

If a user is new and setting up a wallet for the first time, they will
not receive the Banner Alert. If a user imports a new wallet during a
fresh install of the extension on a new browser or recovers a wallet,
it's possible they may not see the alert if STX was on in a previous
install. The STX Banner Alert is dismissed and will not show again if a
user is in the state to get shown the banner and toggles STX off
independently even if they do not physically dismiss the STX Banner
Alert.

Migration Logic:

1. If `smartTransactionsOptInStatus` is `null` (new/never interacted)
 -  Sets status to true
 - Enables notification flag
2. If status is false (previously opted out):
 - With no Ethereum Mainnet STX activity: Sets to true with notification
 - With existing Mainnet STX activity: Preserves user preference
3. If status is true: No changes needed

UI Components:

- Implements SmartTransactionsBannerAlert component for user
notification

The notification system bridges the migration changes with the UI,
ensuring users are informed of the STX enablement while maintaining
their ability to opt out through settings.

**Target release:** TBD
**Affected user base:** ~5.7M users who previously opted out of STX but
have no STX activity.

---

## **Related issues**

Fixes: N/A

---

## **Running Unit Tests**
Migration Test: 

```bash
yarn jest app/scripts/migrations/135.test.ts --verbose
```

Smart Transaction Banner Component Test: 

```bash
yarn jest ui/pages/confirmations/components/smart-transactions-banner-alert/smart-transactions-banner-alert.test.ts --coverage=false
```

Confirm Transaction base Test: 

```bash
yarn jest ui/pages/confirmations/confirm-transaction-base/confirm-transaction-base.test.js --coverage=false
```

Preferences Controller Test: 

```bash
yarn jest app/scripts/controllers/preferences-controller.test.ts --coverage=false
```

Transaction Alerts Component Test: 

```bash
yarn jest ui/pages/confirmations/components/transaction-alerts/transaction-alerts.test.js --coverage=false
```

## **Manual testing steps**

**Test Migration (using a wallet/account with no STX Transactions)**

1. Switch branch: `git checkout tags/v12.5.0` and run:
```bash
yarn
yarn webpack
```
 - Generate a `dist/chrome` directory
6. In Chrome Extension Manager, "Load Unpacked"  from this directory
7. Import or setup a wallet without STX transactions, launch the wallet
and choose "No Thanks" on the "Enhanced Transaction Protection" popup.
8. Check that toggle is OFF in: `Settings > Advanced > Smart
Transactions`
9. Close MetaMask Extension and toggle the Extension "OFF" in the
Extension Manager
10. Switch to branch from this PR `git checkout
feat/enable-stx-migration`
```bash
yarn
yarn webpack
```
11. Open MetaMask Extension and Check that toggle is ON in: `Settings >
Advanced > Smart Transactions`

**Test STX Banner Alert that it shows on Transaction Confirmations and
not Sign Confirmations)**
**_(using new confirmations flow)_**

12. Check that `Improved transaction requests` is ON in `Settings >
Experimental`
13. Open the E2E TestDapp, try several Signs (ETH Sign, Personal Sign,
Sign Typed Data, etc..) and ensure the STX Banner Alert does not show on
those confirmations screens.
14. Create a Send transaction to your own wallet for `0.0001` ETH
15. Ensure that Smart Transactions Banner Alert IS showing
16. Start a Swaps transaction on Ethereum Mainnet
17. Ensure that Smart Transactions Banner Alert IS showing


**Test STX Banner Alert that it shows on Transaction Confirmations and
not Sign Confirmations)**
**_(using old confirmations flow)_**

18. Check that `Improved transaction requests` is OFF in `Settings >
Experimental`
19. Open the E2E TestDapp, try several Signs (ETH Sign, Personal Sign,
Sign Typed Data, etc..) and ensure the STX Banner Alert does not show on
those confirmations screens.
20. Create a Send transaction to your own wallet for `0.0001` ETH
21. Ensure that Smart Transactions Banner Alert IS showing
22. Start a Swaps transaction on Ethereum Mainnet
23. Ensure that Smart Transactions Banner Alert IS showing
24. Without clicking on Check that "Higher success rates" link (inspect)
goes to: [What is 'Smart
Transactions'?](https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/)
25. Dismiss the Smart Transactions Banner Alert and set `Improved
transaction requests` back to ON in `Settings > Experimental`
26. Create a Send transaction to your own wallet for `0.0001` ETH
27. Ensure that Smart Transactions Banner Alert IS NOT showing

**Congrats, you have manually tested the happy path, now we just need to
test the edge cases:**

1. Remove the extensions from Extension Manager and Repeat steps 1 to 10
above to run migration again
2. Create a Send transaction to your own wallet for `0.0001` ETH
3. Ensure that Smart Transactions Banner Alert IS showing
 - DO NOT DISMISS THE ALERT. Instead
4. Open MetaMask Extension and Check that toggle is ON in: `Settings >
Advanced > Smart Transactions`
5. Turn it off
6. Create a Send transaction to your own wallet for `0.0001` ETH
7. Ensure that Smart Transactions Banner Alert IS NOT showing
8. Perform any other Signing and/or Transaction Confirmations and ensure
there are no modals that show errors and that the Banner Alert does not
show anymore.

**Test edge case that after STX migration runs and Banner is being shown
that clicking on "Higher success rates" link:**

1. Remove the extensions from Extension Manager and Repeat steps 1 to 10
above to run migration again
2. Create a Send transaction to your own wallet for `0.0001` ETH
3. Ensure that Smart Transactions Banner Alert IS showing
 - DO NOT DISMISS THE ALERT WITH CLOSE BUTTON... INSTEAD
4. Click on "Higher success rates" link and ensure that it goes to:
[What is 'Smart
Transactions'?](https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/)
5. Open MetaMask Extension and Check that toggle is ON in: `Settings >
Advanced > Smart Transactions`
6. Turn it off
7. Create a Send transaction to your own wallet for `0.0001` ETH
8. Ensure that Smart Transactions Banner Alert IS NOT showing
9. Perform any other Signing and/or Transaction Confirmations and ensure
there are no modals that show errors and that the Banner Alert does not
show anymore.

Because the NEW confirmation flow does not support alerts using hooks
that are dismissible, we have used the old style Banner Alert, and it is
normal for their to be some variation on where the alert shows up and
it's surroundings. But overall they should look similar.

---

## **Screenshots/Recordings**

### **Before**

<img width="150" alt="01-stx_before"
src="https://github.com/user-attachments/assets/31c0bd4e-1ce5-4bfc-88bc-70a3c1a8898a"
/>
<img width="150" alt="02-legacySend_before"
src="https://github.com/user-attachments/assets/111662b8-182f-44ce-8a3e-e308e08e3c3f"
/>
<img width="150" alt="03-legacySwap_before"
src="https://github.com/user-attachments/assets/25dafb19-bd63-4a29-b0bc-ac394b8f09cf"
/>
<img width="150" alt="04-signTypedDataV4_before"
src="https://github.com/user-attachments/assets/c512ad84-d921-4b1e-8443-6d10d527ab67"
/>
<img width="150" alt="05-contractDeployment_before"
src="https://github.com/user-attachments/assets/31624828-5e3d-4fc8-8264-64703c6a4087"
/>
<img width="150" alt="06-contractInteraction_before"
src="https://github.com/user-attachments/assets/c132c7ff-64dc-4720-9209-331dbaf2bfe3"
/>
<img width="200" alt="07-wideSwap_before"
src="https://github.com/user-attachments/assets/521e8129-368f-45cc-acbe-9c4f90eeb9ec"
/>

### **After**

<img width="150" alt="01-stx_after"
src="https://github.com/user-attachments/assets/ce821edf-bb86-46af-97aa-fb816208c344"
/>
<img width="150" alt="02-legacySend_after"
src="https://github.com/user-attachments/assets/98f3588c-3557-4f4f-9a10-969e27b14dd4"
/>
<img width="150" alt="03-legacySwap_after"
src="https://github.com/user-attachments/assets/b60c91e8-63af-4d45-947d-d250f39fdb74"
/>
<img width="150" alt="04-signTypedDataV4_after"
src="https://github.com/user-attachments/assets/a3600ed9-17eb-4bf2-b5a9-409857fe1911"
/>
<img width="150" alt="05-contractDeployment_after"
src="https://github.com/user-attachments/assets/b6cf1e7e-6dba-4eae-8e78-ce16d12f7306"
/>
<img width="150" alt="06-contractInteraction_after"
src="https://github.com/user-attachments/assets/d65e6a93-c6ca-4328-9622-089ab5fe5d97"
/>
<img width="200" alt="07-wideSwap_after"
src="https://github.com/user-attachments/assets/2161a3a8-6a05-4b9b-a470-e004900c9fc1"
/>

---

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests for the new behavior, covering:
  - [x] Version update handling
  - [x] All logic branches:
    - [x] `null` opt-in status
    - [x] `false` opt-in status with no STX activity
    - [x] `false` opt-in status with existing STX activity
    - [x] `true` opt-in status
  - [x] Notification flag setting
  - [x] Error handling
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
where applicable.
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

---

## **Pre-merge reviewer checklist**

- [x] I've manually tested the PR (e.g. pulled and built the branch, ran
the app, and tested the changes described above).
- [x] I confirm that this PR addresses all acceptance criteria described
in the ticket and includes the necessary testing evidence (e.g.,
recordings, screenshots, or detailed descriptions).

---------

Co-authored-by: Dan J Miller <[email protected]>
Co-authored-by: georgeweiler <[email protected]>
Co-authored-by: dan437 <[email protected]>
  • Loading branch information
4 people authored Jan 7, 2025
1 parent 3c220da commit 41930af
Show file tree
Hide file tree
Showing 25 changed files with 1,791 additions and 48 deletions.
9 changes: 9 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/scripts/controllers/preferences-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@ describe('preferences controller', () => {
privacyMode: false,
showFiatInTestnets: false,
showTestNetworks: false,
smartTransactionsMigrationApplied: false,
smartTransactionsOptInStatus: true,
useNativeCurrencyAsPrimaryCurrency: true,
hideZeroBalanceTokens: false,
Expand Down Expand Up @@ -762,6 +763,7 @@ describe('preferences controller', () => {
showExtensionInFullSizeView: false,
showFiatInTestnets: false,
showTestNetworks: false,
smartTransactionsMigrationApplied: false,
smartTransactionsOptInStatus: true,
useNativeCurrencyAsPrimaryCurrency: true,
hideZeroBalanceTokens: false,
Expand Down
13 changes: 13 additions & 0 deletions app/scripts/controllers/preferences-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export type Preferences = {
showFiatInTestnets: boolean;
showTestNetworks: boolean;
smartTransactionsOptInStatus: boolean;
smartTransactionsMigrationApplied: boolean;
showNativeTokenAsMainBalance: boolean;
useNativeCurrencyAsPrimaryCurrency: boolean;
hideZeroBalanceTokens: boolean;
Expand All @@ -129,6 +130,7 @@ export type PreferencesControllerState = Omit<
PreferencesState,
| 'showTestNetworks'
| 'smartTransactionsOptInStatus'
| 'smartTransactionsMigrationApplied'
| 'privacyMode'
| 'tokenSortConfig'
| 'useMultiRpcMigration'
Expand Down Expand Up @@ -217,6 +219,7 @@ export const getDefaultPreferencesControllerState =
showFiatInTestnets: false,
showTestNetworks: false,
smartTransactionsOptInStatus: true,
smartTransactionsMigrationApplied: false,
showNativeTokenAsMainBalance: false,
useNativeCurrencyAsPrimaryCurrency: true,
hideZeroBalanceTokens: false,
Expand Down Expand Up @@ -406,6 +409,16 @@ const controllerMetadata = {
preferences: {
persist: true,
anonymous: true,
properties: {
smartTransactionsOptInStatus: {
persist: true,
anonymous: true,
},
smartTransactionsMigrationApplied: {
persist: true,
anonymous: true,
},
},
},
ipfsGateway: {
persist: true,
Expand Down
187 changes: 187 additions & 0 deletions app/scripts/migrations/135.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { SmartTransaction } from '@metamask/smart-transactions-controller/dist/types';
import { migrate, VersionedData } from './135';

const prevVersion = 134;

describe('migration #135', () => {
const mockSmartTransaction: SmartTransaction = {
uuid: 'test-uuid',
};

it('should update the version metadata', async () => {
const oldStorage: VersionedData = {
meta: { version: prevVersion },
data: {},
};

const newStorage = await migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({ version: 135 });
});

it('should set stx opt-in to true and migration flag when stx opt-in status is null', async () => {
const oldStorage: VersionedData = {
meta: { version: prevVersion },
data: {
PreferencesController: {
preferences: {
smartTransactionsOptInStatus: null,
},
},
},
};

const newStorage = await migrate(oldStorage);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsOptInStatus,
).toBe(true);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsMigrationApplied,
).toBe(true);
});

it('should set stx opt-in to true and migration flag when stx opt-in status is undefined', async () => {
const oldStorage: VersionedData = {
meta: { version: prevVersion },
data: {
PreferencesController: {},
},
};

const newStorage = await migrate(oldStorage);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsOptInStatus,
).toBe(true);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsMigrationApplied,
).toBe(true);
});

it('should set stx opt-in to true and migration flag when stx opt-in is false and no existing mainnet smart transactions', async () => {
const oldStorage: VersionedData = {
meta: { version: prevVersion },
data: {
PreferencesController: {
preferences: {
smartTransactionsOptInStatus: false,
},
},
SmartTransactionsController: {
smartTransactionsState: {
smartTransactions: {
'0x1': [], // Empty mainnet transactions
'0xAA36A7': [mockSmartTransaction], // Sepolia has transactions
},
},
},
},
};

const newStorage = await migrate(oldStorage);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsOptInStatus,
).toBe(true);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsMigrationApplied,
).toBe(true);
});

it('should not change stx opt-in when stx opt-in is false but has existing smart transactions, but should set migration flag', async () => {
const oldStorage: VersionedData = {
meta: { version: prevVersion },
data: {
PreferencesController: {
preferences: {
smartTransactionsOptInStatus: false,
},
},
SmartTransactionsController: {
smartTransactionsState: {
smartTransactions: {
'0x1': [mockSmartTransaction],
},
},
},
},
};

const newStorage = await migrate(oldStorage);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsOptInStatus,
).toBe(false);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsMigrationApplied,
).toBe(true);
});

it('should not change stx opt-in when stx opt-in is already true, but should set migration flag', async () => {
const oldStorage: VersionedData = {
meta: { version: prevVersion },
data: {
PreferencesController: {
preferences: {
smartTransactionsOptInStatus: true,
},
},
},
};

const newStorage = await migrate(oldStorage);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsOptInStatus,
).toBe(true);
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsMigrationApplied,
).toBe(true);
});

it('should initialize preferences object if it does not exist', async () => {
const oldStorage: VersionedData = {
meta: { version: prevVersion },
data: {
PreferencesController: {
preferences: {
smartTransactionsOptInStatus: true,
},
},
},
};

const newStorage = await migrate(oldStorage);
expect(newStorage.data.PreferencesController?.preferences).toBeDefined();
expect(
newStorage.data.PreferencesController?.preferences
?.smartTransactionsMigrationApplied,
).toBe(true);
});

it('should capture exception if PreferencesController state is invalid', async () => {
const sentryCaptureExceptionMock = jest.fn();
global.sentry = {
captureException: sentryCaptureExceptionMock,
};

const oldStorage = {
meta: { version: prevVersion },
data: {
PreferencesController: 'invalid',
},
} as unknown as VersionedData;

await migrate(oldStorage);

expect(sentryCaptureExceptionMock).toHaveBeenCalledTimes(1);
expect(sentryCaptureExceptionMock).toHaveBeenCalledWith(
new Error('Invalid PreferencesController state: string'),
);
});
});
84 changes: 84 additions & 0 deletions app/scripts/migrations/135.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { hasProperty, isObject } from '@metamask/utils';
import { cloneDeep } from 'lodash';
import type { SmartTransaction } from '@metamask/smart-transactions-controller/dist/types';
import { CHAIN_IDS } from '@metamask/transaction-controller';

export type VersionedData = {
meta: {
version: number;
};
data: {
PreferencesController?: {
preferences?: {
smartTransactionsOptInStatus?: boolean | null;
smartTransactionsMigrationApplied?: boolean;
};
};
SmartTransactionsController?: {
smartTransactionsState: {
smartTransactions: Record<string, SmartTransaction[]>;
};
};
};
};

export const version = 135;

function transformState(state: VersionedData['data']) {
if (
!hasProperty(state, 'PreferencesController') ||
!isObject(state.PreferencesController)
) {
global.sentry?.captureException?.(
new Error(
`Invalid PreferencesController state: ${typeof state.PreferencesController}`,
),
);
return state;
}

const { PreferencesController } = state;

const currentOptInStatus =
PreferencesController.preferences?.smartTransactionsOptInStatus;

if (
currentOptInStatus === undefined ||
currentOptInStatus === null ||
(currentOptInStatus === false && !hasExistingSmartTransactions(state))
) {
state.PreferencesController.preferences = {
...state.PreferencesController.preferences,
smartTransactionsOptInStatus: true,
smartTransactionsMigrationApplied: true,
};
} else {
state.PreferencesController.preferences = {
...state.PreferencesController.preferences,
smartTransactionsMigrationApplied: true,
};
}

return state;
}

function hasExistingSmartTransactions(state: VersionedData['data']): boolean {
const smartTransactions =
state?.SmartTransactionsController?.smartTransactionsState
?.smartTransactions;

if (!isObject(smartTransactions)) {
return false;
}

return (smartTransactions[CHAIN_IDS.MAINNET] || []).length > 0;
}

export async function migrate(
originalVersionedData: VersionedData,
): Promise<VersionedData> {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
transformState(versionedData.data);
return versionedData;
}
1 change: 1 addition & 0 deletions app/scripts/migrations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ const migrations = [
require('./133.1'),
require('./133.2'),
require('./134'),
require('./135'),
];

export default migrations;
2 changes: 2 additions & 0 deletions shared/constants/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export enum AlertTypes {
unconnectedAccount = 'unconnectedAccount',
web3ShimUsage = 'web3ShimUsage',
invalidCustomNetwork = 'invalidCustomNetwork',
smartTransactionsMigration = 'smartTransactionsMigration',
}

/**
Expand All @@ -10,6 +11,7 @@ export enum AlertTypes {
export const TOGGLEABLE_ALERT_TYPES = [
AlertTypes.unconnectedAccount,
AlertTypes.web3ShimUsage,
AlertTypes.smartTransactionsMigration,
];

export enum Web3ShimUsageAlertStates {
Expand Down
Loading

0 comments on commit 41930af

Please sign in to comment.