1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-24 11:01:41 +01:00

Merge branch 'develop' of github.com:MetaMask/metamask-extension into minimal

This commit is contained in:
Matthias Kretschmann 2023-08-25 17:39:52 +01:00
commit a2839ce7aa
Signed by: m
GPG Key ID: 606EEEF3C479A91F
376 changed files with 28140 additions and 8774 deletions

View File

@ -152,8 +152,14 @@ workflows:
- prep-build-test
- test-e2e-chrome-snaps:
requires:
- prep-build-test-flask
- prep-build-test
- test-e2e-firefox-snaps:
requires:
- prep-build-test
- test-e2e-chrome-snaps-flask:
requires:
- prep-build-test-flask
- test-e2e-firefox-snaps-flask:
requires:
- prep-build-test-flask
- test-e2e-chrome-mv3:
@ -215,6 +221,7 @@ workflows:
- prep-build-flask
- all-tests-pass:
requires:
- test-deps-depcheck
- validate-lavamoat-allow-scripts
- validate-lavamoat-policy-build
- validate-lavamoat-policy-webapp
@ -847,6 +854,80 @@ jobs:
path: test/test-results/e2e.xml
test-e2e-firefox-snaps:
executor: node-browsers
parallelism: 4
steps:
- run: *shallow-git-clone
- run:
name: Install Firefox
command: ./.circleci/scripts/firefox-install.sh
- attach_workspace:
at: .
- run:
name: Move test build to dist
command: mv ./dist-test ./dist
- run:
name: Move test zips to builds
command: mv ./builds-test ./builds
- run:
name: test:e2e:firefox:snaps
command: |
if .circleci/scripts/test-run-e2e.sh
then
yarn test:e2e:firefox:snaps --retries 2 --debug --build-type=main
fi
no_output_timeout: 20m
- run:
name: Merge JUnit report
command: |
if [ "$(ls -A test/test-results/e2e)" ]; then
yarn test:e2e:report
fi
when: always
- store_artifacts:
path: test-artifacts
destination: test-artifacts
- store_test_results:
path: test/test-results/e2e.xml
test-e2e-chrome-snaps:
executor: node-browsers
parallelism: 4
steps:
- run: *shallow-git-clone
- run:
name: Re-Install Chrome
command: ./.circleci/scripts/chrome-install.sh
- attach_workspace:
at: .
- run:
name: Move test build to dist
command: mv ./dist-test ./dist
- run:
name: Move test zips to builds
command: mv ./builds-test ./builds
- run:
name: test:e2e:chrome:snaps
command: |
if .circleci/scripts/test-run-e2e.sh
then
yarn test:e2e:chrome:snaps --retries 2 --debug --build-type=main
fi
no_output_timeout: 20m
- run:
name: Merge JUnit report
command: |
if [ "$(ls -A test/test-results/e2e)" ]; then
yarn test:e2e:report
fi
when: always
- store_artifacts:
path: test-artifacts
destination: test-artifacts
- store_test_results:
path: test/test-results/e2e.xml
test-e2e-firefox-snaps-flask:
executor: node-browsers
parallelism: 4
steps:
@ -883,7 +964,7 @@ jobs:
- store_test_results:
path: test/test-results/e2e.xml
test-e2e-chrome-snaps:
test-e2e-chrome-snaps-flask:
executor: node-browsers
parallelism: 4
steps:

View File

@ -239,6 +239,7 @@ module.exports = {
'app/scripts/controllers/app-state.test.js',
'app/scripts/controllers/mmi-controller.test.js',
'app/scripts/controllers/permissions/**/*.test.js',
'app/scripts/controllers/preferences.test.js',
'app/scripts/lib/**/*.test.js',
'app/scripts/migrations/*.test.js',
'app/scripts/platforms/*.test.js',
@ -268,6 +269,7 @@ module.exports = {
'app/scripts/controllers/app-state.test.js',
'app/scripts/controllers/mmi-controller.test.js',
'app/scripts/controllers/permissions/**/*.test.js',
'app/scripts/controllers/preferences.test.js',
'app/scripts/lib/**/*.test.js',
'app/scripts/migrations/*.test.js',
'app/scripts/platforms/*.test.js',

View File

@ -0,0 +1,120 @@
import * as core from '@actions/core';
import { context, getOctokit } from '@actions/github';
import { GitHub } from '@actions/github/lib/utils';
main().catch((error: Error): void => {
console.error(error);
process.exit(1);
});
async function main(): Promise<void> {
// "GITHUB_TOKEN" is an automatically generated, repository-specific access token provided by GitHub Actions.
// We can't use "GITHUB_TOKEN" here, as its permissions are scoped to the repository where the action is running.
// "GITHUB_TOKEN" does not have access to other repositories, even when they belong to the same organization.
// As we want to update bug report issues which are not located in the same repository,
// we need to create our own "BUG_REPORT_TOKEN" with "repo" permissions.
// Such a token allows to access other repositories of the MetaMask organisation.
const personalAccessToken = process.env.BUG_REPORT_TOKEN;
if (!personalAccessToken) {
core.setFailed('BUG_REPORT_TOKEN not found');
process.exit(1);
}
const repoOwner = context.repo.owner; // MetaMask
const bugReportRepo = process.env.BUG_REPORT_REPO;
if (!bugReportRepo) {
core.setFailed('BUG_REPORT_REPO not found');
process.exit(1);
}
// Extract branch name from the context
const branchName: string = context.payload.pull_request?.head.ref || "";
// Extract semver version number from the branch name
const releaseVersionNumberMatch = branchName.match(/^Version-v(\d+\.\d+\.\d+)$/);
if (!releaseVersionNumberMatch) {
core.setFailed(`Failed to extract version number from branch name: ${branchName}`);
process.exit(1);
}
const releaseVersionNumber = releaseVersionNumberMatch[1];
// Initialise octokit, required to call Github GraphQL API
const octokit: InstanceType<typeof GitHub> = getOctokit(personalAccessToken);
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`);
}
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}`);
await closeIssue(octokit, bugReportIssue.id);
console.log(`Issue with id: ${bugReportIssue.id} successfully closed`);
}
// This function retrieves the issue titled "vx.y.z Bug Report" on a specific repo
async function retrieveOpenBugReportIssue(octokit: InstanceType<typeof GitHub>, 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) {
nodes {
... on Issue {
id
title
}
}
}
}
`;
const retrieveOpenBugReportIssueQueryResult: {
search: {
nodes: {
id: string;
title: string;
}[];
};
} = await octokit.graphql(retrieveOpenBugReportIssueQuery);
const bugReportIssues = retrieveOpenBugReportIssueQueryResult?.search?.nodes;
return bugReportIssues?.length > 0 ? bugReportIssues[0] : undefined;
}
// This function closes a Github issue, based on its ID
async function closeIssue(octokit: InstanceType<typeof GitHub>, issueId: string): Promise<string> {
const closeIssueMutation = `
mutation CloseIssue($issueId: ID!) {
updateIssue(input: {id: $issueId, state: CLOSED}) {
clientMutationId
}
}
`;
const closeIssueMutationResult: {
updateIssue: {
clientMutationId: string;
};
} = await octokit.graphql(closeIssueMutation, {
issueId,
});
const clientMutationId = closeIssueMutationResult?.updateIssue?.clientMutationId;
return clientMutationId;
}

View File

@ -37,4 +37,4 @@ jobs:
env:
RELEASE_LABEL_TOKEN: ${{ secrets.RELEASE_LABEL_TOKEN }}
NEXT_SEMVER_VERSION: ${{ env.NEXT_SEMVER_VERSION }}
run: npm run add-release-label-to-pr-and-linked-issues
run: yarn run add-release-label-to-pr-and-linked-issues

View File

@ -35,4 +35,4 @@ jobs:
id: check-pr-has-required-labels
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run check-pr-has-required-labels
run: yarn run check-pr-has-required-labels

34
.github/workflows/close-bug-report.yml vendored Normal file
View File

@ -0,0 +1,34 @@
name: Close release bug report issue when release branch gets merged
on:
pull_request:
branches:
- master
types:
- closed
jobs:
close-bug-report:
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'Version-v')
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 1 # This retrieves only the latest commit.
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn --immutable
- name: Close release bug report issue
id: close-release-bug-report-issue
env:
BUG_REPORT_REPO: MetaMask-planning
BUG_REPORT_TOKEN: ${{ secrets.BUG_REPORT_TOKEN }}
run: yarn run close-release-bug-report-issue

35
.github/workflows/create-bug-report.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Create release bug report issue when release branch gets created
on: create
jobs:
create-bug-report:
runs-on: ubuntu-latest
steps:
- name: Extract version from branch name if release branch
id: extract_version
run: |
if [[ "$GITHUB_REF" == "refs/heads/Version-v"* ]]; then
version="${GITHUB_REF#refs/heads/Version-v}"
echo "New release branch($version), continue next steps"
echo "version=$version" >> "$GITHUB_ENV"
else
echo "Not a release branch, skip next steps"
fi
- name: Create bug report issue on planning repo
if: env.version
run: |
payload=$(cat <<EOF
{
"title": "v${{ env.version }} Bug Report",
"body": "This bug report was automatically created by a GitHub action upon the creation of release branch \`Version-v${{ env.version }}\` (release cut).\n\n**Expected actions for release engineers:**\n\n1. Convert this issue into a Zenhub epic and link all bugs identified during the release regression testing phase to this epic.\n\n2. After completing the first regression run, move this epic to \"Regression Completed\" on the [Extension Release Regression board](https://app.zenhub.com/workspaces/extension-release-regression-6478c62d937eaa15e95c33c5/board?filterLogic=any&labels=release-${{ env.version }},release-task).\n\nNote that once the release is prepared for store submission, meaning the \`Version-v${{ env.version }}\` branch merges into \`master\`, another GitHub action will automatically close this epic.",
"labels": ["type-bug", "regression-RC", "release-${{ env.version }}"]
}
EOF
)
curl -X POST \
-H "Authorization: token ${{ secrets.BUG_REPORT_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/MetaMask/MetaMask-planning/issues \
-d "$payload"

View File

@ -8,6 +8,7 @@ module.exports = {
'./app/scripts/controllers/app-state.test.js',
'./app/scripts/controllers/permissions/**/*.test.js',
'./app/scripts/controllers/mmi-controller.test.js',
'./app/scripts/controllers/preferences.test.js',
'./app/scripts/constants/error-utils.test.js',
'./development/fitness-functions/**/*.test.ts',
'./test/e2e/helpers.test.js',

View File

@ -2,6 +2,7 @@ import { draftTransactionInitialState } from '../ui/ducks/send';
import { KeyringType } from '../shared/constants/keyring';
import { NetworkType } from '@metamask/controller-utils';
import { NetworkStatus } from '@metamask/network-controller';
import { CHAIN_IDS } from '../shared/constants/network';
const state = {
invalidCustomNetwork: {
@ -529,6 +530,12 @@ const state = {
preferences: {
useNativeCurrencyAsPrimaryCurrency: true,
},
incomingTransactionsPreferences: {
[CHAIN_IDS.MAINNET]: true,
[CHAIN_IDS.GOERLI]: false,
[CHAIN_IDS.OPTIMISM_TESTNET]: false,
[CHAIN_IDS.AVALANCHE_TESTNET]: true,
},
firstTimeFlowType: 'create',
completedOnboarding: true,
knownMethodData: {

View File

@ -6,6 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [10.34.5]
### Changed
- Improve error diagnostic information
- Add additional logging for state migrations ([#20424](https://github.com/MetaMask/metamask-extension/pull/20424), [#20517](https://github.com/MetaMask/metamask-extension/pull/20517), [#20521](https://github.com/MetaMask/metamask-extension/pull/20521))
- Improve diagnostic state snapshot ([#20457](https://github.com/MetaMask/metamask-extension/pull/20457), [#20491](https://github.com/MetaMask/metamask-extension/pull/20491), [#20428](https://github.com/MetaMask/metamask-extension/pull/20428), [#20458](https://github.com/MetaMask/metamask-extension/pull/20458))
- Capture additional errors when state migrations fail ([#20427](https://github.com/MetaMask/metamask-extension/pull/20427))
### Fixed
- Fix bug where state was temporarily incomplete upon initial load ([#20468](https://github.com/MetaMask/metamask-extension/pull/20468))
- In rare circumstances, this bug may have resulted in data loss (of preferences, permissions, or tracked NFTs/tokens) or UI crashes.
## [10.34.4]
### Changed
- Updated snaps execution environment ([#20420](https://github.com/MetaMask/metamask-extension/pull/20420))
@ -3885,7 +3896,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Uncategorized
- Added the ability to restore accounts from seed words.
[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.34.4...HEAD
[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.34.5...HEAD
[10.34.5]: https://github.com/MetaMask/metamask-extension/compare/v10.34.4...v10.34.5
[10.34.4]: https://github.com/MetaMask/metamask-extension/compare/v10.34.3...v10.34.4
[10.34.3]: https://github.com/MetaMask/metamask-extension/compare/v10.34.2...v10.34.3
[10.34.2]: https://github.com/MetaMask/metamask-extension/compare/v10.34.1...v10.34.2

View File

@ -133,6 +133,8 @@ Single e2e tests can be run with `yarn test:e2e:single test/e2e/tests/TEST_NAME.
--leave-running Leaves the browser running after a test fails, along with
anything else that the test used (ganache, the test dapp,
etc.) [boolean] [default: false]
--update-snapshot Update E2E test snapshots
[alias: -u] [boolean] [default: false]
```
For example, to run the `account-details` tests using Chrome, with debug logging and with the browser set to remain open upon failure, you would use:

View File

@ -169,9 +169,6 @@
"copyAddress": {
"message": "አድራሻን ወደ ቅንጥብ ሰሌዳ ቅዳ"
},
"copyPrivateKey": {
"message": "የግል ቁልፍዎ ይህ ነው (ለመቅዳት ጠቅ ያድርጉ)"
},
"copyToClipboard": {
"message": "ወደ ቅንጥብ ሰሌዳ ገልብጥ"
},
@ -232,9 +229,6 @@
"ensRegistrationError": {
"message": "በ ENS የስም ምዝገባ ላይ የተፈጠረ ስህተት"
},
"enterPassword": {
"message": "የይለፍ ቃል ያስገቡ"
},
"enterPasswordContinue": {
"message": "ለመቀጠል የይለፍ ቃል ያስገቡ"
},
@ -247,9 +241,6 @@
"expandView": {
"message": "እይታን ዘርጋ"
},
"exportPrivateKey": {
"message": "የግል ቁልፍን ላክ"
},
"failed": {
"message": "አልተሳካም"
},
@ -648,9 +639,6 @@
"showHexDataDescription": {
"message": "በመላኪያ ማያ ላይ የ hex ውሂብ መስክን ለማሳየት ይህን ይምረጡ"
},
"showPrivateKeys": {
"message": "የግል ቁልፎችን አሳይ"
},
"sigRequest": {
"message": "የፊርማ ጥያቄ"
},
@ -771,9 +759,6 @@
"tryAgain": {
"message": "እንደገና ሞክር"
},
"typePassword": {
"message": "የ MetaMask የይለፍ ቃልዎን ይጻፉ"
},
"unapproved": {
"message": "ያልተፈቀደ"
},

View File

@ -179,9 +179,6 @@
"copyAddress": {
"message": "نسخ العنوان إلى الحافظة"
},
"copyPrivateKey": {
"message": "هذا هو مفتاحك الخاص (انقر للنسخ)"
},
"copyToClipboard": {
"message": "نسخ إلى الحافظة"
},
@ -245,9 +242,6 @@
"ensRegistrationError": {
"message": "خطأ في تسجيل اسم ENS"
},
"enterPassword": {
"message": "أدخل كلمة مرور"
},
"enterPasswordContinue": {
"message": "أدخل كلمة المرور للمتابعة"
},
@ -260,9 +254,6 @@
"expandView": {
"message": "توسيع العرض"
},
"exportPrivateKey": {
"message": "تصدير المفتاح الخاص"
},
"failed": {
"message": "فشل"
},
@ -660,9 +651,6 @@
"showHexDataDescription": {
"message": "حدد هذا لإظهار حقل بيانات سداسي عشرية على شاشة الإرسال"
},
"showPrivateKeys": {
"message": "عرض المفاتيح الخاصة"
},
"sigRequest": {
"message": "طلب التوقيع"
},
@ -783,9 +771,6 @@
"tryAgain": {
"message": "إعادة المحاولة"
},
"typePassword": {
"message": "أدخل كلمة مرور MetaMask الخاصة بك"
},
"unapproved": {
"message": "تم الرفض"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Копирайте адреса в клипборда"
},
"copyPrivateKey": {
"message": "Това е Вашият личен ключ (кликнете, за да го копирате)"
},
"copyToClipboard": {
"message": "Копиране в буферната памет"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Грешка при регистрацията на име на ENS"
},
"enterPassword": {
"message": "Въведете парола"
},
"enterPasswordContinue": {
"message": "Въведете парола, за да продължите"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Разгъване на изглед"
},
"exportPrivateKey": {
"message": "Експортиране на частен ключ"
},
"failed": {
"message": "Неуспешно"
},
@ -659,9 +650,6 @@
"showHexDataDescription": {
"message": "Изберете това, за да се покаже полето с шестнадесетични данни на екрана за изпращане"
},
"showPrivateKeys": {
"message": "Показване на частни ключове"
},
"sigRequest": {
"message": "Заявка за подпис"
},
@ -782,9 +770,6 @@
"tryAgain": {
"message": "Нов опит"
},
"typePassword": {
"message": "Въведете паролата си за MetaMask"
},
"unapproved": {
"message": "Неодобрено"
},

View File

@ -172,9 +172,6 @@
"copyAddress": {
"message": "ক্লিপবোর্ডে ঠিকানা কপি করুন"
},
"copyPrivateKey": {
"message": "এটি হল আপনার গোপন কী (কপি করতে ক্লিক করুন)"
},
"copyToClipboard": {
"message": "ক্লিপবোর্ডে কপি করুন"
},
@ -238,9 +235,6 @@
"ensRegistrationError": {
"message": "ENS নাম নিবন্ধীকরণে ত্রুটি হয়েছে"
},
"enterPassword": {
"message": "পাসওয়ার্ড লিখুন"
},
"enterPasswordContinue": {
"message": "অবিরত রাখতে পাসওয়ার্ড লিখুন"
},
@ -253,9 +247,6 @@
"expandView": {
"message": "ভিউ সম্প্রসারিত করুন"
},
"exportPrivateKey": {
"message": "ব্যক্তিগত কী রপ্তানি করুন"
},
"failed": {
"message": "ব্যর্থ হয়েছে"
},
@ -657,9 +648,6 @@
"showHexDataDescription": {
"message": "পাঠাবার স্ক্রিনে হেক্স ডেটা ফিল্ডটি দেখাবার জন্য এটি নির্বাচন করুন"
},
"showPrivateKeys": {
"message": "গোপনীয় কীগুলি দেখান"
},
"sigRequest": {
"message": "স্বাক্ষরের অনুরোধ"
},
@ -780,9 +768,6 @@
"tryAgain": {
"message": "আবার করুন"
},
"typePassword": {
"message": "আপনার MetaMask পাসওয়ার্ড টাইপ করুন"
},
"unapproved": {
"message": "অননুমোদিত"
},

View File

@ -172,9 +172,6 @@
"copyAddress": {
"message": "Copiar adreça al porta-retalls"
},
"copyPrivateKey": {
"message": "Aquesta és la teva clau privada (fes clic per a copiar)"
},
"copyToClipboard": {
"message": "Copia al porta-retalls"
},
@ -238,9 +235,6 @@
"ensRegistrationError": {
"message": "Error al registre de nom ENS"
},
"enterPassword": {
"message": "Introdueix contrasenya"
},
"enterPasswordContinue": {
"message": "Introdueix la contrasenya per continuar"
},
@ -253,9 +247,6 @@
"expandView": {
"message": "Eixamplar Vista"
},
"exportPrivateKey": {
"message": "Exportar Clau Privada."
},
"failed": {
"message": "Fallit"
},
@ -644,9 +635,6 @@
"showHexDataDescription": {
"message": "Selecciona això per a mostrar el camp de dades Hex a la pantalla d'enviament"
},
"showPrivateKeys": {
"message": "Mostrar Claus Privades"
},
"sigRequest": {
"message": "Sol·licitud de Signatura"
},
@ -761,9 +749,6 @@
"tryAgain": {
"message": "Torna-ho a provar"
},
"typePassword": {
"message": "Tecleja la teva contrasenya de MetaMask"
},
"unapproved": {
"message": "Pendent d'aprovació"
},

View File

@ -72,9 +72,6 @@
"copiedExclamation": {
"message": "Zkopírováno!"
},
"copyPrivateKey": {
"message": "Toto je váš privátní klíč (kliknutím zkopírujte)"
},
"copyToClipboard": {
"message": "Kopírovat do schránky"
},
@ -105,15 +102,9 @@
"edit": {
"message": "Upravit"
},
"enterPassword": {
"message": "Zadejte heslo"
},
"etherscanView": {
"message": "Prohlédněte si účet na Etherscan"
},
"exportPrivateKey": {
"message": "Exportovat privátní klíč"
},
"failed": {
"message": "Neúspěšné"
},
@ -304,9 +295,6 @@
"settings": {
"message": "Nastavení"
},
"showPrivateKeys": {
"message": "Zobrazit privátní klíče"
},
"sigRequest": {
"message": "Požadavek podpisu"
},
@ -355,9 +343,6 @@
"transactionError": {
"message": "Chyba transakce. Vyhozena výjimka v kódu kontraktu."
},
"typePassword": {
"message": "Zadejte své heslo"
},
"unapproved": {
"message": "Neschváleno"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Kopier adresse til udklipsholder"
},
"copyPrivateKey": {
"message": "Dette er din private nøgle (klik for at kopiere)"
},
"copyToClipboard": {
"message": "Kopiér til udklipsholderen"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Fejl i ENS-navneregistrering"
},
"enterPassword": {
"message": "Indtast kodeord"
},
"enterPasswordContinue": {
"message": "Indtast adgangskode for at fortsætte"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Udvis Visning"
},
"exportPrivateKey": {
"message": "Eksporter privat nøgle"
},
"failed": {
"message": "Mislykkedes"
},
@ -641,9 +632,6 @@
"showHexDataDescription": {
"message": "Vælg dette for at vise hex-datafeltet på send-skærmen"
},
"showPrivateKeys": {
"message": "Vis private nøgler"
},
"sigRequest": {
"message": "Signaturforespørgsel"
},
@ -755,9 +743,6 @@
"tryAgain": {
"message": "Prøv igen"
},
"typePassword": {
"message": "Skriv din MetaMask-adgangskode"
},
"unapproved": {
"message": "Ikke godkendt"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -287,6 +287,9 @@
"address": {
"message": "Address"
},
"addressCopied": {
"message": "Address copied!"
},
"advanced": {
"message": "Advanced"
},
@ -834,6 +837,9 @@
"connectionRequest": {
"message": "Connection request"
},
"connections": {
"message": "Connections"
},
"contactUs": {
"message": "Contact us"
},
@ -898,9 +904,6 @@
"copyAddress": {
"message": "Copy address to clipboard"
},
"copyPrivateKey": {
"message": "This is your private key (click to copy)"
},
"copyRawTransactionData": {
"message": "Copy raw transaction data"
},
@ -1482,9 +1485,6 @@
"enterOptionalPassword": {
"message": "Enter optional password"
},
"enterPassword": {
"message": "Enter password"
},
"enterPasswordContinue": {
"message": "Enter password to continue"
},
@ -1561,11 +1561,8 @@
"exploreMetaMaskSnaps": {
"message": "Explore MetaMask Snaps"
},
"exportPrivateKey": {
"message": "Export private key"
},
"extendWalletWithSnaps": {
"message": "Extend the wallet experience."
"message": "Customize your wallet experience."
},
"externalExtension": {
"message": "External extension"
@ -2870,7 +2867,7 @@
"message": "👓 We are making transactions easier to read."
},
"notificationsEmptyText": {
"message": "Nothing to see here."
"message": "This is where you can find notifications from your installed snaps."
},
"notificationsHeader": {
"message": "Notifications"
@ -3019,10 +3016,6 @@
"onboardingPinExtensionTitle": {
"message": "Your MetaMask install is complete!"
},
"onboardingShowIncomingTransactionsDescription": {
"message": "Showing incoming transactions in your wallet relies on communication with $1. Etherscan will have access to your Ethereum address and your IP address. View $2.",
"description": "$1 is a clickable link with text defined by the 'etherscan' key. $2 is a clickable link with text defined by the 'privacyMsg' key."
},
"onboardingUsePhishingDetectionDescription": {
"message": "Phishing detection alerts rely on communication with $1. jsDeliver will have access to your IP address. View $2.",
"description": "The $1 is the word 'jsDeliver', from key 'jsDeliver' and $2 is the words Privacy Policy from key 'privacyMsg', both separated here so that it can be wrapped as a link"
@ -3872,9 +3865,6 @@
"showPrivateKey": {
"message": "Show private key"
},
"showPrivateKeys": {
"message": "Show Private Keys"
},
"showTestnetNetworks": {
"message": "Show test networks"
},
@ -4377,6 +4367,9 @@
"swap": {
"message": "Swap"
},
"swapAdjustSlippage": {
"message": "Adjust slippage"
},
"swapAggregator": {
"message": "Aggregator"
},
@ -4435,9 +4428,6 @@
"swapEditLimit": {
"message": "Edit limit"
},
"swapEditTransactionSettings": {
"message": "Edit transaction settings"
},
"swapEnableDescription": {
"message": "This is required and gives MetaMask permission to swap your $1.",
"description": "Gives the user info about the required approval transaction for swaps. $1 will be the symbol of a token being approved for swaps."
@ -4498,6 +4488,9 @@
"message": "Gas fees are paid to crypto miners who process transactions on the $1 network. MetaMask does not profit from gas fees.",
"description": "$1 is the selected network, e.g. Ethereum or BSC"
},
"swapHighSlippage": {
"message": "High slippage"
},
"swapHighSlippageWarning": {
"message": "Slippage amount is very high."
},
@ -4512,6 +4505,9 @@
"swapLearnMore": {
"message": "Learn more about Swaps"
},
"swapLowSlippage": {
"message": "Low slippage"
},
"swapLowSlippageError": {
"message": "Transaction may fail, max slippage too low."
},
@ -4622,6 +4618,20 @@
"swapShowLatestQuotes": {
"message": "Show latest quotes"
},
"swapSlippageHighDescription": {
"message": "The slippage entered ($1%) is considered very high and may result in a bad rate",
"description": "$1 is the amount of % for slippage"
},
"swapSlippageHighTitle": {
"message": "High slippage"
},
"swapSlippageLowDescription": {
"message": "A value this low ($1%) may result in a failed swap",
"description": "$1 is the amount of % for slippage"
},
"swapSlippageLowTitle": {
"message": "Low slippage"
},
"swapSlippageNegative": {
"message": "Slippage must be greater or equal to zero"
},
@ -4635,27 +4645,15 @@
"message": "Slippage tolerance must be 15% or less. Anything higher will result in a bad rate."
},
"swapSlippageOverLimitTitle": {
"message": "Reduce slippage to continue"
"message": "Very high slippage"
},
"swapSlippagePercent": {
"message": "$1%",
"description": "$1 is the amount of % for slippage"
},
"swapSlippageTooLowDescription": {
"message": "Max slippage is too low which may cause your transaction to fail."
},
"swapSlippageTooLowTitle": {
"message": "Increase slippage to avoid transaction failure"
},
"swapSlippageTooltip": {
"message": "If the price changes between the time your order is placed and confirmed its called “slippage”. Your swap will automatically cancel if slippage exceeds your “slippage tolerance” setting."
},
"swapSlippageVeryHighDescription": {
"message": "The slippage entered is considered very high and may result in a bad rate"
},
"swapSlippageVeryHighTitle": {
"message": "Very high slippage"
},
"swapSlippageZeroDescription": {
"message": "There are fewer zero-slippage quote providers which will result in a less competitive quote."
},
@ -5134,9 +5132,6 @@
"txInsightsNotSupported": {
"message": "Transaction insights not supported for this contract at this time."
},
"typePassword": {
"message": "Type your MetaMask password"
},
"typeYourSRP": {
"message": "Type your Secret Recovery Phrase"
},
@ -5211,7 +5206,7 @@
"message": "Decode smart contracts"
},
"use4ByteResolutionDescription": {
"message": "To improve user experience, we customize the activity tab with messages based on the smart contracts you interact with. MetaMask uses a service called 4byte.directory to decode data and show you a version of a smart contact that's easier to read. This helps reduce your chances of approving malicious smart contract actions, but can result in your IP address being shared."
"message": "To improve user experience, we customize the activity tab with messages based on the smart contracts you interact with. MetaMask uses a service called 4byte.directory to decode data and show you a version of a smart contract that's easier to read. This helps reduce your chances of approving malicious smart contract actions, but can result in your IP address being shared."
},
"useMultiAccountBalanceChecker": {
"message": "Batch account balance requests"
@ -5324,6 +5319,9 @@
"visitWebSite": {
"message": "Visit our website"
},
"wallet": {
"message": "Wallet"
},
"walletConnectionGuide": {
"message": "our hardware wallet connection guide"
},
@ -5351,8 +5349,7 @@
"message": "Want to add this network?"
},
"wantsToAddThisAsset": {
"message": "$1 wants to add this asset to your wallet",
"description": "$1 is the name of the website that wants to add an asset to your wallet"
"message": "This allows the following asset to be added to your wallet."
},
"warning": {
"message": "Warning"

File diff suppressed because it is too large Load Diff

View File

@ -475,9 +475,6 @@
"copyAddress": {
"message": "Copiar dirección al Portapapeles"
},
"copyPrivateKey": {
"message": "Esta es su clave privada (haga clic para copiarla)"
},
"copyRawTransactionData": {
"message": "Copiar los datos de las transacciones en bruto"
},
@ -767,9 +764,6 @@
"enterMaxSpendLimit": {
"message": "Escribir límite máximo de gastos"
},
"enterPassword": {
"message": "Escribir contraseña"
},
"enterPasswordContinue": {
"message": "Escribir contraseña para continuar"
},
@ -826,9 +820,6 @@
"experimental": {
"message": "Experimental"
},
"exportPrivateKey": {
"message": "Exportar clave privada"
},
"externalExtension": {
"message": "Extensión externa"
},
@ -1623,14 +1614,6 @@
"onboardingPinExtensionTitle": {
"message": "¡Su instalación de MetaMask ha finalizado!"
},
"onboardingShowIncomingTransactionsDescription": {
"message": "Mostrar las transacciones entrantes en su cartera depende de la comunicación con $1. Etherscan tendrá acceso a su dirección de Ethereum y a su dirección IP. Ver $2.",
"description": "$1 is a clickable link with text defined by the 'etherscan' key. $2 is a clickable link with text defined by the 'privacyMsg' key."
},
"onboardingUsePhishingDetectionDescription": {
"message": "Las alertas de detección de phishing se basan en la comunicación con $1. jsDeliver tendrá acceso a su dirección IP. Ver $2.",
"description": "The $1 is the word 'jsDeliver', from key 'jsDeliver' and $2 is the words Privacy Policy from key 'privacyMsg', both separated here so that it can be wrapped as a link"
},
"onlyAddTrustedNetworks": {
"message": "Un proveedor de red malintencionado puede mentir sobre el estado de la cadena de bloques y registrar su actividad de red. Agregue solo redes personalizadas de confianza."
},
@ -2020,9 +2003,6 @@
"showPermissions": {
"message": "Mostrar permisos"
},
"showPrivateKeys": {
"message": "Mostrar claves privadas"
},
"showTestnetNetworks": {
"message": "Mostrar redes de prueba"
},
@ -2637,9 +2617,6 @@
"txInsightsNotSupported": {
"message": "En este momento no se admiten informaciones sobre las transacciones para este contrato."
},
"typePassword": {
"message": "Escriba su contraseña de MetaMask"
},
"u2f": {
"message": "U2F",
"description": "A name on an API for the browser to interact with devices that support the U2F protocol. On some browsers we use it to connect MetaMask to Ledger devices."

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Kopeeri aadress lõikelauale"
},
"copyPrivateKey": {
"message": "See on teie privaatne võti (klõpsake kopeerimiseks)"
},
"copyToClipboard": {
"message": "Kopeeri lõikelauale"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Tõrge ENS-i nime registreerimisel"
},
"enterPassword": {
"message": "Sisestage parool"
},
"enterPasswordContinue": {
"message": "Jätkamiseks sisestage parool"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Laienda vaadet"
},
"exportPrivateKey": {
"message": "Ekspordi privaatvõti"
},
"failed": {
"message": "Nurjus"
},
@ -653,9 +644,6 @@
"showHexDataDescription": {
"message": "Valige see, et kuvada saatmisekraanil hex-andmete väli"
},
"showPrivateKeys": {
"message": "Kuva privaatvõtmed"
},
"sigRequest": {
"message": "Allkirja taotlus"
},
@ -776,9 +764,6 @@
"tryAgain": {
"message": "Proovi uuesti"
},
"typePassword": {
"message": "Sisestage oma MetaMaski parool"
},
"unapproved": {
"message": "Kinnitamata"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "کاپی آدرس به کلیپ بورد"
},
"copyPrivateKey": {
"message": "این کلید خصوصی شما است (برای کاپی نمودن کلیک کنید)"
},
"copyToClipboard": {
"message": "کپی در بریده‌دان"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "خطا در ثبت نام ENS"
},
"enterPassword": {
"message": "رمز عبور را وارد کنید"
},
"enterPasswordContinue": {
"message": "برای ادامه رمز عبور را وارد کنید"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "توسعه ساحه دید"
},
"exportPrivateKey": {
"message": "صدور کلید شخصی"
},
"failed": {
"message": "ناموفق شد"
},
@ -663,9 +654,6 @@
"showHexDataDescription": {
"message": "برای نمایش بخش اطلاعات hex در صفحه ارسال این را انتخاب نمایید"
},
"showPrivateKeys": {
"message": "نمایش کلید های شخصی"
},
"sigRequest": {
"message": "درخواست امضاء"
},
@ -786,9 +774,6 @@
"tryAgain": {
"message": "امتحان مجدد"
},
"typePassword": {
"message": "رمز عبور MetaMask تان را تایپ نمایید"
},
"unapproved": {
"message": "تصدیق ناشده"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Kopioi osoite leikepöydälle"
},
"copyPrivateKey": {
"message": "Tämä on yksityinen avaimesi (kopioi napsauttamalla)"
},
"copyToClipboard": {
"message": "Kopioi leikepöydälle"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Virhe ENS-nimen rekisteröinnissä"
},
"enterPassword": {
"message": "Kirjoita salasana"
},
"enterPasswordContinue": {
"message": "Syötä salasana voidaksesi jatkaa"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Laajenna näkymää"
},
"exportPrivateKey": {
"message": "Vie yksityinen avain"
},
"failed": {
"message": "Epäonnistui"
},
@ -660,9 +651,6 @@
"showHexDataDescription": {
"message": "Näytä hex-tietokenttä lähetysnäytössä valitsemalla tämän"
},
"showPrivateKeys": {
"message": "Näytä yksityiset avaimet"
},
"sigRequest": {
"message": "Allekirjoitus pyydetään"
},
@ -783,9 +771,6 @@
"tryAgain": {
"message": "Yritä uudelleen"
},
"typePassword": {
"message": "Kirjoita MetaMask-salasanasi"
},
"unapproved": {
"message": "Ei hyväksytty"
},

View File

@ -154,9 +154,6 @@
"copyAddress": {
"message": "Kopyahin ang address sa clipboard"
},
"copyPrivateKey": {
"message": "Ito ang iyong pribadong private key (i-click para kopyahin)"
},
"copyToClipboard": {
"message": "Kopyahin sa clipboard"
},
@ -217,9 +214,6 @@
"ensRegistrationError": {
"message": "May error sa pagrerehistro ng ENS name"
},
"enterPassword": {
"message": "Ilagay ang password"
},
"enterPasswordContinue": {
"message": "Ilagay ang password para magpatuloy"
},
@ -229,9 +223,6 @@
"expandView": {
"message": "I-expand ang View"
},
"exportPrivateKey": {
"message": "I-export ang Private Key"
},
"failed": {
"message": "Nabigo"
},
@ -587,9 +578,6 @@
"showHexDataDescription": {
"message": "Piliin ito para ipakita ang hex data field sa screen ng pagpapadala"
},
"showPrivateKeys": {
"message": "Ipakita ang mga Private Key"
},
"sign": {
"message": "I-sign"
},
@ -698,9 +686,6 @@
"tryAgain": {
"message": "Subukang muli"
},
"typePassword": {
"message": "I-type ang iyong password sa MetaMask"
},
"unapproved": {
"message": "Hindi inaprubahan"
},

File diff suppressed because it is too large Load Diff

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "העתק כתובת ללוח"
},
"copyPrivateKey": {
"message": "זה המפתח הפרטי שלך (נא להקיש כדי להעתיק)"
},
"copyToClipboard": {
"message": "העתק ללוח"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "שגיאה ברישום שם ENS"
},
"enterPassword": {
"message": "יש להזין ססמה"
},
"enterPasswordContinue": {
"message": "יש להזין ססמה כדי להמשיך"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "הרחב תצוגה"
},
"exportPrivateKey": {
"message": "יצא/י מפתח פרטי"
},
"failed": {
"message": "נכשל"
},
@ -660,9 +651,6 @@
"showHexDataDescription": {
"message": "בחר/י בזה כדי להציג את שדה הנתונים ההקסדצימאלים על מסך השליחה"
},
"showPrivateKeys": {
"message": "הצג מפתחות פרטיים"
},
"sigRequest": {
"message": "בקשת חתימה"
},
@ -783,9 +771,6 @@
"tryAgain": {
"message": "ניסיון חוזר"
},
"typePassword": {
"message": "נא להקליד את סיסמת MetaMask שלך"
},
"unapproved": {
"message": "לא אושר"
},

File diff suppressed because it is too large Load Diff

View File

@ -60,9 +60,6 @@
"copiedExclamation": {
"message": "कॉपी कर दिया गया!"
},
"copyPrivateKey": {
"message": "यह आपकी निजी कुंजी है (कॉपी करने के लिए क्लिक करें)।"
},
"copyToClipboard": {
"message": "क्लिपबोर्ड पर कॉपी करें"
},
@ -87,15 +84,9 @@
"edit": {
"message": "संपादित करें"
},
"enterPassword": {
"message": "पासवर्ड दर्ज करें"
},
"etherscanView": {
"message": "ईथरस्कैन पर खाता देखें"
},
"exportPrivateKey": {
"message": "निजी कुंजी निर्यात करें"
},
"failed": {
"message": "विफल"
},
@ -281,9 +272,6 @@
"settings": {
"message": "सेटिंग्स"
},
"showPrivateKeys": {
"message": "निजी कुंजी दिखाएँ"
},
"sigRequest": {
"message": "हस्ताक्षर अनुरोध"
},
@ -317,9 +305,6 @@
"total": {
"message": "कुल"
},
"typePassword": {
"message": "अपना पासवर्ड टाइप करें"
},
"unknown": {
"message": "अज्ञात नेटवर्क"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Kopiraj adresu u međuspremnik"
},
"copyPrivateKey": {
"message": "Ovo je vaš privatni ključ (kliknite za kopiranje)"
},
"copyToClipboard": {
"message": "Kopiraj u međuspremnik"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Greška u registraciji naziva ENS"
},
"enterPassword": {
"message": "Upiši lozinku"
},
"enterPasswordContinue": {
"message": "Upišite lozinku za nastavak"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Proširi prikaz"
},
"exportPrivateKey": {
"message": "Izvezi privatni ključ"
},
"failed": {
"message": "Neuspješno"
},
@ -656,9 +647,6 @@
"showHexDataDescription": {
"message": "Odaberite ovu stavku za prikaz polja namijenjenog za podatke hex na zaslonu za slanje"
},
"showPrivateKeys": {
"message": "Prikaži privatne ključe"
},
"sigRequest": {
"message": "Zahtjev za potpisom"
},
@ -776,9 +764,6 @@
"tryAgain": {
"message": "Pokušaj ponovo"
},
"typePassword": {
"message": "Upišite svoju lozinku MetaMask."
},
"unapproved": {
"message": "Neodobreno"
},

View File

@ -108,9 +108,6 @@
"copyAddress": {
"message": "Kopi adrès clipboard"
},
"copyPrivateKey": {
"message": "Sa a se kle prive ou (klike pou ou kopye)"
},
"copyToClipboard": {
"message": "Kopi clipboard"
},
@ -147,9 +144,6 @@
"edit": {
"message": "Korije"
},
"enterPassword": {
"message": "Mete modpas"
},
"enterPasswordContinue": {
"message": "Mete modpas pou kontinye"
},
@ -159,9 +153,6 @@
"expandView": {
"message": "Elaji Wè"
},
"exportPrivateKey": {
"message": "Voye Kòd Prive"
},
"failed": {
"message": "Tonbe"
},
@ -482,9 +473,6 @@
"showHexDataDescription": {
"message": "Pran sa pouw ka montre chan entèfas hex data a"
},
"showPrivateKeys": {
"message": "Montre Kle Prive"
},
"sigRequest": {
"message": "Demann Siyati"
},
@ -554,9 +542,6 @@
"tryAgain": {
"message": "Eseye anko"
},
"typePassword": {
"message": "Tape modpas ou"
},
"unapproved": {
"message": "Pa apwouve"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Másolja a címet a vágólapra"
},
"copyPrivateKey": {
"message": "Ez a saját titkos kulcsod (kattints rá a másoláshoz)"
},
"copyToClipboard": {
"message": "Másolás a vágólapra"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Hiba történt az ENS név regisztrációjakor"
},
"enterPassword": {
"message": "Adja meg a jelszót"
},
"enterPasswordContinue": {
"message": "A folytatáshoz adja meg a jelszót"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Nézet nagyítása"
},
"exportPrivateKey": {
"message": "Privát kulcs exportálása"
},
"failed": {
"message": "Sikertelen"
},
@ -656,9 +647,6 @@
"showHexDataDescription": {
"message": "Válassza ezt, ha a hex adatmezőt a küldő képernyőn szeretné megnézni"
},
"showPrivateKeys": {
"message": "Mutassa a privát kulcsokat"
},
"sigRequest": {
"message": "Aláírás kérése"
},
@ -776,9 +764,6 @@
"tryAgain": {
"message": "Újra"
},
"typePassword": {
"message": "Írd be MetaMask jelszavadat"
},
"unapproved": {
"message": "Jóvá nem hagyott"
},

File diff suppressed because it is too large Load Diff

View File

@ -579,9 +579,6 @@
"copyAddress": {
"message": "Copia l'indirizzo"
},
"copyPrivateKey": {
"message": "Questa è la tua chiave privata (clicca per copiare)"
},
"copyRawTransactionData": {
"message": "Copia i dati grezzi della transazione"
},
@ -840,9 +837,6 @@
"enterMaxSpendLimit": {
"message": "Inserisici Limite Spesa"
},
"enterPassword": {
"message": "Inserisci password"
},
"enterPasswordContinue": {
"message": "Inserisci la tua password per continuare"
},
@ -875,9 +869,6 @@
"expandView": {
"message": "Espandi Vista"
},
"exportPrivateKey": {
"message": "Esporta Chiave Privata"
},
"externalExtension": {
"message": "Estensione Esterna"
},
@ -1439,9 +1430,6 @@
"showPermissions": {
"message": "Mostra permessi"
},
"showPrivateKeys": {
"message": "Mostra Chiave Privata"
},
"sigRequest": {
"message": "Firma Richiesta"
},
@ -1794,9 +1782,6 @@
"tryAgain": {
"message": "Prova di nuovo"
},
"typePassword": {
"message": "Inserisci Password"
},
"unapproved": {
"message": "Non approvata"
},

File diff suppressed because it is too large Load Diff

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "ವಿಳಾಸವನ್ನು ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸಿ"
},
"copyPrivateKey": {
"message": "ಇದು ನಿಮ್ಮ ಖಾಸಗಿ ಕೀ ಆಗಿದೆ (ನಕಲಿಸಲು ಕ್ಲಿಕ್ ಮಾಡಿ)"
},
"copyToClipboard": {
"message": "ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸಿ"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "ENS ಹೆಸರಿನ ನೋಂದಣಿಯಲ್ಲಿ ದೋಷ"
},
"enterPassword": {
"message": "ಪಾಸ್‌ವರ್ಡ್‌ ಅನ್ನು ನಮೂದಿಸಿ"
},
"enterPasswordContinue": {
"message": "ಮುಂದುವರೆಯಲು ಪಾಸ್‌ವರ್ಡ್ ನಮೂದಿಸಿ"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "ವಿಸ್ತರಿಸಿದ ವೀಕ್ಷಣೆ"
},
"exportPrivateKey": {
"message": "ಖಾಸಗಿ ಕೀಲಿಯನ್ನು ರಫ್ತು ಮಾಡಿ"
},
"failed": {
"message": "ವಿಫಲವಾಗಿದೆ"
},
@ -663,9 +654,6 @@
"showHexDataDescription": {
"message": "ಕಳುಹಿಸುವ ಪರದೆಯಲ್ಲಿ ಹೆಕ್ಸ್ ಡೇಟಾ ಕ್ಷೇತ್ರವನ್ನು ತೋರಿಸಲು ಇದನ್ನು ಆಯ್ಕೆಮಾಡಿ"
},
"showPrivateKeys": {
"message": "ಖಾಸಗಿ ಕೀಗಳನ್ನು ತೋರಿಸಿ"
},
"sigRequest": {
"message": "ಸಹಿಯ ವಿನಂತಿ"
},
@ -786,9 +774,6 @@
"tryAgain": {
"message": "ಪುನಃ ಪ್ರಯತ್ನಿಸಿ"
},
"typePassword": {
"message": "ನಿಮ್ಮ MetaMask ಪಾಸ್‌ವರ್ಡ್ ಅನ್ನು ಟೈಪ್ ಮಾಡಿ"
},
"unapproved": {
"message": "ಅನುಮೋದಿಸದಿರುವುದು"
},

File diff suppressed because it is too large Load Diff

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Kopijuoti adresą į iškarpinę"
},
"copyPrivateKey": {
"message": "Tai yra jūsų asmeninis raktas (spustelėkite, kad nukopijuotumėte)"
},
"copyToClipboard": {
"message": "Kopijuoti į iškarpinę"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "ENS pavadinimo registracijos klaida"
},
"enterPassword": {
"message": "Įveskite slaptažodį"
},
"enterPasswordContinue": {
"message": "Norėdami tęsti, įveskite slaptažodį"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Išskleisti rodinį"
},
"exportPrivateKey": {
"message": "Eksportuoti asmeninį raktą"
},
"failed": {
"message": "Nepavyko"
},
@ -663,9 +654,6 @@
"showHexDataDescription": {
"message": "Pasirinkite tai, kad siuntimo ekrane būtų rodomas šešioliktainių duomenų laukas"
},
"showPrivateKeys": {
"message": "Rodyti asmeninius raktus"
},
"sigRequest": {
"message": "Parašo užklausa"
},
@ -786,9 +774,6 @@
"tryAgain": {
"message": "Bandyti dar kartą"
},
"typePassword": {
"message": "Įveskite savo „MetaMask“ slaptažodį"
},
"unapproved": {
"message": "Nepatvirtinta"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Iekopēt adresi starpliktuvē"
},
"copyPrivateKey": {
"message": "Šī ir jūsu privātā atslēga (noklikšķiniet, lai nokopētu)"
},
"copyToClipboard": {
"message": "Kopēt starpliktuvē"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Kļūda ENS vārda reģistrācijā"
},
"enterPassword": {
"message": "Ievadiet paroli"
},
"enterPasswordContinue": {
"message": "Ievadiet paroli, lai turpinātu"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Izvērst skatījumu"
},
"exportPrivateKey": {
"message": "Eksportēt privāto atslēgu"
},
"failed": {
"message": "Neizdevās"
},
@ -659,9 +650,6 @@
"showHexDataDescription": {
"message": "Atlasiet šo, lai atvērtu hex datus sūtīšanas ekrānā"
},
"showPrivateKeys": {
"message": "Rādīt privātās atslēgas"
},
"sigRequest": {
"message": "Paraksta pieprasījums"
},
@ -782,9 +770,6 @@
"tryAgain": {
"message": "Mēģināt vēlreiz"
},
"typePassword": {
"message": "Ievadiet savu MetaMask paroli"
},
"unapproved": {
"message": "Nav apstiprināts"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Salin alamat kepada papan klip"
},
"copyPrivateKey": {
"message": "Ini kunci persendirian anda (klik untuk menyalin)"
},
"copyToClipboard": {
"message": "Salin ke papan keratan"
},
@ -238,9 +235,6 @@
"ensRegistrationError": {
"message": "Ralat dalam pendaftaran nama ENS"
},
"enterPassword": {
"message": "Masukkan kata laluan"
},
"enterPasswordContinue": {
"message": "Masukkan kata laluan untuk teruskan"
},
@ -253,9 +247,6 @@
"expandView": {
"message": "Kembangkan Paparan"
},
"exportPrivateKey": {
"message": "Eksport Kekunci Persendirian"
},
"failed": {
"message": "Gagal"
},
@ -643,9 +634,6 @@
"showHexDataDescription": {
"message": "Pilih ini untuk menunjukkan medan data hex pada skrin hantar"
},
"showPrivateKeys": {
"message": "Tunjukkan Kunci Persendirian"
},
"sigRequest": {
"message": "Permintaan Tandatangan"
},
@ -763,9 +751,6 @@
"tryAgain": {
"message": "Cuba lagi"
},
"typePassword": {
"message": "Taip kata laluan MetaMask anda"
},
"unapproved": {
"message": "Belum Diluluskan"
},

View File

@ -60,9 +60,6 @@
"copiedExclamation": {
"message": "Gekopieerd!"
},
"copyPrivateKey": {
"message": "Dit is uw privésleutel (klik om te kopiëren)"
},
"copyToClipboard": {
"message": "Kopieer naar klembord"
},
@ -84,15 +81,9 @@
"edit": {
"message": "Bewerk"
},
"enterPassword": {
"message": "Voer wachtwoord in"
},
"etherscanView": {
"message": "Bekijk account op Etherscan"
},
"exportPrivateKey": {
"message": "Exporteer privésleutel"
},
"failed": {
"message": "mislukt"
},
@ -271,9 +262,6 @@
"settings": {
"message": "instellingen"
},
"showPrivateKeys": {
"message": "Privésleutels weergeven"
},
"sigRequest": {
"message": "Ondertekeningsverzoek"
},
@ -307,9 +295,6 @@
"total": {
"message": "Totaal"
},
"typePassword": {
"message": "Typ uw wachtwoord"
},
"unknown": {
"message": "Onbekend"
},

View File

@ -172,9 +172,6 @@
"copyAddress": {
"message": "Kopier adresse til utklippstavlen "
},
"copyPrivateKey": {
"message": "Dette er din private nøkkel (klikk for å kopiere)"
},
"copyToClipboard": {
"message": "Kopiér til utklippstavlen"
},
@ -238,9 +235,6 @@
"ensRegistrationError": {
"message": "Feil i ENS-navneregistrering"
},
"enterPassword": {
"message": "Skriv inn passord "
},
"enterPasswordContinue": {
"message": "Skriv inn passord for å fortsette"
},
@ -253,9 +247,6 @@
"expandView": {
"message": "Utvid visning"
},
"exportPrivateKey": {
"message": "Eksporter privat nøkkel"
},
"failed": {
"message": "Mislyktes"
},
@ -644,9 +635,6 @@
"showHexDataDescription": {
"message": "Velg dette for å vise hex-datafeltet på sendskjermen"
},
"showPrivateKeys": {
"message": "Vis private nøkler"
},
"sigRequest": {
"message": "Signaturforespørsel "
},
@ -761,9 +749,6 @@
"tryAgain": {
"message": "Prøv igjen"
},
"typePassword": {
"message": "Skriv inn MetaMask-passordet"
},
"unapproved": {
"message": "Ikke godkjent "
},

View File

@ -338,9 +338,6 @@
"copyAddress": {
"message": "Kopyahin ang address sa clipboard"
},
"copyPrivateKey": {
"message": "Ito ang iyong pribadong key (i-click para kopyahin)"
},
"copyToClipboard": {
"message": "Kopyahin sa clipboard"
},
@ -489,9 +486,6 @@
"enterMaxSpendLimit": {
"message": "Ilagay ang Max na Limitasyon sa Paggastos"
},
"enterPassword": {
"message": "Ilagay ang password"
},
"enterPasswordContinue": {
"message": "Ilagay ang password para magpatuloy"
},
@ -542,9 +536,6 @@
"expandView": {
"message": "I-expand ang view"
},
"exportPrivateKey": {
"message": "I-export ang Pribadong Key"
},
"externalExtension": {
"message": "External Extension"
},
@ -1309,9 +1300,6 @@
"showPermissions": {
"message": "Ipakita ang mga pahintulot"
},
"showPrivateKeys": {
"message": "Ipakita ang Mga Private Key"
},
"sigRequest": {
"message": "Request ng Signature"
},
@ -1764,9 +1752,6 @@
"tryAgain": {
"message": "Subukan ulit"
},
"typePassword": {
"message": "Uri ng password ng iyong MetaMask"
},
"unapproved": {
"message": "Hindi inaprubahan"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Skopiuj adres do schowka"
},
"copyPrivateKey": {
"message": "To jest Twój prywatny klucz (kliknij żeby skopiować)"
},
"copyToClipboard": {
"message": "Skopiuj do schowka"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Błąd w rejestracji nazwy ENS"
},
"enterPassword": {
"message": "Wpisz hasło"
},
"enterPasswordContinue": {
"message": "Podaj hasło żeby kontynuować"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Rozwiń widok"
},
"exportPrivateKey": {
"message": "Eksportuj klucz prywatny"
},
"failed": {
"message": "Nie udało się"
},
@ -657,9 +648,6 @@
"showHexDataDescription": {
"message": "Wybierz to żeby pokazać pole danych hex na ekranie wysyłania"
},
"showPrivateKeys": {
"message": "Pokaż prywatne klucze"
},
"sigRequest": {
"message": "Prośba o podpis"
},
@ -774,9 +762,6 @@
"tryAgain": {
"message": "Spróbuj ponownie"
},
"typePassword": {
"message": "Wpisz hasło"
},
"unapproved": {
"message": "Niezatwierdzone"
},

File diff suppressed because it is too large Load Diff

View File

@ -475,9 +475,6 @@
"copyAddress": {
"message": "Copiar endereço para a área de transferência"
},
"copyPrivateKey": {
"message": "Essa é a sua chave privada (clique para copiar)"
},
"copyRawTransactionData": {
"message": "Copiar dados brutos da transação"
},
@ -767,9 +764,6 @@
"enterMaxSpendLimit": {
"message": "Digite um limite máximo de gastos"
},
"enterPassword": {
"message": "Digite a senha"
},
"enterPasswordContinue": {
"message": "Digite a senha para continuar"
},
@ -826,9 +820,6 @@
"experimental": {
"message": "Experimental"
},
"exportPrivateKey": {
"message": "Exportar chave privada"
},
"externalExtension": {
"message": "Extensão externa"
},
@ -1623,10 +1614,6 @@
"onboardingPinExtensionTitle": {
"message": "Sua instalação da MetaMask está concluída!"
},
"onboardingShowIncomingTransactionsDescription": {
"message": "A exibição de transações recebidas na sua carteira depende de comunicação com $1. O Etherscan terá acesso ao seu endereço Ethereum e ao seu endereço IP. Veja $2.",
"description": "$1 is a clickable link with text defined by the 'etherscan' key. $2 is a clickable link with text defined by the 'privacyMsg' key."
},
"onboardingUsePhishingDetectionDescription": {
"message": "Os alertas de detecção de phishing dependem de comunicação com $1. O jsDeliver terá acesso ao seu endereço IP. Veja $2.",
"description": "The $1 is the word 'jsDeliver', from key 'jsDeliver' and $2 is the words Privacy Policy from key 'privacyMsg', both separated here so that it can be wrapped as a link"
@ -2020,9 +2007,6 @@
"showPermissions": {
"message": "Mostrar permissões"
},
"showPrivateKeys": {
"message": "Mostrar chaves privadas"
},
"showTestnetNetworks": {
"message": "Mostrar redes de teste"
},
@ -2637,9 +2621,6 @@
"txInsightsNotSupported": {
"message": "As informações sobre transações não são suportadas para esse contrato, por ora."
},
"typePassword": {
"message": "Digite sua senha da MetaMask"
},
"u2f": {
"message": "U2F",
"description": "A name on an API for the browser to interact with devices that support the U2F protocol. On some browsers we use it to connect MetaMask to Ledger devices."

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Copiere adresă în clipboard"
},
"copyPrivateKey": {
"message": "Aceasta este cheia dumneavoastră privată (clic pentru a copia)"
},
"copyToClipboard": {
"message": "Copiați în clipboard"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Eroare la înregistrarea numelui ENS"
},
"enterPassword": {
"message": "Introduceți parola"
},
"enterPasswordContinue": {
"message": "Introduceți parola pentru a continua"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Extindeți vizualizarea"
},
"exportPrivateKey": {
"message": "Exportați cheia privată"
},
"failed": {
"message": "Eșuat"
},
@ -650,9 +641,6 @@
"showHexDataDescription": {
"message": "Selectați această opțiune pentru a arăta câmpul de date hexazecimale în ecranul de trimitere."
},
"showPrivateKeys": {
"message": "Afișați cheile private"
},
"sigRequest": {
"message": "Solicitare de semnătură"
},
@ -767,9 +755,6 @@
"tryAgain": {
"message": "Încearcă din nou"
},
"typePassword": {
"message": "Scrieți parola dvs. pentru MetaMask"
},
"unapproved": {
"message": "Neaprobat"
},

File diff suppressed because it is too large Load Diff

View File

@ -169,9 +169,6 @@
"copyAddress": {
"message": "Kopírovať adresu do schránky"
},
"copyPrivateKey": {
"message": "Toto je váš privátní klíč (kliknutím zkopírujte)"
},
"copyToClipboard": {
"message": "Kopírovat do schránky"
},
@ -235,9 +232,6 @@
"ensRegistrationError": {
"message": "Chyba pri registrácii názvu ENS"
},
"enterPassword": {
"message": "Zadejte heslo"
},
"enterPasswordContinue": {
"message": "Pokračujte zadaním hesla"
},
@ -250,9 +244,6 @@
"expandView": {
"message": "Rozbaliť zobrazenie"
},
"exportPrivateKey": {
"message": "Exportovat privátní klíč"
},
"failed": {
"message": "Neúspěšné"
},
@ -635,9 +626,6 @@
"showHexDataDescription": {
"message": "Vyberte toto, ak chcete, aby sa na obrazovke odosielania zobrazilo hex dátové pole"
},
"showPrivateKeys": {
"message": "Zobrazit privátní klíče"
},
"sigRequest": {
"message": "Požadavek podpisu"
},
@ -752,9 +740,6 @@
"tryAgain": {
"message": "Skúsiť znova"
},
"typePassword": {
"message": "Zadejte své heslo"
},
"unapproved": {
"message": "Neschváleno"
},

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Kopiraj naslov v odložišče"
},
"copyPrivateKey": {
"message": "To je vaš zesebni ključ (kliknite za kopiranje)"
},
"copyToClipboard": {
"message": "Kopiraj v odložišče"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Napaka pri registraciji imena ENS"
},
"enterPassword": {
"message": "Vnesite geslo"
},
"enterPasswordContinue": {
"message": "Za nadaljevanje vnesite geslo"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Razširi pogled"
},
"exportPrivateKey": {
"message": "Izvozi zasebni ključ"
},
"failed": {
"message": "Ni uspelo"
},
@ -651,9 +642,6 @@
"showHexDataDescription": {
"message": "Izberite za prikaz hex podatkov na zaslonu za pošiljanje"
},
"showPrivateKeys": {
"message": "Pokaži zasebni ključ"
},
"sigRequest": {
"message": "Zahteva za podpis"
},
@ -774,9 +762,6 @@
"tryAgain": {
"message": "Poskusi znova"
},
"typePassword": {
"message": "Vnesite vaše MetaMask geslo"
},
"unapproved": {
"message": "Neodobreno"
},

View File

@ -172,9 +172,6 @@
"copyAddress": {
"message": "Kopirajte adresu u ostavu"
},
"copyPrivateKey": {
"message": "Ovo je vaš privatni ključ (kliknite kako biste ga kopirali)"
},
"copyToClipboard": {
"message": "Копирај у меморију"
},
@ -238,9 +235,6 @@
"ensRegistrationError": {
"message": "Greška u registraciji ENS imena."
},
"enterPassword": {
"message": "Unesite lozinku"
},
"enterPasswordContinue": {
"message": "Unesite lozinku kako biste nastavili"
},
@ -253,9 +247,6 @@
"expandView": {
"message": "Proširite prikaz"
},
"exportPrivateKey": {
"message": "Izvezite privatni ključ"
},
"failed": {
"message": "Neuspešno"
},
@ -654,9 +645,6 @@
"showHexDataDescription": {
"message": "Izaberite ovo da bi se pokazalo polje sa hex podacima na „Pošalji” ekranu "
},
"showPrivateKeys": {
"message": "Prikažite privatne ključeve"
},
"sigRequest": {
"message": "Zahtev za potpisom"
},
@ -774,9 +762,6 @@
"tryAgain": {
"message": "Пробај поново"
},
"typePassword": {
"message": "Ukucajte svoju MetaMask šifru"
},
"unapproved": {
"message": "Neodobren"
},

View File

@ -169,9 +169,6 @@
"copyAddress": {
"message": "Kopiera adress till urklipp"
},
"copyPrivateKey": {
"message": "Det här är din privata nyckel (klicka för att kopiera)"
},
"copyToClipboard": {
"message": "Kopiera till Urklipp"
},
@ -235,9 +232,6 @@
"ensRegistrationError": {
"message": "Fel i ENS-namnregistrering"
},
"enterPassword": {
"message": "Ange lösenord"
},
"enterPasswordContinue": {
"message": "Ange lösenord för att fortsätta"
},
@ -250,9 +244,6 @@
"expandView": {
"message": "Expandera vy"
},
"exportPrivateKey": {
"message": "Exportera privat nyckel"
},
"failed": {
"message": "Misslyckades"
},
@ -647,9 +638,6 @@
"showHexDataDescription": {
"message": "Välj detta för att visa hex-datafältet på sändarskärmen"
},
"showPrivateKeys": {
"message": "Visa privata nycklar"
},
"sigRequest": {
"message": "Signaturförfrågan"
},
@ -761,9 +749,6 @@
"tryAgain": {
"message": "Försök igen"
},
"typePassword": {
"message": "Ange ditt MetaMask-lösenord"
},
"unapproved": {
"message": "Inte godkänd"
},

View File

@ -169,9 +169,6 @@
"copyAddress": {
"message": "Nakili anwani kwenye ubao wa kunakilia"
},
"copyPrivateKey": {
"message": "Huu ni ufunguo wako wa kibinafsi (bofya ili unakili)"
},
"copyToClipboard": {
"message": "Nakili kwenye ubao wa kunakili"
},
@ -235,9 +232,6 @@
"ensRegistrationError": {
"message": "Hitilafu imetokea kwenye usajili wa jina la ENS"
},
"enterPassword": {
"message": "Ingiza nenosiri"
},
"enterPasswordContinue": {
"message": "Ingiza nenosiri ili uendelee"
},
@ -250,9 +244,6 @@
"expandView": {
"message": "Panua Mwonekano"
},
"exportPrivateKey": {
"message": "Panua Mwonekano"
},
"failed": {
"message": "Imeshindwa"
},
@ -641,9 +632,6 @@
"showHexDataDescription": {
"message": "Chagua hii ili uonyeshe sehemu ya data ya hex kwenye skrini ya tuma"
},
"showPrivateKeys": {
"message": "Onyesha Fungo Binafsi"
},
"sigRequest": {
"message": "Ombi la Saini"
},
@ -764,9 +752,6 @@
"tryAgain": {
"message": "Jaribu tena"
},
"typePassword": {
"message": "Andika nenosiri lako la MetaMask"
},
"unapproved": {
"message": "Haijaidhinishwa"
},

View File

@ -87,9 +87,6 @@
"copiedExclamation": {
"message": "நகலெடுக்கப்பட்டன!"
},
"copyPrivateKey": {
"message": "இது உங்கள் தனிப்பட்ட விசை (நகலெடுக்க கிளிக் செய்யவும்)"
},
"copyToClipboard": {
"message": "கிளிப்போர்டுக்கு நகலெடு"
},
@ -126,15 +123,9 @@
"edit": {
"message": "திருத்து"
},
"enterPassword": {
"message": "கடவுச்சொல்லை உள்ளிடவும்"
},
"etherscanView": {
"message": "Etherscan கணக்கைப் பார்க்கவும்"
},
"exportPrivateKey": {
"message": "தனியார் விசை ஐ ஏற்றுமதி செய்க"
},
"failed": {
"message": "தோல்வி"
},
@ -374,9 +365,6 @@
"settings": {
"message": "அமைப்புகள்"
},
"showPrivateKeys": {
"message": "தனிப்பட்ட விசைகளைக் காண்பி"
},
"sigRequest": {
"message": "கையொப்பம் கோரிக்கை"
},
@ -425,9 +413,6 @@
"tryAgain": {
"message": "மீண்டும் முயல்க"
},
"typePassword": {
"message": "உங்கள் கடவுச்சொல்லை தட்டச்சு செய்யவும்"
},
"unapproved": {
"message": "அங்கீகரிக்கப்படாத"
},

View File

@ -78,9 +78,6 @@
"copiedExclamation": {
"message": "คัดลอกแล้ว!"
},
"copyPrivateKey": {
"message": "นี่คือคีย์ส่วนตัวของคุณ(กดเพื่อคัดลอก)"
},
"copyToClipboard": {
"message": "คัดลอกไปคลิปบอร์ด"
},
@ -117,18 +114,12 @@
"editContact": {
"message": "แก้ไขผู้ติดต่อ"
},
"enterPassword": {
"message": "ใส่รหัสผ่าน"
},
"etherscanView": {
"message": "ดูบัญชีบน Etherscan"
},
"expandView": {
"message": "ขยายมุมมอง"
},
"exportPrivateKey": {
"message": "ส่งออกคีย์ส่วนตัว"
},
"failed": {
"message": "ล้มเหลว"
},
@ -338,9 +329,6 @@
"settings": {
"message": "การตั้งค่า"
},
"showPrivateKeys": {
"message": "แสดงคีย์ส่วนตัว"
},
"sigRequest": {
"message": "ขอลายเซ็น"
},
@ -392,9 +380,6 @@
"transactionDropped": {
"message": "ธุรกรรมถูกยกเลิกเมื่อ $2"
},
"typePassword": {
"message": "พิมพ์รหัสผ่านของคุณ"
},
"unknown": {
"message": "ไม่รู้จัก"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -175,9 +175,6 @@
"copyAddress": {
"message": "Копіювати адресу в буфер обміну"
},
"copyPrivateKey": {
"message": "Це ваш закритий ключ (натисніть, щоб скопіювати)"
},
"copyToClipboard": {
"message": "Копіювати в буфер"
},
@ -241,9 +238,6 @@
"ensRegistrationError": {
"message": "Помилка у реєстрації ENS ім'я"
},
"enterPassword": {
"message": "Введіть пароль"
},
"enterPasswordContinue": {
"message": "Введіть пароль, щоб продовжити"
},
@ -256,9 +250,6 @@
"expandView": {
"message": "Розгорнути подання"
},
"exportPrivateKey": {
"message": "Експортувати приватний ключ"
},
"failed": {
"message": "Помилка"
},
@ -663,9 +654,6 @@
"showHexDataDescription": {
"message": "Оберіть це, щоб показати поле для шістнадцятирикових даних на екрані надсилання"
},
"showPrivateKeys": {
"message": "Показати приватні ключі"
},
"sigRequest": {
"message": "Запит підпису"
},
@ -786,9 +774,6 @@
"tryAgain": {
"message": "Повторити"
},
"typePassword": {
"message": "Введіть ваш пароль MetaMask"
},
"unapproved": {
"message": "Не затверджено"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -334,9 +334,6 @@
"copyAddress": {
"message": "複製到剪貼簿"
},
"copyPrivateKey": {
"message": "這是您的私鑰(點擊複製)"
},
"copyToClipboard": {
"message": "複製到剪貼簿"
},
@ -497,9 +494,6 @@
"enterMaxSpendLimit": {
"message": "輸入最大花費限制"
},
"enterPassword": {
"message": "請輸入密碼"
},
"enterPasswordContinue": {
"message": "請輸入密碼"
},
@ -547,9 +541,6 @@
"expandView": {
"message": "展開畫面"
},
"exportPrivateKey": {
"message": "匯出私鑰"
},
"externalExtension": {
"message": "外部擴充功能"
},
@ -1231,9 +1222,6 @@
"showPermissions": {
"message": "顯示權限"
},
"showPrivateKeys": {
"message": "顯示私鑰"
},
"sigRequest": {
"message": "請求簽署"
},
@ -1440,9 +1428,6 @@
"tryAgain": {
"message": "再試一次"
},
"typePassword": {
"message": "請輸入密碼"
},
"unapproved": {
"message": "未批准"
},

View File

@ -1,72 +0,0 @@
import { strict as assert } from 'assert';
import { stripHexPrefix } from '../../../shared/modules/hexstring-utils';
import accountImporter from '.';
describe('Account Import Strategies', function () {
const privkey =
'0x4cfd3e90fc78b0f86bf7524722150bb8da9c60cd532564d7ff43f5716514f553';
const json =
'{"version":3,"id":"dbb54385-0a99-437f-83c0-647de9f244c3","address":"a7f92ce3fba24196cf6f4bd2e1eb3db282ba998c","Crypto":{"ciphertext":"bde13d9ade5c82df80281ca363320ce254a8a3a06535bbf6ffdeaf0726b1312c","cipherparams":{"iv":"fbf93718a57f26051b292f072f2e5b41"},"cipher":"aes-128-ctr","kdf":"scrypt","kdfparams":{"dklen":32,"salt":"7ffe00488319dec48e4c49a120ca49c6afbde9272854c64d9541c83fc6acdffe","n":8192,"r":8,"p":1},"mac":"2adfd9c4bc1cdac4c85bddfb31d9e21a684e0e050247a70c5698facf6b7d4681"}}';
describe('private key import', function () {
it('imports a private key and strips 0x prefix', async function () {
const importPrivKey = await accountImporter.importAccount('Private Key', [
privkey,
]);
assert.equal(importPrivKey, stripHexPrefix(privkey));
});
it('throws an error for empty string private key', async function () {
await assert.rejects(
async () => {
await accountImporter.importAccount('Private Key', ['']);
},
Error,
'no empty strings',
);
});
it('throws an error for undefined string private key', async function () {
await assert.rejects(async () => {
await accountImporter.importAccount('Private Key', [undefined]);
});
await assert.rejects(async () => {
await accountImporter.importAccount('Private Key', []);
});
});
it('throws an error for invalid private key', async function () {
await assert.rejects(async () => {
await accountImporter.importAccount('Private Key', ['popcorn']);
});
});
});
describe('JSON keystore import', function () {
it('fails when password is incorrect for keystore', async function () {
const wrongPassword = 'password2';
try {
await accountImporter.importAccount('JSON File', [json, wrongPassword]);
} catch (error) {
assert.equal(
error.message,
'Key derivation failed - possibly wrong passphrase',
);
}
});
it('imports json string and password to return a private key', async function () {
const fileContentsPassword = 'password1';
const importJson = await accountImporter.importAccount('JSON File', [
json,
fileContentsPassword,
]);
assert.equal(
importJson,
'0x5733876abe94146069ce8bcbabbde2677f2e35fa33e875e92041ed2ac87e5bc7',
);
});
});
});

View File

@ -1,75 +0,0 @@
import { isValidMnemonic } from '@ethersproject/hdnode';
import {
bufferToHex,
getBinarySize,
isValidPrivate,
toBuffer,
} from 'ethereumjs-util';
import Wallet from 'ethereumjs-wallet';
import importers from 'ethereumjs-wallet/thirdparty';
import log from 'loglevel';
import { stripHexPrefix } from '../../../shared/modules/hexstring-utils';
import { addHexPrefix } from '../lib/util';
const accountImporter = {
async importAccount(strategy, args) {
const importer = this.strategies[strategy];
const privateKeyHex = importer(...args);
return privateKeyHex;
},
strategies: {
'Private Key': (privateKey) => {
if (!privateKey) {
throw new Error('Cannot import an empty key.'); // It should never get here, because this should be stopped in the UI
}
// Check if the user has entered an SRP by mistake instead of a private key
if (isValidMnemonic(privateKey.trim())) {
throw new Error(`t('importAccountErrorIsSRP')`);
}
const trimmedPrivateKey = privateKey.replace(/\s+/gu, ''); // Remove all whitespace
const prefixedPrivateKey = addHexPrefix(trimmedPrivateKey);
let buffer;
try {
buffer = toBuffer(prefixedPrivateKey);
} catch (e) {
throw new Error(`t('importAccountErrorNotHexadecimal')`);
}
try {
if (
!isValidPrivate(buffer) ||
getBinarySize(prefixedPrivateKey) !== 64 + '0x'.length // Fixes issue #17719 -- isValidPrivate() will let a key of 63 hex digits through without complaining, this line ensures 64 hex digits + '0x' = 66 digits
) {
throw new Error(`t('importAccountErrorNotAValidPrivateKey')`);
}
} catch (e) {
throw new Error(`t('importAccountErrorNotAValidPrivateKey')`);
}
const strippedPrivateKey = stripHexPrefix(prefixedPrivateKey);
return strippedPrivateKey;
},
'JSON File': (input, password) => {
let wallet;
try {
wallet = importers.fromEtherWallet(input, password);
} catch (e) {
log.debug('Attempt to import as EtherWallet format failed, trying V3');
wallet = Wallet.fromV3(input, password, true);
}
return walletToPrivateKey(wallet);
},
},
};
function walletToPrivateKey(wallet) {
const privateKeyBuffer = wallet.getPrivateKey();
return bufferToHex(privateKeyBuffer);
}
export default accountImporter;

View File

@ -2,9 +2,11 @@
* @file The entry point for the web extension singleton process.
*/
// This import sets up a global function required for Sentry to function.
// Disabled to allow setting up initial state hooks first
// This import sets up global functions required for Sentry to function.
// It must be run first in case an error is thrown later during initialization.
import './lib/setup-persisted-state-hook';
import './lib/setup-initial-state-hooks';
import EventEmitter from 'events';
import endOfStream from 'end-of-stream';
@ -13,6 +15,7 @@ import debounce from 'debounce-stream';
import log from 'loglevel';
import browser from 'webextension-polyfill';
import { storeAsStream } from '@metamask/obs-store';
import { isObject } from '@metamask/utils';
///: BEGIN:ONLY_INCLUDE_IN(snaps)
import { ApprovalType } from '@metamask/controller-utils';
///: END:ONLY_INCLUDE_IN
@ -41,7 +44,7 @@ import Migrator from './lib/migrator';
import ExtensionPlatform from './platforms/extension';
import LocalStore from './lib/local-store';
import ReadOnlyNetworkStore from './lib/network-store';
import { SENTRY_STATE } from './lib/setupSentry';
import { SENTRY_BACKGROUND_STATE } from './lib/setupSentry';
import createStreamSink from './lib/createStreamSink';
import NotificationManager, {
@ -68,6 +71,12 @@ import DesktopManager from '@metamask/desktop/dist/desktop-manager';
///: END:ONLY_INCLUDE_IN
/* eslint-enable import/order */
// Setup global hook for improved Sentry state snapshots during initialization
const inTest = process.env.IN_TEST;
const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore();
global.stateHooks.getMostRecentPersistedState = () =>
localStore.mostRecentRetrievedState;
const { sentry } = global;
const firstTimeState = { ...rawFirstTimeState };
@ -79,7 +88,7 @@ const metamaskInternalProcessHash = {
const metamaskBlockedPorts = ['trezor-connect'];
log.setDefaultLevel(process.env.METAMASK_DEBUG ? 'debug' : 'info');
log.setLevel(process.env.METAMASK_DEBUG ? 'debug' : 'info', false);
const platform = new ExtensionPlatform();
const notificationManager = new NotificationManager();
@ -90,10 +99,6 @@ let uiIsTriggering = false;
const openMetamaskTabsIDs = {};
const requestAccountTabIds = {};
let controller;
// state persistence
const inTest = process.env.IN_TEST;
const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore();
let versionedData;
if (inTest || process.env.METAMASK_DEBUG) {
@ -264,7 +269,8 @@ browser.runtime.onConnectExternal.addListener(async (...args) => {
*/
async function initialize() {
try {
const initState = await loadStateFromPersistence();
const initData = await loadStateFromPersistence();
const initState = initData.data;
const initLangCode = await getFirstPreferredLangCode();
///: BEGIN:ONLY_INCLUDE_IN(desktop)
@ -287,6 +293,7 @@ async function initialize() {
initLangCode,
{},
isFirstMetaMaskControllerSetup,
initData.meta,
);
if (!isManifestV3) {
await loadPhishingWarningPage();
@ -409,6 +416,19 @@ export async function loadStateFromPersistence() {
versionedData = await migrator.migrateData(versionedData);
if (!versionedData) {
throw new Error('MetaMask - migrator returned undefined');
} else if (!isObject(versionedData.meta)) {
throw new Error(
`MetaMask - migrator metadata has invalid type '${typeof versionedData.meta}'`,
);
} else if (typeof versionedData.meta.version !== 'number') {
throw new Error(
`MetaMask - migrator metadata version has invalid type '${typeof versionedData
.meta.version}'`,
);
} else if (!isObject(versionedData.data)) {
throw new Error(
`MetaMask - migrator data has invalid type '${typeof versionedData.data}'`,
);
}
// this initializes the meta/version data as a class variable to be used for future writes
localStore.setMetadata(versionedData.meta);
@ -417,7 +437,7 @@ export async function loadStateFromPersistence() {
localStore.set(versionedData.data);
// return just the data
return versionedData.data;
return versionedData;
}
/**
@ -430,12 +450,14 @@ export async function loadStateFromPersistence() {
* @param {string} initLangCode - The region code for the language preferred by the current user.
* @param {object} overrides - object with callbacks that are allowed to override the setup controller logic (usefull for desktop app)
* @param isFirstMetaMaskControllerSetup
* @param {object} stateMetadata - Metadata about the initial state and migrations, including the most recent migration version
*/
export function setupController(
initState,
initLangCode,
overrides,
isFirstMetaMaskControllerSetup,
stateMetadata,
) {
//
// MetaMask Controller
@ -462,6 +484,7 @@ export function setupController(
localStore,
overrides,
isFirstMetaMaskControllerSetup,
currentMigrationVersion: stateMetadata.version,
});
setupEnsIpfsResolver({
@ -880,14 +903,9 @@ browser.runtime.onInstalled.addListener(({ reason }) => {
});
function setupSentryGetStateGlobal(store) {
global.stateHooks.getSentryState = function () {
const fullState = store.getState();
const debugState = maskObject({ metamask: fullState }, SENTRY_STATE);
return {
browser: window.navigator.userAgent,
store: debugState,
version: platform.getVersion(),
};
global.stateHooks.getSentryAppState = function () {
const backgroundState = store.memStore.getState();
return maskObject(backgroundState, SENTRY_BACKGROUND_STATE);
};
}

View File

@ -0,0 +1,104 @@
import assert from 'assert';
import AppMetadataController from './app-metadata';
const EXPECTED_DEFAULT_STATE = {
currentAppVersion: '',
previousAppVersion: '',
previousMigrationVersion: 0,
currentMigrationVersion: 0,
};
describe('AppMetadataController', () => {
describe('constructor', () => {
it('accepts initial state and does not modify it if currentMigrationVersion and platform.getVersion() match respective values in state', async () => {
const initState = {
currentAppVersion: '1',
previousAppVersion: '1',
previousMigrationVersion: 1,
currentMigrationVersion: 1,
};
const appMetadataController = new AppMetadataController({
state: initState,
currentMigrationVersion: 1,
currentAppVersion: '1',
});
assert.deepStrictEqual(appMetadataController.store.getState(), initState);
});
it('sets default state and does not modify it', async () => {
const appMetadataController = new AppMetadataController({
state: {},
});
assert.deepStrictEqual(
appMetadataController.store.getState(),
EXPECTED_DEFAULT_STATE,
);
});
it('sets default state and does not modify it if options version parameters match respective default values', async () => {
const appMetadataController = new AppMetadataController({
state: {},
currentMigrationVersion: 0,
currentAppVersion: '',
});
assert.deepStrictEqual(
appMetadataController.store.getState(),
EXPECTED_DEFAULT_STATE,
);
});
it('updates the currentAppVersion state property if options.currentAppVersion does not match the default value', async () => {
const appMetadataController = new AppMetadataController({
state: {},
currentMigrationVersion: 0,
currentAppVersion: '1',
});
assert.deepStrictEqual(appMetadataController.store.getState(), {
...EXPECTED_DEFAULT_STATE,
currentAppVersion: '1',
});
});
it('updates the currentAppVersion and previousAppVersion state properties if options.currentAppVersion, currentAppVersion and previousAppVersion are all different', async () => {
const appMetadataController = new AppMetadataController({
state: {
currentAppVersion: '2',
previousAppVersion: '1',
},
currentAppVersion: '3',
currentMigrationVersion: 0,
});
assert.deepStrictEqual(appMetadataController.store.getState(), {
...EXPECTED_DEFAULT_STATE,
currentAppVersion: '3',
previousAppVersion: '2',
});
});
it('updates the currentMigrationVersion state property if the currentMigrationVersion param does not match the default value', async () => {
const appMetadataController = new AppMetadataController({
state: {},
currentMigrationVersion: 1,
});
assert.deepStrictEqual(appMetadataController.store.getState(), {
...EXPECTED_DEFAULT_STATE,
currentMigrationVersion: 1,
});
});
it('updates the currentMigrationVersion and previousMigrationVersion state properties if the currentMigrationVersion param, the currentMigrationVersion state property and the previousMigrationVersion state property are all different', async () => {
const appMetadataController = new AppMetadataController({
state: {
currentMigrationVersion: 2,
previousMigrationVersion: 1,
},
currentMigrationVersion: 3,
});
assert.deepStrictEqual(appMetadataController.store.getState(), {
...EXPECTED_DEFAULT_STATE,
currentMigrationVersion: 3,
previousMigrationVersion: 2,
});
});
});
});

View File

@ -0,0 +1,99 @@
import EventEmitter from 'events';
import { ObservableStore } from '@metamask/obs-store';
/**
* The state of the AppMetadataController
*/
export type AppMetadataControllerState = {
currentAppVersion: string;
previousAppVersion: string;
previousMigrationVersion: number;
currentMigrationVersion: number;
};
/**
* The options that NetworkController takes.
*/
export type AppMetadataControllerOptions = {
currentMigrationVersion?: number;
currentAppVersion?: string;
state?: Partial<AppMetadataControllerState>;
};
const defaultState: AppMetadataControllerState = {
currentAppVersion: '',
previousAppVersion: '',
previousMigrationVersion: 0,
currentMigrationVersion: 0,
};
/**
* The AppMetadata controller stores metadata about the current extension instance,
* including the currently and previously installed versions, and the most recently
* run migration.
*
*/
export default class AppMetadataController extends EventEmitter {
/**
* Observable store containing controller data.
*/
store: ObservableStore<AppMetadataControllerState>;
/**
* Constructs a AppMetadata controller.
*
* @param options - the controller options
* @param options.state - Initial controller state.
* @param options.currentMigrationVersion
* @param options.currentAppVersion
*/
constructor({
currentAppVersion = '',
currentMigrationVersion = 0,
state = {},
}: AppMetadataControllerOptions) {
super();
this.store = new ObservableStore({
...defaultState,
...state,
});
this.#maybeUpdateAppVersion(currentAppVersion);
this.#maybeUpdateMigrationVersion(currentMigrationVersion);
}
/**
* Updates the currentAppVersion in state, and sets the previousAppVersion to the old currentAppVersion.
*
* @param maybeNewAppVersion
*/
#maybeUpdateAppVersion(maybeNewAppVersion: string): void {
const oldCurrentAppVersion = this.store.getState().currentAppVersion;
if (maybeNewAppVersion !== oldCurrentAppVersion) {
this.store.updateState({
currentAppVersion: maybeNewAppVersion,
previousAppVersion: oldCurrentAppVersion,
});
}
}
/**
* Updates the migrationVersion in state.
*
* @param maybeNewMigrationVersion
*/
#maybeUpdateMigrationVersion(maybeNewMigrationVersion: number): void {
const oldCurrentMigrationVersion =
this.store.getState().currentMigrationVersion;
if (maybeNewMigrationVersion !== oldCurrentMigrationVersion) {
this.store.updateState({
previousMigrationVersion: oldCurrentMigrationVersion,
currentMigrationVersion: maybeNewMigrationVersion,
});
}
}
}

View File

@ -225,6 +225,7 @@ describe('DetectTokensController', function () {
tokenListController,
onInfuraIsBlocked: sinon.stub(),
onInfuraIsUnblocked: sinon.stub(),
networkConfigurations: {},
});
preferences.setAddresses([
'0x7e57e2',

View File

@ -1,320 +0,0 @@
import { ObservableStore } from '@metamask/obs-store';
import log from 'loglevel';
import BN from 'bn.js';
import createId from '../../../shared/modules/random-id';
import { previousValueComparator } from '../lib/util';
import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout';
import {
TransactionType,
TransactionStatus,
} from '../../../shared/constants/transaction';
import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../shared/constants/network';
import { bnToHex } from '../../../shared/modules/conversion.utils';
const fetchWithTimeout = getFetchWithTimeout();
/**
* @typedef {import('../../../shared/constants/transaction').TransactionMeta} TransactionMeta
*/
/**
* A transaction object in the format returned by the Etherscan API.
*
* Note that this is not an exhaustive type definiton; only the properties we use are defined
*
* @typedef {object} EtherscanTransaction
* @property {string} blockNumber - The number of the block this transaction was found in, in decimal
* @property {string} from - The hex-prefixed address of the sender
* @property {string} gas - The gas limit, in decimal GWEI
* @property {string} [gasPrice] - The gas price, in decimal WEI
* @property {string} [maxFeePerGas] - The maximum fee per gas, inclusive of tip, in decimal WEI
* @property {string} [maxPriorityFeePerGas] - The maximum tip per gas in decimal WEI
* @property {string} hash - The hex-prefixed transaction hash
* @property {string} isError - Whether the transaction was confirmed or failed (0 for confirmed, 1 for failed)
* @property {string} nonce - The transaction nonce, in decimal
* @property {string} timeStamp - The timestamp for the transaction, in seconds
* @property {string} to - The hex-prefixed address of the recipient
* @property {string} value - The amount of ETH sent in this transaction, in decimal WEI
*/
/**
* This controller is responsible for retrieving incoming transactions. Etherscan is polled once every block to check
* for new incoming transactions for the current selected account on the current network
*
* Note that only Etherscan-compatible networks are supported. We will not attempt to retrieve incoming transactions
* on non-compatible custom RPC endpoints.
*/
export default class IncomingTransactionsController {
constructor(opts = {}) {
const {
blockTracker,
onNetworkDidChange,
getCurrentChainId,
preferencesController,
onboardingController,
} = opts;
this.blockTracker = blockTracker;
this.getCurrentChainId = getCurrentChainId;
this.preferencesController = preferencesController;
this.onboardingController = onboardingController;
this._onLatestBlock = async (newBlockNumberHex) => {
const selectedAddress = this.preferencesController.getSelectedAddress();
const newBlockNumberDec = parseInt(newBlockNumberHex, 16);
await this._update(selectedAddress, newBlockNumberDec);
};
const incomingTxLastFetchedBlockByChainId = Object.keys(
ETHERSCAN_SUPPORTED_NETWORKS,
).reduce((network, chainId) => {
network[chainId] = null;
return network;
}, {});
const initState = {
incomingTransactions: {},
incomingTxLastFetchedBlockByChainId,
...opts.initState,
};
this.store = new ObservableStore(initState);
this.preferencesController.store.subscribe(
previousValueComparator((prevState, currState) => {
const {
featureFlags: {
showIncomingTransactions: prevShowIncomingTransactions,
} = {},
} = prevState;
const {
featureFlags: {
showIncomingTransactions: currShowIncomingTransactions,
} = {},
} = currState;
if (currShowIncomingTransactions === prevShowIncomingTransactions) {
return;
}
if (prevShowIncomingTransactions && !currShowIncomingTransactions) {
this.stop();
return;
}
this.start();
}, this.preferencesController.store.getState()),
);
this.preferencesController.store.subscribe(
previousValueComparator(async (prevState, currState) => {
const { selectedAddress: prevSelectedAddress } = prevState;
const { selectedAddress: currSelectedAddress } = currState;
if (currSelectedAddress === prevSelectedAddress) {
return;
}
await this._update(currSelectedAddress);
}, this.preferencesController.store.getState()),
);
this.onboardingController.store.subscribe(
previousValueComparator(async (prevState, currState) => {
const { completedOnboarding: prevCompletedOnboarding } = prevState;
const { completedOnboarding: currCompletedOnboarding } = currState;
if (!prevCompletedOnboarding && currCompletedOnboarding) {
const address = this.preferencesController.getSelectedAddress();
await this._update(address);
}
}, this.onboardingController.store.getState()),
);
onNetworkDidChange(async () => {
const address = this.preferencesController.getSelectedAddress();
await this._update(address);
});
}
start() {
const chainId = this.getCurrentChainId();
if (this._allowedToMakeFetchIncomingTx(chainId)) {
this.blockTracker.removeListener('latest', this._onLatestBlock);
this.blockTracker.addListener('latest', this._onLatestBlock);
}
}
stop() {
this.blockTracker.removeListener('latest', this._onLatestBlock);
}
/**
* Determines the correct block number to begin looking for new transactions
* from, fetches the transactions and then saves them and the next block
* number to begin fetching from in state. Block numbers and transactions are
* stored per chainId.
*
* @private
* @param {string} address - address to lookup transactions for
* @param {number} [newBlockNumberDec] - block number to begin fetching from
*/
async _update(address, newBlockNumberDec) {
const chainId = this.getCurrentChainId();
if (!address || !this._allowedToMakeFetchIncomingTx(chainId)) {
return;
}
try {
const currentState = this.store.getState();
const currentBlock = parseInt(this.blockTracker.getCurrentBlock(), 16);
const mostRecentlyFetchedBlock =
currentState.incomingTxLastFetchedBlockByChainId[chainId];
const blockToFetchFrom =
mostRecentlyFetchedBlock ?? newBlockNumberDec ?? currentBlock;
const newIncomingTxs = await this._getNewIncomingTransactions(
address,
blockToFetchFrom,
chainId,
);
let newMostRecentlyFetchedBlock = blockToFetchFrom;
newIncomingTxs.forEach((tx) => {
if (
tx.blockNumber &&
parseInt(newMostRecentlyFetchedBlock, 10) <
parseInt(tx.blockNumber, 10)
) {
newMostRecentlyFetchedBlock = parseInt(tx.blockNumber, 10);
}
});
this.store.updateState({
incomingTxLastFetchedBlockByChainId: {
...currentState.incomingTxLastFetchedBlockByChainId,
[chainId]: newMostRecentlyFetchedBlock + 1,
},
incomingTransactions: newIncomingTxs.reduce(
(transactions, tx) => {
transactions[tx.hash] = tx;
return transactions;
},
{
...currentState.incomingTransactions,
},
),
});
} catch (err) {
log.error(err);
}
}
/**
* fetches transactions for the given address and chain, via etherscan, then
* processes the data into the necessary shape for usage in this controller.
*
* @private
* @param {string} [address] - Address to fetch transactions for
* @param {number} [fromBlock] - Block to look for transactions at
* @param {string} [chainId] - The chainId for the current network
* @returns {TransactionMeta[]}
*/
async _getNewIncomingTransactions(address, fromBlock, chainId) {
const etherscanDomain = ETHERSCAN_SUPPORTED_NETWORKS[chainId].domain;
const etherscanSubdomain = ETHERSCAN_SUPPORTED_NETWORKS[chainId].subdomain;
const apiUrl = `https://${etherscanSubdomain}.${etherscanDomain}`;
let url = `${apiUrl}/api?module=account&action=txlist&address=${address}&tag=latest&page=1`;
if (fromBlock) {
url += `&startBlock=${parseInt(fromBlock, 10)}`;
}
const response = await fetchWithTimeout(url);
const { status, result } = await response.json();
let newIncomingTxs = [];
if (status === '1' && Array.isArray(result) && result.length > 0) {
const remoteTxList = {};
const remoteTxs = [];
result.forEach((tx) => {
if (!remoteTxList[tx.hash]) {
remoteTxs.push(this._normalizeTxFromEtherscan(tx, chainId));
remoteTxList[tx.hash] = 1;
}
});
newIncomingTxs = remoteTxs.filter(
(tx) => tx.txParams?.to?.toLowerCase() === address.toLowerCase(),
);
newIncomingTxs.sort((a, b) => (a.time < b.time ? -1 : 1));
}
return newIncomingTxs;
}
/**
* Transmutes a EtherscanTransaction into a TransactionMeta
*
* @param {EtherscanTransaction} etherscanTransaction - the transaction to normalize
* @param {string} chainId - The chainId of the current network
* @returns {TransactionMeta}
*/
_normalizeTxFromEtherscan(etherscanTransaction, chainId) {
const time = parseInt(etherscanTransaction.timeStamp, 10) * 1000;
const status =
etherscanTransaction.isError === '0'
? TransactionStatus.confirmed
: TransactionStatus.failed;
const txParams = {
from: etherscanTransaction.from,
gas: bnToHex(new BN(etherscanTransaction.gas)),
nonce: bnToHex(new BN(etherscanTransaction.nonce)),
to: etherscanTransaction.to,
value: bnToHex(new BN(etherscanTransaction.value)),
};
if (etherscanTransaction.gasPrice) {
txParams.gasPrice = bnToHex(new BN(etherscanTransaction.gasPrice));
} else if (etherscanTransaction.maxFeePerGas) {
txParams.maxFeePerGas = bnToHex(
new BN(etherscanTransaction.maxFeePerGas),
);
txParams.maxPriorityFeePerGas = bnToHex(
new BN(etherscanTransaction.maxPriorityFeePerGas),
);
}
return {
blockNumber: etherscanTransaction.blockNumber,
id: createId(),
chainId,
metamaskNetworkId: ETHERSCAN_SUPPORTED_NETWORKS[chainId].networkId,
status,
time,
txParams,
hash: etherscanTransaction.hash,
type: TransactionType.incoming,
};
}
/**
* @param chainId - {string} The chainId of the current network
* @returns {boolean} Whether or not the user has consented to show incoming transactions
*/
_allowedToMakeFetchIncomingTx(chainId) {
const { featureFlags = {} } = this.preferencesController.store.getState();
const { completedOnboarding } = this.onboardingController.store.getState();
const hasIncomingTransactionsFeatureEnabled = Boolean(
featureFlags.showIncomingTransactions,
);
const isEtherscanSupportedNetwork = Boolean(
ETHERSCAN_SUPPORTED_NETWORKS[chainId],
);
return (
completedOnboarding &&
isEtherscanSupportedNetwork &&
hasIncomingTransactionsFeatureEnabled
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -292,9 +292,9 @@ export default class MMIController extends EventEmitter {
})),
);
newAccounts.forEach(
async () => await this.keyringController.addNewAccount(keyring),
);
for (let i = 0; i < newAccounts.length; i++) {
await this.keyringController.addNewAccount(keyring);
}
const allAccounts = await this.keyringController.getAccounts();
@ -303,13 +303,34 @@ export default class MMIController extends EventEmitter {
...new Set(oldAccounts.concat(allAccounts.map((a) => a.toLowerCase()))),
];
// Create a Set of lowercased addresses from oldAccounts for efficient existence checks
const oldAccountsSet = new Set(
oldAccounts.map((address) => address.toLowerCase()),
);
// Create a map of lowercased addresses to names from newAccounts for efficient lookups
const accountNameMap = newAccounts.reduce((acc, item) => {
// For each account in newAccounts, add an entry to the map with the lowercased address as the key and the name as the value
acc[item.toLowerCase()] = accounts[item].name;
return acc;
}, {});
// Iterate over all accounts
allAccounts.forEach((address) => {
if (!oldAccounts.includes(address.toLowerCase())) {
const label = newAccounts
.filter((item) => item.toLowerCase() === address)
.map((item) => accounts[item].name)[0];
// Convert the address to lowercase for consistent comparisons
const lowercasedAddress = address.toLowerCase();
// If the address is not in oldAccounts
if (!oldAccountsSet.has(lowercasedAddress)) {
// Look up the label in the map
const label = accountNameMap[lowercasedAddress];
// If the label is defined
if (label) {
// Set the label for the address
this.preferencesController.setAccountLabel(address, label);
}
}
});
this.accountTracker.syncWithAddresses(accountsToTrack);
@ -569,7 +590,7 @@ export default class MMIController extends EventEmitter {
const mmiDashboardData = await this.handleMmiDashboardData();
const cookieSetUrls =
this.mmiConfigurationController.store.mmiConfiguration?.portfolio
?.cookieSetUrls;
?.cookieSetUrls || [];
setDashboardCookie(mmiDashboardData, cookieSetUrls);
} catch (error) {
console.error(error);
@ -578,7 +599,12 @@ export default class MMIController extends EventEmitter {
}
async newUnsignedMessage(msgParams, req, version) {
const updatedMsgParams = { ...msgParams, deferSetAsSigned: true };
// The code path triggered by deferSetAsSigned: true is for custodial accounts
const accountDetails = this.custodyController.getAccountDetails(
msgParams.from,
);
const isCustodial = Boolean(accountDetails);
const updatedMsgParams = { ...msgParams, deferSetAsSigned: isCustodial };
if (req.method.includes('eth_signTypedData')) {
return await this.signatureController.newUnsignedTypedMessage(

View File

@ -53,6 +53,7 @@ describe('MMIController', function () {
onInfuraIsBlocked: jest.fn(),
onInfuraIsUnblocked: jest.fn(),
provider: {},
networkConfigurations: {},
}),
appStateController: new AppStateController({
addUnlockListener: jest.fn(),

View File

@ -1,6 +1,9 @@
import { ObservableStore } from '@metamask/obs-store';
import { normalize as normalizeAddress } from 'eth-sig-util';
import { IPFS_DEFAULT_GATEWAY_URL } from '../../../shared/constants/network';
import {
CHAIN_IDS,
IPFS_DEFAULT_GATEWAY_URL,
} from '../../../shared/constants/network';
import { LedgerTransportTypes } from '../../../shared/constants/hardware-wallets';
import { ThemeType } from '../../../shared/constants/preferences';
import { shouldShowLineaMainnet } from '../../../shared/modules/network.utils';
@ -8,6 +11,17 @@ import { shouldShowLineaMainnet } from '../../../shared/modules/network.utils';
import { KEYRING_SNAPS_REGISTRY_URL } from '../../../shared/constants/app';
///: END:ONLY_INCLUDE_IN
const mainNetworks = {
[CHAIN_IDS.MAINNET]: true,
[CHAIN_IDS.LINEA_MAINNET]: true,
};
const testNetworks = {
[CHAIN_IDS.GOERLI]: true,
[CHAIN_IDS.SEPOLIA]: true,
[CHAIN_IDS.LINEA_GOERLI]: true,
};
export default class PreferencesController {
/**
*
@ -25,6 +39,13 @@ export default class PreferencesController {
* @property {string} store.selectedAddress A hex string that matches the currently selected address in the app
*/
constructor(opts = {}) {
const addedNonMainNetwork = Object.values(
opts.networkConfigurations,
).reduce((acc, element) => {
acc[element.chainId] = true;
return acc;
}, {});
const initState = {
useBlockie: false,
useNonceField: false,
@ -51,8 +72,11 @@ export default class PreferencesController {
// Feature flag toggling is available in the global namespace
// for convenient testing of pre-release features, and should never
// perform sensitive operations.
featureFlags: {
showIncomingTransactions: true,
featureFlags: {},
incomingTransactionsPreferences: {
...mainNetworks,
...addedNonMainNetwork,
...testNetworks,
},
knownMethodData: {},
currentLocale: opts.initLangCode,
@ -84,6 +108,7 @@ export default class PreferencesController {
};
this.network = opts.network;
this._onInfuraIsBlocked = opts.onInfuraIsBlocked;
this._onInfuraIsUnblocked = opts.onInfuraIsUnblocked;
this.store = new ObservableStore(initState);
@ -448,7 +473,7 @@ export default class PreferencesController {
* found in the settings page.
*
* @param {string} preference - The preference to enable or disable.
* @param {boolean} value - Indicates whether or not the preference should be enabled or disabled.
* @param {boolean |object} value - Indicates whether or not the preference should be enabled or disabled.
* @returns {Promise<object>} Promises a new object; the updated preferences object.
*/
async setPreference(preference, value) {
@ -550,6 +575,18 @@ export default class PreferencesController {
});
}
/**
* A setter for the incomingTransactions in preference to be updated
*
* @param {string} chainId - chainId of the network
* @param {bool} value - preference of certain network, true to be enabled
*/
setIncomingTransactionsPreferences(chainId, value) {
const previousValue = this.store.getState().incomingTransactionsPreferences;
const updatedValue = { ...previousValue, [chainId]: value };
this.store.updateState({ incomingTransactionsPreferences: updatedValue });
}
getRpcMethodPreferences() {
return this.store.getState().disabledRpcMethodPreferences;
}
@ -570,6 +607,7 @@ export default class PreferencesController {
}
this.store.updateState({ snapRegistryList: snapRegistry });
}
///: END:ONLY_INCLUDE_IN
//

View File

@ -1,67 +1,85 @@
import { strict as assert } from 'assert';
import sinon from 'sinon';
/**
* @jest-environment node
*/
import { ControllerMessenger } from '@metamask/base-controller';
import { TokenListController } from '@metamask/assets-controllers';
import { CHAIN_IDS } from '../../../shared/constants/network';
import PreferencesController from './preferences';
describe('preferences controller', function () {
const NETWORK_CONFIGURATION_DATA = {
'test-networkConfigurationId-1': {
rpcUrl: 'https://testrpc.com',
chainId: CHAIN_IDS.GOERLI,
nickname: '0X5',
rpcPrefs: { blockExplorerUrl: 'https://etherscan.io' },
},
'test-networkConfigurationId-2': {
rpcUrl: 'http://localhost:8545',
chainId: '0x539',
ticker: 'ETH',
nickname: 'Localhost 8545',
rpcPrefs: {},
},
};
describe('preferences controller', () => {
let preferencesController;
let tokenListController;
beforeEach(function () {
beforeEach(() => {
const tokenListMessenger = new ControllerMessenger().getRestricted({
name: 'TokenListController',
});
tokenListController = new TokenListController({
chainId: '1',
preventPollingOnNetworkRestart: false,
onNetworkStateChange: sinon.spy(),
onPreferencesStateChange: sinon.spy(),
onNetworkStateChange: jest.fn(),
onPreferencesStateChange: jest.fn(),
messenger: tokenListMessenger,
});
preferencesController = new PreferencesController({
initLangCode: 'en_US',
tokenListController,
onInfuraIsBlocked: sinon.spy(),
onInfuraIsUnblocked: sinon.spy(),
onInfuraIsBlocked: jest.fn(),
onInfuraIsUnblocked: jest.fn(),
networkConfigurations: NETWORK_CONFIGURATION_DATA,
});
});
afterEach(function () {
sinon.restore();
describe('useBlockie', () => {
it('defaults useBlockie to false', () => {
expect(preferencesController.store.getState().useBlockie).toStrictEqual(
false,
);
});
describe('useBlockie', function () {
it('defaults useBlockie to false', function () {
assert.equal(preferencesController.store.getState().useBlockie, false);
});
it('setUseBlockie to true', function () {
it('setUseBlockie to true', () => {
preferencesController.setUseBlockie(true);
assert.equal(preferencesController.store.getState().useBlockie, true);
expect(preferencesController.store.getState().useBlockie).toStrictEqual(
true,
);
});
});
describe('setCurrentLocale', function () {
it('checks the default currentLocale', function () {
describe('setCurrentLocale', () => {
it('checks the default currentLocale', () => {
const { currentLocale } = preferencesController.store.getState();
assert.equal(currentLocale, 'en_US');
expect(currentLocale).toStrictEqual('en_US');
});
it('sets current locale in preferences controller', function () {
it('sets current locale in preferences controller', () => {
preferencesController.setCurrentLocale('ja');
const { currentLocale } = preferencesController.store.getState();
assert.equal(currentLocale, 'ja');
expect(currentLocale).toStrictEqual('ja');
});
});
describe('setAddresses', function () {
it('should keep a map of addresses to names and addresses in the store', function () {
describe('setAddresses', () => {
it('should keep a map of addresses to names and addresses in the store', () => {
preferencesController.setAddresses(['0xda22le', '0x7e57e2']);
const { identities } = preferencesController.store.getState();
assert.deepEqual(identities, {
expect(identities).toStrictEqual({
'0xda22le': {
name: 'Account 1',
address: '0xda22le',
@ -73,12 +91,12 @@ describe('preferences controller', function () {
});
});
it('should replace its list of addresses', function () {
it('should replace its list of addresses', () => {
preferencesController.setAddresses(['0xda22le', '0x7e57e2']);
preferencesController.setAddresses(['0xda22le77', '0x7e57e277']);
const { identities } = preferencesController.store.getState();
assert.deepEqual(identities, {
expect(identities).toStrictEqual({
'0xda22le77': {
name: 'Account 1',
address: '0xda22le77',
@ -91,237 +109,235 @@ describe('preferences controller', function () {
});
});
describe('removeAddress', function () {
it('should remove an address from state', function () {
describe('removeAddress', () => {
it('should remove an address from state', () => {
preferencesController.setAddresses(['0xda22le', '0x7e57e2']);
preferencesController.removeAddress('0xda22le');
assert.equal(
expect(
preferencesController.store.getState().identities['0xda22le'],
undefined,
);
).toStrictEqual(undefined);
});
it('should switch accounts if the selected address is removed', function () {
it('should switch accounts if the selected address is removed', () => {
preferencesController.setAddresses(['0xda22le', '0x7e57e2']);
preferencesController.setSelectedAddress('0x7e57e2');
preferencesController.removeAddress('0x7e57e2');
assert.equal(preferencesController.getSelectedAddress(), '0xda22le');
expect(preferencesController.getSelectedAddress()).toStrictEqual(
'0xda22le',
);
});
});
describe('setAccountLabel', function () {
it('should update a label for the given account', function () {
describe('setAccountLabel', () => {
it('should update a label for the given account', () => {
preferencesController.setAddresses(['0xda22le', '0x7e57e2']);
assert.deepEqual(
expect(
preferencesController.store.getState().identities['0xda22le'],
{
).toStrictEqual({
name: 'Account 1',
address: '0xda22le',
},
);
});
preferencesController.setAccountLabel('0xda22le', 'Dazzle');
assert.deepEqual(
expect(
preferencesController.store.getState().identities['0xda22le'],
{
).toStrictEqual({
name: 'Dazzle',
address: '0xda22le',
},
);
});
});
});
describe('setPasswordForgotten', function () {
it('should default to false', function () {
const state = preferencesController.store.getState();
assert.equal(state.forgottenPassword, false);
});
it('should set the forgottenPassword property in state', function () {
assert.equal(
describe('setPasswordForgotten', () => {
it('should default to false', () => {
expect(
preferencesController.store.getState().forgottenPassword,
false,
);
).toStrictEqual(false);
});
it('should set the forgottenPassword property in state', () => {
preferencesController.setPasswordForgotten(true);
assert.equal(
expect(
preferencesController.store.getState().forgottenPassword,
true,
);
).toStrictEqual(true);
});
});
describe('setUsePhishDetect', function () {
it('should default to true', function () {
const state = preferencesController.store.getState();
assert.equal(state.usePhishDetect, true);
});
it('should set the usePhishDetect property in state', function () {
assert.equal(preferencesController.store.getState().usePhishDetect, true);
preferencesController.setUsePhishDetect(false);
assert.equal(
describe('setUsePhishDetect', () => {
it('should default to true', () => {
expect(
preferencesController.store.getState().usePhishDetect,
false,
);
).toStrictEqual(true);
});
it('should set the usePhishDetect property in state', () => {
preferencesController.setUsePhishDetect(false);
expect(
preferencesController.store.getState().usePhishDetect,
).toStrictEqual(false);
});
});
describe('setUseMultiAccountBalanceChecker', function () {
it('should default to true', function () {
const state = preferencesController.store.getState();
assert.equal(state.useMultiAccountBalanceChecker, true);
});
it('should set the setUseMultiAccountBalanceChecker property in state', function () {
assert.equal(
describe('setUseMultiAccountBalanceChecker', () => {
it('should default to true', () => {
expect(
preferencesController.store.getState().useMultiAccountBalanceChecker,
true,
);
).toStrictEqual(true);
});
it('should set the setUseMultiAccountBalanceChecker property in state', () => {
preferencesController.setUseMultiAccountBalanceChecker(false);
assert.equal(
expect(
preferencesController.store.getState().useMultiAccountBalanceChecker,
false,
);
).toStrictEqual(false);
});
});
describe('setUseTokenDetection', function () {
it('should default to false', function () {
const state = preferencesController.store.getState();
assert.equal(state.useTokenDetection, false);
});
it('should set the useTokenDetection property in state', function () {
assert.equal(
describe('setUseTokenDetection', () => {
it('should default to false', () => {
expect(
preferencesController.store.getState().useTokenDetection,
false,
);
).toStrictEqual(false);
});
it('should set the useTokenDetection property in state', () => {
preferencesController.setUseTokenDetection(true);
assert.equal(
expect(
preferencesController.store.getState().useTokenDetection,
true,
);
).toStrictEqual(true);
});
});
describe('setUseNftDetection', function () {
it('should default to false', function () {
const state = preferencesController.store.getState();
assert.equal(state.useNftDetection, false);
});
it('should set the useNftDetection property in state', function () {
assert.equal(
describe('setUseNftDetection', () => {
it('should default to false', () => {
expect(
preferencesController.store.getState().useNftDetection,
false,
);
).toStrictEqual(false);
});
it('should set the useNftDetection property in state', () => {
preferencesController.setOpenSeaEnabled(true);
preferencesController.setUseNftDetection(true);
assert.equal(
expect(
preferencesController.store.getState().useNftDetection,
true,
);
).toStrictEqual(true);
});
});
describe('setUse4ByteResolution', function () {
it('should default to true', function () {
const state = preferencesController.store.getState();
assert.equal(state.use4ByteResolution, true);
expect(
preferencesController.store.getState().use4ByteResolution,
).toStrictEqual(true);
});
it('should set the use4ByteResolution property in state', function () {
assert.equal(
preferencesController.store.getState().use4ByteResolution,
true,
);
preferencesController.setUse4ByteResolution(false);
assert.equal(
expect(
preferencesController.store.getState().use4ByteResolution,
false,
);
).toStrictEqual(false);
});
});
describe('setOpenSeaEnabled', function () {
it('should default to false', function () {
const state = preferencesController.store.getState();
assert.equal(state.openSeaEnabled, false);
});
it('should set the openSeaEnabled property in state', function () {
assert.equal(
describe('setOpenSeaEnabled', () => {
it('should default to false', () => {
expect(
preferencesController.store.getState().openSeaEnabled,
false,
);
).toStrictEqual(false);
});
it('should set the openSeaEnabled property in state', () => {
preferencesController.setOpenSeaEnabled(true);
assert.equal(preferencesController.store.getState().openSeaEnabled, true);
expect(
preferencesController.store.getState().openSeaEnabled,
).toStrictEqual(true);
});
});
describe('setAdvancedGasFee', function () {
it('should default to null', function () {
const state = preferencesController.store.getState();
assert.equal(state.advancedGasFee, null);
describe('setAdvancedGasFee', () => {
it('should default to null', () => {
expect(
preferencesController.store.getState().advancedGasFee,
).toStrictEqual(null);
});
it('should set the setAdvancedGasFee property in state', function () {
const state = preferencesController.store.getState();
assert.equal(state.advancedGasFee, null);
it('should set the setAdvancedGasFee property in state', () => {
preferencesController.setAdvancedGasFee({
maxBaseFee: '1.5',
priorityFee: '2',
});
assert.equal(
expect(
preferencesController.store.getState().advancedGasFee.maxBaseFee,
'1.5',
);
assert.equal(
).toStrictEqual('1.5');
expect(
preferencesController.store.getState().advancedGasFee.priorityFee,
'2',
);
).toStrictEqual('2');
});
});
describe('setTheme', function () {
it('should default to value "OS"', function () {
const state = preferencesController.store.getState();
assert.equal(state.theme, 'os');
describe('setTheme', () => {
it('should default to value "OS"', () => {
expect(preferencesController.store.getState().theme).toStrictEqual('os');
});
it('should set the setTheme property in state', function () {
const state = preferencesController.store.getState();
assert.equal(state.theme, 'os');
it('should set the setTheme property in state', () => {
preferencesController.setTheme('dark');
assert.equal(preferencesController.store.getState().theme, 'dark');
});
});
describe('setUseCurrencyRateCheck', function () {
it('should default to false', function () {
const state = preferencesController.store.getState();
assert.equal(state.useCurrencyRateCheck, true);
});
it('should set the useCurrencyRateCheck property in state', function () {
assert.equal(
preferencesController.store.getState().useCurrencyRateCheck,
true,
expect(preferencesController.store.getState().theme).toStrictEqual(
'dark',
);
preferencesController.setUseCurrencyRateCheck(false);
assert.equal(
});
});
describe('setUseCurrencyRateCheck', () => {
it('should default to false', () => {
expect(
preferencesController.store.getState().useCurrencyRateCheck,
).toStrictEqual(true);
});
it('should set the useCurrencyRateCheck property in state', () => {
preferencesController.setUseCurrencyRateCheck(false);
expect(
preferencesController.store.getState().useCurrencyRateCheck,
).toStrictEqual(false);
});
});
describe('setIncomingTransactionsPreferences', () => {
const addedNonTestNetworks = Object.keys(NETWORK_CONFIGURATION_DATA);
it('should have default value combined', () => {
const state = preferencesController.store.getState();
expect(state.incomingTransactionsPreferences).toStrictEqual({
[CHAIN_IDS.MAINNET]: true,
[CHAIN_IDS.LINEA_MAINNET]: true,
[NETWORK_CONFIGURATION_DATA[addedNonTestNetworks[0]].chainId]: true,
[NETWORK_CONFIGURATION_DATA[addedNonTestNetworks[1]].chainId]: true,
[CHAIN_IDS.GOERLI]: true,
[CHAIN_IDS.SEPOLIA]: true,
[CHAIN_IDS.LINEA_GOERLI]: true,
});
});
it('should update incomingTransactionsPreferences with given value set', () => {
preferencesController.setIncomingTransactionsPreferences(
[CHAIN_IDS.LINEA_MAINNET],
false,
);
const state = preferencesController.store.getState();
expect(state.incomingTransactionsPreferences).toStrictEqual({
[CHAIN_IDS.MAINNET]: true,
[CHAIN_IDS.LINEA_MAINNET]: false,
[NETWORK_CONFIGURATION_DATA[addedNonTestNetworks[0]].chainId]: true,
[NETWORK_CONFIGURATION_DATA[addedNonTestNetworks[1]].chainId]: true,
[CHAIN_IDS.GOERLI]: true,
[CHAIN_IDS.SEPOLIA]: true,
[CHAIN_IDS.LINEA_GOERLI]: true,
});
});
});
});

View File

@ -0,0 +1,219 @@
import { CHAIN_IDS } from '../../../../shared/constants/network';
import {
TransactionStatus,
TransactionType,
} from '../../../../shared/constants/transaction';
import createRandomId from '../../../../shared/modules/random-id';
import type {
EtherscanTokenTransactionMeta,
EtherscanTransactionMeta,
EtherscanTransactionMetaBase,
EtherscanTransactionResponse,
} from './etherscan';
import {
fetchEtherscanTokenTransactions,
fetchEtherscanTransactions,
} from './etherscan';
import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource';
jest.mock('./etherscan', () => ({
fetchEtherscanTransactions: jest.fn(),
fetchEtherscanTokenTransactions: jest.fn(),
}));
jest.mock('../../../../shared/modules/random-id');
const ID_MOCK = 123;
const ETHERSCAN_TRANSACTION_BASE_MOCK: EtherscanTransactionMetaBase = {
blockNumber: '4535105',
confirmations: '4',
contractAddress: '',
cumulativeGasUsed: '693910',
from: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207',
gas: '335208',
gasPrice: '20000000000',
gasUsed: '21000',
hash: '0x342e9d73e10004af41d04973339fc7219dbadcbb5629730cfe65e9f9cb15ff91',
nonce: '1',
timeStamp: '1543596356',
transactionIndex: '13',
value: '50000000000000000',
blockHash: '0x0000000001',
to: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207',
};
const ETHERSCAN_TRANSACTION_SUCCESS_MOCK: EtherscanTransactionMeta = {
...ETHERSCAN_TRANSACTION_BASE_MOCK,
functionName: 'testFunction',
input: '0x',
isError: '0',
methodId: 'testId',
txreceipt_status: '1',
};
const ETHERSCAN_TRANSACTION_ERROR_MOCK: EtherscanTransactionMeta = {
...ETHERSCAN_TRANSACTION_SUCCESS_MOCK,
isError: '1',
};
const ETHERSCAN_TOKEN_TRANSACTION_MOCK: EtherscanTokenTransactionMeta = {
...ETHERSCAN_TRANSACTION_BASE_MOCK,
tokenDecimal: '456',
tokenName: 'TestToken',
tokenSymbol: 'ABC',
};
const ETHERSCAN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse<EtherscanTransactionMeta> =
{
result: [
ETHERSCAN_TRANSACTION_SUCCESS_MOCK,
ETHERSCAN_TRANSACTION_ERROR_MOCK,
],
};
const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse<EtherscanTokenTransactionMeta> =
{
result: [
ETHERSCAN_TOKEN_TRANSACTION_MOCK,
ETHERSCAN_TOKEN_TRANSACTION_MOCK,
],
};
const ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse<EtherscanTransactionMeta> =
{
result: [],
};
const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse<EtherscanTokenTransactionMeta> =
ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK as any;
const EXPECTED_NORMALISED_TRANSACTION_BASE = {
blockNumber: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.blockNumber,
chainId: undefined,
hash: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.hash,
id: ID_MOCK,
metamaskNetworkId: undefined,
status: TransactionStatus.confirmed,
time: 1543596356000,
txParams: {
from: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.from,
gas: '0x51d68',
gasPrice: '0x4a817c800',
nonce: '0x1',
to: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.to,
value: '0xb1a2bc2ec50000',
},
type: TransactionType.incoming,
};
const EXPECTED_NORMALISED_TRANSACTION_SUCCESS = {
...EXPECTED_NORMALISED_TRANSACTION_BASE,
txParams: {
...EXPECTED_NORMALISED_TRANSACTION_BASE.txParams,
data: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.input,
},
};
const EXPECTED_NORMALISED_TRANSACTION_ERROR = {
...EXPECTED_NORMALISED_TRANSACTION_SUCCESS,
status: TransactionStatus.failed,
};
const EXPECTED_NORMALISED_TOKEN_TRANSACTION = {
...EXPECTED_NORMALISED_TRANSACTION_BASE,
};
describe('EtherscanRemoteTransactionSource', () => {
const fetchEtherscanTransactionsMock =
fetchEtherscanTransactions as jest.MockedFn<
typeof fetchEtherscanTransactions
>;
const fetchEtherscanTokenTransactionsMock =
fetchEtherscanTokenTransactions as jest.MockedFn<
typeof fetchEtherscanTokenTransactions
>;
const createIdMock = createRandomId as jest.MockedFn<typeof createRandomId>;
beforeEach(() => {
jest.resetAllMocks();
fetchEtherscanTransactionsMock.mockResolvedValue(
ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK,
);
fetchEtherscanTokenTransactionsMock.mockResolvedValue(
ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK,
);
createIdMock.mockReturnValue(ID_MOCK);
});
describe('isSupportedNetwork', () => {
it('returns true if chain ID in constant', () => {
expect(
new EtherscanRemoteTransactionSource().isSupportedNetwork(
CHAIN_IDS.MAINNET,
'1',
),
).toBe(true);
});
it('returns false if chain ID not in constant', () => {
expect(
new EtherscanRemoteTransactionSource().isSupportedNetwork(
CHAIN_IDS.LOCALHOST,
'1',
),
).toBe(false);
});
});
describe('fetchTransactions', () => {
it('returns normalized transactions fetched from Etherscan', async () => {
fetchEtherscanTransactionsMock.mockResolvedValueOnce(
ETHERSCAN_TRANSACTION_RESPONSE_MOCK,
);
const transactions =
await new EtherscanRemoteTransactionSource().fetchTransactions(
{} as any,
);
expect(transactions).toStrictEqual([
EXPECTED_NORMALISED_TRANSACTION_SUCCESS,
EXPECTED_NORMALISED_TRANSACTION_ERROR,
]);
});
it('returns normalized token transactions fetched from Etherscan', async () => {
fetchEtherscanTokenTransactionsMock.mockResolvedValueOnce(
ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK,
);
const transactions =
await new EtherscanRemoteTransactionSource().fetchTransactions(
{} as any,
);
expect(transactions).toStrictEqual([
EXPECTED_NORMALISED_TOKEN_TRANSACTION,
EXPECTED_NORMALISED_TOKEN_TRANSACTION,
]);
});
it('returns no normalized token transactions if flag disabled', async () => {
fetchEtherscanTokenTransactionsMock.mockResolvedValueOnce(
ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK,
);
const transactions = await new EtherscanRemoteTransactionSource({
includeTokenTransfers: false,
}).fetchTransactions({} as any);
expect(transactions).toStrictEqual([]);
});
});
});

View File

@ -0,0 +1,156 @@
import { BNToHex } from '@metamask/controller-utils';
import type { Hex } from '@metamask/utils';
import { BN } from 'ethereumjs-util';
import createId from '../../../../shared/modules/random-id';
import {
TransactionMeta,
TransactionStatus,
TransactionType,
} from '../../../../shared/constants/transaction';
import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../../shared/constants/network';
import type {
EtherscanTokenTransactionMeta,
EtherscanTransactionMeta,
EtherscanTransactionMetaBase,
EtherscanTransactionRequest,
EtherscanTransactionResponse,
} from './etherscan';
import {
fetchEtherscanTokenTransactions,
fetchEtherscanTransactions,
} from './etherscan';
import {
RemoteTransactionSource,
RemoteTransactionSourceRequest,
} from './types';
/**
* A RemoteTransactionSource that fetches transaction data from Etherscan.
*/
export class EtherscanRemoteTransactionSource
implements RemoteTransactionSource
{
#apiKey?: string;
#includeTokenTransfers: boolean;
constructor({
apiKey,
includeTokenTransfers,
}: { apiKey?: string; includeTokenTransfers?: boolean } = {}) {
this.#apiKey = apiKey;
this.#includeTokenTransfers = includeTokenTransfers ?? true;
}
isSupportedNetwork(chainId: Hex, _networkId: string): boolean {
return Object.keys(ETHERSCAN_SUPPORTED_NETWORKS).includes(chainId);
}
async fetchTransactions(
request: RemoteTransactionSourceRequest,
): Promise<TransactionMeta[]> {
const etherscanRequest: EtherscanTransactionRequest = {
...request,
apiKey: this.#apiKey,
chainId: request.currentChainId,
};
const transactionPromise = fetchEtherscanTransactions(etherscanRequest);
const tokenTransactionPromise = this.#includeTokenTransfers
? fetchEtherscanTokenTransactions(etherscanRequest)
: Promise.resolve({
result: [] as EtherscanTokenTransactionMeta[],
} as EtherscanTransactionResponse<EtherscanTokenTransactionMeta>);
const [etherscanTransactions, etherscanTokenTransactions] =
await Promise.all([transactionPromise, tokenTransactionPromise]);
const transactions = etherscanTransactions.result.map((tx) =>
this.#normalizeTransaction(
tx,
request.currentNetworkId,
request.currentChainId,
),
);
const tokenTransactions = etherscanTokenTransactions.result.map((tx) =>
this.#normalizeTokenTransaction(
tx,
request.currentNetworkId,
request.currentChainId,
),
);
return [...transactions, ...tokenTransactions];
}
#normalizeTransaction(
txMeta: EtherscanTransactionMeta,
currentNetworkId: string,
currentChainId: Hex,
): TransactionMeta {
const base = this.#normalizeTransactionBase(
txMeta,
currentNetworkId,
currentChainId,
);
return {
...base,
txParams: {
...base.txParams,
data: txMeta.input,
},
...(txMeta.isError === '0'
? { status: TransactionStatus.confirmed }
: {
status: TransactionStatus.failed,
}),
};
}
#normalizeTokenTransaction(
txMeta: EtherscanTokenTransactionMeta,
currentNetworkId: string,
currentChainId: Hex,
): TransactionMeta {
const base = this.#normalizeTransactionBase(
txMeta,
currentNetworkId,
currentChainId,
);
return {
...base,
};
}
#normalizeTransactionBase(
txMeta: EtherscanTransactionMetaBase,
currentNetworkId: string,
currentChainId: Hex,
): TransactionMeta {
const time = parseInt(txMeta.timeStamp, 10) * 1000;
return {
blockNumber: txMeta.blockNumber,
chainId: currentChainId,
hash: txMeta.hash,
id: createId(),
metamaskNetworkId: currentNetworkId,
status: TransactionStatus.confirmed,
time,
txParams: {
from: txMeta.from,
gas: BNToHex(new BN(txMeta.gas)),
gasPrice: BNToHex(new BN(txMeta.gasPrice)),
nonce: BNToHex(new BN(txMeta.nonce)),
to: txMeta.to,
value: BNToHex(new BN(txMeta.value)),
},
type: TransactionType.incoming,
} as TransactionMeta;
}
}

View File

@ -0,0 +1,585 @@
import { NetworkType } from '@metamask/controller-utils';
import type { BlockTracker, NetworkState } from '@metamask/network-controller';
import {
TransactionMeta,
TransactionStatus,
} from '../../../../shared/constants/transaction';
import { IncomingTransactionHelper } from './IncomingTransactionHelper';
import { RemoteTransactionSource } from './types';
jest.mock('@metamask/controller-utils', () => ({
...jest.requireActual('@metamask/controller-utils'),
isSmartContractCode: jest.fn(),
query: () => Promise.resolve({}),
}));
const NETWORK_STATE_MOCK: NetworkState = {
providerConfig: {
chainId: '0x1',
type: NetworkType.mainnet,
},
networkId: '1',
} as unknown as NetworkState;
const ADDERSS_MOCK = '0x1';
const FROM_BLOCK_HEX_MOCK = '0x20';
const FROM_BLOCK_DECIMAL_MOCK = 32;
const BLOCK_TRACKER_MOCK = {
addListener: jest.fn(),
removeListener: jest.fn(),
getLatestBlock: jest.fn(() => FROM_BLOCK_HEX_MOCK),
} as unknown as jest.Mocked<BlockTracker>;
const CONTROLLER_ARGS_MOCK = {
blockTracker: BLOCK_TRACKER_MOCK,
getCurrentAccount: () => ADDERSS_MOCK,
getNetworkState: () => NETWORK_STATE_MOCK,
remoteTransactionSource: {} as RemoteTransactionSource,
transactionLimit: 1,
};
const TRANSACTION_MOCK: TransactionMeta = {
blockNumber: '123',
chainId: '0x1',
status: TransactionStatus.submitted,
time: 0,
txParams: { to: '0x1' },
} as unknown as TransactionMeta;
const TRANSACTION_MOCK_2: TransactionMeta = {
blockNumber: '234',
chainId: '0x1',
hash: '0x2',
time: 1,
txParams: { to: '0x1' },
} as unknown as TransactionMeta;
const createRemoteTransactionSourceMock = (
remoteTransactions: TransactionMeta[],
{
isSupportedNetwork,
error,
}: { isSupportedNetwork?: boolean; error?: boolean } = {},
): RemoteTransactionSource => ({
isSupportedNetwork: jest.fn(() => isSupportedNetwork ?? true),
fetchTransactions: jest.fn(() =>
error
? Promise.reject(new Error('Test Error'))
: Promise.resolve(remoteTransactions),
),
});
async function emitBlockTrackerLatestEvent(
helper: IncomingTransactionHelper,
{ start, error }: { start?: boolean; error?: boolean } = {},
) {
const transactionsListener = jest.fn();
const blockNumberListener = jest.fn();
if (error) {
transactionsListener.mockImplementation(() => {
throw new Error('Test Error');
});
}
helper.hub.addListener('transactions', transactionsListener);
helper.hub.addListener('updatedLastFetchedBlockNumbers', blockNumberListener);
if (start !== false) {
helper.start();
}
await BLOCK_TRACKER_MOCK.addListener.mock.calls[0]?.[1]?.(
FROM_BLOCK_HEX_MOCK,
);
return {
transactions: transactionsListener.mock.calls[0]?.[0],
lastFetchedBlockNumbers:
blockNumberListener.mock.calls[0]?.[0].lastFetchedBlockNumbers,
transactionsListener,
blockNumberListener,
};
}
describe('IncomingTransactionHelper', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('on block tracker latest event', () => {
it('handles errors', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
await emitBlockTrackerLatestEvent(helper, { error: true });
});
describe('fetches remote transactions', () => {
it('using remote transaction source', async () => {
const remoteTransactionSource = createRemoteTransactionSourceMock([]);
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource,
});
await emitBlockTrackerLatestEvent(helper);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes(
1,
);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith({
address: ADDERSS_MOCK,
currentChainId: NETWORK_STATE_MOCK.providerConfig.chainId,
currentNetworkId: NETWORK_STATE_MOCK.networkId,
fromBlock: expect.any(Number),
limit: CONTROLLER_ARGS_MOCK.transactionLimit,
});
});
it('using from block as latest block minus ten if no last fetched data', async () => {
const remoteTransactionSource = createRemoteTransactionSourceMock([]);
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource,
});
await emitBlockTrackerLatestEvent(helper);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes(
1,
);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith(
expect.objectContaining({
fromBlock: FROM_BLOCK_DECIMAL_MOCK - 10,
}),
);
});
it('using from block as last fetched value plus one', async () => {
const remoteTransactionSource = createRemoteTransactionSourceMock([]);
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource,
lastFetchedBlockNumbers: {
[`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]:
FROM_BLOCK_DECIMAL_MOCK,
},
});
await emitBlockTrackerLatestEvent(helper);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes(
1,
);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith(
expect.objectContaining({
fromBlock: FROM_BLOCK_DECIMAL_MOCK + 1,
}),
);
});
});
describe('emits transactions event', () => {
it('if new transaction fetched', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [TRANSACTION_MOCK_2],
updated: [],
});
});
it('if new outgoing transaction fetched and update transactions enabled', async () => {
const outgoingTransaction = {
...TRANSACTION_MOCK_2,
txParams: {
...TRANSACTION_MOCK_2.txParams,
from: '0x1',
to: '0x2',
},
};
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
outgoingTransaction,
]),
updateTransactions: true,
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [outgoingTransaction],
updated: [],
});
});
it('if existing transaction fetched with different status and update transactions enabled', async () => {
const updatedTransaction = {
...TRANSACTION_MOCK,
status: TransactionStatus.confirmed,
} as TransactionMeta;
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
updatedTransaction,
]),
getLocalTransactions: () => [TRANSACTION_MOCK],
updateTransactions: true,
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [],
updated: [updatedTransaction],
});
});
it('sorted by time in ascending order', async () => {
const firstTransaction = { ...TRANSACTION_MOCK, time: 5 };
const secondTransaction = { ...TRANSACTION_MOCK, time: 6 };
const thirdTransaction = { ...TRANSACTION_MOCK, time: 7 };
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
firstTransaction,
thirdTransaction,
secondTransaction,
]),
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [firstTransaction, secondTransaction, thirdTransaction],
updated: [],
});
});
it('does not if identical transaction fetched and update transactions enabled', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK,
]),
getLocalTransactions: () => [TRANSACTION_MOCK],
updateTransactions: true,
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if disabled', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK,
]),
isEnabled: jest
.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if current network is not supported by remote transaction source', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock(
[TRANSACTION_MOCK],
{ isSupportedNetwork: false },
),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if no remote transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if update transactions disabled and no incoming transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
{
...TRANSACTION_MOCK,
txParams: { to: '0x2' },
} as TransactionMeta,
{
...TRANSACTION_MOCK,
txParams: { to: undefined } as any,
} as TransactionMeta,
]),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if error fetching transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock(
[TRANSACTION_MOCK],
{ error: true },
),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if not started', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK,
]),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
{ start: false },
);
expect(transactionsListener).not.toHaveBeenCalled();
});
});
describe('emits updatedLastFetchedBlockNumbers event', () => {
it('if fetched transaction has higher block number', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
const { lastFetchedBlockNumbers } = await emitBlockTrackerLatestEvent(
helper,
);
expect(lastFetchedBlockNumbers).toStrictEqual({
[`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]:
parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10),
});
});
it('does not if no fetched transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
it('does not if no block number on fetched transaction', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
{ ...TRANSACTION_MOCK_2, blockNumber: undefined },
]),
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
it('does not if fetch transaction not to current account', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
{
...TRANSACTION_MOCK_2,
txParams: { to: '0x2' },
} as TransactionMeta,
]),
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
it('does not if fetched transaction has same block number', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
lastFetchedBlockNumbers: {
[`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]:
parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10),
},
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
});
});
describe('start', () => {
it('adds listener to block tracker', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).toHaveBeenCalledTimes(1);
});
it('does nothing if already started', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).toHaveBeenCalledTimes(1);
});
it('does nothing if disabled', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
isEnabled: () => false,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).not.toHaveBeenCalled();
});
it('does nothing if network not supported by remote transaction source', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([], {
isSupportedNetwork: false,
}),
});
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).not.toHaveBeenCalled();
});
});
describe('stop', () => {
it('removes listener from block tracker', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
helper.stop();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.removeListener,
).toHaveBeenCalledTimes(1);
});
});
describe('update', () => {
it('emits transactions event', async () => {
const listener = jest.fn();
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
helper.hub.on('transactions', listener);
await helper.update();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
added: [TRANSACTION_MOCK_2],
updated: [],
});
});
});
});

View File

@ -0,0 +1,282 @@
import EventEmitter from 'events';
import type { BlockTracker, NetworkState } from '@metamask/network-controller';
import type { Hex } from '@metamask/utils';
import log from 'loglevel';
import { TransactionMeta } from '../../../../shared/constants/transaction';
import { RemoteTransactionSource } from './types';
const UPDATE_CHECKS: ((txMeta: TransactionMeta) => any)[] = [
(txMeta) => txMeta.status,
];
export class IncomingTransactionHelper {
hub: EventEmitter;
#blockTracker: BlockTracker;
#getCurrentAccount: () => string;
#getLocalTransactions: () => TransactionMeta[];
#getNetworkState: () => NetworkState;
#isEnabled: () => boolean;
#isRunning: boolean;
#isUpdating: boolean;
#lastFetchedBlockNumbers: Record<string, number>;
#onLatestBlock: (blockNumberHex: Hex) => Promise<void>;
#remoteTransactionSource: RemoteTransactionSource;
#transactionLimit?: number;
#updateTransactions: boolean;
constructor({
blockTracker,
getCurrentAccount,
getLocalTransactions,
getNetworkState,
isEnabled,
lastFetchedBlockNumbers,
remoteTransactionSource,
transactionLimit,
updateTransactions,
}: {
blockTracker: BlockTracker;
getCurrentAccount: () => string;
getNetworkState: () => NetworkState;
getLocalTransactions?: () => TransactionMeta[];
isEnabled?: () => boolean;
lastFetchedBlockNumbers?: Record<string, number>;
remoteTransactionSource: RemoteTransactionSource;
transactionLimit?: number;
updateTransactions?: boolean;
}) {
this.hub = new EventEmitter();
this.#blockTracker = blockTracker;
this.#getCurrentAccount = getCurrentAccount;
this.#getLocalTransactions = getLocalTransactions || (() => []);
this.#getNetworkState = getNetworkState;
this.#isEnabled = isEnabled ?? (() => true);
this.#isRunning = false;
this.#isUpdating = false;
this.#lastFetchedBlockNumbers = lastFetchedBlockNumbers ?? {};
this.#remoteTransactionSource = remoteTransactionSource;
this.#transactionLimit = transactionLimit;
this.#updateTransactions = updateTransactions ?? false;
// Using a property instead of a method to provide a listener reference
// with the correct scope that we can remove later if stopped.
this.#onLatestBlock = async (blockNumberHex: Hex) => {
await this.update(blockNumberHex);
};
}
start() {
if (this.#isRunning) {
return;
}
if (!this.#canStart()) {
return;
}
this.#blockTracker.addListener('latest', this.#onLatestBlock);
this.#isRunning = true;
}
stop() {
this.#blockTracker.removeListener('latest', this.#onLatestBlock);
this.#isRunning = false;
}
async update(latestBlockNumberHex?: Hex): Promise<void> {
if (this.#isUpdating) {
return;
}
this.#isUpdating = true;
try {
if (!this.#canStart()) {
return;
}
const latestBlockNumber = parseInt(
latestBlockNumberHex || (await this.#blockTracker.getLatestBlock()),
16,
);
const fromBlock = this.#getFromBlock(latestBlockNumber);
const address = this.#getCurrentAccount();
const currentChainId = this.#getCurrentChainId();
const currentNetworkId = this.#getCurrentNetworkId();
let remoteTransactions = [];
try {
remoteTransactions =
await this.#remoteTransactionSource.fetchTransactions({
address,
currentChainId,
currentNetworkId,
fromBlock,
limit: this.#transactionLimit,
});
} catch (error: any) {
return;
}
if (!this.#updateTransactions) {
remoteTransactions = remoteTransactions.filter(
(tx) => tx.txParams.to?.toLowerCase() === address.toLowerCase(),
);
}
const localTransactions = this.#updateTransactions
? this.#getLocalTransactions()
: [];
const newTransactions = this.#getNewTransactions(
remoteTransactions,
localTransactions,
);
const updatedTransactions = this.#getUpdatedTransactions(
remoteTransactions,
localTransactions,
);
if (newTransactions.length > 0 || updatedTransactions.length > 0) {
this.#sortTransactionsByTime(newTransactions);
this.#sortTransactionsByTime(updatedTransactions);
this.hub.emit('transactions', {
added: newTransactions,
updated: updatedTransactions,
});
}
this.#updateLastFetchedBlockNumber(remoteTransactions);
} catch (error) {
log.error('Error while checking incoming transactions', error);
} finally {
this.#isUpdating = false;
}
}
#sortTransactionsByTime(transactions: TransactionMeta[]) {
transactions.sort((a, b) => (a.time < b.time ? -1 : 1));
}
#getNewTransactions(
remoteTxs: TransactionMeta[],
localTxs: TransactionMeta[],
): TransactionMeta[] {
return remoteTxs.filter(
(tx) => !localTxs.some(({ hash }) => hash === tx.hash),
);
}
#getUpdatedTransactions(
remoteTxs: TransactionMeta[],
localTxs: TransactionMeta[],
): TransactionMeta[] {
return remoteTxs.filter((remoteTx) =>
localTxs.some(
(localTx) =>
remoteTx.hash === localTx.hash &&
this.#isTransactionOutdated(remoteTx, localTx),
),
);
}
#isTransactionOutdated(
remoteTx: TransactionMeta,
localTx: TransactionMeta,
): boolean {
return UPDATE_CHECKS.some(
(getValue) => getValue(remoteTx) !== getValue(localTx),
);
}
#getFromBlock(latestBlockNumber: number): number {
const lastFetchedKey = this.#getBlockNumberKey();
const lastFetchedBlockNumber =
this.#lastFetchedBlockNumbers[lastFetchedKey];
if (lastFetchedBlockNumber) {
return lastFetchedBlockNumber + 1;
}
// Avoid using latest block as remote transaction source
// may not have indexed it yet
return Math.max(latestBlockNumber - 10, 0);
}
#updateLastFetchedBlockNumber(remoteTxs: TransactionMeta[]) {
let lastFetchedBlockNumber = -1;
for (const tx of remoteTxs) {
const currentBlockNumberValue = tx.blockNumber
? parseInt(tx.blockNumber, 10)
: -1;
lastFetchedBlockNumber = Math.max(
lastFetchedBlockNumber,
currentBlockNumberValue,
);
}
if (lastFetchedBlockNumber === -1) {
return;
}
const lastFetchedKey = this.#getBlockNumberKey();
const previousValue = this.#lastFetchedBlockNumbers[lastFetchedKey];
if (previousValue === lastFetchedBlockNumber) {
return;
}
this.#lastFetchedBlockNumbers[lastFetchedKey] = lastFetchedBlockNumber;
this.hub.emit('updatedLastFetchedBlockNumbers', {
lastFetchedBlockNumbers: this.#lastFetchedBlockNumbers,
blockNumber: lastFetchedBlockNumber,
});
}
#getBlockNumberKey(): string {
return `${this.#getCurrentChainId()}#${this.#getCurrentAccount().toLowerCase()}`;
}
#canStart(): boolean {
const isEnabled = this.#isEnabled();
const currentChainId = this.#getCurrentChainId();
const currentNetworkId = this.#getCurrentNetworkId();
const isSupportedNetwork = this.#remoteTransactionSource.isSupportedNetwork(
currentChainId,
currentNetworkId,
);
return isEnabled && isSupportedNetwork;
}
#getCurrentChainId(): Hex {
return this.#getNetworkState().providerConfig.chainId;
}
#getCurrentNetworkId(): string {
return this.#getNetworkState().networkId as string;
}
}

View File

@ -0,0 +1,153 @@
import { handleFetch } from '@metamask/controller-utils';
import {
CHAIN_IDS,
ETHERSCAN_SUPPORTED_NETWORKS,
} from '../../../../shared/constants/network';
import type {
EtherscanTransactionMeta,
EtherscanTransactionRequest,
EtherscanTransactionResponse,
} from './etherscan';
import * as Etherscan from './etherscan';
jest.mock('@metamask/controller-utils', () => ({
...jest.requireActual('@metamask/controller-utils'),
handleFetch: jest.fn(),
}));
const ADDERSS_MOCK = '0x2A2D72308838A6A46a0B5FDA3055FE915b5D99eD';
const REQUEST_MOCK: EtherscanTransactionRequest = {
address: ADDERSS_MOCK,
chainId: CHAIN_IDS.GOERLI,
limit: 3,
fromBlock: 2,
apiKey: 'testApiKey',
};
const RESPONSE_MOCK: EtherscanTransactionResponse<EtherscanTransactionMeta> = {
result: [
{ from: ADDERSS_MOCK, nonce: '0x1' } as EtherscanTransactionMeta,
{ from: ADDERSS_MOCK, nonce: '0x2' } as EtherscanTransactionMeta,
],
};
describe('Etherscan', () => {
const handleFetchMock = handleFetch as jest.MockedFunction<
typeof handleFetch
>;
beforeEach(() => {
jest.resetAllMocks();
});
describe.each([
['fetchEtherscanTransactions', 'txlist'],
['fetchEtherscanTokenTransactions', 'tokentx'],
])('%s', (method, action) => {
it('returns fetched response', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
const result = await (Etherscan as any)[method](REQUEST_MOCK);
expect(result).toStrictEqual(RESPONSE_MOCK);
});
it('fetches from Etherscan URL', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
await (Etherscan as any)[method](REQUEST_MOCK);
expect(handleFetchMock).toHaveBeenCalledTimes(1);
expect(handleFetchMock).toHaveBeenCalledWith(
`https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${
ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain
}/api?` +
`module=account` +
`&address=${REQUEST_MOCK.address}` +
`&startBlock=${REQUEST_MOCK.fromBlock}` +
`&apikey=${REQUEST_MOCK.apiKey}` +
`&offset=${REQUEST_MOCK.limit}` +
`&order=desc` +
`&action=${action}` +
`&tag=latest` +
`&page=1`,
);
});
it('supports alternate networks', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
await (Etherscan as any)[method]({
...REQUEST_MOCK,
chainId: CHAIN_IDS.MAINNET,
});
expect(handleFetchMock).toHaveBeenCalledTimes(1);
expect(handleFetchMock).toHaveBeenCalledWith(
`https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.MAINNET].subdomain}.${
ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.MAINNET].domain
}/api?` +
`module=account` +
`&address=${REQUEST_MOCK.address}` +
`&startBlock=${REQUEST_MOCK.fromBlock}` +
`&apikey=${REQUEST_MOCK.apiKey}` +
`&offset=${REQUEST_MOCK.limit}` +
`&order=desc` +
`&action=${action}` +
`&tag=latest` +
`&page=1`,
);
});
it('throws if message is not ok', async () => {
handleFetchMock.mockResolvedValueOnce({
status: '0',
message: 'NOTOK',
result: 'test error',
});
await expect((Etherscan as any)[method](REQUEST_MOCK)).rejects.toThrow(
'Etherscan request failed - test error',
);
});
it('throws if chain is not supported', async () => {
const unsupportedChainId = '0x11111111111111111111';
await expect(
(Etherscan as any)[method]({
...REQUEST_MOCK,
chainId: unsupportedChainId,
}),
).rejects.toThrow(
`Etherscan does not support chain with ID: ${unsupportedChainId}`,
);
});
it('does not include empty values in fetched URL', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
await (Etherscan as any)[method]({
...REQUEST_MOCK,
fromBlock: undefined,
apiKey: undefined,
});
expect(handleFetchMock).toHaveBeenCalledTimes(1);
expect(handleFetchMock).toHaveBeenCalledWith(
`https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${
ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain
}/api?` +
`module=account` +
`&address=${REQUEST_MOCK.address}` +
`&offset=${REQUEST_MOCK.limit}` +
`&order=desc` +
`&action=${action}` +
`&tag=latest` +
`&page=1`,
);
});
});
});

View File

@ -0,0 +1,205 @@
import { handleFetch } from '@metamask/controller-utils';
import { Hex } from '@metamask/utils';
import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../../shared/constants/network';
export interface EtherscanTransactionMetaBase {
blockNumber: string;
blockHash: string;
confirmations: string;
contractAddress: string;
cumulativeGasUsed: string;
from: string;
gas: string;
gasPrice: string;
gasUsed: string;
hash: string;
nonce: string;
timeStamp: string;
to: string;
transactionIndex: string;
value: string;
}
export interface EtherscanTransactionMeta extends EtherscanTransactionMetaBase {
functionName: string;
input: string;
isError: string;
methodId: string;
txreceipt_status: string;
}
export interface EtherscanTokenTransactionMeta
extends EtherscanTransactionMetaBase {
tokenDecimal: string;
tokenName: string;
tokenSymbol: string;
}
export interface EtherscanTransactionResponse<
T extends EtherscanTransactionMetaBase,
> {
result: T[];
}
export interface EtherscanTransactionRequest {
address: string;
apiKey?: string;
chainId: Hex;
fromBlock?: number;
limit?: number;
}
interface RawEtherscanResponse<T extends EtherscanTransactionMetaBase> {
status: '0' | '1';
message: string;
result: string | T[];
}
/**
* Retrieves transaction data from Etherscan.
*
* @param request - Configuration required to fetch transactions.
* @param request.address - Address to retrieve transactions for.
* @param request.apiKey - Etherscan API key.
* @param request.chainId - Current chain ID used to determine subdomain and domain.
* @param request.fromBlock - Block number to start fetching transactions from.
* @param request.limit - Number of transactions to retrieve.
* @returns An Etherscan response object containing the request status and an array of token transaction data.
*/
export async function fetchEtherscanTransactions({
address,
apiKey,
chainId,
fromBlock,
limit,
}: EtherscanTransactionRequest): Promise<
EtherscanTransactionResponse<EtherscanTransactionMeta>
> {
return await fetchTransactions('txlist', {
address,
apiKey,
chainId,
fromBlock,
limit,
});
}
/**
* Retrieves token transaction data from Etherscan.
*
* @param request - Configuration required to fetch token transactions.
* @param request.address - Address to retrieve token transactions for.
* @param request.apiKey - Etherscan API key.
* @param request.chainId - Current chain ID used to determine subdomain and domain.
* @param request.fromBlock - Block number to start fetching token transactions from.
* @param request.limit - Number of token transactions to retrieve.
* @returns An Etherscan response object containing the request status and an array of token transaction data.
*/
export async function fetchEtherscanTokenTransactions({
address,
apiKey,
chainId,
fromBlock,
limit,
}: EtherscanTransactionRequest): Promise<
EtherscanTransactionResponse<EtherscanTokenTransactionMeta>
> {
return await fetchTransactions('tokentx', {
address,
apiKey,
chainId,
fromBlock,
limit,
});
}
/**
* Retrieves transaction data from Etherscan from a specific endpoint.
*
* @param action - The Etherscan endpoint to use.
* @param options - Options bag.
* @param options.address - Address to retrieve transactions for.
* @param options.apiKey - Etherscan API key.
* @param options.chainId - Current chain ID used to determine subdomain and domain.
* @param options.fromBlock - Block number to start fetching transactions from.
* @param options.limit - Number of transactions to retrieve.
* @returns An object containing the request status and an array of transaction data.
*/
async function fetchTransactions<T extends EtherscanTransactionMetaBase>(
action: string,
{
address,
apiKey,
chainId,
fromBlock,
limit,
}: {
address: string;
apiKey?: string;
chainId: Hex;
fromBlock?: number;
limit?: number;
},
): Promise<EtherscanTransactionResponse<T>> {
const urlParams = {
module: 'account',
address,
startBlock: fromBlock?.toString(),
apikey: apiKey,
offset: limit?.toString(),
order: 'desc',
};
const etherscanTxUrl = getEtherscanApiUrl(chainId, {
...urlParams,
action,
});
const response = (await handleFetch(
etherscanTxUrl,
)) as RawEtherscanResponse<T>;
if (response.status === '0' && response.message === 'NOTOK') {
throw new Error(`Etherscan request failed - ${response.result}`);
}
return { result: response.result as T[] };
}
/**
* Return a URL that can be used to fetch data from Etherscan.
*
* @param chainId - Current chain ID used to determine subdomain and domain.
* @param urlParams - The parameters used to construct the URL.
* @returns URL to access Etherscan data.
*/
function getEtherscanApiUrl(
chainId: Hex,
urlParams: Record<string, string | undefined>,
): string {
type SupportedChainId = keyof typeof ETHERSCAN_SUPPORTED_NETWORKS;
const networkInfo = ETHERSCAN_SUPPORTED_NETWORKS[chainId as SupportedChainId];
if (!networkInfo) {
throw new Error(`Etherscan does not support chain with ID: ${chainId}`);
}
const apiUrl = `https://${networkInfo.subdomain}.${networkInfo.domain}`;
let url = `${apiUrl}/api?`;
// eslint-disable-next-line guard-for-in
for (const paramKey in urlParams) {
const value = urlParams[paramKey];
if (!value) {
continue;
}
url += `${paramKey}=${value}&`;
}
url += 'tag=latest&page=1';
return url;
}

View File

@ -72,6 +72,8 @@ import TransactionStateManager from './tx-state-manager';
import TxGasUtil from './tx-gas-utils';
import PendingTransactionTracker from './pending-tx-tracker';
import * as txUtils from './lib/util';
import { IncomingTransactionHelper } from './IncomingTransactionHelper';
import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource';
const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory
const UPDATE_POST_TX_BALANCE_TIMEOUT = 5000;
@ -127,6 +129,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain';
* @param {object} opts.initState - initial transaction list default is an empty array
* @param {Function} opts.getNetworkId - Get the current network ID.
* @param {Function} opts.getNetworkStatus - Get the current network status.
* @param {Function} opts.getNetworkState - Get the network state.
* @param {Function} opts.onNetworkStateChange - Subscribe to network state change events.
* @param {object} opts.blockTracker - An instance of eth-blocktracker
* @param {object} opts.provider - A network provider.
@ -134,6 +137,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain';
* @param {object} opts.getPermittedAccounts - get accounts that an origin has permissions for
* @param {Function} opts.signTransaction - ethTx signer that returns a rawTx
* @param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state
* @param {Function} opts.hasCompletedOnboarding - Returns whether or not the user has completed the onboarding flow
* @param {object} opts.preferencesStore
*/
@ -142,6 +146,7 @@ export default class TransactionController extends EventEmitter {
super();
this.getNetworkId = opts.getNetworkId;
this.getNetworkStatus = opts.getNetworkStatus;
this._getNetworkState = opts.getNetworkState;
this._getCurrentChainId = opts.getCurrentChainId;
this.getProviderConfig = opts.getProviderConfig;
this._getCurrentNetworkEIP1559Compatibility =
@ -166,6 +171,7 @@ export default class TransactionController extends EventEmitter {
this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails;
this.securityProviderRequest = opts.securityProviderRequest;
this.messagingSystem = opts.messenger;
this._hasCompletedOnboarding = opts.hasCompletedOnboarding;
this.memStore = new ObservableStore({});
@ -216,6 +222,33 @@ export default class TransactionController extends EventEmitter {
this.txStateManager.getConfirmedTransactions.bind(this.txStateManager),
});
this.incomingTransactionHelper = new IncomingTransactionHelper({
blockTracker: this.blockTracker,
getCurrentAccount: () => this.getSelectedAddress(),
getNetworkState: () => this._getNetworkState(),
isEnabled: () =>
Boolean(
this.preferencesStore.getState().incomingTransactionsPreferences?.[
this._getChainId()
] && this._hasCompletedOnboarding(),
),
lastFetchedBlockNumbers: opts.initState?.lastFetchedBlockNumbers || {},
remoteTransactionSource: new EtherscanRemoteTransactionSource({
includeTokenTransfers: false,
}),
updateTransactions: false,
});
this.incomingTransactionHelper.hub.on(
'transactions',
this._onIncomingTransactions.bind(this),
);
this.incomingTransactionHelper.hub.on(
'updatedLastFetchedBlockNumbers',
this._onUpdatedLastFetchedBlockNumbers.bind(this),
);
this.txStateManager.store.subscribe(() =>
this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE),
);
@ -759,6 +792,18 @@ export default class TransactionController extends EventEmitter {
);
}
startIncomingTransactionPolling() {
this.incomingTransactionHelper.start();
}
stopIncomingTransactionPolling() {
this.incomingTransactionHelper.stop();
}
async updateIncomingTransactions() {
await this.incomingTransactionHelper.update();
}
//
// PRIVATE METHODS
//
@ -1779,7 +1824,11 @@ export default class TransactionController extends EventEmitter {
// MMI does not broadcast transactions, as that is the responsibility of the custodian
if (txMeta.custodyStatus) {
this.inProcessOfSigning.delete(txId);
// Custodial nonces and gas params are set by the custodian, so MMI follows the approve
// workflow before the transaction parameters are sent to the keyring
this.txStateManager.setTxStatusApproved(txId);
await this._signTransaction(txId);
// MMI relies on custodian to publish transactions so exits this code path early
return;
}
///: END:ONLY_INCLUDE_IN
@ -2082,11 +2131,18 @@ export default class TransactionController extends EventEmitter {
* Updates the memStore in transaction controller
*/
_updateMemstore() {
const { transactions } = this.store.getState();
const unapprovedTxs = this.txStateManager.getUnapprovedTxList();
const currentNetworkTxList = this.txStateManager.getTransactions({
limit: MAX_MEMSTORE_TX_LIST_SIZE,
});
this.memStore.updateState({ unapprovedTxs, currentNetworkTxList });
this.memStore.updateState({
unapprovedTxs,
currentNetworkTxList,
transactions,
});
}
_calculateTransactionsCost(txMeta, approvalTxMeta) {
@ -2730,6 +2786,34 @@ export default class TransactionController extends EventEmitter {
);
}
_onIncomingTransactions({ added: transactions }) {
log.debug('Detected new incoming transactions', transactions);
const currentTransactions = this.store.getState().transactions || {};
const incomingTransactions = transactions
.filter((tx) => !this._hasTransactionHash(tx.hash, currentTransactions))
.reduce((result, tx) => {
result[tx.id] = tx;
return result;
}, {});
const updatedTransactions = {
...currentTransactions,
...incomingTransactions,
};
this.store.updateState({ transactions: updatedTransactions });
}
_onUpdatedLastFetchedBlockNumbers({ lastFetchedBlockNumbers }) {
this.store.updateState({ lastFetchedBlockNumbers });
}
_hasTransactionHash(hash, transactions) {
return Object.values(transactions).some((tx) => tx.hash === hash);
}
// Approvals
async _requestTransactionApproval(

View File

@ -38,6 +38,7 @@ import { ORIGIN_METAMASK } from '../../../../shared/constants/app';
import { NetworkStatus } from '../../../../shared/constants/network';
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils';
import TxGasUtil from './tx-gas-utils';
import * as IncomingTransactionHelperClass from './IncomingTransactionHelper';
import TransactionController from '.';
const noop = () => true;
@ -51,6 +52,16 @@ const actionId = 'DUMMY_ACTION_ID';
const VALID_ADDRESS = '0x0000000000000000000000000000000000000000';
const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001';
const TRANSACTION_META_MOCK = {
hash: '0x1',
id: 1,
status: TransactionStatus.confirmed,
transaction: {
from: VALID_ADDRESS,
},
time: 123456789,
};
async function flushPromises() {
await new Promise((resolve) => setImmediate(resolve));
}
@ -65,7 +76,9 @@ describe('Transaction Controller', function () {
getCurrentChainId,
messengerMock,
resultCallbacksMock,
updateSpy;
updateSpy,
incomingTransactionHelperClassMock,
incomingTransactionHelperEventMock;
beforeEach(function () {
fragmentExists = false;
@ -101,6 +114,16 @@ describe('Transaction Controller', function () {
call: sinon.stub(),
};
incomingTransactionHelperEventMock = sinon.spy();
incomingTransactionHelperClassMock = sinon
.stub(IncomingTransactionHelperClass, 'IncomingTransactionHelper')
.returns({
hub: {
on: incomingTransactionHelperEventMock,
},
});
txController = new TransactionController({
provider,
getGasPrice() {
@ -148,6 +171,10 @@ describe('Transaction Controller', function () {
);
});
afterEach(function () {
incomingTransactionHelperClassMock.restore();
});
function getLastTxMeta() {
return updateSpy.lastCall.args[0];
}
@ -3374,4 +3401,78 @@ describe('Transaction Controller', function () {
assert.deepEqual(transaction1, transaction2);
});
});
describe('on incoming transaction helper transactions event', function () {
it('adds new transactions to state', async function () {
const existingTransaction = TRANSACTION_META_MOCK;
const incomingTransaction1 = {
...TRANSACTION_META_MOCK,
id: 2,
hash: '0x2',
};
const incomingTransaction2 = {
...TRANSACTION_META_MOCK,
id: 3,
hash: '0x3',
};
txController.store.getState().transactions = {
[existingTransaction.id]: existingTransaction,
};
await incomingTransactionHelperEventMock.firstCall.args[1]({
added: [incomingTransaction1, incomingTransaction2],
updated: [],
});
assert.deepEqual(txController.store.getState().transactions, {
[existingTransaction.id]: existingTransaction,
[incomingTransaction1.id]: incomingTransaction1,
[incomingTransaction2.id]: incomingTransaction2,
});
});
it('ignores new transactions if hash matches existing transaction', async function () {
const existingTransaction = TRANSACTION_META_MOCK;
const incomingTransaction1 = { ...TRANSACTION_META_MOCK, id: 2 };
const incomingTransaction2 = { ...TRANSACTION_META_MOCK, id: 3 };
txController.store.getState().transactions = {
[existingTransaction.id]: existingTransaction,
};
await incomingTransactionHelperEventMock.firstCall.args[1]({
added: [incomingTransaction1, incomingTransaction2],
updated: [],
});
assert.deepEqual(txController.store.getState().transactions, {
[existingTransaction.id]: existingTransaction,
});
});
});
describe('on incoming transaction helper updatedLastFetchedBlockNumbers event', function () {
it('updates state', async function () {
const lastFetchedBlockNumbers = {
key: 234,
};
assert.deepEqual(
txController.store.getState().lastFetchedBlockNumbers,
undefined,
);
await incomingTransactionHelperEventMock.secondCall.args[1]({
lastFetchedBlockNumbers,
});
assert.deepEqual(
txController.store.getState().lastFetchedBlockNumbers,
lastFetchedBlockNumbers,
);
});
});
});

View File

@ -0,0 +1,49 @@
import { Hex } from '@metamask/utils';
import { TransactionMeta } from '../../../../shared/constants/transaction';
/**
* The configuration required to fetch transaction data from a RemoteTransactionSource.
*/
export interface RemoteTransactionSourceRequest {
/**
* The address of the account to fetch transactions for.
*/
address: string;
/**
* API key if required by the remote source.
*/
apiKey?: string;
/**
* The chainId of the current network.
*/
currentChainId: Hex;
/**
* The networkId of the current network.
*/
currentNetworkId: string;
/**
* Block number to start fetching transactions from.
*/
fromBlock?: number;
/**
* Maximum number of transactions to retrieve.
*/
limit?: number;
}
/**
* An object capable of fetching transaction data from a remote source.
* Used by the IncomingTransactionHelper to retrieve remote transaction data.
*/
export interface RemoteTransactionSource {
isSupportedNetwork: (chainId: Hex, networkId: string) => boolean;
fetchTransactions: (
request: RemoteTransactionSourceRequest,
) => Promise<TransactionMeta[]>;
}

View File

@ -51,6 +51,7 @@ export default class ComposableObservableStore extends ObservableStore {
updateStructure(config) {
this.config = config;
this.removeAllListeners();
const initialState = {};
for (const key of Object.keys(config)) {
if (!config[key]) {
throw new Error(`Undefined '${key}'`);
@ -72,7 +73,10 @@ export default class ComposableObservableStore extends ObservableStore {
},
);
}
initialState[key] = store.state ?? store.getState?.();
}
this.updateState(initialState);
}
/**

View File

@ -120,6 +120,46 @@ describe('ComposableObservableStore', () => {
});
});
it('should initialize state with all three types of stores', () => {
const controllerMessenger = new ControllerMessenger();
const exampleStore = new ObservableStore();
const exampleController = new ExampleController({
messenger: controllerMessenger,
});
const oldExampleController = new OldExampleController();
exampleStore.putState('state');
exampleController.updateBar('state');
oldExampleController.updateBaz('state');
const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({
Example: exampleController,
OldExample: oldExampleController,
Store: exampleStore,
});
expect(store.getState()).toStrictEqual({
Example: { bar: 'state' },
OldExample: { baz: 'state' },
Store: 'state',
});
});
it('should initialize falsy state', () => {
const controllerMessenger = new ControllerMessenger();
const exampleStore = new ObservableStore();
exampleStore.putState(false);
const store = new ComposableObservableStore({ controllerMessenger });
store.updateStructure({
Example: exampleStore,
});
expect(store.getState()).toStrictEqual({
Example: false,
});
});
it('should return flattened state', () => {
const controllerMessenger = new ControllerMessenger();
const fooStore = new ObservableStore({ foo: 'foo' });

View File

@ -1,6 +1,6 @@
import { prependZero } from '../../../shared/modules/string-utils';
export default class BackupController {
export default class Backup {
constructor(opts = {}) {
const {
preferencesController,

View File

@ -1,6 +1,6 @@
import { strict as assert } from 'assert';
import sinon from 'sinon';
import BackupController from './backup';
import Backup from './backup';
function getMockPreferencesController() {
const mcState = {
@ -131,7 +131,6 @@ const jsonData = JSON.stringify({
advancedGasFee: null,
featureFlags: {
sendHexData: true,
showIncomingTransactions: true,
},
knownMethodData: {},
currentLocale: 'en',
@ -151,9 +150,9 @@ const jsonData = JSON.stringify({
},
});
describe('BackupController', function () {
const getBackupController = () => {
return new BackupController({
describe('Backup', function () {
const getBackup = () => {
return new Backup({
preferencesController: getMockPreferencesController(),
addressBookController: getMockAddressBookController(),
networkController: getMockNetworkController(),
@ -163,85 +162,81 @@ describe('BackupController', function () {
describe('constructor', function () {
it('should setup correctly', async function () {
const backupController = getBackupController();
const selectedAddress =
backupController.preferencesController.getSelectedAddress();
const backup = getBackup();
const selectedAddress = backup.preferencesController.getSelectedAddress();
assert.equal(selectedAddress, '0x01');
});
it('should restore backup', async function () {
const backupController = getBackupController();
await backupController.restoreUserData(jsonData);
const backup = getBackup();
await backup.restoreUserData(jsonData);
// check networks backup
assert.equal(
backupController.networkController.state.networkConfigurations[
backup.networkController.state.networkConfigurations[
'network-configuration-id-1'
].chainId,
'0x539',
);
assert.equal(
backupController.networkController.state.networkConfigurations[
backup.networkController.state.networkConfigurations[
'network-configuration-id-2'
].chainId,
'0x38',
);
assert.equal(
backupController.networkController.state.networkConfigurations[
backup.networkController.state.networkConfigurations[
'network-configuration-id-3'
].chainId,
'0x61',
);
assert.equal(
backupController.networkController.state.networkConfigurations[
backup.networkController.state.networkConfigurations[
'network-configuration-id-4'
].chainId,
'0x89',
);
// make sure identities are not lost after restore
assert.equal(
backupController.preferencesController.store.identities[
backup.preferencesController.store.identities[
'0x295e26495CEF6F69dFA69911d9D8e4F3bBadB89B'
].lastSelected,
1655380342907,
);
assert.equal(
backupController.preferencesController.store.identities[
backup.preferencesController.store.identities[
'0x295e26495CEF6F69dFA69911d9D8e4F3bBadB89B'
].name,
'Account 3',
);
assert.equal(
backupController.preferencesController.store.lostIdentities[
backup.preferencesController.store.lostIdentities[
'0xfd59bbe569376e3d3e4430297c3c69ea93f77435'
].lastSelected,
1655379648197,
);
assert.equal(
backupController.preferencesController.store.lostIdentities[
backup.preferencesController.store.lostIdentities[
'0xfd59bbe569376e3d3e4430297c3c69ea93f77435'
].name,
'Ledger 1',
);
// make sure selected address is not lost after restore
assert.equal(
backupController.preferencesController.store.selectedAddress,
'0x01',
);
assert.equal(backup.preferencesController.store.selectedAddress, '0x01');
// check address book backup
assert.equal(
backupController.addressBookController.store.addressBook['0x61'][
backup.addressBookController.store.addressBook['0x61'][
'0x42EB768f2244C8811C63729A21A3569731535f06'
].chainId,
'0x61',
);
assert.equal(
backupController.addressBookController.store.addressBook['0x61'][
backup.addressBookController.store.addressBook['0x61'][
'0x42EB768f2244C8811C63729A21A3569731535f06'
].address,
'0x42EB768f2244C8811C63729A21A3569731535f06',
);
assert.equal(
backupController.addressBookController.store.addressBook['0x61'][
backup.addressBookController.store.addressBook['0x61'][
'0x42EB768f2244C8811C63729A21A3569731535f06'
].isEns,
false,

View File

@ -16,6 +16,7 @@ export default class ExtensionStore {
// once data persistence fails once and it flips true we don't send further
// data persistence errors to sentry
this.dataPersistenceFailing = false;
this.mostRecentRetrievedState = null;
}
setMetadata(initMetaData) {
@ -66,8 +67,10 @@ export default class ExtensionStore {
// extension.storage.local always returns an obj
// if the object is empty, treat it as undefined
if (isEmpty(result)) {
this.mostRecentRetrievedState = null;
return undefined;
}
this.mostRecentRetrievedState = result;
return result;
}

View File

@ -2,11 +2,12 @@ import browser from 'webextension-polyfill';
import LocalStore from './local-store';
jest.mock('webextension-polyfill', () => ({
runtime: { lastError: null },
storage: { local: true },
}));
const setup = ({ isSupported }) => {
browser.storage.local = isSupported;
const setup = ({ localMock = jest.fn() } = {}) => {
browser.storage.local = localMock;
return new LocalStore();
};
describe('LocalStore', () => {
@ -15,21 +16,27 @@ describe('LocalStore', () => {
});
describe('contructor', () => {
it('should set isSupported property to false when browser does not support local storage', () => {
const localStore = setup({ isSupported: false });
const localStore = setup({ localMock: false });
expect(localStore.isSupported).toBe(false);
});
it('should set isSupported property to true when browser supports local storage', () => {
const localStore = setup({ isSupported: true });
const localStore = setup();
expect(localStore.isSupported).toBe(true);
});
it('should initialize mostRecentRetrievedState to null', () => {
const localStore = setup({ localMock: false });
expect(localStore.mostRecentRetrievedState).toBeNull();
});
});
describe('setMetadata', () => {
it('should set the metadata property on LocalStore', () => {
const metadata = { version: 74 };
const localStore = setup({ isSupported: true });
const localStore = setup();
localStore.setMetadata(metadata);
expect(localStore.metadata).toStrictEqual(metadata);
@ -38,21 +45,21 @@ describe('LocalStore', () => {
describe('set', () => {
it('should throw an error if called in a browser that does not support local storage', async () => {
const localStore = setup({ isSupported: false });
const localStore = setup({ localMock: false });
await expect(() => localStore.set()).rejects.toThrow(
'Metamask- cannot persist state to local store as this browser does not support this action',
);
});
it('should throw an error if not passed a truthy value as an argument', async () => {
const localStore = setup({ isSupported: true });
const localStore = setup();
await expect(() => localStore.set()).rejects.toThrow(
'MetaMask - updated state is missing',
);
});
it('should throw an error if passed a valid argument but metadata has not yet been set', async () => {
const localStore = setup({ isSupported: true });
const localStore = setup();
await expect(() =>
localStore.set({ appState: { test: true } }),
).rejects.toThrow(
@ -61,7 +68,7 @@ describe('LocalStore', () => {
});
it('should not throw if passed a valid argument and metadata has been set', async () => {
const localStore = setup({ isSupported: true });
const localStore = setup();
localStore.setMetadata({ version: 74 });
await expect(async function () {
localStore.set({ appState: { test: true } });
@ -71,9 +78,39 @@ describe('LocalStore', () => {
describe('get', () => {
it('should return undefined if called in a browser that does not support local storage', async () => {
const localStore = setup({ isSupported: false });
const localStore = setup({ localMock: false });
const result = await localStore.get();
expect(result).toStrictEqual(undefined);
});
it('should update mostRecentRetrievedState', async () => {
const localStore = setup({
localMock: {
get: jest
.fn()
.mockImplementation(() =>
Promise.resolve({ appState: { test: true } }),
),
},
});
await localStore.get();
expect(localStore.mostRecentRetrievedState).toStrictEqual({
appState: { test: true },
});
});
it('should reset mostRecentRetrievedState to null if storage.local is empty', async () => {
const localStore = setup({
localMock: {
get: jest.fn().mockImplementation(() => Promise.resolve({})),
},
});
await localStore.get();
expect(localStore.mostRecentRetrievedState).toStrictEqual(null);
});
});
});

View File

@ -1,4 +1,5 @@
import EventEmitter from 'events';
import log from 'loglevel';
/**
* @typedef {object} Migration
@ -36,6 +37,8 @@ export default class Migrator extends EventEmitter {
// perform each migration
for (const migration of pendingMigrations) {
try {
log.info(`Running migration ${migration.version}...`);
// attempt migration and validate
const migratedData = await migration.migrate(versionedData);
if (!migratedData.data) {
@ -52,6 +55,8 @@ export default class Migrator extends EventEmitter {
// accept the migration as good
// eslint-disable-next-line no-param-reassign
versionedData = migratedData;
log.info(`Migration ${migration.version} complete`);
} catch (err) {
// rewrite error message to add context without clobbering stack
const originalErrorMessage = err.message;

View File

@ -15,6 +15,7 @@ export default class ReadOnlyNetworkStore {
this._initialized = false;
this._initializing = this._init();
this._state = undefined;
this.mostRecentRetrievedState = null;
}
/**
@ -47,6 +48,11 @@ export default class ReadOnlyNetworkStore {
if (!this._initialized) {
await this._initializing;
}
// Delay setting this until after the first read, to match the
// behavior of the local store.
if (!this.mostRecentRetrievedState) {
this.mostRecentRetrievedState = this._state;
}
return this._state;
}

View File

@ -1,16 +1,16 @@
import { ethErrors, errorCodes } from 'eth-rpc-errors';
import validUrl from 'valid-url';
import { omit } from 'lodash';
import { ApprovalType } from '@metamask/controller-utils';
import { errorCodes, ethErrors } from 'eth-rpc-errors';
import { omit } from 'lodash';
import {
MESSAGE_TYPE,
UNKNOWN_TICKER_SYMBOL,
} from '../../../../../shared/constants/app';
import { MetaMetricsNetworkEventSource } from '../../../../../shared/constants/metametrics';
import {
isPrefixedFormattedHexString,
isSafeChainId,
} from '../../../../../shared/modules/network.utils';
import { MetaMetricsNetworkEventSource } from '../../../../../shared/constants/metametrics';
import { getValidUrl } from '../../util';
const addEthereumChain = {
methodNames: [MESSAGE_TYPE.ADD_ETHEREUM_CHAIN],
@ -83,27 +83,25 @@ async function addEthereumChainHandler(
);
}
const isLocalhost = (strUrl) => {
try {
const url = new URL(strUrl);
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
} catch (error) {
return false;
function isLocalhostOrHttps(urlString) {
const url = getValidUrl(urlString);
return (
url !== null &&
(url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.protocol === 'https:')
);
}
};
const firstValidRPCUrl = Array.isArray(rpcUrls)
? rpcUrls.find(
(rpcUrl) => isLocalhost(rpcUrl) || validUrl.isHttpsUri(rpcUrl),
)
? rpcUrls.find((rpcUrl) => isLocalhostOrHttps(rpcUrl))
: null;
const firstValidBlockExplorerUrl =
blockExplorerUrls !== null && Array.isArray(blockExplorerUrls)
? blockExplorerUrls.find(
(blockExplorerUrl) =>
isLocalhost(blockExplorerUrl) ||
validUrl.isHttpsUri(blockExplorerUrl),
? blockExplorerUrls.find((blockExplorerUrl) =>
isLocalhostOrHttps(blockExplorerUrl),
)
: null;

View File

@ -1,18 +1,15 @@
import { MESSAGE_TYPE } from '../../../../../shared/constants/app';
import { MetaMetricsEventCategory } from '../../../../../shared/constants/metametrics';
/**
* This RPC method is called by the inpage provider whenever it detects the
* accessing of a non-existent property on our window.web3 shim.
* We collect this data to understand which sites are breaking due to the
* removal of our window.web3.
* accessing of a non-existent property on our window.web3 shim. We use this
* to alert the user that they are using a legacy dapp, and will have to take
* further steps to be able to use it.
*/
const logWeb3ShimUsage = {
methodNames: [MESSAGE_TYPE.LOG_WEB3_SHIM_USAGE],
implementation: logWeb3ShimUsageHandler,
hookNames: {
sendMetrics: true,
getWeb3ShimUsageState: true,
setWeb3ShimUsageRecorded: true,
},
@ -21,7 +18,6 @@ export default logWeb3ShimUsage;
/**
* @typedef {object} LogWeb3ShimUsageOptions
* @property {Function} sendMetrics - A function that registers a metrics event.
* @property {Function} getWeb3ShimUsageState - A function that gets web3 shim
* usage state for the given origin.
* @property {Function} setWeb3ShimUsageRecorded - A function that records web3 shim
@ -40,24 +36,11 @@ function logWeb3ShimUsageHandler(
res,
_next,
end,
{ sendMetrics, getWeb3ShimUsageState, setWeb3ShimUsageRecorded },
{ getWeb3ShimUsageState, setWeb3ShimUsageRecorded },
) {
const { origin } = req;
if (getWeb3ShimUsageState(origin) === undefined) {
setWeb3ShimUsageRecorded(origin);
sendMetrics(
{
event: `Website Accessed window.web3 Shim`,
category: MetaMetricsEventCategory.InpageProvider,
referrer: {
url: origin,
},
},
{
excludeMetaMetricsId: true,
},
);
}
res.result = true;

View File

@ -0,0 +1,76 @@
import { maskObject } from '../../../shared/modules/object.utils';
import ExtensionPlatform from '../platforms/extension';
import LocalStore from './local-store';
import ReadOnlyNetworkStore from './network-store';
import { SENTRY_BACKGROUND_STATE } from './setupSentry';
const platform = new ExtensionPlatform();
// This instance of `localStore` is used by Sentry to get the persisted state
const sentryLocalStore = process.env.IN_TEST
? new ReadOnlyNetworkStore()
: new LocalStore();
/**
* Get the persisted wallet state.
*
* @returns The persisted wallet state.
*/
globalThis.stateHooks.getPersistedState = async function () {
return await sentryLocalStore.get();
};
const persistedStateMask = {
data: SENTRY_BACKGROUND_STATE,
meta: {
version: true,
},
};
/**
* Get a state snapshot for Sentry. This is used to add additional context to
* error reports, and it's used when processing errors and breadcrumbs to
* determine whether the user has opted into Metametrics.
*
* This uses the persisted state pre-initialization, and the in-memory state
* post-initialization. In both cases the state is anonymized.
*
* @returns A Sentry state snapshot.
*/
globalThis.stateHooks.getSentryState = function () {
const sentryState = {
browser: window.navigator.userAgent,
version: platform.getVersion(),
};
// If `getSentryAppState` is set, it implies that initialization has completed
if (globalThis.stateHooks.getSentryAppState) {
return {
...sentryState,
state: globalThis.stateHooks.getSentryAppState(),
};
} else if (
// This is truthy if Sentry has retrieved state at least once already. This
// should always be true when getting context for an error report, but can
// be unset when Sentry is performing the opt-in check.
sentryLocalStore.mostRecentRetrievedState ||
// This is only set in the background process.
globalThis.stateHooks.getMostRecentPersistedState
) {
const persistedState =
sentryLocalStore.mostRecentRetrievedState ||
globalThis.stateHooks.getMostRecentPersistedState();
// This can be unset when this method is called in the background for an
// opt-in check, but the state hasn't been loaded yet.
if (persistedState) {
return {
...sentryState,
persistedState: maskObject(persistedState, persistedStateMask),
};
}
}
// This branch means that local storage has not yet been read, so we have
// no choice but to omit the application state.
// This should be unreachable when getting context for an error report, but
// can be false when Sentry is performing the opt-in check.
return sentryState;
};

View File

@ -1,10 +0,0 @@
import LocalStore from './local-store';
import ReadOnlyNetworkStore from './network-store';
const localStore = process.env.IN_TEST
? new ReadOnlyNetworkStore()
: new LocalStore();
globalThis.stateHooks.getPersistedState = async function () {
return await localStore.get();
};

View File

@ -23,58 +23,172 @@ export const ERROR_URL_ALLOWLIST = {
SEGMENT: 'segment.io',
};
// This describes the subset of Redux state attached to errors sent to Sentry
// These properties have some potential to be useful for debugging, and they do
// not contain any identifiable information.
export const SENTRY_STATE = {
gas: true,
history: true,
metamask: {
// This describes the subset of background controller state attached to errors
// sent to Sentry These properties have some potential to be useful for
// debugging, and they do not contain any identifiable information.
export const SENTRY_BACKGROUND_STATE = {
AccountTracker: {
currentBlockGasLimit: true,
},
AlertController: {
alertEnabledness: true,
completedOnboarding: true,
},
AppMetadataController: {
currentAppVersion: true,
previousAppVersion: true,
previousMigrationVersion: true,
currentMigrationVersion: true,
},
AppStateController: {
connectedStatusPopoverHasBeenShown: true,
defaultHomeActiveTabName: true,
},
CurrencyController: {
conversionDate: true,
conversionRate: true,
currentBlockGasLimit: true,
currentCurrency: true,
currentLocale: true,
customNonceValue: true,
defaultHomeActiveTabName: true,
desktopEnabled: true,
featureFlags: true,
firstTimeFlowType: true,
forgottenPassword: true,
incomingTxLastFetchedBlockByChainId: true,
ipfsGateway: true,
isAccountMenuOpen: true,
isInitialized: true,
isUnlocked: true,
metaMetricsId: true,
nativeCurrency: true,
},
DecryptMessageController: {
unapprovedDecryptMsgCount: true,
},
DesktopController: {
desktopEnabled: true,
},
EncryptionPublicKeyController: {
unapprovedEncryptionPublicKeyMsgCount: true,
},
KeyringController: {
isUnlocked: true,
},
MetaMetricsController: {
metaMetricsId: true,
participateInMetaMetrics: true,
},
NetworkController: {
networkId: true,
networkStatus: true,
nextNonce: true,
participateInMetaMetrics: true,
preferences: true,
providerConfig: {
nickname: true,
ticker: true,
type: true,
},
},
OnboardingController: {
completedOnboarding: true,
firstTimeFlowType: true,
seedPhraseBackedUp: true,
unapprovedDecryptMsgCount: true,
unapprovedEncryptionPublicKeyMsgCount: true,
unapprovedMsgCount: true,
unapprovedPersonalMsgCount: true,
unapprovedTypedMessagesCount: true,
},
PreferencesController: {
currentLocale: true,
featureFlags: true,
forgottenPassword: true,
ipfsGateway: true,
preferences: true,
useBlockie: true,
useNonceField: true,
usePhishDetect: true,
},
SignatureController: {
unapprovedMsgCount: true,
unapprovedPersonalMsgCount: true,
unapprovedTypedMessagesCount: true,
},
};
const flattenedBackgroundStateMask = Object.values(
SENTRY_BACKGROUND_STATE,
).reduce((partialBackgroundState, controllerState) => {
return {
...partialBackgroundState,
...controllerState,
};
}, {});
// This describes the subset of Redux state attached to errors sent to Sentry
// These properties have some potential to be useful for debugging, and they do
// not contain any identifiable information.
export const SENTRY_UI_STATE = {
gas: true,
history: true,
metamask: {
...flattenedBackgroundStateMask,
// This property comes from the background but isn't in controller state
isInitialized: true,
// These properties are in the `metamask` slice but not in the background state
customNonceValue: true,
isAccountMenuOpen: true,
nextNonce: true,
welcomeScreenSeen: true,
},
unconnectedAccount: true,
};
/**
* Returns whether MetaMetrics is enabled, given the application state.
*
* @param {{ state: unknown} | { persistedState: unknown }} appState - Application state
* @returns `true` if MetaMask's state has been initialized, and MetaMetrics
* is enabled, `false` otherwise.
*/
function getMetaMetricsEnabledFromAppState(appState) {
// during initialization after loading persisted state
if (appState.persistedState) {
return getMetaMetricsEnabledFromPersistedState(appState.persistedState);
// After initialization
} else if (appState.state) {
// UI
if (appState.state.metamask) {
return Boolean(appState.state.metamask.participateInMetaMetrics);
}
// background
return Boolean(
appState.state.MetaMetricsController?.participateInMetaMetrics,
);
}
// during initialization, before first persisted state is read
return false;
}
/**
* Returns whether MetaMetrics is enabled, given the persisted state.
*
* @param {unknown} persistedState - Application state
* @returns `true` if MetaMask's state has been initialized, and MetaMetrics
* is enabled, `false` otherwise.
*/
function getMetaMetricsEnabledFromPersistedState(persistedState) {
return Boolean(
persistedState?.data?.MetaMetricsController?.participateInMetaMetrics,
);
}
/**
* Returns whether onboarding has completed, given the application state.
*
* @param {Record<string, unknown>} appState - Application state
* @returns `true` if MetaMask's state has been initialized, and MetaMetrics
* is enabled, `false` otherwise.
*/
function getOnboardingCompleteFromAppState(appState) {
// during initialization after loading persisted state
if (appState.persistedState) {
return Boolean(
appState.persistedState.data?.OnboardingController?.completedOnboarding,
);
// After initialization
} else if (appState.state) {
// UI
if (appState.state.metamask) {
return Boolean(appState.state.metamask.completedOnboarding);
}
// background
return Boolean(appState.state.OnboardingController?.completedOnboarding);
}
// during initialization, before first persisted state is read
return false;
}
export default function setupSentry({ release, getState }) {
if (!release) {
throw new Error('Missing release');
@ -112,22 +226,21 @@ export default function setupSentry({ release, getState }) {
}
/**
* A function that returns whether MetaMetrics is enabled. This should also
* return `false` if state has not yet been initialzed.
* Returns whether MetaMetrics is enabled. If the application hasn't yet
* been initialized, the persisted state will be used (if any).
*
* @returns `true` if MetaMask's state has been initialized, and MetaMetrics
* is enabled, `false` otherwise.
* @returns `true` if MetaMetrics is enabled, `false` otherwise.
*/
async function getMetaMetricsEnabled() {
const appState = getState();
if (Object.keys(appState) > 0) {
return Boolean(appState?.store?.metamask?.participateInMetaMetrics);
if (appState.state || appState.persistedState) {
return getMetaMetricsEnabledFromAppState(appState);
}
// If we reach here, it means the error was thrown before initialization
// completed, and before we loaded the persisted state for the first time.
try {
const persistedState = await globalThis.stateHooks.getPersistedState();
return Boolean(
persistedState?.data?.MetaMetricsController?.participateInMetaMetrics,
);
return getMetaMetricsEnabledFromPersistedState(persistedState);
} catch (error) {
console.error(error);
return false;
@ -269,17 +382,15 @@ function hideUrlIfNotInternal(url) {
*/
export function beforeBreadcrumb(getState) {
return (breadcrumb) => {
if (getState) {
const appState = getState();
if (
Object.values(appState).length &&
(!appState?.store?.metamask?.participateInMetaMetrics ||
!appState?.store?.metamask?.completedOnboarding ||
breadcrumb?.category === 'ui.input')
) {
if (!getState) {
return null;
}
} else {
const appState = getState();
if (
!getMetaMetricsEnabledFromAppState(appState) ||
!getOnboardingCompleteFromAppState(appState) ||
breadcrumb?.category === 'ui.input'
) {
return null;
}
const newBreadcrumb = removeUrlsFromBreadCrumb(breadcrumb);

View File

@ -1,24 +1,27 @@
import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils';
import {
ENVIRONMENT_TYPE_POPUP,
ENVIRONMENT_TYPE_NOTIFICATION,
ENVIRONMENT_TYPE_FULLSCREEN,
ENVIRONMENT_TYPE_BACKGROUND,
PLATFORM_FIREFOX,
PLATFORM_OPERA,
ENVIRONMENT_TYPE_FULLSCREEN,
ENVIRONMENT_TYPE_NOTIFICATION,
ENVIRONMENT_TYPE_POPUP,
PLATFORM_CHROME,
PLATFORM_EDGE,
PLATFORM_FIREFOX,
PLATFORM_OPERA,
} from '../../../shared/constants/app';
import {
TransactionEnvelopeType,
TransactionStatus,
TransactionType,
TransactionEnvelopeType,
} from '../../../shared/constants/transaction';
import { isPrefixedFormattedHexString } from '../../../shared/modules/network.utils';
import {
addUrlProtocolPrefix,
deferredPromise,
formatTxMetaForRpcResult,
getEnvironmentType,
getPlatform,
formatTxMetaForRpcResult,
getValidUrl,
isWebUrl,
} from './util';
describe('app utils', () => {
@ -73,6 +76,39 @@ describe('app utils', () => {
});
});
describe('URL utils', () => {
it('should test addUrlProtocolPrefix', () => {
expect(addUrlProtocolPrefix('http://example.com')).toStrictEqual(
'http://example.com',
);
expect(addUrlProtocolPrefix('https://example.com')).toStrictEqual(
'https://example.com',
);
expect(addUrlProtocolPrefix('example.com')).toStrictEqual(
'https://example.com',
);
expect(addUrlProtocolPrefix('exa mple.com')).toStrictEqual(null);
});
it('should test isWebUrl', () => {
expect(isWebUrl('http://example.com')).toStrictEqual(true);
expect(isWebUrl('https://example.com')).toStrictEqual(true);
expect(isWebUrl('https://exa mple.com')).toStrictEqual(false);
expect(isWebUrl('')).toStrictEqual(false);
});
it('should test getValidUrl', () => {
expect(getValidUrl('http://example.com').toString()).toStrictEqual(
'http://example.com/',
);
expect(getValidUrl('https://example.com').toString()).toStrictEqual(
'https://example.com/',
);
expect(getValidUrl('https://exa%20mple.com')).toStrictEqual(null);
expect(getValidUrl('')).toStrictEqual(null);
});
});
describe('isPrefixedFormattedHexString', () => {
it('should return true for valid hex strings', () => {
expect(isPrefixedFormattedHexString('0x1')).toStrictEqual(true);

View File

@ -1,24 +1,24 @@
import urlLib from 'url';
import { AccessList } from '@ethereumjs/tx';
import BN from 'bn.js';
import { memoize } from 'lodash';
import { AccessList } from '@ethereumjs/tx';
import { CHAIN_IDS, TEST_CHAINS } from '../../../shared/constants/network';
import {
ENVIRONMENT_TYPE_POPUP,
ENVIRONMENT_TYPE_NOTIFICATION,
ENVIRONMENT_TYPE_FULLSCREEN,
ENVIRONMENT_TYPE_BACKGROUND,
PLATFORM_FIREFOX,
PLATFORM_OPERA,
ENVIRONMENT_TYPE_FULLSCREEN,
ENVIRONMENT_TYPE_NOTIFICATION,
ENVIRONMENT_TYPE_POPUP,
PLATFORM_BRAVE,
PLATFORM_CHROME,
PLATFORM_EDGE,
PLATFORM_BRAVE,
PLATFORM_FIREFOX,
PLATFORM_OPERA,
} from '../../../shared/constants/app';
import { stripHexPrefix } from '../../../shared/modules/hexstring-utils';
import { CHAIN_IDS, TEST_CHAINS } from '../../../shared/constants/network';
import {
TransactionEnvelopeType,
TransactionMeta,
} from '../../../shared/constants/transaction';
import { stripHexPrefix } from '../../../shared/modules/hexstring-utils';
/**
* @see {@link getEnvironmentType}
@ -143,13 +143,13 @@ function checkAlarmExists(alarmList: { name: string }[], alarmName: string) {
}
export {
getPlatform,
getEnvironmentType,
hexToBn,
BnMultiplyByFraction,
addHexPrefix,
getChainType,
checkAlarmExists,
getChainType,
getEnvironmentType,
getPlatform,
hexToBn,
};
// Taken from https://stackoverflow.com/a/1349426/3696652
@ -235,10 +235,43 @@ export function previousValueComparator<A>(
}
export function addUrlProtocolPrefix(urlString: string) {
if (!urlString.match(/(^http:\/\/)|(^https:\/\/)/u)) {
return `https://${urlString}`;
let trimmed = urlString.trim();
if (trimmed.length && !urlLib.parse(trimmed).protocol) {
trimmed = `https://${trimmed}`;
}
return urlString;
if (getValidUrl(trimmed) !== null) {
return trimmed;
}
return null;
}
export function getValidUrl(urlString: string): URL | null {
try {
const url = new URL(urlString);
if (url.hostname.length === 0 || url.pathname.length === 0) {
return null;
}
if (url.hostname !== decodeURIComponent(url.hostname)) {
return null; // will happen if there's a %, a space, or other invalid character in the hostname
}
return url;
} catch (error) {
return null;
}
}
export function isWebUrl(urlString: string): boolean {
const url = getValidUrl(urlString);
return (
url !== null && (url.protocol === 'https:' || url.protocol === 'http:')
);
}
interface FormattedTransactionMeta {

View File

@ -145,27 +145,21 @@ describe('MetaMaskController', function () {
metamaskController.addNewAccount(1),
metamaskController.addNewAccount(1),
]);
assert.deepEqual(
Object.keys(addNewAccountResult1.identities),
Object.keys(addNewAccountResult2.identities),
);
assert.equal(addNewAccountResult1, addNewAccountResult2);
});
it('two successive calls with same accountCount give same result', async function () {
await metamaskController.createNewVaultAndKeychain('test@123');
const addNewAccountResult1 = await metamaskController.addNewAccount(1);
const addNewAccountResult2 = await metamaskController.addNewAccount(1);
assert.deepEqual(
Object.keys(addNewAccountResult1.identities),
Object.keys(addNewAccountResult2.identities),
);
assert.equal(addNewAccountResult1, addNewAccountResult2);
});
it('two successive calls with different accountCount give different results', async function () {
await metamaskController.createNewVaultAndKeychain('test@123');
const addNewAccountResult1 = await metamaskController.addNewAccount(1);
const addNewAccountResult2 = await metamaskController.addNewAccount(2);
assert.notDeepEqual(addNewAccountResult1, addNewAccountResult2);
assert.notEqual(addNewAccountResult1, addNewAccountResult2);
});
});
@ -178,14 +172,14 @@ describe('MetaMaskController', function () {
await metamaskController.createNewVaultAndKeychain('test@123');
await Promise.all([
metamaskController.importAccountWithStrategy('Private Key', [
metamaskController.importAccountWithStrategy('privateKey', [
importPrivkey,
]),
Promise.resolve(1).then(() => {
keyringControllerState1 = JSON.stringify(
metamaskController.keyringController.memStore.getState(),
);
metamaskController.importAccountWithStrategy('Private Key', [
metamaskController.importAccountWithStrategy('privateKey', [
importPrivkey,
]);
}),
@ -226,6 +220,14 @@ describe('MetaMaskController', function () {
});
});
describe('#setLocked', function () {
it('should lock the wallet', async function () {
const { isUnlocked, keyrings } = await metamaskController.setLocked();
assert(!isUnlocked);
assert.deepEqual(keyrings, []);
});
});
describe('#addToken', function () {
const address = '0x514910771af9ca656af840dff83e8264ecf986ca';
const symbol = 'LINK';

Some files were not shown because too many files have changed in this diff Show More