diff --git a/.github/workflows/add-team-label.yml b/.github/workflows/add-team-label.yml index 6ed486bb7b90..c88854d105a3 100644 --- a/.github/workflows/add-team-label.yml +++ b/.github/workflows/add-team-label.yml @@ -7,7 +7,11 @@ on: jobs: add-team-label: + name: Add team label if: ${{ !github.event.pull_request.head.repo.fork }} - uses: metamask/github-tools/.github/workflows/add-team-label.yml@7fe185fdb0e60981c898e88d82e44ff33f604daa - secrets: - TEAM_LABEL_TOKEN: ${{ secrets.TEAM_LABEL_TOKEN }} + runs-on: ubuntu-latest + steps: + - name: Add team label + uses: MetaMask/github-tools/.github/actions/add-team-label@v1 + with: + team-label-token: ${{ secrets.TEAM_LABEL_TOKEN }} diff --git a/.github/workflows/automated-rca.yml b/.github/workflows/automated-rca.yml index 22e4f3b534fb..c57e8beefe1e 100644 --- a/.github/workflows/automated-rca.yml +++ b/.github/workflows/automated-rca.yml @@ -10,9 +10,14 @@ permissions: jobs: automated-rca: - uses: MetaMask/github-tools/.github/workflows/post-gh-rca.yml@5da154078ddf6c022ed89dc3dbf378594afb8266 - with: - repo-owner: ${{ github.repository_owner }} - repo-name: ${{ github.event.repository.name }} - issue-number: ${{ github.event.issue.number }} - issue-labels: '["Sev0-urgent", "Sev1-high"]' + name: Automated RCA + runs-on: ubuntu-latest + steps: + - name: Automated RCA + uses: MetaMask/github-tools/.github/actions/post-gh-rca@v1 + with: + repo-owner: ${{ github.repository_owner }} + repo-name: ${{ github.event.repository.name }} + issue-number: ${{ github.event.issue.number }} + issue-labels: '["Sev0-urgent", "Sev1-high"]' + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index 2b407efa6f86..35d674bcee51 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -56,7 +56,7 @@ jobs: echo "✅ System images installed" - name: Setup Android Build Environment - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@6742ebe1a3541cb13972d65352a0622a6a6677db + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 with: platform: android setup-simulator: false diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml index ec839409ea9a..97a8c8cc8d71 100644 --- a/.github/workflows/build-ios-e2e.yml +++ b/.github/workflows/build-ios-e2e.yml @@ -67,7 +67,7 @@ jobs: # Install Node.js, Xcode tools, and other iOS development dependencies - name: Installing iOS Environment Setup - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@6742ebe1a3541cb13972d65352a0622a6a6677db + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 with: platform: ios setup-simulator: false diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index a381814ec430..aa274211f55c 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -1,4 +1,4 @@ -name: ChangeLog Check +name: Check Changelog on: pull_request: @@ -6,17 +6,18 @@ on: jobs: check-changelog: + name: Check Changelog + runs-on: ubuntu-latest # Asking engineers to update CHANGELOG.md increases the potential for # conflicts across all pull requests. # Disable this workflow until we can refine the new changelog process. if: false - - uses: MetaMask/github-tools/.github/workflows/changelog-check.yml@91e349d177db2c569e03c7aa69d2acb404b62f75 - with: - base-branch: ${{ github.event.pull_request.base.ref }} - head-ref: ${{ github.head_ref }} - labels: ${{ toJSON(github.event.pull_request.labels) }} - pr-number: ${{ github.event.pull_request.number }} - repo: ${{ github.repository }} - secrets: - gh-token: ${{ secrets.PR_TOKEN }} + steps: + - name: Check changelog + uses: MetaMask/github-tools/.github/actions/check-changelog@v1 + with: + base-branch: ${{ github.event.pull_request.base.ref }} + head-ref: ${{ github.head_ref }} + labels: ${{ toJSON(github.event.pull_request.labels) }} + pr-number: ${{ github.event.pull_request.number }} + repo: ${{ github.repository }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e3e62058952..4a32b9ea6def 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -576,13 +576,16 @@ jobs: log-merge-group-failure: name: Log merge group failure + runs-on: ubuntu-latest # Only run this job if the merge group event fails, skip on forks if: ${{ github.event_name == 'merge_group' && failure() && !github.event.repository.fork }} needs: - check-all-jobs-pass - uses: metamask/github-tools/.github/workflows/log-merge-group-failure.yml@6bbad335a01fce1a9ec1eabd9515542c225d46c0 - secrets: - GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} - GOOGLE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} - SPREADSHEET_ID: ${{ secrets.GOOGLE_MERGE_QUEUE_SPREADSHEET_ID }} - SHEET_NAME: ${{ secrets.GOOGLE_MERGE_QUEUE_SHEET_NAME }} + steps: + - name: Log merge group failure to Google Sheets + uses: MetaMask/github-tools/.github/actions/log-merge-group-failure@v1 + with: + google-application-credentials: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} + google-service-account: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} + spreadsheet-id: ${{ secrets.GOOGLE_MERGE_QUEUE_SPREADSHEET_ID }} + sheet-name: ${{ secrets.GOOGLE_MERGE_QUEUE_SHEET_NAME }} diff --git a/.github/workflows/create-release-pr-legacy.yml b/.github/workflows/create-release-pr-legacy.yml index 3d54c3a92a62..c8546b588d90 100644 --- a/.github/workflows/create-release-pr-legacy.yml +++ b/.github/workflows/create-release-pr-legacy.yml @@ -19,21 +19,24 @@ jobs: id-token: write create-release-pr: + name: Create release pull request + runs-on: ubuntu-latest needs: generate-build-version - uses: MetaMask/github-tools/.github/workflows/create-release-pr.yml@fc6fe1a3fb591f6afa61f0dbbe7698bd50fab9c7 - with: - platform: mobile - base-branch: ${{ inputs.base-branch }} - semver-version: ${{ inputs.semver-version }} - previous-version-tag: ${{ inputs.previous-version-tag }} - mobile-build-version: ${{ needs.generate-build-version.outputs.build-version }} - github-tools-version: fc6fe1a3fb591f6afa61f0dbbe7698bd50fab9c7 - - secrets: - # This token needs read permissions to metamask-planning & write permissions to metamask-mobile - github-token: ${{ secrets.PR_TOKEN }} - google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} permissions: contents: write pull-requests: write + steps: + - name: Create Release pull request + uses: MetaMask/github-tools/.github/actions/create-release-pr@v1 + with: + platform: mobile + checkout-base-branch: ${{ inputs.base-branch }} + release-pr-base-branch: ${{ inputs.base-branch }} + semver-version: ${{ inputs.semver-version }} + previous-version-ref: ${{ inputs.previous-version-tag }} + mobile-build-version: ${{ needs.generate-build-version.outputs.build-version }} + google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} + # This token needs read permissions to metamask-planning & write + # permissions to metamask-mobile. + github-token: ${{ secrets.PR_TOKEN }} diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index bb993c51b947..26c57f2f8104 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -85,7 +85,7 @@ jobs: pull-requests: write steps: - name: Create Release PR - uses: MetaMask/github-tools/.github/actions/create-release-pr@v1.1.2 + uses: MetaMask/github-tools/.github/actions/create-release-pr@v1 with: platform: mobile checkout-base-branch: ${{ needs.resolve-bases.outputs.checkout_base }} diff --git a/.github/workflows/merge-stable-sync-pr.yml b/.github/workflows/merge-stable-sync-pr.yml index 27e3b760ca0c..32792c82ce24 100644 --- a/.github/workflows/merge-stable-sync-pr.yml +++ b/.github/workflows/merge-stable-sync-pr.yml @@ -15,11 +15,11 @@ jobs: github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER' ) - uses: MetaMask/github-tools/.github/workflows/merge-approved-pr.yml@7c0ab4db1e9c1d5673fe7958e8959f1842edbebd + uses: MetaMask/github-tools/.github/workflows/merge-approved-pr.yml@v1 with: pr-number: ${{ github.event.issue.number }} # Merge PRs from stable-main-X.Y.Z into main required-base-branch: 'main' head-branch-pattern: '^stable-main-[0-9]+\.[0-9]+\.[0-9]+$' secrets: - github-token: ${{ secrets.METAMASK_MOBILE_BRANCH_SYNC_TOKEN }} \ No newline at end of file + github-token: ${{ secrets.METAMASK_MOBILE_BRANCH_SYNC_TOKEN }} diff --git a/.github/workflows/merge-version-bump-pr.yml b/.github/workflows/merge-version-bump-pr.yml index 9bfe35cb40ec..cb2f531b75b4 100644 --- a/.github/workflows/merge-version-bump-pr.yml +++ b/.github/workflows/merge-version-bump-pr.yml @@ -15,11 +15,11 @@ jobs: github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'OWNER' ) - uses: MetaMask/github-tools/.github/workflows/merge-approved-pr.yml@7c0ab4db1e9c1d5673fe7958e8959f1842edbebd + uses: MetaMask/github-tools/.github/workflows/merge-approved-pr.yml@v1 with: pr-number: ${{ github.event.issue.number }} # Merge PRs from version-bump/X.Y.Z into main required-base-branch: 'main' head-branch-pattern: '^version-bump/[0-9]+\.[0-9]+\.[0-9]+$' secrets: - github-token: ${{ secrets.METAMASK_MOBILE_BRANCH_SYNC_TOKEN }} \ No newline at end of file + github-token: ${{ secrets.METAMASK_MOBILE_BRANCH_SYNC_TOKEN }} diff --git a/.github/workflows/publish-slack-release-testing-status.yml b/.github/workflows/publish-slack-release-testing-status.yml index 87296185ec3a..9d9db12ea582 100644 --- a/.github/workflows/publish-slack-release-testing-status.yml +++ b/.github/workflows/publish-slack-release-testing-status.yml @@ -7,15 +7,18 @@ on: jobs: call-publish-slack-release-testing-status: + name: Publish Slack release testing status + runs-on: ubuntu-latest permissions: contents: write pull-requests: write - uses: MetaMask/github-tools/.github/workflows/publish-slack-release-testing-status.yml@bce51a03da4736bef72f67b71ca77714a38fc067 - with: - platform: 'mobile' - test-only: 'true' - google-document-id: '1tsoodlAlyvEUpkkcNcbZ4PM9HuC9cEM80RZeoVv5OCQ' - secrets: - slack-api-key: ${{ secrets.SLACKBOT_RLS_TOKEN }} - github-token: ${{ secrets.PR_TOKEN }} - google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} + steps: + - name: Publish Slack release testing status + uses: MetaMask/github-tools/.github/actions/publish-slack-release-testing-status@v1 + with: + platform: 'mobile' + test-only: 'true' + google-document-id: '1tsoodlAlyvEUpkkcNcbZ4PM9HuC9cEM80RZeoVv5OCQ' + slack-api-key: ${{ secrets.SLACKBOT_RLS_TOKEN }} + github-token: ${{ secrets.PR_TOKEN }} + google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} diff --git a/.github/workflows/remove-rca-needed-label-sheets.yml b/.github/workflows/remove-rca-needed-label-sheets.yml index 16ce0b3df679..e66ea5450787 100644 --- a/.github/workflows/remove-rca-needed-label-sheets.yml +++ b/.github/workflows/remove-rca-needed-label-sheets.yml @@ -11,11 +11,13 @@ permissions: jobs: remove-rca-labels: - name: Remove RCA-needed Labels - uses: MetaMask/github-tools/.github/workflows/remove-rca-needed-label-sheets.yml@d354252842b91355deb6d57c752812f745f99679 - with: - spreadsheet_id: '1Y16QEnDwZuR3DAQIe3T5LTWy1ye07GNYqxIei_cMg24' - sheet_name: 'Form Responses 1' - secrets: - github-token: ${{ secrets.GITHUB_TOKEN }} - google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} + name: Remove RCA-needed labels + runs-on: ubuntu-latest + steps: + - name: Remove RCA-needed labels + uses: MetaMask/github-tools/.github/actions/remove-rca-needed-label-sheets@v1 + with: + spreadsheet-id: '1Y16QEnDwZuR3DAQIe3T5LTWy1ye07GNYqxIei_cMg24' + sheet-name: 'Form Responses 1' + github-token: ${{ secrets.GITHUB_TOKEN }} + google-application-creds-base64: ${{ secrets.GCP_RLS_SHEET_ACCOUNT_BASE64 }} diff --git a/.github/workflows/run-e2e-smoke-tests-android-flask.yml b/.github/workflows/run-e2e-smoke-tests-android-flask.yml index 4dfe3f34e947..e9a35cfd7731 100644 --- a/.github/workflows/run-e2e-smoke-tests-android-flask.yml +++ b/.github/workflows/run-e2e-smoke-tests-android-flask.yml @@ -42,7 +42,7 @@ jobs: run: yarn setup:github-ci --no-build-ios - name: Configure Keystore - uses: MetaMask/github-tools/.github/actions/configure-keystore@0259e8a920318b02a8860e178d79796eaa08de02 + uses: MetaMask/github-tools/.github/actions/configure-keystore@v1 with: aws-role-to-assume: ${{ secrets.METAMASK_MOBILE_BUILDER_SIGNER_QA }} aws-region: us-east-2 diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index b42fb80530a3..fcc4a9e05e62 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -109,7 +109,7 @@ jobs: echo "✅ System images installed" - name: Set up E2E environment - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@6742ebe1a3541cb13972d65352a0622a6a6677db + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@v1 with: platform: ${{ inputs.platform }} setup-simulator: ${{ inputs.platform == 'ios' }} diff --git a/.github/workflows/stable-branch-sync.yml b/.github/workflows/stable-branch-sync.yml index 0dbf7a5b588a..ddc420946ea9 100644 --- a/.github/workflows/stable-branch-sync.yml +++ b/.github/workflows/stable-branch-sync.yml @@ -36,12 +36,13 @@ jobs: run-stable-sync: name: Run Stable branch sync + runs-on: ubuntu-latest needs: get-next-version - uses: metamask/github-tools/.github/workflows/stable-sync.yml@701a894f38883ab48560f948e98b76cc6b4d623f - secrets: - github-token: ${{ secrets.STABLE_SYNC_TOKEN }} - with: - semver-version: ${{ needs.get-next-version.outputs.next-version }} - repo-type: 'mobile' # Accepts 'mobile' or 'extension' - github-tools-version: '701a894f38883ab48560f948e98b76cc6b4d623f' - stable-branch-name: 'stable' + steps: + - name: Run stable branch sync + uses: MetaMask/github-tools/.github/actions/stable-sync@v1 + with: + semver-version: ${{ needs.get-next-version.outputs.next-version }} + repo-type: 'mobile' + stable-branch-name: 'stable' + github-token: ${{ secrets.STABLE_SYNC_TOKEN }} diff --git a/.github/workflows/stale-issue-pr.yml b/.github/workflows/stale-issue-pr.yml index f5b92e17c4f9..3914ab9f6c3c 100644 --- a/.github/workflows/stale-issue-pr.yml +++ b/.github/workflows/stale-issue-pr.yml @@ -7,7 +7,12 @@ on: jobs: stale: - uses: metamask/github-tools/.github/workflows/stale-issue-pr.yml@566da3332757544da431707bde71a242b182b3ac + name: Close stale issues and PRs + runs-on: ubuntu-latest permissions: issues: write pull-requests: write + steps: + - name: Close stale issues and PRs + uses: MetaMask/github-tools/.github/actions/stale-issue-pr@v1 + diff --git a/.github/workflows/update-release-changelog.yml b/.github/workflows/update-release-changelog.yml index bad290531c9c..0d80ee4e8ea1 100644 --- a/.github/workflows/update-release-changelog.yml +++ b/.github/workflows/update-release-changelog.yml @@ -26,7 +26,7 @@ jobs: run: | BRANCH_NAME="${{ github.ref_name }}" echo "Checking branch: $BRANCH_NAME" - + # Validate branch matches release/x.y.z format (semantic versioning) if [[ "$BRANCH_NAME" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then VERSION="${BRANCH_NAME#release/}" @@ -48,11 +48,10 @@ jobs: pull-requests: write steps: - name: Update Release Changelog - uses: MetaMask/github-tools/.github/actions/update-release-changelog@v1.1.3 + uses: MetaMask/github-tools/.github/actions/update-release-changelog@v1 with: release-branch: ${{ github.ref_name }} repository-url: ${{ github.server_url }}/${{ github.repository }} platform: mobile previous-version-ref: 'null' - github-tools-version: v1.1.3 github-token: ${{ secrets.PR_TOKEN }} diff --git a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx index fd7776f863ef..737c3f217832 100644 --- a/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx +++ b/app/component-library/components-temp/Tabs/TabsList/TabsList.tsx @@ -235,7 +235,7 @@ const TabsList = forwardRef( return ( {tab.content} diff --git a/app/components/Base/RemoteImage/index.tsx b/app/components/Base/RemoteImage/index.tsx index 1cbd2a6e8b81..855f24430675 100644 --- a/app/components/Base/RemoteImage/index.tsx +++ b/app/components/Base/RemoteImage/index.tsx @@ -36,23 +36,21 @@ interface RemoteImageProps { contentFit?: ImageContentFit; } -const createStyles = () => - StyleSheet.create({ - imageStyle: { - width: '100%', - height: '100%', - borderRadius: 8, - }, - detailedImageStyle: { - borderRadius: 8, - }, - }); +const styles = StyleSheet.create({ + imageStyle: { + width: '100%', + height: '100%', + borderRadius: 8, + }, + detailedImageStyle: { + borderRadius: 8, + }, +}); const RemoteImage: React.FC = (props) => { const [error, setError] = useState(undefined); const source = resolveAssetSource(props.source); const ipfsGateway = useIpfsGateway(); - const styles = createStyles(); const [resolvedIpfsUrl, setResolvedIpfsUrl] = useState(false); const uri = @@ -117,7 +115,17 @@ const RemoteImage: React.FC = (props) => { if (width && height) { const { width: calculatedWidth, height: calculatedHeight } = calculateImageDimensions(width, height); - setDimensions({ width: calculatedWidth, height: calculatedHeight }); + + // Only update if dimensions actually changed + setDimensions((prevDimensions) => { + if ( + prevDimensions?.width === calculatedWidth && + prevDimensions?.height === calculatedHeight + ) { + return prevDimensions; // Return same reference, no re-render + } + return { width: calculatedWidth, height: calculatedHeight }; + }); } }, [calculateImageDimensions], diff --git a/app/components/UI/NftGrid/NftGrid.test.tsx b/app/components/UI/NftGrid/NftGrid.test.tsx index 589ff522f737..1229b72aa570 100644 --- a/app/components/UI/NftGrid/NftGrid.test.tsx +++ b/app/components/UI/NftGrid/NftGrid.test.tsx @@ -11,6 +11,7 @@ import { isNftFetchingProgressSelector, multichainCollectiblesByEnabledNetworksSelector, } from '../../../reducers/collectibles'; +import { selectHomepageRedesignV1Enabled } from '../../../selectors/featureFlagController/homepage'; const mockStore = configureMockStore(); const mockNavigate = jest.fn(); @@ -70,6 +71,7 @@ jest.mock('@shopify/flash-list', () => ({ ListEmptyComponent: React.ReactElement; ListFooterComponent: React.ReactElement; testID: string; + numColumns?: number; }) => { const { View } = jest.requireActual('react-native'); return ( @@ -301,6 +303,32 @@ describe('NftGrid', () => { engine: { backgroundState }, }; + /** + * Helper function to setup selector mocks in a maintainable way + */ + const setupSelectorMocks = ({ + isHomepageRedesignEnabled = false, + collectibles = {}, + isNftFetching = false, + }: { + isHomepageRedesignEnabled?: boolean; + collectibles?: Record; + isNftFetching?: boolean; + }) => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectHomepageRedesignV1Enabled) { + return isHomepageRedesignEnabled; + } + if (selector === multichainCollectiblesByEnabledNetworksSelector) { + return collectibles; + } + if (selector === isNftFetchingProgressSelector) { + return isNftFetching; + } + return undefined; + }); + }; + beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); @@ -312,10 +340,11 @@ describe('NftGrid', () => { it('renders NFT grid when collectibles are present', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -336,10 +365,11 @@ describe('NftGrid', () => { it('renders NFT grid directly without FlashList when homepage redesign is enabled', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -360,10 +390,11 @@ describe('NftGrid', () => { it('renders control bar with add button', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -384,10 +415,11 @@ describe('NftGrid', () => { it('applies full view styling when isFullView is true', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -413,10 +445,11 @@ describe('NftGrid', () => { tokenId: `${i}`, })), }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -441,10 +474,11 @@ describe('NftGrid', () => { tokenId: `${i}`, })), }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -480,10 +514,11 @@ describe('NftGrid', () => { tokenId: `${i}`, })), }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled (maxItems = undefined) - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { queryByTestId } = render( @@ -508,10 +543,11 @@ describe('NftGrid', () => { { ...mockNft, tokenId: '789', isCurrentlyOwned: false }, ], }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { queryByTestId, getByTestId } = render( @@ -532,14 +568,10 @@ describe('NftGrid', () => { it('navigates to AddAsset when add collectible button is pressed', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector.mockImplementation((selector) => { - if (selector === isNftFetchingProgressSelector) { - return false; - } - if (selector === multichainCollectiblesByEnabledNetworksSelector) { - return mockCollectibles; - } - return {}; + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, }); const store = mockStore(initialState); @@ -564,10 +596,11 @@ describe('NftGrid', () => { it('navigates to NFT details when NFT item is pressed', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -593,10 +626,11 @@ describe('NftGrid', () => { it('passes mobile-nft-list source when navigating from homepage view', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -622,10 +656,11 @@ describe('NftGrid', () => { it('passes mobile-nft-list-page source when navigating from full view', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -652,10 +687,11 @@ describe('NftGrid', () => { it('handles NFT without name gracefully', async () => { const nftWithoutName = { ...mockNft, name: null }; const mockCollectibles = { '0x1': [nftWithoutName] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -675,10 +711,11 @@ describe('NftGrid', () => { it('renders NFT items when not fetching without homepage redesign', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -699,10 +736,11 @@ describe('NftGrid', () => { it('shows empty state when not fetching with homepage redesign enabled and no collectibles', async () => { const mockCollectibles = { '0x1': [] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -722,10 +760,11 @@ describe('NftGrid', () => { it('hides spinner in footer when NFTs are not being fetched', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { queryByTestId } = render( @@ -744,10 +783,11 @@ describe('NftGrid', () => { }); it('shows empty state when no collectibles and not fetching', async () => { - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce({}); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: {}, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -766,10 +806,11 @@ describe('NftGrid', () => { }); it('hides empty state when fetching NFTs without homepage redesign', async () => { - mockUseSelector - .mockReturnValueOnce(true) // isNftFetchingProgress - .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce({}); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: false, + collectibles: {}, + isNftFetching: true, + }); const store = mockStore(initialState); const { queryByTestId } = render( @@ -789,10 +830,11 @@ describe('NftGrid', () => { it('renders NFT items when not fetching with homepage redesign enabled', async () => { const mockCollectibles = { '0x1': [mockNft] }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId, queryByTestId } = render( @@ -818,10 +860,11 @@ describe('NftGrid', () => { tokenId: `${i}`, })), }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId } = render( @@ -850,10 +893,11 @@ describe('NftGrid', () => { name: `NFT ${i}`, })), }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId, queryByTestId } = render( @@ -888,10 +932,11 @@ describe('NftGrid', () => { name: `NFT ${i}`, })), }; - mockUseSelector - .mockReturnValueOnce(false) // isNftFetchingProgress - .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled - .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + setupSelectorMocks({ + isHomepageRedesignEnabled: true, + collectibles: mockCollectibles, + isNftFetching: false, + }); const store = mockStore(initialState); const { getByTestId, queryByTestId } = render( diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index 067602ea879e..692440629858 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -49,32 +49,39 @@ interface NftGridProps { isFullView?: boolean; } -const NftRow = ({ - items, - onLongPress, - source, +const NftGridContent = ({ + allFilteredCollectibles, + nftRowList, + goToAddCollectible, + isAddNFTEnabled, }: { - items: Nft[]; - onLongPress: (nft: Nft) => void; - source?: 'mobile-nft-list' | 'mobile-nft-list-page'; -}) => ( - - {items.map((item, index) => { - // Create a truly unique key combining multiple identifiers - const uniqueKey = `${item.address}-${item.tokenId}-${item.chainId}-${index}`; - return ( - - - - ); - })} - {/* Fill remaining slots if less than 3 items */} - {items.length < 3 && - Array.from({ length: 3 - items.length }).map((_, index) => ( - - ))} - -); + allFilteredCollectibles: Nft[]; + nftRowList: React.ReactNode; + goToAddCollectible: () => void; + isAddNFTEnabled: boolean; +}) => { + const isNftFetchingProgress = useSelector(isNftFetchingProgressSelector); + + if (allFilteredCollectibles.length > 0) { + return <>{nftRowList}; + } + + if (isNftFetchingProgress) { + return ; + } + + return ( + + ); +}; const NftGrid = ({ isFullView = false }: NftGridProps) => { const navigation = @@ -85,7 +92,6 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { useState(null); const tw = useTailwind(); - const isNftFetchingProgress = useSelector(isNftFetchingProgressSelector); const isHomepageRedesignV1Enabled = useSelector( selectHomepageRedesignV1Enabled, ); @@ -115,16 +121,12 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { return isHomepageRedesignV1Enabled ? 18 : undefined; }, [isFullView, isHomepageRedesignV1Enabled]); - const groupedCollectibles: Nft[][] = useMemo(() => { - const groups: Nft[][] = []; + const collectiblesToRender: Nft[] = useMemo(() => { const itemsToProcess = maxItems ? allFilteredCollectibles.slice(0, maxItems) : allFilteredCollectibles; - for (let i = 0; i < itemsToProcess.length; i += 3) { - groups.push(itemsToProcess.slice(i, i + 3)); - } - return groups; + return itemsToProcess; }, [allFilteredCollectibles, maxItems]); useEffect(() => { @@ -142,6 +144,10 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { setIsAddNFTEnabled(true); }, [navigation, trackEvent, createEventBuilder]); + const handleLongPress = useCallback((nft: Nft) => { + setLongPressedCollectible(nft); + }, []); + const handleViewAllNfts = useCallback(() => { trackEvent( createEventBuilder(MetaMetricsEvents.VIEW_ALL_ASSETS_CLICKED) @@ -151,61 +157,38 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { navigation.navigate(Routes.WALLET.NFTS_FULL_VIEW); }, [navigation, trackEvent, createEventBuilder]); - const nftRowList = - !isFullView && isHomepageRedesignV1Enabled ? ( - - - - {groupedCollectibles.map((items, index) => ( - - ))} - - - ) : ( + const nftRowList = useMemo( + () => ( } - data={groupedCollectibles} - renderItem={({ item }) => ( - + data={collectiblesToRender} + renderItem={({ item, index }) => ( + + + )} keyExtractor={(_, index) => `nft-row-${index}`} testID={RefreshTestId} decelerationRate="fast" refreshControl={} contentContainerStyle={!isFullView ? undefined : tw`px-4`} + scrollEnabled={isFullView || !isHomepageRedesignV1Enabled} + numColumns={3} /> - ); - - const renderNftContent = () => { - if (isNftFetchingProgress) { - return ; - } - - if (allFilteredCollectibles.length > 0) { - return nftRowList; - } - - return ( - - ); - }; + ), + [ + collectiblesToRender, + isFullView, + isHomepageRedesignV1Enabled, + handleLongPress, + nftSource, + tw, + ], + ); return ( <> @@ -224,7 +207,12 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { hideSort style={isFullView ? tw`px-4 pb-4` : tw`pb-3`} /> - {renderNftContent()} + {/* View all NFTs button - shown when there are more items than maxItems */} {maxItems && allFilteredCollectibles.length > maxItems && ( diff --git a/app/components/UI/NftGrid/NftGridItem.tsx b/app/components/UI/NftGrid/NftGridItem.tsx index 29ebf89e1bf7..1e3a8605b054 100644 --- a/app/components/UI/NftGrid/NftGridItem.tsx +++ b/app/components/UI/NftGrid/NftGridItem.tsx @@ -34,7 +34,7 @@ const NftGridItem = ({ return ( onLongPress(item)} testID={`collectible-${item.name}-${item.tokenId}`} diff --git a/app/components/UI/NftGrid/NftGridSkeleton.tsx b/app/components/UI/NftGrid/NftGridSkeleton.tsx index ddde4667ad0f..ebc5fa372a39 100644 --- a/app/components/UI/NftGrid/NftGridSkeleton.tsx +++ b/app/components/UI/NftGrid/NftGridSkeleton.tsx @@ -15,7 +15,7 @@ const NftGridSkeleton = () => { highlightColor={colors.background.subsection} > - {Array.from({ length: 18 }, (_, index) => ( + {Array.from({ length: 9 }, (_, index) => ( diff --git a/app/util/transactions/hooks/delegation-7702-publish.ts b/app/util/transactions/hooks/delegation-7702-publish.ts index 9161a0ddf5bf..77debee563b5 100644 --- a/app/util/transactions/hooks/delegation-7702-publish.ts +++ b/app/util/transactions/hooks/delegation-7702-publish.ts @@ -39,8 +39,10 @@ import { } from '../transaction-relay'; import { NetworkClientId } from '@metamask/network-controller'; import { toHex } from '@metamask/controller-utils'; -import { stripSingleLeadingZero } from '../util'; +import { isE2ETest, stripSingleLeadingZero } from '../util'; +// Test chain ID (Sepolia) used in E2E tests to match the delegation package's test contract configuration +const SEPOLIA_CHAIN_ID = '0xaa36a7'; const EMPTY_HEX = '0x'; const POLLING_INTERVAL_MS = 1000; // 1 Second @@ -155,7 +157,7 @@ export class Delegation7702PublishHook { } const delegationEnvironment = getDeleGatorEnvironment( - parseInt(transactionMeta.chainId, 16), + parseInt(isE2ETest(chainId) ? SEPOLIA_CHAIN_ID : chainId, 16), ); const delegationManagerAddress = delegationEnvironment.DelegationManager; const includeTransfer = @@ -261,6 +263,7 @@ export class Delegation7702PublishHook { gasFeeToken: GasFeeToken | undefined, includeTransfer: boolean, ): Promise { + const { chainId } = transactionMeta; const unsignedDelegation = this.#buildUnsignedDelegation( delegationEnvironment, transactionMeta, @@ -273,7 +276,7 @@ export class Delegation7702PublishHook { const delegationSignature = (await this.#messenger.call( 'DelegationController:signDelegation', { - chainId: transactionMeta.chainId, + chainId: isE2ETest(chainId) ? SEPOLIA_CHAIN_ID : chainId, delegation: unsignedDelegation, }, )) as Hex; diff --git a/app/util/transactions/util.ts b/app/util/transactions/util.ts index 02068b2aa1d8..e94bbe6b9ce3 100644 --- a/app/util/transactions/util.ts +++ b/app/util/transactions/util.ts @@ -1,6 +1,13 @@ +import { NETWORKS_CHAIN_ID } from '../../constants/network'; +import { isE2E } from '../test/utils'; + export function stripSingleLeadingZero(hex: string): string { if (!hex.startsWith('0x0') || hex.length <= 3) { return hex; } return `0x${hex.slice(3)}`; } + +export function isE2ETest(chainId: string): boolean { + return isE2E && chainId === NETWORKS_CHAIN_ID.LOCALHOST; +} diff --git a/e2e/api-mocking/mock-responses/transaction-relay-mocks.ts b/e2e/api-mocking/mock-responses/transaction-relay-mocks.ts new file mode 100644 index 000000000000..a64c4884a069 --- /dev/null +++ b/e2e/api-mocking/mock-responses/transaction-relay-mocks.ts @@ -0,0 +1,46 @@ +import { device } from 'detox'; +import { RelayStatus } from '../../../app/util/transactions/transaction-relay'; + +const SENDER_ADDRESS_MOCK = '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3'; +const RECIPIENT_ADDRESS_MOCK = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb'; + +const UUID = '1234-5678'; +const TRANSACTION_HASH = + '0xf25183af3bf64af01e9210201a2ede3c1dcd6d16091283152d13265242939fc4'; + +const SENTINEL_URL = 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io'; +const LOCALHOST_SENTINEL_URL = + device.getPlatform() === 'android' + ? 'https://tx-sentinel-127.0.0.1.api.cx.metamask.io/' + : 'https://tx-sentinel-localhost.api.cx.metamask.io/'; + +export const SEND_ETH_TRANSACTION_MOCK = { + data: '0x', + from: SENDER_ADDRESS_MOCK, + to: RECIPIENT_ADDRESS_MOCK, + value: '0xde0b6B3a7640000', +}; + +export const TRANSACTION_RELAY_SUBMIT_NETWORKS_MOCK = { + urlEndpoint: `${LOCALHOST_SENTINEL_URL}`, + responseCode: 200, + response: { + jsonrpc: '2.0', + result: { + uuid: UUID, + }, + }, +}; + +export const TRANSACTION_RELAY_STATUS_NETWORKS_MOCK = { + urlEndpoint: `${SENTINEL_URL}/smart-transactions/${UUID}`, + responseCode: 200, + response: { + transactions: [ + { + hash: TRANSACTION_HASH, + status: RelayStatus.Success, + }, + ], + }, +}; diff --git a/e2e/pages/Browser/Confirmations/RowComponents.ts b/e2e/pages/Browser/Confirmations/RowComponents.ts index bfe9d49a537b..ad6db90bed99 100644 --- a/e2e/pages/Browser/Confirmations/RowComponents.ts +++ b/e2e/pages/Browser/Confirmations/RowComponents.ts @@ -53,6 +53,12 @@ class RowComponents { get NetworkAndOrigin(): DetoxElement { return Matchers.getElementByID(ConfirmationRowComponentIDs.NETWORK); } + + get NetworkFeePaidByMetaMask(): DetoxElement { + return Matchers.getElementByID( + ConfirmationRowComponentIDs.PAID_BY_METAMASK, + ); + } } export default new RowComponents(); diff --git a/e2e/selectors/Confirmation/ConfirmationView.selectors.ts b/e2e/selectors/Confirmation/ConfirmationView.selectors.ts index 785f174874ac..cff7a76086bd 100644 --- a/e2e/selectors/Confirmation/ConfirmationView.selectors.ts +++ b/e2e/selectors/Confirmation/ConfirmationView.selectors.ts @@ -40,6 +40,7 @@ export const ConfirmationRowComponentIDs = { SIWE_SIGNING_ACCOUNT_INFO: 'siwe-signing-account-info', STAKING_DETAILS: 'staking-details', TOKEN_HERO: 'token-hero', + PAID_BY_METAMASK: 'paid-by-metamask', } as const; export const ConfirmationFooterSelectorIDs = { diff --git a/e2e/specs/confirmations-redesigned/transactions/7702/withDelegatorContracts.json b/e2e/specs/confirmations-redesigned/transactions/7702/withDelegatorContracts.json index 56f2f499b7ef..66bdf6afc459 100644 --- a/e2e/specs/confirmations-redesigned/transactions/7702/withDelegatorContracts.json +++ b/e2e/specs/confirmations-redesigned/transactions/7702/withDelegatorContracts.json @@ -6,6 +6,12 @@ "code": "0x", "storage": {} }, + "0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0xef01008438Ad1C834623CfF278AB6829a248E37C2D7E3f", + "storage": {} + }, "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": { "nonce": 0, "balance": "0x21e19e0c9bab2400000", diff --git a/e2e/specs/confirmations-redesigned/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts b/e2e/specs/confirmations-redesigned/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts new file mode 100644 index 000000000000..6a352225a023 --- /dev/null +++ b/e2e/specs/confirmations-redesigned/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts @@ -0,0 +1,266 @@ +import FixtureBuilder from '../../../framework/fixtures/FixtureBuilder'; +import FooterActions from '../../../pages/Browser/Confirmations/FooterActions'; +import SendView from '../../../pages/Send/RedesignedSendView'; +import TabBarComponent from '../../../pages/wallet/TabBarComponent'; +import WalletView from '../../../pages/wallet/WalletView'; +import { + Assertions, + LocalNode, + LocalNodeType, + Utilities, +} from '../../../framework'; +import { SmokeConfirmationsRedesigned } from '../../../tags'; +import { AnvilPort } from '../../../framework/fixtures/FixtureUtils'; +import { loginToApp } from '../../../viewHelper'; +import { withFixtures } from '../../../framework/fixtures/FixtureHelper'; +import RowComponents from '../../../pages/Browser/Confirmations/RowComponents'; +import { AnvilManager, Hardfork } from '../../../seeder/anvil-manager'; +import { + setupMockRequest, + setupMockPostRequest, +} from '../../../api-mocking/helpers/mockHelpers'; +import { SIMULATION_ENABLED_NETWORKS_MOCK } from '../../../api-mocking/mock-responses/simulations'; +import { setupRemoteFeatureFlagsMock } from '../../../api-mocking/helpers/remoteFeatureFlagsHelper'; +import { remoteFeatureEip7702 } from '../../../api-mocking/mock-responses/feature-flags-mocks'; +import { Mockttp } from 'mockttp'; +import { + TRANSACTION_RELAY_STATUS_NETWORKS_MOCK, + TRANSACTION_RELAY_SUBMIT_NETWORKS_MOCK, +} from '../../../api-mocking/mock-responses/transaction-relay-mocks'; +import { RelayStatus } from '../../../../app/util/transactions/transaction-relay'; + +const TRANSACTION_UUID_MOCK = '1234-5678'; +const SENDER_ADDRESS_MOCK = '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3'; +const RECIPIENT_ADDRESS_MOCK = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb'; +const SENTINEL_URL = 'https://tx-sentinel-localhost.api.cx.metamask.io'; + +const SEND_ETH_TRANSACTION_MOCK = { + data: '0x', + from: SENDER_ADDRESS_MOCK, + to: RECIPIENT_ADDRESS_MOCK, + value: '0xde0b6B3a7640000', +}; + +const SIMULATION_ENABLED_NETWORKS_WITH_RELAY = { + ...SIMULATION_ENABLED_NETWORKS_MOCK, + response: { + ...SIMULATION_ENABLED_NETWORKS_MOCK.response, + 1337: { + ...SIMULATION_ENABLED_NETWORKS_MOCK.response[1337], + relayTransactions: true, + }, + }, +}; + +const SIMULATION_RESPONSE = { + jsonrpc: '2.0', + result: { + transactions: [ + { + return: + '0x0000000000000000000000000000000000000000000000000000000000000000', + status: '0x1', + gasUsed: '0x5de2', + gasLimit: '0x5f34', + fees: [ + { + maxFeePerGas: '0xf19b9f48d', + maxPriorityFeePerGas: '0x9febc9', + balanceNeeded: '0x59d9d3b865ed8', + currentBalance: '0x77f9fd8d99e7e0', + error: '', + tokenFees: [], + }, + ], + stateDiff: {}, + feeEstimate: 972988071597550, + baseFeePerGas: 40482817574, + }, + ], + blockNumber: '0x1293669', + id: 'faaab4c5-edf5-4077-ac75-8d26278ca2c5', + sponsorship: { isSponsored: true }, + }, +}; + +const setupCommonMocks = async (mockServer: Mockttp) => { + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: SIMULATION_ENABLED_NETWORKS_WITH_RELAY.urlEndpoint, + response: SIMULATION_ENABLED_NETWORKS_WITH_RELAY.response, + responseCode: 200, + }); + + // Mock infura_simulateTransactions + await setupMockPostRequest( + mockServer, + SENTINEL_URL, + { + jsonrpc: '2.0', + method: 'infura_simulateTransactions', + params: [ + { + transactions: [SEND_ETH_TRANSACTION_MOCK], + suggestFees: { withFeeTransfer: true, withTransfer: true }, + }, + ], + }, + SIMULATION_RESPONSE, + { + statusCode: 200, + ignoreFields: [ + 'id', + 'params.0.blockOverrides', + 'params.0.transactions', + 'params.0.suggestFees', + ], + priority: 1000, + }, + ); + + await setupRemoteFeatureFlagsMock( + mockServer, + Object.assign({}, ...remoteFeatureEip7702), + ); +}; + +const createFixture = ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + const rpcPort = + node instanceof AnvilManager ? (node.getPort() ?? AnvilPort()) : undefined; + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: '0x539', + rpcUrl: `http://localhost:${rpcPort ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', + }, + }) + .withDisabledSmartTransactions() + .build(); +}; + +const localNodeOptions = [ + { + type: LocalNodeType.anvil, + options: { + hardfork: 'prague' as Hardfork, + loadState: + './e2e/specs/confirmations-redesigned/transactions/7702/withDelegatorContracts.json', + }, + }, +]; + +const performSendTransaction = async () => { + await loginToApp(); + await device.disableSynchronization(); + await WalletView.tapWalletSendButton(); + await SendView.selectEthereumToken(); + await SendView.pressAmountFiveButton(); + await SendView.pressContinueButton(); + await SendView.inputRecipientAddress(RECIPIENT_ADDRESS_MOCK); + await SendView.pressReviewButton(); + await Assertions.expectElementToBeVisible( + RowComponents.NetworkFeePaidByMetaMask, + ); + await Utilities.waitForElementToBeVisible(FooterActions.confirmButton); + // Silenced errors from confirm button not being tappable due to toast overlapping + try { + await FooterActions.tapConfirmButton(); + } catch { + console.log('Confirm button not tappable'); + } + await TabBarComponent.tapActivity(); +}; + +describe( + SmokeConfirmationsRedesigned( + 'Send native asset using EIP-7702 - Success Case', + ), + () => { + beforeAll(async () => { + jest.setTimeout(2500000); + }); + + it('sends ETH sponsored', async () => { + await withFixtures( + { + fixture: createFixture, + restartDevice: true, + localNodeOptions, + testSpecificMock: async (mockServer: Mockttp) => { + await setupCommonMocks(mockServer); + + // Mock eth_sendRelayTransaction + await setupMockPostRequest( + mockServer, + SENTINEL_URL, + { + jsonrpc: '2.0', + method: 'eth_sendRelayTransaction', + }, + TRANSACTION_RELAY_SUBMIT_NETWORKS_MOCK.response, + { + statusCode: 200, + ignoreFields: ['id', 'params'], + priority: 999, + }, + ); + + // Status check mock + await setupMockRequest(mockServer, { + requestMethod: 'GET', + url: `${SENTINEL_URL}/smart-transactions/${TRANSACTION_UUID_MOCK}`, + response: { + transactions: [ + { + hash: TRANSACTION_RELAY_STATUS_NETWORKS_MOCK.response + .transactions[0].hash, + status: RelayStatus.Success, + }, + ], + }, + responseCode: 200, + }); + }, + }, + async () => { + await performSendTransaction(); + await Assertions.expectTextDisplayed('Confirmed'); + await device.enableSynchronization(); + }, + ); + }); + }, +); + +describe( + SmokeConfirmationsRedesigned( + 'Send native asset using EIP-7702 - Failure Case', + ), + () => { + beforeAll(async () => { + jest.setTimeout(2500000); + }); + + it('fails transaction if error occurs on API', async () => { + await withFixtures( + { + fixture: createFixture, + restartDevice: true, + localNodeOptions, + testSpecificMock: async (mockServer: Mockttp) => { + await setupCommonMocks(mockServer); + }, + }, + async () => { + await performSendTransaction(); + await Assertions.expectTextDisplayed('Failed'); + await device.enableSynchronization(); + }, + ); + }); + }, +);