diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index 8f6ffbd6e5b..c849eb46a2d 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -4,7 +4,7 @@ To maintain a consistent and efficient development workflow, we have set specifi ### Mandatory team labels: - **Internal Developers**: Every PR raised by an internal developer must include a label prefixed with `team-` (e.g., `team-mobile-ux`, `team-mobile-platform`, etc.). This indicates the respective internal team responsible for the PR. -- **External Contributors**: PRs from contributors outside the organization must have the `external-contributor` label. +- **External Contributors**: PRs submitted by contributors who are not part of the organization will be automatically labeled with `external-contributor`. It's essential to ensure that PRs have the appropriate labels before they are considered for merging. diff --git a/.github/pull-request-template.md b/.github/pull-request-template.md index 0ad3545f82e..bb7f6ebc664 100644 --- a/.github/pull-request-template.md +++ b/.github/pull-request-template.md @@ -37,7 +37,7 @@ Fixes: # - [ ] I've included screenshots/recordings if applicable - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable -- [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). +- [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. - [ ] I’ve properly set the pull request status: - [ ] In case it's not yet "ready for review", I've set it to "draft". - [ ] In case it's "ready for review", I've changed it from "draft" to "non-draft". diff --git a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts index 0bf34182e01..a9b84532c8e 100644 --- a/.github/scripts/add-release-label-to-pr-and-linked-issues.ts +++ b/.github/scripts/add-release-label-to-pr-and-linked-issues.ts @@ -2,14 +2,10 @@ import * as core from '@actions/core'; import { context, getOctokit } from '@actions/github'; import { GitHub } from '@actions/github/lib/utils'; -// A labelable object can be a pull request or an issue -interface Labelable { - id: string; - number: number; - repoOwner: string; - repoName: string; - createdAt: string; -} +import { retrieveLinkedIssues } from './shared/issue'; +import { Label } from './shared/label'; +import { Labelable, addLabelToLabelable } from './shared/labelable'; +import { retrievePullRequest } from './shared/pull-request'; main().catch((error: Error): void => { console.error(error); @@ -42,45 +38,56 @@ async function main(): Promise { } if (!isValidVersionFormat(nextReleaseVersionNumber)) { - core.setFailed(`NEXT_SEMVER_VERSION (${nextReleaseVersionNumber}) is not a valid version format. The expected format is "x.y.z", where "x", "y" and "z" are numbers.`); + core.setFailed( + `NEXT_SEMVER_VERSION (${nextReleaseVersionNumber}) is not a valid version format. The expected format is "x.y.z", where "x", "y" and "z" are numbers.`, + ); process.exit(1); } // Release label indicates the next release version number // Example release label: "release-6.5.0" - const releaseLabelName = `release-${nextReleaseVersionNumber}`; - const releaseLabelColor = "ededed"; - const releaseLabelDescription = `Issue or pull request that will be included in release ${nextReleaseVersionNumber}`; + const releaseLabel: Label = { + name: `release-${nextReleaseVersionNumber}`, + color: 'EDEDED', + description: `Issue or pull request that will be included in release ${nextReleaseVersionNumber}`, + }; // Initialise octokit, required to call Github GraphQL API - const octokit: InstanceType = getOctokit( - personalAccessToken, - { - previews: ["bane"], // The "bane" preview is required for adding, updating, creating and deleting labels. - }, - ); + const octokit: InstanceType = getOctokit(personalAccessToken, { + previews: ['bane'], // The "bane" preview is required for adding, updating, creating and deleting labels. + }); // Retrieve pull request info from context - const prRepoOwner = context.repo.owner; - const prRepoName = context.repo.repo; - const prNumber = context.payload.pull_request?.number; - if (!prNumber) { + const pullRequestRepoOwner = context.repo.owner; + const pullRequestRepoName = context.repo.repo; + const pullRequestNumber = context.payload.pull_request?.number; + if (!pullRequestNumber) { core.setFailed('Pull request number not found'); process.exit(1); } // Retrieve pull request - const pullRequest: Labelable = await retrievePullRequest(octokit, prRepoOwner, prRepoName, prNumber); + const pullRequest: Labelable = await retrievePullRequest( + octokit, + pullRequestRepoOwner, + pullRequestRepoName, + pullRequestNumber, + ); // Add the release label to the pull request - await addLabelToLabelable(octokit, pullRequest, releaseLabelName, releaseLabelColor, releaseLabelDescription); + await addLabelToLabelable(octokit, pullRequest, releaseLabel); // Retrieve linked issues for the pull request - const linkedIssues: Labelable[] = await retrieveLinkedIssues(octokit, prRepoOwner, prRepoName, prNumber); + const linkedIssues: Labelable[] = await retrieveLinkedIssues( + octokit, + pullRequestRepoOwner, + pullRequestRepoName, + pullRequestNumber, + ); // Add the release label to the linked issues for (const linkedIssue of linkedIssues) { - await addLabelToLabelable(octokit, linkedIssue, releaseLabelName, releaseLabelColor, releaseLabelDescription); + await addLabelToLabelable(octokit, linkedIssue, releaseLabel); } } @@ -89,240 +96,3 @@ function isValidVersionFormat(str: string): boolean { const regex = /^\d+\.\d+\.\d+$/; return regex.test(str); } - -// This function retrieves the repo -async function retrieveRepo(octokit: InstanceType, repoOwner: string, repoName: string): Promise { - - const retrieveRepoQuery = ` - query RetrieveRepo($repoOwner: String!, $repoName: String!) { - repository(owner: $repoOwner, name: $repoName) { - id - } - } -`; - - const retrieveRepoResult: { - repository: { - id: string; - }; - } = await octokit.graphql(retrieveRepoQuery, { - repoOwner, - repoName, - }); - - const repoId = retrieveRepoResult?.repository?.id; - - return repoId; -} - -// This function retrieves the label on a specific repo -async function retrieveLabel(octokit: InstanceType, repoOwner: string, repoName: string, labelName: string): Promise { - - const retrieveLabelQuery = ` - query RetrieveLabel($repoOwner: String!, $repoName: String!, $labelName: String!) { - repository(owner: $repoOwner, name: $repoName) { - label(name: $labelName) { - id - } - } - } - `; - - const retrieveLabelResult: { - repository: { - label: { - id: string; - }; - }; - } = await octokit.graphql(retrieveLabelQuery, { - repoOwner, - repoName, - labelName, - }); - - const labelId = retrieveLabelResult?.repository?.label?.id; - - return labelId; -} - -// This function creates the label on a specific repo -async function createLabel(octokit: InstanceType, repoId: string, labelName: string, labelColor: string, labelDescription: string): Promise { - - const createLabelMutation = ` - mutation CreateLabel($repoId: ID!, $labelName: String!, $labelColor: String!, $labelDescription: String) { - createLabel(input: {repositoryId: $repoId, name: $labelName, color: $labelColor, description: $labelDescription}) { - label { - id - } - } - } - `; - - const createLabelResult: { - createLabel: { - label: { - id: string; - }; - }; - } = await octokit.graphql(createLabelMutation, { - repoId, - labelName, - labelColor, - labelDescription, - }); - - const labelId = createLabelResult?.createLabel?.label?.id; - - return labelId; -} - -// This function creates or retrieves the label on a specific repo -async function createOrRetrieveLabel(octokit: InstanceType, repoOwner: string, repoName: string, labelName: string, labelColor: string, labelDescription: string): Promise { - - // Check if label already exists on the repo - let labelId = await retrieveLabel(octokit, repoOwner, repoName, labelName); - - // If label doesn't exist on the repo, create it - if (!labelId) { - // Retrieve PR's repo - const repoId = await retrieveRepo(octokit, repoOwner, repoName); - - // Create label on repo - labelId = await createLabel(octokit, repoId, labelName, labelColor, labelDescription); - } - - return labelId; -} - -// This function retrieves the pull request on a specific repo -async function retrievePullRequest(octokit: InstanceType, repoOwner: string, repoName: string, prNumber: number): Promise { - - const retrievePullRequestQuery = ` - query GetPullRequest($repoOwner: String!, $repoName: String!, $prNumber: Int!) { - repository(owner: $repoOwner, name: $repoName) { - pullRequest(number: $prNumber) { - id - createdAt - } - } - } - `; - - const retrievePullRequestResult: { - repository: { - pullRequest: { - id: string; - createdAt: string; - }; - }; - } = await octokit.graphql(retrievePullRequestQuery, { - repoOwner, - repoName, - prNumber, - }); - - const pullRequest: Labelable = { - id: retrievePullRequestResult?.repository?.pullRequest?.id, - number: prNumber, - repoOwner: repoOwner, - repoName: repoName, - createdAt: retrievePullRequestResult?.repository?.pullRequest?.createdAt, - } - - return pullRequest; -} - - -// This function retrieves the list of linked issues for a pull request -async function retrieveLinkedIssues(octokit: InstanceType, repoOwner: string, repoName: string, prNumber: number): Promise { - - // We assume there won't be more than 100 linked issues - const retrieveLinkedIssuesQuery = ` - query ($repoOwner: String!, $repoName: String!, $prNumber: Int!) { - repository(owner: $repoOwner, name: $repoName) { - pullRequest(number: $prNumber) { - closingIssuesReferences(first: 100) { - nodes { - id - number - createdAt - repository { - name - owner { - login - } - } - } - } - } - } - } - `; - - const retrieveLinkedIssuesResult: { - repository: { - pullRequest: { - closingIssuesReferences: { - nodes: Array<{ - id: string; - number: number; - createdAt: string; - repository: { - name: string; - owner: { - login: string; - }; - }; - }>; - }; - }; - }; - } = await octokit.graphql(retrieveLinkedIssuesQuery, { - repoOwner, - repoName, - prNumber - }); - - const linkedIssues = retrieveLinkedIssuesResult?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map((issue: { - id: string; - number: number; - createdAt: string; - repository: { - name: string; - owner: { - login: string; - }; - }; - }) => { - return { - id: issue?.id, - number: issue?.number, - repoOwner: issue?.repository?.owner?.login, - repoName: issue?.repository?.name, - createdAt: issue?.createdAt - }; - }) || []; - - return linkedIssues; -} - -// This function adds label to a labelable object (i.e. a pull request or an issue) -async function addLabelToLabelable(octokit: InstanceType, labelable: Labelable, labelName: string, labelColor: string, labelDescription: string): Promise { - - // Retrieve label from the labelable's repo, or create label if required - const labelId = await createOrRetrieveLabel(octokit, labelable?.repoOwner, labelable?.repoName, labelName, labelColor, labelDescription); - - const addLabelsToLabelableMutation = ` - mutation AddLabelsToLabelable($labelableId: ID!, $labelIds: [ID!]!) { - addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { - clientMutationId - } - } - `; - - await octokit.graphql(addLabelsToLabelableMutation, { - labelableId: labelable?.id, - labelIds: [labelId], - }); - -} diff --git a/.github/scripts/check-issue-template-and-add-labels.ts b/.github/scripts/check-issue-template-and-add-labels.ts deleted file mode 100644 index a2e16baab3d..00000000000 --- a/.github/scripts/check-issue-template-and-add-labels.ts +++ /dev/null @@ -1,550 +0,0 @@ -import * as core from '@actions/core'; -import { context, getOctokit } from '@actions/github'; -import { GitHub } from '@actions/github/lib/utils'; - -// A labelable object can be a pull request or an issue -interface Labelable { - id: string; - number: number; - repoOwner: string; - repoName: string; - body: string; - author: string; - labels: { - id: string; - name: string; - }[]; -} - -// An enum, to categorise issues, based on template it matches -enum IssueType { - GeneralIssue, - BugReport, - None, -} - -// Titles of our two issues templates ('general-issue.yml' and 'bug-report.yml' issue) -const generalIssueTemplateTitles = [ - '### What is this about?', - '### Scenario', - '### Design', - '### Technical Details', - '### Threat Modeling Framework', - '### Acceptance Criteria', - '### References', -]; -const bugReportTemplateTitles = [ - '### Describe the bug', - '### Expected behavior', - '### Screenshots', // TODO: replace '### Screenshots' by '### Screenshots/Recordings' in January 2024 (as most issues will meet this criteria by then) - '### Steps to reproduce', - '### Error messages or log output', - '### Version', - '### Build type', - '### Device', - '### Operating system', - '### Additional context', - '### Severity', -]; - -// External contributor label -const externalContributorLabelName = `external-contributor`; -const externalContributorLabelColor = 'B60205'; // red -const externalContributorLabelDescription = `Issue or PR created by user outside MetaMask organisation`; - -// Craft invalid issue template label -const invalidIssueTemplateLabelName = `INVALID-ISSUE-TEMPLATE`; -const invalidIssueTemplateLabelColor = 'EDEDED'; // grey -const invalidIssueTemplateLabelDescription = `Issue's body doesn't match any issue template.`; - -main().catch((error: Error): void => { - console.error(error); - process.exit(1); -}); - -async function main(): Promise { - // "GITHUB_TOKEN" is an automatically generated, repository-specific access token provided by GitHub Actions. - // We can't use "GITHUB_TOKEN" here, as its permissions don't allow neither to create new labels - // nor to retrieve the list of organisations a user belongs to. - // In our case, we may want to create "regression-prod-x.y.z" label when it doesn't already exist. - // We may also want to retrieve the list of organisations a user belongs to. - // As a consequence, we need to create our own "LABEL_TOKEN" with "repo" and "read:org" permissions. - // Such a token allows both to create new labels and fetch user's list of organisations. - const personalAccessToken = process.env.LABEL_TOKEN; - if (!personalAccessToken) { - core.setFailed('LABEL_TOKEN not found'); - process.exit(1); - } - - // Retrieve pull request info from context - const issueRepoOwner = context.repo.owner; - const issueRepoName = context.repo.repo; - const issueNumber = context.payload.issue?.number; - if (!issueNumber) { - core.setFailed('Issue number not found'); - process.exit(1); - } - - // Initialise octokit, required to call Github GraphQL API - const octokit: InstanceType = getOctokit(personalAccessToken, { - previews: ['bane'], // The "bane" preview is required for adding, updating, creating and deleting labels. - }); - - // Retrieve issue - const issue: Labelable = await retrieveIssue( - octokit, - issueRepoOwner, - issueRepoName, - issueNumber, - ); - - // Add external contributor label to the issue, in case author is not part of the MetaMask organisation - await addExternalContributorLabel(octokit, issue); - - // Check if issue's body matches one of the two issues templates ('general-issue.yml' or 'bug-report.yml') - const issueType: IssueType = extractIssueTypeFromIssueBody(issue.body); - - if (issueType === IssueType.GeneralIssue) { - console.log("Issue matches 'general-issue.yml' template."); - await removeInvalidIssueTemplateLabelIfPresent(octokit, issue); - } else if (issueType === IssueType.BugReport) { - console.log("Issue matches 'bug-report.yml' template."); - await removeInvalidIssueTemplateLabelIfPresent(octokit, issue); - - // Extract release version from issue body (is existing) - const releaseVersion = extractReleaseVersionFromIssueBody(issue.body); - - // Add regression prod label to the issue if release version was found is issue body - if (releaseVersion) { - await addRegressionProdLabel(octokit, releaseVersion, issue); - } else { - console.log( - `No release version was found in body of issue ${issue?.number}.`, - ); - } - } else { - const errorMessage = - "Issue body does not match any of expected templates ('general-issue.yml' or 'bug-report.yml')."; - console.log(errorMessage); - - // Add invalid issue template label to the issue, in case issue doesn't match any template - await addInvalidIssueTemplateLabel(octokit, issue); - - // Github action shall fail in case issue doesn't match any template - throw new Error(errorMessage); - } -} - -// This helper function checks if issue's body matches one of the two issues templates ('general-issue.yml' or 'bug-report.yml'). -function extractIssueTypeFromIssueBody(issueBody: string): IssueType { - let missingGeneralIssueTitle: boolean = false; - for (const title of generalIssueTemplateTitles) { - if (!issueBody.includes(title)) { - missingGeneralIssueTitle = true; - } - } - - let missingBugReportTitle: boolean = false; - for (const title of bugReportTemplateTitles) { - if (!issueBody.includes(title)) { - missingBugReportTitle = true; - } - } - - if (!missingGeneralIssueTitle) { - return IssueType.GeneralIssue; - } else if (!missingBugReportTitle) { - return IssueType.BugReport; - } else { - return IssueType.None; - } -} - -// This helper function checks if issue's body has a bug report format. -function extractReleaseVersionFromIssueBody( - issueBody: string, -): string | undefined { - // Remove newline characters - const cleanedIssueBody = issueBody.replace(/\r?\n/g, ' '); - - // Extract version from the cleaned issue body - const regex = /### Version\s+((.*?)(?= |$))/; - const versionMatch = cleanedIssueBody.match(regex); - const version = versionMatch?.[1]; - - // Check if version is in the format x.y.z - if (version && !/^(\d+\.)?(\d+\.)?(\*|\d+)$/.test(version)) { - throw new Error('Version is not in the format x.y.z'); - } - - return version; -} - -// This function adds the "external-contributor" label to the issue, in case author is not part of the MetaMask organisation -async function addExternalContributorLabel( - octokit: InstanceType, - issue: Labelable, -): Promise { - // If author is not part of the MetaMask organisation - if (!(await userBelongsToMetaMaskOrg(octokit, issue?.author))) { - // Add external contributor label to the issue - await addLabelToLabelable( - octokit, - issue, - externalContributorLabelName, - externalContributorLabelColor, - externalContributorLabelDescription, - ); - } -} - -// This function adds the correct "regression-prod-x.y.z" label to the issue, and removes other ones -async function addRegressionProdLabel( - octokit: InstanceType, - releaseVersion: string, - issue: Labelable, -): Promise { - // Craft regression prod label to add - const regressionProdLabelName = `regression-prod-${releaseVersion}`; - const regressionProdLabelColor = '5319E7'; // violet - const regressionProdLabelDescription = `Regression bug that was found in production in release ${releaseVersion}`; - - let regressionProdLabelFound: boolean = false; - const regressionProdLabelsToBeRemoved: { - id: string; - name: string; - }[] = []; - - // Loop over issue's labels, to see if regression labels are either missing, or to be removed - issue?.labels?.forEach((label) => { - if (label?.name === regressionProdLabelName) { - regressionProdLabelFound = true; - } else if (label?.name?.startsWith('regression-prod-')) { - regressionProdLabelsToBeRemoved.push(label); - } - }); - - // Add regression prod label to the issue if missing - if (regressionProdLabelFound) { - console.log( - `Issue ${issue?.number} already has ${regressionProdLabelName} label.`, - ); - } else { - console.log( - `Add ${regressionProdLabelName} label to issue ${issue?.number}.`, - ); - await addLabelToLabelable( - octokit, - issue, - regressionProdLabelName, - regressionProdLabelColor, - regressionProdLabelDescription, - ); - } - - // Remove other regression prod label from the issue - await Promise.all( - regressionProdLabelsToBeRemoved.map((label) => { - removeLabelFromLabelable(octokit, issue, label?.id); - }), - ); -} - -// This function adds the "INVALID-ISSUE-TEMPLATE" label to the issue -async function addInvalidIssueTemplateLabel( - octokit: InstanceType, - issue: Labelable, -): Promise { - // Add label to issue - await addLabelToLabelable( - octokit, - issue, - invalidIssueTemplateLabelName, - invalidIssueTemplateLabelColor, - invalidIssueTemplateLabelDescription, - ); -} - -// This function removes the "INVALID-ISSUE-TEMPLATE" label from the issue, in case it's present -async function removeInvalidIssueTemplateLabelIfPresent( - octokit: InstanceType, - issue: Labelable, -): Promise { - // Check if label is present on issue - const label = issue?.labels?.find( - (label) => label.name === invalidIssueTemplateLabelName, - ); - - if (label?.id) { - // Remove label from issue - await removeLabelFromLabelable(octokit, issue, label.id); - } -} - -// This function retrieves the repo -async function retrieveRepo( - octokit: InstanceType, - repoOwner: string, - repoName: string, -): Promise { - const retrieveRepoQuery = ` - query RetrieveRepo($repoOwner: String!, $repoName: String!) { - repository(owner: $repoOwner, name: $repoName) { - id - } - } -`; - - const retrieveRepoResult: { - repository: { - id: string; - }; - } = await octokit.graphql(retrieveRepoQuery, { - repoOwner, - repoName, - }); - - const repoId = retrieveRepoResult?.repository?.id; - - return repoId; -} - -// This function retrieves the label on a specific repo -async function retrieveLabel( - octokit: InstanceType, - repoOwner: string, - repoName: string, - labelName: string, -): Promise { - const retrieveLabelQuery = ` - query RetrieveLabel($repoOwner: String!, $repoName: String!, $labelName: String!) { - repository(owner: $repoOwner, name: $repoName) { - label(name: $labelName) { - id - } - } - } - `; - - const retrieveLabelResult: { - repository: { - label: { - id: string; - }; - }; - } = await octokit.graphql(retrieveLabelQuery, { - repoOwner, - repoName, - labelName, - }); - - const labelId = retrieveLabelResult?.repository?.label?.id; - - return labelId; -} - -// This function creates the label on a specific repo -async function createLabel( - octokit: InstanceType, - repoId: string, - labelName: string, - labelColor: string, - labelDescription: string, -): Promise { - const createLabelMutation = ` - mutation CreateLabel($repoId: ID!, $labelName: String!, $labelColor: String!, $labelDescription: String) { - createLabel(input: {repositoryId: $repoId, name: $labelName, color: $labelColor, description: $labelDescription}) { - label { - id - } - } - } - `; - - const createLabelResult: { - createLabel: { - label: { - id: string; - }; - }; - } = await octokit.graphql(createLabelMutation, { - repoId, - labelName, - labelColor, - labelDescription, - }); - - const labelId = createLabelResult?.createLabel?.label?.id; - - return labelId; -} - -// This function creates or retrieves the label on a specific repo -async function createOrRetrieveLabel( - octokit: InstanceType, - repoOwner: string, - repoName: string, - labelName: string, - labelColor: string, - labelDescription: string, -): Promise { - // Check if label already exists on the repo - let labelId = await retrieveLabel(octokit, repoOwner, repoName, labelName); - - // If label doesn't exist on the repo, create it - if (!labelId) { - // Retrieve PR's repo - const repoId = await retrieveRepo(octokit, repoOwner, repoName); - - // Create label on repo - labelId = await createLabel( - octokit, - repoId, - labelName, - labelColor, - labelDescription, - ); - } - - return labelId; -} - -// This function retrieves the issue on a specific repo -async function retrieveIssue( - octokit: InstanceType, - repoOwner: string, - repoName: string, - issueNumber: number, -): Promise { - const retrieveIssueQuery = ` - query GetIssue($repoOwner: String!, $repoName: String!, $issueNumber: Int!) { - repository(owner: $repoOwner, name: $repoName) { - issue(number: $issueNumber) { - id - body - author { - login - } - labels(first: 100) { - nodes { - id - name - } - } - } - } - } - `; - - const retrieveIssueResult: { - repository: { - issue: { - id: string; - body: string; - author: { - login: string; - }; - labels: { - nodes: { - id: string; - name: string; - }[]; - }; - }; - }; - } = await octokit.graphql(retrieveIssueQuery, { - repoOwner, - repoName, - issueNumber, - }); - - const issue: Labelable = { - id: retrieveIssueResult?.repository?.issue?.id, - number: issueNumber, - repoOwner: repoOwner, - repoName: repoName, - body: retrieveIssueResult?.repository?.issue?.body, - author: retrieveIssueResult?.repository?.issue?.author?.login, - labels: retrieveIssueResult?.repository?.issue?.labels?.nodes, - }; - - return issue; -} - -// This function adds label to a labelable object (i.e. a pull request or an issue) -async function addLabelToLabelable( - octokit: InstanceType, - labelable: Labelable, - labelName: string, - labelColor: string, - labelDescription: string, -): Promise { - // Retrieve label from the labelable's repo, or create label if required - const labelId = await createOrRetrieveLabel( - octokit, - labelable?.repoOwner, - labelable?.repoName, - labelName, - labelColor, - labelDescription, - ); - - const addLabelsToLabelableMutation = ` - mutation AddLabelsToLabelable($labelableId: ID!, $labelIds: [ID!]!) { - addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { - clientMutationId - } - } - `; - - await octokit.graphql(addLabelsToLabelableMutation, { - labelableId: labelable?.id, - labelIds: [labelId], - }); -} - -// This function removes a label from a labelable object (i.e. a pull request or an issue) -async function removeLabelFromLabelable( - octokit: InstanceType, - labelable: Labelable, - labelId: string, -): Promise { - const removeLabelsFromLabelableMutation = ` - mutation RemoveLabelsFromLabelable($labelableId: ID!, $labelIds: [ID!]!) { - removeLabelsFromLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { - clientMutationId - } - } - `; - - await octokit.graphql(removeLabelsFromLabelableMutation, { - labelableId: labelable?.id, - labelIds: [labelId], - }); -} - -// This function checks if user belongs to MetaMask organization on Github -async function userBelongsToMetaMaskOrg( - octokit: InstanceType, - username: string, -): Promise { - const userBelongsToMetaMaskOrgQuery = ` - query UserBelongsToMetaMaskOrg($login: String!) { - user(login: $login) { - organization(login: "MetaMask") { - id - } - } - } - `; - - const userBelongsToMetaMaskOrgResult: { - user: { - organization: { - id: string; - }; - }; - } = await octokit.graphql(userBelongsToMetaMaskOrgQuery, { login: username }); - - return Boolean(userBelongsToMetaMaskOrgResult?.user?.organization?.id); -} diff --git a/.github/scripts/check-pr-has-required-labels.ts b/.github/scripts/check-pr-has-required-labels.ts index 1dfb92bc612..fadb2856ac2 100644 --- a/.github/scripts/check-pr-has-required-labels.ts +++ b/.github/scripts/check-pr-has-required-labels.ts @@ -1,6 +1,9 @@ import * as core from '@actions/core'; import { context, getOctokit } from '@actions/github'; import { GitHub } from '@actions/github/lib/utils'; +import { externalContributorLabel } from './shared/label'; +import { Labelable } from './shared/labelable'; +import { retrievePullRequest } from './shared/pull-request'; main().catch((error: Error): void => { console.error(error); @@ -19,29 +22,46 @@ async function main(): Promise { const octokit: InstanceType = getOctokit(githubToken); // Retrieve pull request info from context - const prRepoOwner = context.repo.owner; - const prRepoName = context.repo.repo; - const prNumber = context.payload.pull_request?.number; - if (!prNumber) { + const pullRequestRepoOwner = context.repo.owner; + const pullRequestRepoName = context.repo.repo; + const pullRequestNumber = context.payload.pull_request?.number; + if (!pullRequestNumber) { core.setFailed('Pull request number not found'); process.exit(1); } // Retrieve pull request labels - const prLabels = await retrievePullRequestLabels(octokit, prRepoOwner, prRepoName, prNumber); - - const preventMergeLabels = ["needs-qa", "QA'd but questions", "issues-found", "need-ux-ds-review", "blocked", "stale", "DO-NOT-MERGE"]; + const pullRequest: Labelable = await retrievePullRequest( + octokit, + pullRequestRepoOwner, + pullRequestRepoName, + pullRequestNumber, + ); + const pullRequestLabels = + pullRequest.labels?.map((labelObject) => labelObject?.name) || []; + const preventMergeLabels = [ + 'needs-qa', + "QA'd but questions", + 'issues-found', + 'need-ux-ds-review', + 'blocked', + 'stale', + 'DO-NOT-MERGE', + ]; let hasTeamLabel = false; // Check pull request has at least required QA label and team label - for (const label of prLabels) { - if (label.startsWith("team-") || label === "external-contributor") { + for (const label of pullRequestLabels) { + if (label.startsWith('team-') || label === externalContributorLabel.name) { console.log(`PR contains a team label as expected: ${label}`); hasTeamLabel = true; } if (preventMergeLabels.includes(label)) { - throw new Error(`PR cannot be merged because it still contains this label: ${label}`); + core.setFailed( + `PR cannot be merged because it still contains this label: ${label}`, + ); + process.exit(1); } if (hasTeamLabel) { return; @@ -54,44 +74,6 @@ async function main(): Promise { errorMessage += 'No team labels found on the PR. '; } errorMessage += `Please make sure the PR is appropriately labeled before merging it.\n\nSee labeling guidelines for more detail: https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md`; - throw new Error(errorMessage); - -} - -// This function retrieves the pull request on a specific repo -async function retrievePullRequestLabels(octokit: InstanceType, repoOwner: string, repoName: string, prNumber: number): Promise { - - const retrievePullRequestLabelsQuery = ` - query RetrievePullRequestLabels($repoOwner: String!, $repoName: String!, $prNumber: Int!) { - repository(owner: $repoOwner, name: $repoName) { - pullRequest(number: $prNumber) { - labels(first: 100) { - nodes { - name - } - } - } - } - } - `; - - const retrievePullRequestLabelsResult: { - repository: { - pullRequest: { - labels: { - nodes: { - name: string; - }[]; - } - }; - }; - } = await octokit.graphql(retrievePullRequestLabelsQuery, { - repoOwner, - repoName, - prNumber, - }); - - const pullRequestLabels = retrievePullRequestLabelsResult?.repository?.pullRequest?.labels?.nodes?.map(labelObject => labelObject?.name); - - return pullRequestLabels || []; + core.setFailed(errorMessage); + process.exit(1); } diff --git a/.github/scripts/check-template-and-add-labels.ts b/.github/scripts/check-template-and-add-labels.ts new file mode 100644 index 00000000000..e5f51fe1447 --- /dev/null +++ b/.github/scripts/check-template-and-add-labels.ts @@ -0,0 +1,271 @@ +import * as core from '@actions/core'; +import { context, getOctokit } from '@actions/github'; +import { GitHub } from '@actions/github/lib/utils'; + +import { retrieveIssue } from './shared/issue'; +import { + Labelable, + LabelableType, + addLabelToLabelable, + removeLabelFromLabelable, + removeLabelFromLabelableIfPresent, +} from './shared/labelable'; +import { + Label, + externalContributorLabel, + invalidIssueTemplateLabel, + invalidPullRequestTemplateLabel, +} from './shared/label'; +import { TemplateType, templates } from './shared/template'; +import { retrievePullRequest } from './shared/pull-request'; + +main().catch((error: Error): void => { + console.error(error); + process.exit(1); +}); + +async function main(): Promise { + // "GITHUB_TOKEN" is an automatically generated, repository-specific access token provided by GitHub Actions. + // We can't use "GITHUB_TOKEN" here, as its permissions don't allow neither to create new labels + // nor to retrieve the list of organisations a user belongs to. + // In our case, we may want to create "regression-prod-x.y.z" label when it doesn't already exist. + // We may also want to retrieve the list of organisations a user belongs to. + // As a consequence, we need to create our own "LABEL_TOKEN" with "repo" and "read:org" permissions. + // Such a token allows both to create new labels and fetch user's list of organisations. + const personalAccessToken = process.env.LABEL_TOKEN; + if (!personalAccessToken) { + core.setFailed('LABEL_TOKEN not found'); + process.exit(1); + } + + // Initialise octokit, required to call Github GraphQL API + const octokit: InstanceType = getOctokit(personalAccessToken, { + previews: ['bane'], // The "bane" preview is required for adding, updating, creating and deleting labels. + }); + + // Retrieve labelable object (i.e. a pull request or an issue) info from context + const labelableRepoOwner = context.repo.owner; + const labelableRepoName = context.repo.repo; + let labelable: Labelable; + if (context.payload.issue?.number) { + // Retrieve issue + labelable = await retrieveIssue( + octokit, + labelableRepoOwner, + labelableRepoName, + context.payload.issue?.number, + ); + } else if (context.payload.pull_request?.number) { + // Retrieve PR + labelable = await retrievePullRequest( + octokit, + labelableRepoOwner, + labelableRepoName, + context.payload.pull_request?.number, + ); + } else { + core.setFailed( + 'Labelable object (i.e. a pull request or an issue) number not found', + ); + process.exit(1); + } + + // If author is not part of the MetaMask organisation + if (!(await userBelongsToMetaMaskOrg(octokit, labelable?.author))) { + // Add external contributor label to the issue + await addLabelToLabelable(octokit, labelable, externalContributorLabel); + } + + // Check if labelable's body matches one of the issue or PR templates ('general-issue.yml' or 'bug-report.yml' or 'pull-request-template.md'). + const templateType: TemplateType = extractTemplateTypeFromBody( + labelable.body, + ); + + if (labelable.type === LabelableType.Issue) { + if (templateType === TemplateType.GeneralIssue) { + console.log("Issue matches 'general-issue.yml' template."); + await removeLabelFromLabelableIfPresent( + octokit, + labelable, + invalidIssueTemplateLabel, + ); + } else if (templateType === TemplateType.BugReportIssue) { + console.log("Issue matches 'bug-report.yml' template."); + await removeLabelFromLabelableIfPresent( + octokit, + labelable, + invalidIssueTemplateLabel, + ); + + // Extract release version from bug report issue body (if existing) + const releaseVersion = extractReleaseVersionFromBugReportIssueBody( + labelable.body, + ); + + // Add regression prod label to the bug report issue if release version was found in issue body + if (releaseVersion) { + await addRegressionProdLabelToIssue(octokit, releaseVersion, labelable); + } else { + console.log( + `No release version was found in body of bug report issue ${labelable?.number}.`, + ); + } + } else { + const errorMessage = + "Issue body does not match any of expected templates ('general-issue.yml' or 'bug-report.yml')."; + console.log(errorMessage); + + // Add label to indicate issue doesn't match any template + await addLabelToLabelable(octokit, labelable, invalidIssueTemplateLabel); + + // Github action shall fail in case issue body doesn't match any template + core.setFailed(errorMessage); + process.exit(1); + } + } else if (labelable.type === LabelableType.PullRequest) { + if (templateType === TemplateType.PullRequest) { + console.log("PR matches 'pull-request-template.md' template."); + await removeLabelFromLabelableIfPresent( + octokit, + labelable, + invalidPullRequestTemplateLabel, + ); + } else { + const errorMessage = + "PR body does not match template ('pull-request-template.md')."; + console.log(errorMessage); + + // Add label to indicate PR body doesn't match template + await addLabelToLabelable( + octokit, + labelable, + invalidPullRequestTemplateLabel, + ); + + // Github action shall fail in case PR doesn't match template + core.setFailed(errorMessage); + process.exit(1); + } + } else { + core.setFailed( + `Shall never happen: Labelable is neither an issue nor a PR (${JSON.stringify( + labelable, + )}).`, + ); + process.exit(1); + } +} + +// This helper function checks if body matches one of the issue or PR templates ('general-issue.yml' or 'bug-report.yml' or 'pull-request-template.md') +function extractTemplateTypeFromBody(body: string): TemplateType { + for (const [templateType, template] of templates) { + let matches = true; + + for (const title of template.titles) { + if (!body.includes(title)) { + matches = false; + break; + } + } + + if (matches) { + return templateType; + } + } + + return TemplateType.None; +} + +// This helper function extracts release version from bug report issue's body. +function extractReleaseVersionFromBugReportIssueBody( + body: string, +): string | undefined { + // Remove newline characters + const cleanedBody = body.replace(/\r?\n/g, ' '); + + // Extract version from the cleaned body + const regex = /### Version\s+((.*?)(?= |$))/; + const versionMatch = cleanedBody.match(regex); + const version = versionMatch?.[1]; + + // Check if version is in the format x.y.z + if (version && !/^(\d+\.)?(\d+\.)?(\*|\d+)$/.test(version)) { + throw new Error('Version is not in the format x.y.z'); + } + + return version; +} + +// This function adds the correct "regression-prod-x.y.z" label to the issue, and removes other ones +async function addRegressionProdLabelToIssue( + octokit: InstanceType, + releaseVersion: string, + issue: Labelable, +): Promise { + // Craft regression prod label to add + const regressionProdLabel: Label = { + name: `regression-prod-${releaseVersion}`, + color: '5319E7', // violet + description: `Regression bug that was found in production in release ${releaseVersion}`, + }; + + let regressionProdLabelFound: boolean = false; + const regressionProdLabelsToBeRemoved: { + id: string; + name: string; + }[] = []; + + // Loop over issue's labels, to see if regression labels are either missing, or to be removed + issue?.labels?.forEach((label) => { + if (label?.name === regressionProdLabel.name) { + regressionProdLabelFound = true; + } else if (label?.name?.startsWith('regression-prod-')) { + regressionProdLabelsToBeRemoved.push(label); + } + }); + + // Add regression prod label to the issue if missing + if (regressionProdLabelFound) { + console.log( + `Issue ${issue?.number} already has ${regressionProdLabel.name} label.`, + ); + } else { + console.log( + `Add ${regressionProdLabel.name} label to issue ${issue?.number}.`, + ); + await addLabelToLabelable(octokit, issue, regressionProdLabel); + } + + // Remove other regression prod label from the issue + await Promise.all( + regressionProdLabelsToBeRemoved.map((label) => { + removeLabelFromLabelable(octokit, issue, label?.id); + }), + ); +} + +// This function checks if user belongs to MetaMask organization on Github +async function userBelongsToMetaMaskOrg( + octokit: InstanceType, + username: string, +): Promise { + const userBelongsToMetaMaskOrgQuery = ` + query UserBelongsToMetaMaskOrg($login: String!) { + user(login: $login) { + organization(login: "MetaMask") { + id + } + } + } + `; + + const userBelongsToMetaMaskOrgResult: { + user: { + organization: { + id: string; + }; + }; + } = await octokit.graphql(userBelongsToMetaMaskOrgQuery, { login: username }); + + return Boolean(userBelongsToMetaMaskOrgResult?.user?.organization?.id); +} diff --git a/.github/scripts/close-release-bug-report-issue.ts b/.github/scripts/close-release-bug-report-issue.ts index 3306aadc0da..625d865aced 100644 --- a/.github/scripts/close-release-bug-report-issue.ts +++ b/.github/scripts/close-release-bug-report-issue.ts @@ -29,13 +29,17 @@ async function main(): Promise { } // Extract branch name from the context - const branchName: string = context.payload.pull_request?.head.ref || ""; + const branchName: string = context.payload.pull_request?.head.ref || ''; // Extract semver version number from the branch name - const releaseVersionNumberMatch = branchName.match(/^release\/(\d+\.\d+\.\d+)$/); + const releaseVersionNumberMatch = branchName.match( + /^release\/(\d+\.\d+\.\d+)$/, + ); if (!releaseVersionNumberMatch) { - core.setFailed(`Failed to extract version number from branch name: ${branchName}`); + core.setFailed( + `Failed to extract version number from branch name: ${branchName}`, + ); process.exit(1); } @@ -44,17 +48,31 @@ async function main(): Promise { // Initialise octokit, required to call Github GraphQL API const octokit: InstanceType = getOctokit(personalAccessToken); - const bugReportIssue = await retrieveOpenBugReportIssue(octokit, repoOwner, bugReportRepo, releaseVersionNumber); + const bugReportIssue = await retrieveOpenBugReportIssue( + octokit, + repoOwner, + bugReportRepo, + releaseVersionNumber, + ); if (!bugReportIssue) { - throw new Error(`No open bug report issue was found for release ${releaseVersionNumber} on ${repoOwner}/${bugReportRepo} repo`); + throw new Error( + `No open bug report issue was found for release ${releaseVersionNumber} on ${repoOwner}/${bugReportRepo} repo`, + ); } - if (bugReportIssue.title?.toLocaleLowerCase() !== `v${releaseVersionNumber} Bug Report`.toLocaleLowerCase()) { - throw new Error(`Unexpected bug report title: "${bugReportIssue.title}" instead of "v${releaseVersionNumber} Bug Report"`); + if ( + bugReportIssue.title?.toLocaleLowerCase() !== + `v${releaseVersionNumber} Bug Report`.toLocaleLowerCase() + ) { + throw new Error( + `Unexpected bug report title: "${bugReportIssue.title}" instead of "v${releaseVersionNumber} Bug Report"`, + ); } - console.log(`Closing bug report issue with title "${bugReportIssue.title}" and id: ${bugReportIssue.id}`); + console.log( + `Closing bug report issue with title "${bugReportIssue.title}" and id: ${bugReportIssue.id}`, + ); await closeIssue(octokit, bugReportIssue.id); @@ -62,11 +80,18 @@ async function main(): Promise { } // This function retrieves the issue titled "vx.y.z Bug Report" on a specific repo -async function retrieveOpenBugReportIssue(octokit: InstanceType, repoOwner: string, repoName: string, releaseVersionNumber: string): Promise<{ - id: string; - title: string; -} | undefined> { - +async function retrieveOpenBugReportIssue( + octokit: InstanceType, + repoOwner: string, + repoName: string, + releaseVersionNumber: string, +): Promise< + | { + id: string; + title: string; + } + | undefined +> { const retrieveOpenBugReportIssueQuery = ` query RetrieveOpenBugReportIssue { search(query: "repo:${repoOwner}/${repoName} type:issue is:open in:title v${releaseVersionNumber} Bug Report", type: ISSUE, first: 1) { @@ -94,10 +119,11 @@ async function retrieveOpenBugReportIssue(octokit: InstanceType, return bugReportIssues?.length > 0 ? bugReportIssues[0] : undefined; } - // This function closes a Github issue, based on its ID -async function closeIssue(octokit: InstanceType, issueId: string): Promise { - +async function closeIssue( + octokit: InstanceType, + issueId: string, +): Promise { const closeIssueMutation = ` mutation CloseIssue($issueId: ID!) { updateIssue(input: {id: $issueId, state: CLOSED}) { @@ -114,7 +140,8 @@ async function closeIssue(octokit: InstanceType, issueId: string) issueId, }); - const clientMutationId = closeIssueMutationResult?.updateIssue?.clientMutationId; + const clientMutationId = + closeIssueMutationResult?.updateIssue?.clientMutationId; return clientMutationId; } diff --git a/.github/scripts/shared/issue.ts b/.github/scripts/shared/issue.ts new file mode 100644 index 00000000000..e5c6804630e --- /dev/null +++ b/.github/scripts/shared/issue.ts @@ -0,0 +1,183 @@ +import { GitHub } from '@actions/github/lib/utils'; + +import { LabelableType, Labelable } from './labelable'; + +// This function retrieves an issue on a specific repo +export async function retrieveIssue( + octokit: InstanceType, + repoOwner: string, + repoName: string, + issueNumber: number, +): Promise { + const retrieveIssueQuery = ` + query GetIssue($repoOwner: String!, $repoName: String!, $issueNumber: Int!) { + repository(owner: $repoOwner, name: $repoName) { + issue(number: $issueNumber) { + id + createdAt + body + author { + login + } + labels(first: 100) { + nodes { + id + name + } + } + } + } + } + `; + + const retrieveIssueResult: { + repository: { + issue: { + id: string; + createdAt: string; + body: string; + author: { + login: string; + }; + labels: { + nodes: { + id: string; + name: string; + }[]; + }; + }; + }; + } = await octokit.graphql(retrieveIssueQuery, { + repoOwner, + repoName, + issueNumber, + }); + + const issue: Labelable = { + id: retrieveIssueResult?.repository?.issue?.id, + type: LabelableType.Issue, + number: issueNumber, + repoOwner: repoOwner, + repoName: repoName, + createdAt: retrieveIssueResult?.repository?.issue?.createdAt, + body: retrieveIssueResult?.repository?.issue?.body, + author: retrieveIssueResult?.repository?.issue?.author?.login, + labels: retrieveIssueResult?.repository?.issue?.labels?.nodes, + }; + + return issue; +} + +// This function retrieves the list of linked issues for a pull request +export async function retrieveLinkedIssues( + octokit: InstanceType, + repoOwner: string, + repoName: string, + prNumber: number, +): Promise { + // We assume there won't be more than 100 linked issues + const retrieveLinkedIssuesQuery = ` + query ($repoOwner: String!, $repoName: String!, $prNumber: Int!) { + repository(owner: $repoOwner, name: $repoName) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 100) { + nodes { + id + number + createdAt + body + author { + login + } + labels(first: 100) { + nodes { + id + name + } + } + repository { + name + owner { + login + } + } + } + } + } + } + } + `; + + const retrieveLinkedIssuesResult: { + repository: { + pullRequest: { + closingIssuesReferences: { + nodes: Array<{ + id: string; + number: number; + createdAt: string; + body: string; + author: { + login: string; + }; + labels: { + nodes: { + id: string; + name: string; + }[]; + }; + repository: { + name: string; + owner: { + login: string; + }; + }; + }>; + }; + }; + }; + } = await octokit.graphql(retrieveLinkedIssuesQuery, { + repoOwner, + repoName, + prNumber, + }); + + const linkedIssues: Labelable[] = + retrieveLinkedIssuesResult?.repository?.pullRequest?.closingIssuesReferences?.nodes?.map( + (issue: { + id: string; + number: number; + createdAt: string; + body: string; + author: { + login: string; + }; + labels: { + nodes: { + id: string; + name: string; + }[]; + }; + repository: { + name: string; + owner: { + login: string; + }; + }; + }) => { + return { + id: issue?.id, + type: LabelableType.Issue, + number: issue?.number, + repoOwner: issue?.repository?.owner?.login, + repoName: issue?.repository?.name, + createdAt: issue?.createdAt, + body: issue?.body, + author: issue?.author?.login, + labels: issue?.labels?.nodes, + }; + }, + ) || []; + + return linkedIssues; +} diff --git a/.github/scripts/shared/label.ts b/.github/scripts/shared/label.ts new file mode 100644 index 00000000000..32097fda66e --- /dev/null +++ b/.github/scripts/shared/label.ts @@ -0,0 +1,121 @@ +import { GitHub } from '@actions/github/lib/utils'; + +import { retrieveRepo } from './repo'; + +export interface Label { + name: string; + color: string; + description: string; +} + +export const externalContributorLabel: Label = { + name: 'external-contributor', + color: '7057FF', + description: 'Issue or PR created by user outside org', +}; + +export const invalidIssueTemplateLabel: Label = { + name: 'INVALID-ISSUE-TEMPLATE', + color: 'EDEDED', + description: "Issue's body doesn't match template", +}; + +export const invalidPullRequestTemplateLabel: Label = { + name: 'INVALID-PR-TEMPLATE', + color: 'EDEDED', + description: "PR's body doesn't match template", +}; + +// This function creates or retrieves the label on a specific repo +export async function createOrRetrieveLabel( + octokit: InstanceType, + repoOwner: string, + repoName: string, + label: Label, +): Promise { + // Check if label already exists on the repo + let labelId = await retrieveLabel(octokit, repoOwner, repoName, label.name); + + // If label doesn't exist on the repo, create it + if (!labelId) { + console.log( + `${label.name} label doesn't exist on ${repoName} repo. It needs to be created.`, + ); + + // Retrieve PR's repo + const repoId = await retrieveRepo(octokit, repoOwner, repoName); + + // Create label on repo + labelId = await createLabel(octokit, repoId, label); + } + + return labelId; +} + +// This function creates the label on a specific repo +async function createLabel( + octokit: InstanceType, + repoId: string, + label: Label, +): Promise { + const createLabelMutation = ` + mutation CreateLabel($repoId: ID!, $labelName: String!, $labelColor: String!, $labelDescription: String) { + createLabel(input: {repositoryId: $repoId, name: $labelName, color: $labelColor, description: $labelDescription}) { + label { + id + } + } + } + `; + + const createLabelResult: { + createLabel: { + label: { + id: string; + }; + }; + } = await octokit.graphql(createLabelMutation, { + repoId, + labelName: label.name, + labelColor: label.color, + labelDescription: label.description, + }); + + const labelId = createLabelResult?.createLabel?.label?.id; + + return labelId; +} + +// This function retrieves the label on a specific repo +async function retrieveLabel( + octokit: InstanceType, + repoOwner: string, + repoName: string, + labelName: string, +): Promise { + const retrieveLabelQuery = ` + query RetrieveLabel($repoOwner: String!, $repoName: String!, $labelName: String!) { + repository(owner: $repoOwner, name: $repoName) { + label(name: $labelName) { + id + } + } + } + `; + + const retrieveLabelResult: { + repository: { + label: { + id: string; + }; + }; + } = await octokit.graphql(retrieveLabelQuery, { + repoOwner, + repoName, + labelName, + }); + + const labelId = retrieveLabelResult?.repository?.label?.id; + + return labelId; +} diff --git a/.github/scripts/shared/labelable.ts b/.github/scripts/shared/labelable.ts new file mode 100644 index 00000000000..56a8675190c --- /dev/null +++ b/.github/scripts/shared/labelable.ts @@ -0,0 +1,89 @@ +import { GitHub } from '@actions/github/lib/utils'; + +import { Label, createOrRetrieveLabel } from './label'; + +export enum LabelableType { + Issue, + PullRequest, +} + +// A labelable object can be a pull request or an issue +export interface Labelable { + id: string; + type: LabelableType; + number: number; + repoOwner: string; + repoName: string; + createdAt: string; + body: string; + author: string; + labels: { + id: string; + name: string; + }[]; +} + +// This function adds label to a labelable object (i.e. a pull request or an issue) +export async function addLabelToLabelable( + octokit: InstanceType, + labelable: Labelable, + label: Label, +): Promise { + // Retrieve label from the labelable's repo, or create label if required + const labelId = await createOrRetrieveLabel( + octokit, + labelable?.repoOwner, + labelable?.repoName, + label, + ); + + const addLabelsToLabelableMutation = ` + mutation AddLabelsToLabelable($labelableId: ID!, $labelIds: [ID!]!) { + addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { + clientMutationId + } + } + `; + + await octokit.graphql(addLabelsToLabelableMutation, { + labelableId: labelable?.id, + labelIds: [labelId], + }); +} + +// This function removes a label from a labelable object (i.e. a pull request or an issue) +export async function removeLabelFromLabelable( + octokit: InstanceType, + labelable: Labelable, + labelId: string, +): Promise { + const removeLabelsFromLabelableMutation = ` + mutation RemoveLabelsFromLabelable($labelableId: ID!, $labelIds: [ID!]!) { + removeLabelsFromLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { + clientMutationId + } + } + `; + + await octokit.graphql(removeLabelsFromLabelableMutation, { + labelableId: labelable?.id, + labelIds: [labelId], + }); +} + +// This function removes a label from a labelable object (i.e. a pull request or an issue) if present +export async function removeLabelFromLabelableIfPresent( + octokit: InstanceType, + labelable: Labelable, + labelToRemove: Label, +): Promise { + // Check if label is present on issue + const labelFound = labelable?.labels?.find( + (label) => label.name === labelToRemove?.name, + ); + + if (labelFound?.id) { + // Remove label from labelable + await removeLabelFromLabelable(octokit, labelable, labelFound?.id); + } +} diff --git a/.github/scripts/shared/pull-request.ts b/.github/scripts/shared/pull-request.ts new file mode 100644 index 00000000000..8cf6805f57c --- /dev/null +++ b/.github/scripts/shared/pull-request.ts @@ -0,0 +1,69 @@ +import { GitHub } from '@actions/github/lib/utils'; + +import { LabelableType, Labelable } from './labelable'; + +// This function retrieves a pull request on a specific repo +export async function retrievePullRequest( + octokit: InstanceType, + repoOwner: string, + repoName: string, + prNumber: number, +): Promise { + const retrievePullRequestQuery = ` + query RetrievePullRequestLabels($repoOwner: String!, $repoName: String!, $prNumber: Int!) { + repository(owner: $repoOwner, name: $repoName) { + pullRequest(number: $prNumber) { + id + createdAt + body + author { + login + } + labels(first: 100) { + nodes { + id + name + } + } + } + } + } + `; + + const retrievePullRequestResult: { + repository: { + pullRequest: { + id: string; + createdAt: string; + body: string; + author: { + login: string; + }; + labels: { + nodes: { + id: string; + name: string; + }[]; + }; + }; + }; + } = await octokit.graphql(retrievePullRequestQuery, { + repoOwner, + repoName, + prNumber, + }); + + const pullRequest: Labelable = { + id: retrievePullRequestResult?.repository?.pullRequest?.id, + type: LabelableType.PullRequest, + number: prNumber, + repoOwner: repoOwner, + repoName: repoName, + createdAt: retrievePullRequestResult?.repository?.pullRequest?.createdAt, + body: retrievePullRequestResult?.repository?.pullRequest?.body, + author: retrievePullRequestResult?.repository?.pullRequest?.author?.login, + labels: retrievePullRequestResult?.repository?.pullRequest?.labels?.nodes, + }; + + return pullRequest; +} diff --git a/.github/scripts/shared/repo.ts b/.github/scripts/shared/repo.ts new file mode 100644 index 00000000000..09e47535001 --- /dev/null +++ b/.github/scripts/shared/repo.ts @@ -0,0 +1,29 @@ +import { GitHub } from '@actions/github/lib/utils'; + +// This function retrieves the repo +export async function retrieveRepo( + octokit: InstanceType, + repoOwner: string, + repoName: string, +): Promise { + const retrieveRepoQuery = ` + query RetrieveRepo($repoOwner: String!, $repoName: String!) { + repository(owner: $repoOwner, name: $repoName) { + id + } + } + `; + + const retrieveRepoResult: { + repository: { + id: string; + }; + } = await octokit.graphql(retrieveRepoQuery, { + repoOwner, + repoName, + }); + + const repoId = retrieveRepoResult?.repository?.id; + + return repoId; +} diff --git a/.github/scripts/shared/template.ts b/.github/scripts/shared/template.ts new file mode 100644 index 00000000000..e65528abc1c --- /dev/null +++ b/.github/scripts/shared/template.ts @@ -0,0 +1,70 @@ +interface Template { + titles: string[]; +} + +// An enum for different templates and issue/PR can match +export enum TemplateType { + GeneralIssue, + BugReportIssue, + PullRequest, + None, +} + +// Titles of general issue template +const generalIssueTemplateTitles = [ + '### What is this about?', + '### Scenario', + '### Design', + '### Technical Details', + '### Threat Modeling Framework', + '### Acceptance Criteria', + '### References', +]; + +// Titles of bug report template +const bugReportIssueTemplateTitles = [ + '### Describe the bug', + '### Expected behavior', + '### Screenshots', // TODO: replace '### Screenshots' by '### Screenshots/Recordings' in January 2024 (as most issues will meet this criteria by then) + '### Steps to reproduce', + '### Error messages or log output', + '### Version', + '### Build type', + '### Device', + '### Operating system', + '### Additional context', + '### Severity', +]; + +// Titles of PR template +const prTemplateTitles = [ + '## **Description**', + '## **Related issues**', + '## **Manual testing steps**', + '## **Screenshots/Recordings**', + '### **Before**', + '### **After**', + '## **Pre-merge author checklist**', + '## **Pre-merge reviewer checklist**', +]; + +export const templates = new Map([ + [ + TemplateType.GeneralIssue, + { + titles: generalIssueTemplateTitles, + }, + ], + [ + TemplateType.BugReportIssue, + { + titles: bugReportIssueTemplateTitles, + }, + ], + [ + TemplateType.PullRequest, + { + titles: prTemplateTitles, + }, + ], +]); diff --git a/.github/workflows/check-issue-template-and-add-labels.yml b/.github/workflows/check-template-and-add-labels.yml similarity index 53% rename from .github/workflows/check-issue-template-and-add-labels.yml rename to .github/workflows/check-template-and-add-labels.yml index 11baaeafbc6..e5311b70d22 100644 --- a/.github/workflows/check-issue-template-and-add-labels.yml +++ b/.github/workflows/check-template-and-add-labels.yml @@ -1,19 +1,19 @@ -name: Check issue template and add labels +name: Check template and add labels on: issues: - types: - - opened - - edited + types: [opened, edited] + pull_request_target: + types: [opened, edited] jobs: - add-regression-prod-label: + check-template-and-add-labels: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 with: - fetch-depth: 1 # This retrieves only the latest commit. + fetch-depth: 1 # This retrieves only the latest commit. - name: Set up Node.js uses: actions/setup-node@v3 @@ -24,8 +24,8 @@ jobs: - name: Install dependencies run: yarn --immutable - - name: Check issue template and add labels - id: check-issue-template-and-add-labels + - name: Check template and add labels + id: check-template-and-add-labels env: LABEL_TOKEN: ${{ secrets.LABEL_TOKEN }} - run: npm run check-issue-template-and-add-labels + run: npm run check-template-and-add-labels diff --git a/app/components/UI/BlockaidBanner/BlockaidBanner.styles.ts b/app/components/UI/BlockaidBanner/BlockaidBanner.styles.ts index 97ad0fd9d45..68838a48d05 100644 --- a/app/components/UI/BlockaidBanner/BlockaidBanner.styles.ts +++ b/app/components/UI/BlockaidBanner/BlockaidBanner.styles.ts @@ -30,6 +30,11 @@ const styleSheet = (_params: { }, details: { marginLeft: 10, marginBottom: 10 }, securityTickIcon: { marginTop: 4 }, + failed: { + marginTop: 10, + marginLeft: 10, + marginRight: 10, + }, }); export default styleSheet; diff --git a/app/components/UI/BlockaidBanner/BlockaidBanner.test.tsx b/app/components/UI/BlockaidBanner/BlockaidBanner.test.tsx index 02073a7f9c8..f470e7b100f 100644 --- a/app/components/UI/BlockaidBanner/BlockaidBanner.test.tsx +++ b/app/components/UI/BlockaidBanner/BlockaidBanner.test.tsx @@ -170,7 +170,7 @@ describe('BlockaidBanner', () => { , diff --git a/app/components/UI/BlockaidBanner/BlockaidBanner.tsx b/app/components/UI/BlockaidBanner/BlockaidBanner.tsx index ab8ea0c6177..30843f10939 100644 --- a/app/components/UI/BlockaidBanner/BlockaidBanner.tsx +++ b/app/components/UI/BlockaidBanner/BlockaidBanner.tsx @@ -72,11 +72,13 @@ const BlockaidBanner = (bannerProps: BlockaidBannerProps) => { if (result_type === ResultType.Failed) { return ( - + + + ); } diff --git a/app/components/UI/BlockaidBanner/__snapshots__/BlockaidBanner.test.tsx.snap b/app/components/UI/BlockaidBanner/__snapshots__/BlockaidBanner.test.tsx.snap index 54df67077b6..823982a5bfa 100644 --- a/app/components/UI/BlockaidBanner/__snapshots__/BlockaidBanner.test.tsx.snap +++ b/app/components/UI/BlockaidBanner/__snapshots__/BlockaidBanner.test.tsx.snap @@ -726,76 +726,88 @@ exports[`BlockaidBanner should render correctly with reason "raw_signature_farmi exports[`BlockaidBanner should render normal banner alert if resultType is failed 1`] = ` - - - - - This is a suspicious request - - + + - If you approve this request, you might lose your assets. - + + Request may not be safe + + + Because of an error, this request was not verified by the security provider. Proceed with caution. + + `; diff --git a/app/core/SDKConnect/Connection.ts b/app/core/SDKConnect/Connection.ts new file mode 100644 index 00000000000..bb5ad32b2f2 --- /dev/null +++ b/app/core/SDKConnect/Connection.ts @@ -0,0 +1,791 @@ +import { + TransactionController, + WalletDevice, +} from '@metamask/transaction-controller'; +import { Platform } from 'react-native'; +import Logger from '../../util/Logger'; +import AppConstants from '../AppConstants'; +import BackgroundBridge from '../BackgroundBridge/BackgroundBridge'; +import Engine from '../Engine'; +import getRpcMethodMiddleware, { + ApprovalTypes, +} from '../RPCMethods/RPCMethodMiddleware'; + +import { ApprovalController } from '@metamask/approval-controller'; +import { Json } from '@metamask/controller-utils'; +import { KeyringController } from '@metamask/keyring-controller'; +import { PreferencesController } from '@metamask/preferences-controller'; +import { + CommunicationLayerMessage, + CommunicationLayerPreference, + EventType, + MessageType, + OriginatorInfo, + RemoteCommunication, +} from '@metamask/sdk-communication-layer'; +import { ethErrors } from 'eth-rpc-errors'; +import { EventEmitter2 } from 'eventemitter2'; +import { PROTOCOLS } from '../../constants/deeplinks'; +import { Minimizer } from '../NativeModules'; +import RPCQueueManager from './RPCQueueManager'; +import { + ApprovedHosts, + CONNECTION_LOADING_EVENT, + HOUR_IN_MS, + METHODS_TO_DELAY, + METHODS_TO_REDIRECT, + approveHostProps, +} from './SDKConnect'; +import DevLogger from './utils/DevLogger'; +import generateOTP from './utils/generateOTP.util'; +import { + wait, + waitForConnectionReadiness, + waitForEmptyRPCQueue, + waitForKeychainUnlocked, +} from './utils/wait.util'; + +export interface ConnectionProps { + id: string; + otherPublicKey: string; + origin: string; + reconnect?: boolean; + initialConnection?: boolean; + originatorInfo?: OriginatorInfo; + validUntil: number; + lastAuthorized?: number; // timestamp of last received activity +} + +// eslint-disable-next-line +const { version } = require('../../../package.json'); + +export class Connection extends EventEmitter2 { + channelId; + remote: RemoteCommunication; + requestsToRedirect: { [request: string]: boolean } = {}; + origin: string; + host: string; + originatorInfo?: OriginatorInfo; + isReady = false; + backgroundBridge?: BackgroundBridge; + reconnect: boolean; + /** + * Sometime the dapp disconnect and reconnect automatically through socket.io which doesnt inform the wallet of the reconnection. + * We keep track of the disconnect event to avoid waiting for ready after a message. + */ + receivedDisconnect = false; + /** + * isResumed is used to manage the loading state. + */ + isResumed = false; + initialConnection: boolean; + + /* + * Timestamp of last activity, used to check if channel is still active and to prevent showing OTP approval modal too often. + */ + lastAuthorized?: number; + + /** + * Prevent double sending 'authorized' message. + */ + authorizedSent = false; + + /** + * Array of random number to use during reconnection and otp verification. + */ + otps?: number[]; + + /** + * Should only be accesses via getter / setter. + */ + private _loading = false; + private approvalPromise?: Promise; + + private rpcQueueManager: RPCQueueManager; + + approveHost: ({ host, hostname }: approveHostProps) => void; + getApprovedHosts: (context: string) => ApprovedHosts; + disapprove: (channelId: string) => void; + revalidate: ({ channelId }: { channelId: string }) => void; + isApproved: ({ + channelId, + }: { + channelId: string; + context?: string; + }) => boolean; + onTerminate: ({ channelId }: { channelId: string }) => void; + + constructor({ + id, + otherPublicKey, + origin, + reconnect, + initialConnection, + rpcQueueManager, + originatorInfo, + approveHost, + lastAuthorized, + getApprovedHosts, + disapprove, + revalidate, + isApproved, + updateOriginatorInfos, + onTerminate, + }: ConnectionProps & { + rpcQueueManager: RPCQueueManager; + approveHost: ({ host, hostname }: approveHostProps) => void; + getApprovedHosts: (context: string) => ApprovedHosts; + disapprove: (channelId: string) => void; + revalidate: ({ channelId }: { channelId: string }) => void; + isApproved: ({ channelId }: { channelId: string }) => boolean; + onTerminate: ({ channelId }: { channelId: string }) => void; + updateOriginatorInfos: (params: { + channelId: string; + originatorInfo: OriginatorInfo; + }) => void; + }) { + super(); + this.origin = origin; + this.channelId = id; + this.lastAuthorized = lastAuthorized; + this.reconnect = reconnect || false; + this.isResumed = false; + this.originatorInfo = originatorInfo; + this.initialConnection = initialConnection === true; + this.host = `${AppConstants.MM_SDK.SDK_REMOTE_ORIGIN}${this.channelId}`; + this.rpcQueueManager = rpcQueueManager; + this.approveHost = approveHost; + this.getApprovedHosts = getApprovedHosts; + this.disapprove = disapprove; + this.revalidate = revalidate; + this.isApproved = isApproved; + this.onTerminate = onTerminate; + + this.setLoading(true); + + DevLogger.log( + `Connection::constructor() id=${this.channelId} initialConnection=${this.initialConnection} lastAuthorized=${this.lastAuthorized}`, + ); + + this.remote = new RemoteCommunication({ + platformType: AppConstants.MM_SDK.PLATFORM as 'metamask-mobile', + communicationServerUrl: AppConstants.MM_SDK.SERVER_URL, + communicationLayerPreference: CommunicationLayerPreference.SOCKET, + otherPublicKey, + reconnect, + walletInfo: { + type: 'MetaMask Mobile', + version, + }, + context: AppConstants.MM_SDK.PLATFORM, + analytics: true, + logging: { + eciesLayer: false, + keyExchangeLayer: false, + remoteLayer: false, + serviceLayer: false, + // plaintext: true doesn't do anything unless using custom socket server. + plaintext: true, + }, + storage: { + enabled: false, + }, + }); + + this.requestsToRedirect = {}; + + this.sendMessage = this.sendMessage.bind(this); + + this.remote.on(EventType.CLIENTS_CONNECTED, () => { + this.setLoading(true); + this.receivedDisconnect = false; + // Auto hide after 3seconds if 'ready' wasn't received + setTimeout(() => { + this.setLoading(false); + }, 3000); + }); + + this.remote.on(EventType.CLIENTS_DISCONNECTED, () => { + this.setLoading(false); + // Disapprove a given host everytime there is a disconnection to prevent hijacking. + if (!this.remote.isPaused()) { + // don't disapprove on deeplink + if (this.origin !== AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) { + disapprove(this.channelId); + } + this.initialConnection = false; + this.otps = undefined; + } + this.receivedDisconnect = true; + this.isReady = false; + }); + + this.remote.on( + EventType.CLIENTS_READY, + async (clientsReadyMsg: { originatorInfo: OriginatorInfo }) => { + const approvalController = ( + Engine.context as { ApprovalController: ApprovalController } + ).ApprovalController; + + // clients_ready may be sent multple time (from sdk <0.2.0). + const updatedOriginatorInfo = clientsReadyMsg?.originatorInfo; + const apiVersion = updatedOriginatorInfo?.apiVersion; + + // backward compatibility with older sdk -- always first request approval + if (!apiVersion) { + // clear previous pending approval + if (approvalController.get(this.channelId)) { + approvalController.reject( + this.channelId, + ethErrors.provider.userRejectedRequest(), + ); + } + + this.approvalPromise = undefined; + } + + if (!updatedOriginatorInfo) { + return; + } + + this.originatorInfo = updatedOriginatorInfo; + updateOriginatorInfos({ + channelId: this.channelId, + originatorInfo: updatedOriginatorInfo, + }); + + if (this.isReady) { + return; + } + + // TODO following logic blocks should be simplified (too many conditions) + // Should be done in a separate PR to avoid breaking changes and separate SDKConnect / Connection logic in different files. + if ( + this.initialConnection && + this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE + ) { + // Ask for authorisation? + // Always need to re-approve connection first. + await this.checkPermissions({ + lastAuthorized: this.lastAuthorized, + }); + + this.sendAuthorized(true); + } else if ( + !this.initialConnection && + this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE + ) { + const currentTime = Date.now(); + + const OTPExpirationDuration = + Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; + + const channelWasActiveRecently = + !!this.lastAuthorized && + currentTime - this.lastAuthorized < OTPExpirationDuration; + + if (channelWasActiveRecently) { + this.approvalPromise = undefined; + + // Prevent auto approval if metamask is killed and restarted + disapprove(this.channelId); + + // Always need to re-approve connection first. + await this.checkPermissions({ + lastAuthorized: this.lastAuthorized, + }); + + this.sendAuthorized(true); + } else { + if (approvalController.get(this.channelId)) { + // cleaning previous pending approval + approvalController.reject( + this.channelId, + ethErrors.provider.userRejectedRequest(), + ); + } + this.approvalPromise = undefined; + + if (!this.otps) { + this.otps = generateOTP(); + } + this.sendMessage({ + type: MessageType.OTP, + otpAnswer: this.otps?.[0], + }).catch((err) => { + Logger.log(err, `SDKConnect:: Connection failed to send otp`); + }); + // Prevent auto approval if metamask is killed and restarted + disapprove(this.channelId); + + // Always need to re-approve connection first. + await this.checkPermissions(); + this.sendAuthorized(true); + this.lastAuthorized = Date.now(); + } + } else if ( + !this.initialConnection && + this.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK + ) { + // Deeplink channels are automatically approved on re-connection. + const hostname = + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN + this.channelId; + approveHost({ + host: hostname, + hostname, + context: 'clients_ready', + }); + this.remote + .sendMessage({ type: 'authorized' as MessageType }) + .catch((err) => { + Logger.log(err, `Connection failed to send 'authorized`); + }); + } else if ( + this.initialConnection && + this.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK + ) { + // Should ask for confirmation to reconnect? + await this.checkPermissions(); + this.sendAuthorized(true); + } + + this.setupBridge(updatedOriginatorInfo); + this.isReady = true; + }, + ); + + this.remote.on( + EventType.MESSAGE, + async (message: CommunicationLayerMessage) => { + // TODO should probably handle this in a separate EventType.TERMINATE event. + // handle termination message + if (message.type === MessageType.TERMINATE) { + // Delete connection from storage + this.onTerminate({ channelId: this.channelId }); + return; + } + + // ignore anything other than RPC methods + if (!message.method || !message.id) { + return; + } + + let needsRedirect = METHODS_TO_REDIRECT[message?.method] ?? false; + + if (needsRedirect) { + this.requestsToRedirect[message?.id] = true; + } + + // Keep this section only for backward compatibility otherwise metamask doesn't redirect properly. + if ( + !this.originatorInfo?.apiVersion && + !needsRedirect && + // this.originatorInfo?.platform !== 'unity' && + message?.method === 'metamask_getProviderState' + ) { + // Manually force redirect if apiVersion isn't defined for backward compatibility + needsRedirect = true; + this.requestsToRedirect[message?.id] = true; + } + + // Wait for keychain to be unlocked before handling rpc calls. + const keyringController = ( + Engine.context as { KeyringController: KeyringController } + ).KeyringController; + await waitForKeychainUnlocked({ keyringController }); + + this.setLoading(false); + + // Wait for bridge to be ready before handling messages. + // It will wait until user accept/reject the connection request. + try { + await this.checkPermissions({ message }); + if (!this.receivedDisconnect) { + await waitForConnectionReadiness({ connection: this }); + this.sendAuthorized(); + } else { + // Reset state to continue communication after reconnection. + this.isReady = true; + this.receivedDisconnect = false; + } + } catch (error) { + // Approval failed - redirect to app with error. + this.sendMessage({ + data: { + error, + id: message.id, + jsonrpc: '2.0', + }, + name: 'metamask-provider', + }).catch(() => { + Logger.log(error, `Connection approval failed`); + }); + this.approvalPromise = undefined; + return; + } + + // Special case for metamask_connectSign + if (message.method === 'metamask_connectSign') { + // Replace with personal_sign + message.method = 'personal_sign'; + if ( + !( + message.params && + Array.isArray(message?.params) && + message.params.length > 0 + ) + ) { + throw new Error('Invalid message format'); + } + // Append selected address to params + const preferencesController = ( + Engine.context as { + PreferencesController: PreferencesController; + } + ).PreferencesController; + const selectedAddress = preferencesController.state.selectedAddress; + message.params = [(message.params as string[])[0], selectedAddress]; + if (Platform.OS === 'ios') { + // TODO: why does ios (older devices) requires a delay after request is initially approved? + await wait(500); + } + Logger.log(`metamask_connectSign`, message.params); + } + + this.rpcQueueManager.add({ + id: (message.id as string) ?? 'unknown', + method: message.method, + }); + + // We have to implement this method here since the eth_sendTransaction in Engine is not working because we can't send correct origin + if (message.method === 'eth_sendTransaction') { + if ( + !( + message.params && + Array.isArray(message?.params) && + message.params.length > 0 + ) + ) { + throw new Error('Invalid message format'); + } + + const transactionController = ( + Engine.context as { TransactionController: TransactionController } + ).TransactionController; + try { + const hash = await ( + await transactionController.addTransaction(message.params[0], { + deviceConfirmedOn: WalletDevice.MM_MOBILE, + origin: this.originatorInfo?.url + ? AppConstants.MM_SDK.SDK_REMOTE_ORIGIN + + this.originatorInfo?.url + : undefined, + }) + ).result; + await this.sendMessage({ + data: { + id: message.id, + jsonrpc: '2.0', + result: hash, + }, + name: 'metamask-provider', + }); + } catch (error) { + this.sendMessage({ + data: { + error, + id: message.id, + jsonrpc: '2.0', + }, + name: 'metamask-provider', + }).catch((err) => { + Logger.log(err, `Connection failed to send otp`); + }); + } + return; + } + + this.backgroundBridge?.onMessage({ + name: 'metamask-provider', + data: message, + origin: 'sdk', + }); + }, + ); + } + + public connect({ withKeyExchange }: { withKeyExchange: boolean }) { + DevLogger.log( + `Connection::connect() withKeyExchange=${withKeyExchange} id=${this.channelId}`, + ); + this.remote.connectToChannel(this.channelId, withKeyExchange); + this.receivedDisconnect = false; + this.setLoading(true); + } + + sendAuthorized(force?: boolean) { + if (this.authorizedSent && force !== true) { + // Prevent double sending authorized event. + return; + } + + this.remote + .sendMessage({ type: 'authorized' as MessageType }) + .then(() => { + this.authorizedSent = true; + }) + .catch((err) => { + Logger.log(err, `sendAuthorized() failed to send 'authorized'`); + }); + } + + setLoading(loading: boolean) { + this._loading = loading; + this.emit(CONNECTION_LOADING_EVENT, { loading }); + } + + getLoading() { + return this._loading; + } + + private setupBridge(originatorInfo: OriginatorInfo) { + if (this.backgroundBridge) { + return; + } + + this.backgroundBridge = new BackgroundBridge({ + webview: null, + isMMSDK: true, + // TODO: need to rewrite backgroundBridge to directly provide the origin instead of url format. + url: PROTOCOLS.METAMASK + '://' + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN, + isRemoteConn: true, + sendMessage: this.sendMessage, + getApprovedHosts: () => this.getApprovedHosts('backgroundBridge'), + remoteConnHost: this.host, + getRpcMethodMiddleware: ({ + getProviderState, + }: { + hostname: string; + getProviderState: any; + }) => + getRpcMethodMiddleware({ + hostname: this.host, + getProviderState, + isMMSDK: true, + navigation: null, //props.navigation, + getApprovedHosts: () => this.getApprovedHosts('rpcMethodMiddleWare'), + setApprovedHosts: (hostname: string) => { + this.approveHost({ + host: hostname, + hostname, + context: 'setApprovedHosts', + }); + }, + approveHost: (approveHostname) => + this.approveHost({ + host: this.host, + hostname: approveHostname, + context: 'rpcMethodMiddleWare', + }), + // Website info + url: { + current: originatorInfo?.url, + }, + title: { + current: originatorInfo?.title, + }, + icon: { current: undefined }, + // Bookmarks + isHomepage: () => false, + // Show autocomplete + fromHomepage: { current: false }, + // Wizard + wizardScrollAdjusted: { current: false }, + tabId: '', + isWalletConnect: false, + analytics: { + isRemoteConn: true, + platform: + originatorInfo?.platform ?? AppConstants.MM_SDK.UNKNOWN_PARAM, + }, + toggleUrlModal: () => null, + injectHomePageScripts: () => null, + }), + isMainFrame: true, + isWalletConnect: false, + wcRequestActions: undefined, + }); + } + + /** + * Check if current channel has been allowed. + * + * @param message + * @returns {boolean} true when host is approved or user approved the request. + * @throws error if the user reject approval request. + */ + private async checkPermissions({ + // eslint-disable-next-line + message, + lastAuthorized, + }: { + message?: CommunicationLayerMessage; + lastAuthorized?: number; + } = {}): Promise { + const OTPExpirationDuration = + Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; + + const channelWasActiveRecently = + !!lastAuthorized && Date.now() - lastAuthorized < OTPExpirationDuration; + + DevLogger.log( + `SDKConnect checkPermissions initialConnection=${this.initialConnection} lastAuthorized=${lastAuthorized} OTPExpirationDuration ${OTPExpirationDuration} channelWasActiveRecently ${channelWasActiveRecently}`, + ); + // only ask approval if needed + const approved = this.isApproved({ + channelId: this.channelId, + context: 'checkPermission', + }); + + const preferencesController = ( + Engine.context as { PreferencesController: PreferencesController } + ).PreferencesController; + const selectedAddress = preferencesController.state.selectedAddress; + + if (approved && selectedAddress) { + return true; + } + + const approvalController = ( + Engine.context as { ApprovalController: ApprovalController } + ).ApprovalController; + + if (this.approvalPromise) { + // Wait for result and clean the promise afterwards. + await this.approvalPromise; + this.approvalPromise = undefined; + return true; + } + + if (!this.initialConnection && AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) { + this.revalidate({ channelId: this.channelId }); + } + + if (channelWasActiveRecently) { + return true; + } + + const approvalRequest = { + origin: this.origin, + type: ApprovalTypes.CONNECT_ACCOUNTS, + requestData: { + hostname: this.originatorInfo?.title ?? '', + pageMeta: { + channelId: this.channelId, + reconnect: !this.initialConnection, + origin: this.origin, + url: this.originatorInfo?.url ?? '', + title: this.originatorInfo?.title ?? '', + icon: this.originatorInfo?.icon ?? '', + otps: this.otps ?? [], + apiVersion: this.originatorInfo?.apiVersion, + analytics: { + request_source: AppConstants.REQUEST_SOURCES.SDK_REMOTE_CONN, + request_platform: + this.originatorInfo?.platform ?? + AppConstants.MM_SDK.UNKNOWN_PARAM, + }, + } as Json, + }, + id: this.channelId, + }; + this.approvalPromise = approvalController.add(approvalRequest); + + await this.approvalPromise; + // Clear previous permissions if already approved. + this.revalidate({ channelId: this.channelId }); + this.approvalPromise = undefined; + return true; + } + + pause() { + this.remote.pause(); + } + + resume() { + this.remote.resume(); + this.isResumed = true; + this.setLoading(false); + } + + disconnect({ terminate, context }: { terminate: boolean; context?: string }) { + DevLogger.log( + `Connection::disconnect() context=${context} id=${this.channelId} terminate=${terminate}`, + ); + if (terminate) { + this.remote + .sendMessage({ + type: MessageType.TERMINATE, + }) + .catch((err) => { + Logger.log(err, `Connection failed to send terminate`); + }); + } + this.remote.disconnect(); + } + + removeConnection({ + terminate, + context, + }: { + terminate: boolean; + context?: string; + }) { + this.isReady = false; + this.lastAuthorized = 0; + this.authorizedSent = false; + DevLogger.log( + `Connection::removeConnection() context=${context} id=${this.channelId}`, + ); + this.disapprove(this.channelId); + this.disconnect({ terminate, context: 'Connection::removeConnection' }); + this.backgroundBridge?.onDisconnect(); + this.setLoading(false); + } + + async sendMessage(msg: any) { + const needsRedirect = this.requestsToRedirect[msg?.data?.id] !== undefined; + const method = this.rpcQueueManager.getId(msg?.data?.id); + + if (msg?.data?.id && method) { + this.rpcQueueManager.remove(msg?.data?.id); + } + + this.remote.sendMessage(msg).catch((err) => { + Logger.log(err, `Connection::sendMessage failed to send`); + }); + + if (!needsRedirect) { + return; + } + + delete this.requestsToRedirect[msg?.data?.id]; + + if (this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE) return; + + try { + await waitForEmptyRPCQueue(this.rpcQueueManager); + if (METHODS_TO_DELAY[method]) { + await wait(1000); + } + this.setLoading(false); + + Minimizer.goBack(); + } catch (err) { + Logger.log( + err, + `Connection::sendMessage error while waiting for empty rpc queue`, + ); + } + } +} diff --git a/app/core/SDKConnect/SDKConnect.ts b/app/core/SDKConnect/SDKConnect.ts index 03a2ed8121b..dde2490441c 100644 --- a/app/core/SDKConnect/SDKConnect.ts +++ b/app/core/SDKConnect/SDKConnect.ts @@ -1,69 +1,36 @@ import { StackNavigationProp } from '@react-navigation/stack'; -import BackgroundTimer from 'react-native-background-timer'; -import DefaultPreference from 'react-native-default-preference'; -import AppConstants from '../AppConstants'; -import { - TransactionController, - WalletDevice, -} from '@metamask/transaction-controller'; import { AppState, NativeEventSubscription, NativeModules, Platform, } from 'react-native'; +import BackgroundTimer from 'react-native-background-timer'; +import DefaultPreference from 'react-native-default-preference'; import Logger from '../../util/Logger'; import Device from '../../util/device'; -import BackgroundBridge from '../BackgroundBridge/BackgroundBridge'; +import AppConstants from '../AppConstants'; import Engine from '../Engine'; -import getRpcMethodMiddleware, { - ApprovalTypes, -} from '../RPCMethods/RPCMethodMiddleware'; -import { ApprovalController } from '@metamask/approval-controller'; import { KeyringController } from '@metamask/keyring-controller'; -import { PreferencesController } from '@metamask/preferences-controller'; import { - CommunicationLayerMessage, - CommunicationLayerPreference, ConnectionStatus, EventType, - MessageType, OriginatorInfo, - RemoteCommunication, } from '@metamask/sdk-communication-layer'; -import { ethErrors } from 'eth-rpc-errors'; import { EventEmitter2 } from 'eventemitter2'; import Routes from '../../../app/constants/navigation/Routes'; -import generateOTP from './utils/generateOTP.util'; -import { - wait, - waitForConnectionReadiness, - waitForEmptyRPCQueue, - waitForKeychainUnlocked, -} from './utils/wait.util'; -import { Json } from '@metamask/controller-utils'; -import { PROTOCOLS } from '../../constants/deeplinks'; -import { Minimizer } from '../NativeModules'; import AndroidService from './AndroidSDK/AndroidService'; import RPCQueueManager from './RPCQueueManager'; import DevLogger from './utils/DevLogger'; +import { wait, waitForKeychainUnlocked } from './utils/wait.util'; +import { Connection, ConnectionProps } from './Connection'; export const MIN_IN_MS = 1000 * 60; export const HOUR_IN_MS = MIN_IN_MS * 60; export const DAY_IN_MS = HOUR_IN_MS * 24; export const DEFAULT_SESSION_TIMEOUT_MS = 30 * DAY_IN_MS; -export interface ConnectionProps { - id: string; - otherPublicKey: string; - origin: string; - reconnect?: boolean; - initialConnection?: boolean; - originatorInfo?: OriginatorInfo; - validUntil: number; - lastAuthorized?: number; // timestamp of last received activity -} export interface ConnectedSessions { [id: string]: Connection; } @@ -86,7 +53,7 @@ export const TIMEOUT_PAUSE_CONNECTIONS = 20000; export type SDKEventListener = (event: string) => void; -const CONNECTION_LOADING_EVENT = 'loading'; +export const CONNECTION_LOADING_EVENT = 'loading'; export const METHODS_TO_REDIRECT: { [method: string]: boolean } = { eth_requestAccounts: true, @@ -108,740 +75,6 @@ export const METHODS_TO_DELAY: { [method: string]: boolean } = { eth_requestAccounts: false, }; -// eslint-disable-next-line -const { version } = require('../../../package.json'); - -export class Connection extends EventEmitter2 { - channelId; - remote: RemoteCommunication; - requestsToRedirect: { [request: string]: boolean } = {}; - origin: string; - host: string; - originatorInfo?: OriginatorInfo; - isReady = false; - backgroundBridge?: BackgroundBridge; - reconnect: boolean; - /** - * Sometime the dapp disconnect and reconnect automatically through socket.io which doesnt inform the wallet of the reconnection. - * We keep track of the disconnect event to avoid waiting for ready after a message. - */ - receivedDisconnect = false; - /** - * isResumed is used to manage the loading state. - */ - isResumed = false; - initialConnection: boolean; - - /* - * Timestamp of last activity, used to check if channel is still active and to prevent showing OTP approval modal too often. - */ - lastAuthorized?: number; - - /** - * Prevent double sending 'authorized' message. - */ - authorizedSent = false; - - /** - * Array of random number to use during reconnection and otp verification. - */ - otps?: number[]; - - /** - * Should only be accesses via getter / setter. - */ - private _loading = false; - private approvalPromise?: Promise; - - private rpcQueueManager: RPCQueueManager; - - approveHost: ({ host, hostname }: approveHostProps) => void; - getApprovedHosts: (context: string) => ApprovedHosts; - disapprove: (channelId: string) => void; - revalidate: ({ channelId }: { channelId: string }) => void; - isApproved: ({ - channelId, - }: { - channelId: string; - context?: string; - }) => boolean; - onTerminate: ({ channelId }: { channelId: string }) => void; - - constructor({ - id, - otherPublicKey, - origin, - reconnect, - initialConnection, - rpcQueueManager, - originatorInfo, - approveHost, - lastAuthorized, - getApprovedHosts, - disapprove, - revalidate, - isApproved, - updateOriginatorInfos, - onTerminate, - }: ConnectionProps & { - rpcQueueManager: RPCQueueManager; - approveHost: ({ host, hostname }: approveHostProps) => void; - getApprovedHosts: (context: string) => ApprovedHosts; - disapprove: (channelId: string) => void; - revalidate: ({ channelId }: { channelId: string }) => void; - isApproved: ({ channelId }: { channelId: string }) => boolean; - onTerminate: ({ channelId }: { channelId: string }) => void; - updateOriginatorInfos: (params: { - channelId: string; - originatorInfo: OriginatorInfo; - }) => void; - }) { - super(); - this.origin = origin; - this.channelId = id; - this.lastAuthorized = lastAuthorized; - this.reconnect = reconnect || false; - this.isResumed = false; - this.originatorInfo = originatorInfo; - this.initialConnection = initialConnection === true; - this.host = `${AppConstants.MM_SDK.SDK_REMOTE_ORIGIN}${this.channelId}`; - this.rpcQueueManager = rpcQueueManager; - this.approveHost = approveHost; - this.getApprovedHosts = getApprovedHosts; - this.disapprove = disapprove; - this.revalidate = revalidate; - this.isApproved = isApproved; - this.onTerminate = onTerminate; - - this.setLoading(true); - - DevLogger.log( - `Connection::constructor() id=${this.channelId} initialConnection=${this.initialConnection} lastAuthorized=${this.lastAuthorized}`, - ); - - this.remote = new RemoteCommunication({ - platformType: AppConstants.MM_SDK.PLATFORM as 'metamask-mobile', - communicationServerUrl: AppConstants.MM_SDK.SERVER_URL, - communicationLayerPreference: CommunicationLayerPreference.SOCKET, - otherPublicKey, - reconnect, - walletInfo: { - type: 'MetaMask Mobile', - version, - }, - context: AppConstants.MM_SDK.PLATFORM, - analytics: true, - logging: { - eciesLayer: false, - keyExchangeLayer: false, - remoteLayer: false, - serviceLayer: false, - // plaintext: true doesn't do anything unless using custom socket server. - plaintext: true, - }, - storage: { - enabled: false, - }, - }); - - this.requestsToRedirect = {}; - - this.sendMessage = this.sendMessage.bind(this); - - this.remote.on(EventType.CLIENTS_CONNECTED, () => { - this.setLoading(true); - this.receivedDisconnect = false; - // Auto hide after 3seconds if 'ready' wasn't received - setTimeout(() => { - this.setLoading(false); - }, 3000); - }); - - this.remote.on(EventType.CLIENTS_DISCONNECTED, () => { - this.setLoading(false); - // Disapprove a given host everytime there is a disconnection to prevent hijacking. - if (!this.remote.isPaused()) { - // don't disapprove on deeplink - if (this.origin !== AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) { - disapprove(this.channelId); - } - this.initialConnection = false; - this.otps = undefined; - } - this.receivedDisconnect = true; - this.isReady = false; - }); - - this.remote.on( - EventType.CLIENTS_READY, - async (clientsReadyMsg: { originatorInfo: OriginatorInfo }) => { - const approvalController = ( - Engine.context as { ApprovalController: ApprovalController } - ).ApprovalController; - - // clients_ready may be sent multple time (from sdk <0.2.0). - const updatedOriginatorInfo = clientsReadyMsg?.originatorInfo; - const apiVersion = updatedOriginatorInfo?.apiVersion; - - // backward compatibility with older sdk -- always first request approval - if (!apiVersion) { - // clear previous pending approval - if (approvalController.get(this.channelId)) { - approvalController.reject( - this.channelId, - ethErrors.provider.userRejectedRequest(), - ); - } - - this.approvalPromise = undefined; - } - - if (!updatedOriginatorInfo) { - return; - } - - this.originatorInfo = updatedOriginatorInfo; - updateOriginatorInfos({ - channelId: this.channelId, - originatorInfo: updatedOriginatorInfo, - }); - - if (this.isReady) { - return; - } - - // TODO following logic blocks should be simplified (too many conditions) - // Should be done in a separate PR to avoid breaking changes and separate SDKConnect / Connection logic in different files. - if ( - this.initialConnection && - this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE - ) { - // Ask for authorisation? - // Always need to re-approve connection first. - await this.checkPermissions({ - lastAuthorized: this.lastAuthorized, - }); - - this.sendAuthorized(true); - } else if ( - !this.initialConnection && - this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE - ) { - const currentTime = Date.now(); - - const OTPExpirationDuration = - Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; - - const channelWasActiveRecently = - !!this.lastAuthorized && - currentTime - this.lastAuthorized < OTPExpirationDuration; - - if (channelWasActiveRecently) { - this.approvalPromise = undefined; - - // Prevent auto approval if metamask is killed and restarted - disapprove(this.channelId); - - // Always need to re-approve connection first. - await this.checkPermissions({ - lastAuthorized: this.lastAuthorized, - }); - - this.sendAuthorized(true); - } else { - if (approvalController.get(this.channelId)) { - // cleaning previous pending approval - approvalController.reject( - this.channelId, - ethErrors.provider.userRejectedRequest(), - ); - } - this.approvalPromise = undefined; - - if (!this.otps) { - this.otps = generateOTP(); - } - this.sendMessage({ - type: MessageType.OTP, - otpAnswer: this.otps?.[0], - }).catch((err) => { - Logger.log(err, `SDKConnect:: Connection failed to send otp`); - }); - // Prevent auto approval if metamask is killed and restarted - disapprove(this.channelId); - - // Always need to re-approve connection first. - await this.checkPermissions(); - this.sendAuthorized(true); - this.lastAuthorized = Date.now(); - } - } else if ( - !this.initialConnection && - this.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK - ) { - // Deeplink channels are automatically approved on re-connection. - const hostname = - AppConstants.MM_SDK.SDK_REMOTE_ORIGIN + this.channelId; - approveHost({ - host: hostname, - hostname, - context: 'clients_ready', - }); - this.remote - .sendMessage({ type: 'authorized' as MessageType }) - .catch((err) => { - Logger.log(err, `Connection failed to send 'authorized`); - }); - } else if ( - this.initialConnection && - this.origin === AppConstants.DEEPLINKS.ORIGIN_DEEPLINK - ) { - // Should ask for confirmation to reconnect? - await this.checkPermissions(); - this.sendAuthorized(true); - } - - this.setupBridge(updatedOriginatorInfo); - this.isReady = true; - }, - ); - - this.remote.on( - EventType.MESSAGE, - async (message: CommunicationLayerMessage) => { - // TODO should probably handle this in a separate EventType.TERMINATE event. - // handle termination message - if (message.type === MessageType.TERMINATE) { - // Delete connection from storage - this.onTerminate({ channelId: this.channelId }); - return; - } - - // ignore anything other than RPC methods - if (!message.method || !message.id) { - return; - } - - let needsRedirect = METHODS_TO_REDIRECT[message?.method] ?? false; - - if (needsRedirect) { - this.requestsToRedirect[message?.id] = true; - } - - // Keep this section only for backward compatibility otherwise metamask doesn't redirect properly. - if ( - !this.originatorInfo?.apiVersion && - !needsRedirect && - // this.originatorInfo?.platform !== 'unity' && - message?.method === 'metamask_getProviderState' - ) { - // Manually force redirect if apiVersion isn't defined for backward compatibility - needsRedirect = true; - this.requestsToRedirect[message?.id] = true; - } - - // Wait for keychain to be unlocked before handling rpc calls. - const keyringController = ( - Engine.context as { KeyringController: KeyringController } - ).KeyringController; - await waitForKeychainUnlocked({ keyringController }); - - this.setLoading(false); - - // Wait for bridge to be ready before handling messages. - // It will wait until user accept/reject the connection request. - try { - await this.checkPermissions({ message }); - if (!this.receivedDisconnect) { - await waitForConnectionReadiness({ connection: this }); - this.sendAuthorized(); - } else { - // Reset state to continue communication after reconnection. - this.isReady = true; - this.receivedDisconnect = false; - } - } catch (error) { - // Approval failed - redirect to app with error. - this.sendMessage({ - data: { - error, - id: message.id, - jsonrpc: '2.0', - }, - name: 'metamask-provider', - }).catch(() => { - Logger.log(error, `Connection approval failed`); - }); - this.approvalPromise = undefined; - return; - } - - // Special case for metamask_connectSign - if (message.method === 'metamask_connectSign') { - // Replace with personal_sign - message.method = 'personal_sign'; - if ( - !( - message.params && - Array.isArray(message?.params) && - message.params.length > 0 - ) - ) { - throw new Error('Invalid message format'); - } - // Append selected address to params - const preferencesController = ( - Engine.context as { - PreferencesController: PreferencesController; - } - ).PreferencesController; - const selectedAddress = preferencesController.state.selectedAddress; - message.params = [(message.params as string[])[0], selectedAddress]; - if (Platform.OS === 'ios') { - // TODO: why does ios (older devices) requires a delay after request is initially approved? - await wait(500); - } - Logger.log(`metamask_connectSign`, message.params); - } - - this.rpcQueueManager.add({ - id: (message.id as string) ?? 'unknown', - method: message.method, - }); - - // We have to implement this method here since the eth_sendTransaction in Engine is not working because we can't send correct origin - if (message.method === 'eth_sendTransaction') { - if ( - !( - message.params && - Array.isArray(message?.params) && - message.params.length > 0 - ) - ) { - throw new Error('Invalid message format'); - } - - const transactionController = ( - Engine.context as { TransactionController: TransactionController } - ).TransactionController; - try { - const hash = await ( - await transactionController.addTransaction(message.params[0], { - deviceConfirmedOn: WalletDevice.MM_MOBILE, - origin: this.originatorInfo?.url - ? AppConstants.MM_SDK.SDK_REMOTE_ORIGIN + - this.originatorInfo?.url - : undefined, - }) - ).result; - await this.sendMessage({ - data: { - id: message.id, - jsonrpc: '2.0', - result: hash, - }, - name: 'metamask-provider', - }); - } catch (error) { - this.sendMessage({ - data: { - error, - id: message.id, - jsonrpc: '2.0', - }, - name: 'metamask-provider', - }).catch((err) => { - Logger.log(err, `Connection failed to send otp`); - }); - } - return; - } - - this.backgroundBridge?.onMessage({ - name: 'metamask-provider', - data: message, - origin: 'sdk', - }); - }, - ); - } - - public connect({ withKeyExchange }: { withKeyExchange: boolean }) { - DevLogger.log( - `Connection::connect() withKeyExchange=${withKeyExchange} id=${this.channelId}`, - ); - this.remote.connectToChannel(this.channelId, withKeyExchange); - this.receivedDisconnect = false; - this.setLoading(true); - } - - sendAuthorized(force?: boolean) { - if (this.authorizedSent && force !== true) { - // Prevent double sending authorized event. - return; - } - - this.remote - .sendMessage({ type: 'authorized' as MessageType }) - .then(() => { - this.authorizedSent = true; - }) - .catch((err) => { - Logger.log(err, `sendAuthorized() failed to send 'authorized'`); - }); - } - - setLoading(loading: boolean) { - this._loading = loading; - this.emit(CONNECTION_LOADING_EVENT, { loading }); - } - - getLoading() { - return this._loading; - } - - private setupBridge(originatorInfo: OriginatorInfo) { - if (this.backgroundBridge) { - return; - } - - this.backgroundBridge = new BackgroundBridge({ - webview: null, - isMMSDK: true, - // TODO: need to rewrite backgroundBridge to directly provide the origin instead of url format. - url: PROTOCOLS.METAMASK + '://' + AppConstants.MM_SDK.SDK_REMOTE_ORIGIN, - isRemoteConn: true, - sendMessage: this.sendMessage, - getApprovedHosts: () => this.getApprovedHosts('backgroundBridge'), - remoteConnHost: this.host, - getRpcMethodMiddleware: ({ - getProviderState, - }: { - hostname: string; - getProviderState: any; - }) => - getRpcMethodMiddleware({ - hostname: this.host, - getProviderState, - isMMSDK: true, - navigation: null, //props.navigation, - getApprovedHosts: () => this.getApprovedHosts('rpcMethodMiddleWare'), - setApprovedHosts: (hostname: string) => { - this.approveHost({ - host: hostname, - hostname, - context: 'setApprovedHosts', - }); - }, - approveHost: (approveHostname) => - this.approveHost({ - host: this.host, - hostname: approveHostname, - context: 'rpcMethodMiddleWare', - }), - // Website info - url: { - current: originatorInfo?.url, - }, - title: { - current: originatorInfo?.title, - }, - icon: { current: undefined }, - // Bookmarks - isHomepage: () => false, - // Show autocomplete - fromHomepage: { current: false }, - // Wizard - wizardScrollAdjusted: { current: false }, - tabId: '', - isWalletConnect: false, - analytics: { - isRemoteConn: true, - platform: - originatorInfo?.platform ?? AppConstants.MM_SDK.UNKNOWN_PARAM, - }, - toggleUrlModal: () => null, - injectHomePageScripts: () => null, - }), - isMainFrame: true, - isWalletConnect: false, - wcRequestActions: undefined, - }); - } - - /** - * Check if current channel has been allowed. - * - * @param message - * @returns {boolean} true when host is approved or user approved the request. - * @throws error if the user reject approval request. - */ - private async checkPermissions({ - // eslint-disable-next-line - message, - lastAuthorized, - }: { - message?: CommunicationLayerMessage; - lastAuthorized?: number; - } = {}): Promise { - const OTPExpirationDuration = - Number(process.env.OTP_EXPIRATION_DURATION_IN_MS) || HOUR_IN_MS; - - const channelWasActiveRecently = - !!lastAuthorized && Date.now() - lastAuthorized < OTPExpirationDuration; - - DevLogger.log( - `SDKConnect checkPermissions initialConnection=${this.initialConnection} lastAuthorized=${lastAuthorized} OTPExpirationDuration ${OTPExpirationDuration} channelWasActiveRecently ${channelWasActiveRecently}`, - ); - // only ask approval if needed - const approved = this.isApproved({ - channelId: this.channelId, - context: 'checkPermission', - }); - - const preferencesController = ( - Engine.context as { PreferencesController: PreferencesController } - ).PreferencesController; - const selectedAddress = preferencesController.state.selectedAddress; - - if (approved && selectedAddress) { - return true; - } - - const approvalController = ( - Engine.context as { ApprovalController: ApprovalController } - ).ApprovalController; - - if (this.approvalPromise) { - // Wait for result and clean the promise afterwards. - await this.approvalPromise; - this.approvalPromise = undefined; - return true; - } - - if (!this.initialConnection && AppConstants.DEEPLINKS.ORIGIN_DEEPLINK) { - this.revalidate({ channelId: this.channelId }); - } - - if (channelWasActiveRecently) { - return true; - } - - const approvalRequest = { - origin: this.origin, - type: ApprovalTypes.CONNECT_ACCOUNTS, - requestData: { - hostname: this.originatorInfo?.title ?? '', - pageMeta: { - channelId: this.channelId, - reconnect: !this.initialConnection, - origin: this.origin, - url: this.originatorInfo?.url ?? '', - title: this.originatorInfo?.title ?? '', - icon: this.originatorInfo?.icon ?? '', - otps: this.otps ?? [], - apiVersion: this.originatorInfo?.apiVersion, - analytics: { - request_source: AppConstants.REQUEST_SOURCES.SDK_REMOTE_CONN, - request_platform: - this.originatorInfo?.platform ?? - AppConstants.MM_SDK.UNKNOWN_PARAM, - }, - } as Json, - }, - id: this.channelId, - }; - this.approvalPromise = approvalController.add(approvalRequest); - - await this.approvalPromise; - // Clear previous permissions if already approved. - this.revalidate({ channelId: this.channelId }); - this.approvalPromise = undefined; - return true; - } - - pause() { - this.remote.pause(); - } - - resume() { - this.remote.resume(); - this.isResumed = true; - this.setLoading(false); - } - - disconnect({ terminate, context }: { terminate: boolean; context?: string }) { - DevLogger.log( - `Connection::disconnect() context=${context} id=${this.channelId} terminate=${terminate}`, - ); - if (terminate) { - this.remote - .sendMessage({ - type: MessageType.TERMINATE, - }) - .catch((err) => { - Logger.log(err, `Connection failed to send terminate`); - }); - } - this.remote.disconnect(); - } - - removeConnection({ - terminate, - context, - }: { - terminate: boolean; - context?: string; - }) { - this.isReady = false; - this.lastAuthorized = 0; - this.authorizedSent = false; - DevLogger.log( - `Connection::removeConnection() context=${context} id=${this.channelId}`, - ); - this.disapprove(this.channelId); - this.disconnect({ terminate, context: 'Connection::removeConnection' }); - this.backgroundBridge?.onDisconnect(); - this.setLoading(false); - } - - async sendMessage(msg: any) { - const needsRedirect = this.requestsToRedirect[msg?.data?.id] !== undefined; - const method = this.rpcQueueManager.getId(msg?.data?.id); - - if (msg?.data?.id && method) { - this.rpcQueueManager.remove(msg?.data?.id); - } - - this.remote.sendMessage(msg).catch((err) => { - Logger.log(err, `Connection::sendMessage failed to send`); - }); - - if (!needsRedirect) { - return; - } - - delete this.requestsToRedirect[msg?.data?.id]; - - if (this.origin === AppConstants.DEEPLINKS.ORIGIN_QR_CODE) return; - - try { - await waitForEmptyRPCQueue(this.rpcQueueManager); - if (METHODS_TO_DELAY[method]) { - await wait(1000); - } - this.setLoading(false); - - Minimizer.goBack(); - } catch (err) { - Logger.log( - err, - `Connection::sendMessage error while waiting for empty rpc queue`, - ); - } - } -} - export class SDKConnect extends EventEmitter2 { private static instance: SDKConnect; diff --git a/app/core/SDKConnect/utils/wait.util.ts b/app/core/SDKConnect/utils/wait.util.ts index b6335594aeb..b946aa420fe 100644 --- a/app/core/SDKConnect/utils/wait.util.ts +++ b/app/core/SDKConnect/utils/wait.util.ts @@ -1,8 +1,9 @@ import { KeyringController } from '@metamask/keyring-controller'; import { AndroidClient } from '../AndroidSDK/android-sdk-types'; import RPCQueueManager from '../RPCQueueManager'; -import { Connection, SDKConnect } from '../SDKConnect'; +import { SDKConnect } from '../SDKConnect'; import DevLogger from './DevLogger'; +import { Connection } from '../Connection'; export const MAX_QUEUE_LOOP = Infinity; export const wait = (ms: number) => diff --git a/app/lib/ppom/ppom-util.ts b/app/lib/ppom/ppom-util.ts index 3004173efc1..dad47ba07a3 100644 --- a/app/lib/ppom/ppom-util.ts +++ b/app/lib/ppom/ppom-util.ts @@ -1,6 +1,10 @@ import Logger from '../../util/Logger'; import Engine from '../../core/Engine'; import { isBlockaidFeatureEnabled } from '../../util/blockaid'; +import { + Reason, + ResultType, +} from '../../components/UI/BlockaidBanner/BlockaidBanner.types'; const ConfirmationMethods = Object.freeze([ 'eth_sendRawTransaction', @@ -30,6 +34,11 @@ const validateRequest = async (req: any) => { return result; } catch (e) { Logger.log(`Error validating JSON RPC using PPOM: ${e}`); + return { + result_type: ResultType.Failed, + reason: Reason.failed, + description: 'Validating the confirmation failed by throwing error.', + }; return; } }; diff --git a/package.json b/package.json index f1e2ba1cb8e..ee341643d5d 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "add-release-label-to-pr-and-linked-issues": "ts-node ./.github/scripts/add-release-label-to-pr-and-linked-issues.ts", "check-pr-has-required-labels": "ts-node ./.github/scripts/check-pr-has-required-labels.ts", "close-release-bug-report-issue": "ts-node ./.github/scripts/close-release-bug-report-issue.ts", - "check-issue-template-and-add-labels": "ts-node ./.github/scripts/check-issue-template-and-add-labels.ts", + "check-template-and-add-labels": "ts-node ./.github/scripts/check-template-and-add-labels.ts", "patch:tx": "./scripts/patch-transaction-controller.sh", "storybook-generate": "sb-rn-get-stories", "storybook-watch": "sb-rn-watcher" @@ -147,7 +147,8 @@ "detox/**/moment": "^2.29.4", "d3-color": "3.1.0", "**/fast-xml-parser": "4.2.4", - "tough-cookie": "4.1.3" + "tough-cookie": "4.1.3", + "crypto-js": "4.2.0" }, "dependencies": { "@consensys/on-ramp-sdk": "1.23.0", @@ -218,7 +219,7 @@ "buffer": "5.2.1", "compare-versions": "^3.6.0", "content-hash": "2.5.2", - "crypto-js": "^4.1.1", + "crypto-js": "^4.2.0", "d3-shape": "^3.2.0", "dnode": "1.2.2", "eciesjs": "^0.3.15", diff --git a/yarn.lock b/yarn.lock index 06d758735e7..f5019452700 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12556,15 +12556,10 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" -crypto-js@^3.1.4: - version "3.3.0" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.3.0.tgz#846dd1cce2f68aacfa156c8578f926a609b7976b" - integrity sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q== - -crypto-js@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf" - integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw== +crypto-js@4.2.0, crypto-js@^3.1.4, crypto-js@^4.1.1, crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== css-color-keywords@^1.0.0: version "1.0.0"