From 275523a24e4f496384943f107369810cd93aa86e Mon Sep 17 00:00:00 2001 From: jiexi Date: Thu, 22 Feb 2024 11:43:24 -0800 Subject: [PATCH] Bump `@metamask/transaction-controller` to `^23.0.0` (#22979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Currently, Extension can only process transactions for the active / globally selected network. This makes it not possible to facilitate multichain interactions without constantly rotating the active network or some other workaround that would hurt user experience. We want to keep Metamask competitive by making our multichain experience as seamless and frictionless as possible. This PR introduces an updated version of the TransactionController that is capable of handling transactions across multiple network clients concurrently which lays the groundwork for future multichain changes. * Bumps `@metamask/transaction-controller` to `^23.0.0` * Adds `TRANSACTION_MULTICHAIN` environment variable * when set, enables the TransactionController to submit, process, and track transactions concurrently on mutiple networks * defaults off NOTE: This PR does not introduce a CI pipeline for building and testing with TRANSACTION_MULTICHAIN enabled because the SwapController still needs to be updated to be compatible. This feature flag defaults off and does not change the current user facing TransactionController behavior, so current testing pipelines are sufficient. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/1990 ## **Manual testing steps** ### Testing the single globally selected transaction flow There should be no user facing difference in behavior. ### Testing the multichain transaction flow 1. Make a build with `TRANSACTION_MULTICHAIN` set to 1 2. Switch to local testnet network 3. Send a transaction 4. Quickly, before the transaction is marked confirmed, switch the globally selected network to a network on a different chain 5. Wait a few moments 6. Turn off the local testnet so that Metamask cannot connect to it anymore 7. Switch back to the testnet network 8. The transaction should be confirmed This demonstrates that the TransactionController was polling the local testnet network for transaction updates while it was not the active / globally selected network and was able to confirm the transaction onchain (as opposed to it confirming the transaction right as it became the active / globally selected network again since Metamask wouldn't be able to reach the local testnet anymore) ## **Screenshots/Recordings** ### **Before** https://github.com/MetaMask/metamask-extension/assets/918701/d4e72e76-e975-460f-a7c4-c294bea88708 Notice that the transaction was not confirmed when we switched to mainnet and that when we switched back to localhost that it confirmed instantly when we turned ganache back on, implying that the TransactionController is only polling for the currently active network. ### **After** (with TRANSACTION_MULTICHAIN flag enabled) https://github.com/MetaMask/metamask-extension/assets/918701/46edc340-0c91-45cf-928f-20c185aa407e Notice that the local testnet transaction was not confirmed before we switched to mainnet, but was confirmed by the time we switched back to it despite us being on mainnet and turning off ganache, implying that the TransactionController was polling for the local testnet transaction while mainnet was the globally selected network. ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md). - [x] I've clearly explained what problem this PR is solving and how it is solved. - [x] I've linked related issues - [x] I've included manual testing steps - [x] I've included screenshots/recordings if applicable - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. - [ ] I’ve properly set the pull request status: - [ ] In case it's not yet "ready for review", I've set it to "draft". - [ ] In case it's "ready for review", I've changed it from "draft" to "non-draft". ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: MetaMask Bot Co-authored-by: Alex --- .../createRPCMethodTrackingMiddleware.test.js | 15 +++-- app/scripts/lib/middleware/pending.js | 2 +- app/scripts/lib/transaction/util.test.ts | 66 ++++++++++++++----- app/scripts/lib/transaction/util.ts | 16 +++-- app/scripts/metamask-controller.js | 28 ++++++-- builds.yml | 2 + lavamoat/browserify/beta/policy.json | 3 +- lavamoat/browserify/desktop/policy.json | 3 +- lavamoat/browserify/flask/policy.json | 3 +- lavamoat/browserify/main/policy.json | 3 +- lavamoat/browserify/mmi/policy.json | 3 +- package.json | 4 +- ui/store/actions.ts | 3 + yarn.lock | 28 ++++---- 14 files changed, 125 insertions(+), 54 deletions(-) diff --git a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js index 2f3f563b3594..41b78c080e0c 100644 --- a/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js +++ b/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js @@ -79,11 +79,16 @@ function getNext(timeout = 500) { const waitForSeconds = async (seconds) => await new Promise((resolve) => setTimeout(resolve, SECOND * seconds)); -jest.mock('@metamask/controller-utils', () => ({ - detectSIWE: jest.fn().mockImplementation(() => { - return { isSIWEMessage: false }; - }), -})); +jest.mock('@metamask/controller-utils', () => { + const actual = jest.requireActual('@metamask/controller-utils'); + + return { + ...actual, + detectSIWE: jest.fn().mockImplementation(() => { + return { isSIWEMessage: false }; + }), + }; +}); describe('createRPCMethodTrackingMiddleware', () => { afterEach(() => { diff --git a/app/scripts/lib/middleware/pending.js b/app/scripts/lib/middleware/pending.js index 9852d55528ed..9e01d11ffcb2 100644 --- a/app/scripts/lib/middleware/pending.js +++ b/app/scripts/lib/middleware/pending.js @@ -13,7 +13,7 @@ export function createPendingNonceMiddleware({ getPendingNonce }) { next(); return; } - res.result = await getPendingNonce(param); + res.result = await getPendingNonce(param, req.networkClientId); }); } diff --git a/app/scripts/lib/transaction/util.test.ts b/app/scripts/lib/transaction/util.test.ts index 7d27624e8cde..1fc5138b0472 100644 --- a/app/scripts/lib/transaction/util.test.ts +++ b/app/scripts/lib/transaction/util.test.ts @@ -139,10 +139,26 @@ describe('Transaction Utils', () => { ).toHaveBeenCalledTimes(1); expect( request.transactionController.addTransaction, - ).toHaveBeenCalledWith( - TRANSACTION_PARAMS_MOCK, - TRANSACTION_OPTIONS_MOCK, - ); + ).toHaveBeenCalledWith(TRANSACTION_PARAMS_MOCK, { + ...TRANSACTION_OPTIONS_MOCK, + }); + }); + + it('adds transaction with networkClientId if process.env.TRANSACTION_MULTICHAIN is set', async () => { + process.env.TRANSACTION_MULTICHAIN = '1'; + + await addTransaction(request); + + expect( + request.transactionController.addTransaction, + ).toHaveBeenCalledTimes(1); + expect( + request.transactionController.addTransaction, + ).toHaveBeenCalledWith(TRANSACTION_PARAMS_MOCK, { + ...TRANSACTION_OPTIONS_MOCK, + networkClientId: 'mockNetworkClientId', + }); + process.env.TRANSACTION_MULTICHAIN = ''; }); it('returns transaction meta', async () => { @@ -418,10 +434,9 @@ describe('Transaction Utils', () => { ).toHaveBeenCalledTimes(1); expect( request.transactionController.addTransaction, - ).toHaveBeenCalledWith( - TRANSACTION_PARAMS_MOCK, - TRANSACTION_OPTIONS_MOCK, - ); + ).toHaveBeenCalledWith(TRANSACTION_PARAMS_MOCK, { + ...TRANSACTION_OPTIONS_MOCK, + }); expect(request.ppomController.usePPOM).toHaveBeenCalledTimes(0); }); @@ -484,10 +499,9 @@ describe('Transaction Utils', () => { ).toHaveBeenCalledTimes(1); expect( request.transactionController.addTransaction, - ).toHaveBeenCalledWith( - TRANSACTION_PARAMS_MOCK, - TRANSACTION_OPTIONS_MOCK, - ); + ).toHaveBeenCalledWith(TRANSACTION_PARAMS_MOCK, { + ...TRANSACTION_OPTIONS_MOCK, + }); expect(request.ppomController.usePPOM).toHaveBeenCalledTimes(0); }); @@ -504,10 +518,9 @@ describe('Transaction Utils', () => { ).toHaveBeenCalledTimes(1); expect( request.transactionController.addTransaction, - ).toHaveBeenCalledWith( - TRANSACTION_PARAMS_MOCK, - TRANSACTION_OPTIONS_MOCK, - ); + ).toHaveBeenCalledWith(TRANSACTION_PARAMS_MOCK, { + ...TRANSACTION_OPTIONS_MOCK, + }); expect(request.ppomController.usePPOM).toHaveBeenCalledTimes(0); }); @@ -533,6 +546,27 @@ describe('Transaction Utils', () => { }); }); + it('adds transaction with networkClientId if process.env.TRANSACTION_MULTICHAIN is set', async () => { + process.env.TRANSACTION_MULTICHAIN = '1'; + + await addDappTransaction(dappRequest); + + expect( + request.transactionController.addTransaction, + ).toHaveBeenCalledTimes(1); + expect( + request.transactionController.addTransaction, + ).toHaveBeenCalledWith(TRANSACTION_PARAMS_MOCK, { + ...TRANSACTION_OPTIONS_MOCK, + networkClientId: 'mockNetworkClientId', + method: DAPP_REQUEST_MOCK.method, + requireApproval: true, + securityAlertResponse: DAPP_REQUEST_MOCK.securityAlertResponse, + type: undefined, + }); + process.env.TRANSACTION_MULTICHAIN = ''; + }); + it('returns transaction hash', async () => { const transactionHash = await addDappTransaction(dappRequest); expect(transactionHash).toStrictEqual(TRANSACTION_META_MOCK.hash); diff --git a/app/scripts/lib/transaction/util.ts b/app/scripts/lib/transaction/util.ts index 5c0516eb7c2e..2e42719f98ea 100644 --- a/app/scripts/lib/transaction/util.ts +++ b/app/scripts/lib/transaction/util.ts @@ -238,13 +238,17 @@ async function addTransactionOrUserOperation( async function addTransactionWithController( request: FinalAddTransactionRequest, ) { - const { transactionController, transactionOptions, transactionParams } = - request; + const { + transactionController, + transactionOptions, + transactionParams, + networkClientId, + } = request; const { result, transactionMeta } = - await transactionController.addTransaction( - transactionParams, - transactionOptions, - ); + await transactionController.addTransaction(transactionParams, { + ...transactionOptions, + ...(process.env.TRANSACTION_MULTICHAIN ? { networkClientId } : {}), + }); return { transactionMeta, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 4f9d2e67dea1..126ab3ffb981 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -1470,6 +1470,10 @@ export default class MetamaskController extends EventEmitter { getGasFeeEstimates: this.gasFeeController.fetchGasFeeEstimates.bind( this.gasFeeController, ), + getNetworkClientRegistry: + this.networkController.getNetworkClientRegistry.bind( + this.networkController, + ), getNetworkState: () => this.networkController.state, getPermittedAccounts: this.getPermittedAccounts.bind(this), getSavedGasFees: () => @@ -1478,6 +1482,7 @@ export default class MetamaskController extends EventEmitter { ], getSelectedAddress: () => this.accountsController.getSelectedAccount().address, + isMultichainEnabled: process.env.TRANSACTION_MULTICHAIN, incomingTransactions: { includeTokenTransfers: false, isEnabled: () => @@ -1493,7 +1498,12 @@ export default class MetamaskController extends EventEmitter { }, messenger: this.controllerMessenger.getRestricted({ name: 'TransactionController', - allowedActions: [`${this.approvalController.name}:addRequest`], + allowedActions: [ + `${this.approvalController.name}:addRequest`, + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + ], + allowedEvents: [`NetworkController:stateChange`], }), onNetworkStateChange: (listener) => { networkControllerMessenger.subscribe( @@ -4241,7 +4251,9 @@ export default class MetamaskController extends EventEmitter { }) { return { dappRequest, - networkClientId: this.networkController.state.selectedNetworkClientId, + networkClientId: + dappRequest?.networkClientId ?? + this.networkController.state.selectedNetworkClientId, selectedAccount: this.accountsController.getSelectedAccount(), transactionController: this.txController, transactionOptions, @@ -5308,11 +5320,13 @@ export default class MetamaskController extends EventEmitter { * Returns the nonce that will be associated with a transaction once approved * * @param {string} address - The hex string address for the transaction + * @param networkClientId - The optional networkClientId to get the nonce lock with * @returns {Promise} */ - async getPendingNonce(address) { + async getPendingNonce(address, networkClientId) { const { nonceDetails, releaseLock } = await this.txController.getNonceLock( address, + process.env.TRANSACTION_MULTICHAIN ? networkClientId : undefined, ); const pendingNonce = nonceDetails.params.highestSuggested; @@ -5325,10 +5339,14 @@ export default class MetamaskController extends EventEmitter { * Returns the next nonce according to the nonce-tracker * * @param {string} address - The hex string address for the transaction + * @param networkClientId - The optional networkClientId to get the nonce lock with * @returns {Promise} */ - async getNextNonce(address) { - const nonceLock = await this.txController.getNonceLock(address); + async getNextNonce(address, networkClientId) { + const nonceLock = await this.txController.getNonceLock( + address, + process.env.TRANSACTION_MULTICHAIN ? networkClientId : undefined, + ); nonceLock.releaseLock(); return nonceLock.nextNonce; } diff --git a/builds.yml b/builds.yml index 978ae5086e35..3d282460eebd 100644 --- a/builds.yml +++ b/builds.yml @@ -261,6 +261,8 @@ env: - BLOCKAID_PUBLIC_KEY # Determines if feature flagged Multichain UI should be used - MULTICHAIN: '' + # Determines if feature flagged Multichain Transactions should be used + - TRANSACTION_MULTICHAIN: '' ### # Meta variables diff --git a/lavamoat/browserify/beta/policy.json b/lavamoat/browserify/beta/policy.json index 079a449c78c6..c1e2e6710da5 100644 --- a/lavamoat/browserify/beta/policy.json +++ b/lavamoat/browserify/beta/policy.json @@ -47,7 +47,7 @@ "TextEncoder": true }, "packages": { - "@metamask/assets-controllers>multiformats": true + "@ensdomains/content-hash>cids>multibase": true } }, "@ensdomains/content-hash>js-base64": { @@ -1981,6 +1981,7 @@ "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, + "@metamask/network-controller": true, "@metamask/providers>@metamask/rpc-errors": true, "@metamask/transaction-controller>nonce-tracker": true, "@metamask/utils": true, diff --git a/lavamoat/browserify/desktop/policy.json b/lavamoat/browserify/desktop/policy.json index 1fd9c4de9a36..e36bcc712c09 100644 --- a/lavamoat/browserify/desktop/policy.json +++ b/lavamoat/browserify/desktop/policy.json @@ -47,7 +47,7 @@ "TextEncoder": true }, "packages": { - "@metamask/assets-controllers>multiformats": true + "@ensdomains/content-hash>cids>multibase": true } }, "@ensdomains/content-hash>js-base64": { @@ -2234,6 +2234,7 @@ "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, + "@metamask/network-controller": true, "@metamask/providers>@metamask/rpc-errors": true, "@metamask/transaction-controller>nonce-tracker": true, "@metamask/utils": true, diff --git a/lavamoat/browserify/flask/policy.json b/lavamoat/browserify/flask/policy.json index 73cf28268c16..8388ecd2f18b 100644 --- a/lavamoat/browserify/flask/policy.json +++ b/lavamoat/browserify/flask/policy.json @@ -47,7 +47,7 @@ "TextEncoder": true }, "packages": { - "@metamask/assets-controllers>multiformats": true + "@ensdomains/content-hash>cids>multibase": true } }, "@ensdomains/content-hash>js-base64": { @@ -2268,6 +2268,7 @@ "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, + "@metamask/network-controller": true, "@metamask/providers>@metamask/rpc-errors": true, "@metamask/transaction-controller>nonce-tracker": true, "@metamask/utils": true, diff --git a/lavamoat/browserify/main/policy.json b/lavamoat/browserify/main/policy.json index 475459af8e30..8f52bd14b2d7 100644 --- a/lavamoat/browserify/main/policy.json +++ b/lavamoat/browserify/main/policy.json @@ -47,7 +47,7 @@ "TextEncoder": true }, "packages": { - "@metamask/assets-controllers>multiformats": true + "@ensdomains/content-hash>cids>multibase": true } }, "@ensdomains/content-hash>js-base64": { @@ -2191,6 +2191,7 @@ "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, + "@metamask/network-controller": true, "@metamask/providers>@metamask/rpc-errors": true, "@metamask/transaction-controller>nonce-tracker": true, "@metamask/utils": true, diff --git a/lavamoat/browserify/mmi/policy.json b/lavamoat/browserify/mmi/policy.json index 9167168c8b40..6cd1fa3f0130 100644 --- a/lavamoat/browserify/mmi/policy.json +++ b/lavamoat/browserify/mmi/policy.json @@ -47,7 +47,7 @@ "TextEncoder": true }, "packages": { - "@metamask/assets-controllers>multiformats": true + "@ensdomains/content-hash>cids>multibase": true } }, "@ensdomains/content-hash>js-base64": { @@ -2289,6 +2289,7 @@ "@metamask/eth-query": true, "@metamask/gas-fee-controller": true, "@metamask/metamask-eth-abis": true, + "@metamask/network-controller": true, "@metamask/providers>@metamask/rpc-errors": true, "@metamask/transaction-controller>nonce-tracker": true, "@metamask/utils": true, diff --git a/package.json b/package.json index 6d8f589a6a83..261b6b384ead 100644 --- a/package.json +++ b/package.json @@ -225,7 +225,7 @@ "dependencies": { "@babel/runtime": "^7.23.2", "@blockaid/ppom_release": "^1.4.1", - "@ensdomains/content-hash": "^2.5.6", + "@ensdomains/content-hash": "^2.5.7", "@ethereumjs/common": "^3.1.1", "@ethereumjs/tx": "^4.1.1", "@ethersproject/abi": "^5.6.4", @@ -298,7 +298,7 @@ "@metamask/snaps-rpc-methods": "^6.0.0", "@metamask/snaps-sdk": "^2.1.0", "@metamask/snaps-utils": "^6.1.0", - "@metamask/transaction-controller": "^22.0.0", + "@metamask/transaction-controller": "^23.0.0", "@metamask/user-operation-controller": "^1.0.0", "@metamask/utils": "^8.2.1", "@ngraveio/bc-ur": "^1.1.6", diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 7364336a8cd4..560c5cee9b38 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -57,6 +57,7 @@ import { ///: END:ONLY_INCLUDE_IF getInternalAccountByAddress, getSelectedInternalAccount, + getSelectedNetworkClientId, } from '../selectors'; import { computeEstimatedGasLimit, @@ -4087,10 +4088,12 @@ export function getNextNonce(): ThunkAction< > { return async (dispatch, getState) => { const { address } = getSelectedInternalAccount(getState()); + const networkClientId = getSelectedNetworkClientId(getState()); let nextNonce; try { nextNonce = await submitRequestToBackground('getNextNonce', [ address, + networkClientId, ]); } catch (error) { dispatch(displayWarning(error)); diff --git a/yarn.lock b/yarn.lock index f194eae49234..f9d7ad9d1f14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1910,7 +1910,7 @@ __metadata: languageName: node linkType: hard -"@ensdomains/content-hash@npm:^2.5.6": +"@ensdomains/content-hash@npm:^2.5.7": version: 2.5.7 resolution: "@ensdomains/content-hash@npm:2.5.7" dependencies: @@ -5415,9 +5415,9 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^22.0.0": - version: 22.0.0 - resolution: "@metamask/transaction-controller@npm:22.0.0" +"@metamask/transaction-controller@npm:^23.0.0": + version: 23.0.0 + resolution: "@metamask/transaction-controller@npm:23.0.0" dependencies: "@ethereumjs/common": "npm:^3.2.0" "@ethereumjs/tx": "npm:^4.2.0" @@ -5443,7 +5443,7 @@ __metadata: "@metamask/approval-controller": ^5.1.2 "@metamask/gas-fee-controller": ^13.0.0 "@metamask/network-controller": ^17.2.0 - checksum: 58c212278bcb0abcfb9114f56f5d5167bd1b1a6873811583e327aa89008ff67a9362d1cd0a4481d503b1f81a25495c3c05cbd666502e35e48a45b54d82b740c9 + checksum: a81e9253cfe44f7150550cdb9e67b5d5b51dd383f6d7c026891219cd556b98713ccf4e94ba0b91b8f1fd09ba07efd124e226c8ede21e52cf6e21e76c7c5e37af languageName: node linkType: hard @@ -19595,8 +19595,8 @@ __metadata: linkType: hard "gridplus-sdk@npm:^2.5.1": - version: 2.5.2 - resolution: "gridplus-sdk@npm:2.5.2" + version: 2.5.1 + resolution: "gridplus-sdk@npm:2.5.1" dependencies: "@ethereumjs/common": "npm:3.1.1" "@ethereumjs/tx": "npm:4.1.1" @@ -19617,7 +19617,7 @@ __metadata: rlp: "npm:^3.0.0" secp256k1: "npm:4.0.2" uuid: "npm:^9.0.0" - checksum: 566a9cb7a028cd9f9b650aab3d93c32b88eb4fa7bdf44b506b4cdc85e27dc91dbd3f3297aeed0e8abe8135dc7fbfce6c7d0381d26ab88a51acf4d90195140369 + checksum: 57deeae78fc5f904309e689054baabaed8b078b896ecfd5d724889c6ea424a113db64c3fd79d4dca7cc5f558167d7af754506df5c0692ee76087822ae60c3873 languageName: node linkType: hard @@ -24608,7 +24608,7 @@ __metadata: "@babel/register": "npm:^7.22.15" "@babel/runtime": "npm:^7.23.2" "@blockaid/ppom_release": "npm:^1.4.1" - "@ensdomains/content-hash": "npm:^2.5.6" + "@ensdomains/content-hash": "npm:^2.5.7" "@ethereumjs/common": "npm:^3.1.1" "@ethereumjs/tx": "npm:^4.1.1" "@ethersproject/abi": "npm:^5.6.4" @@ -24694,7 +24694,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^2.1.0" "@metamask/snaps-utils": "npm:^6.1.0" "@metamask/test-dapp": "npm:^7.3.1" - "@metamask/transaction-controller": "npm:^22.0.0" + "@metamask/transaction-controller": "npm:^23.0.0" "@metamask/user-operation-controller": "npm:^1.0.0" "@metamask/utils": "npm:^8.2.1" "@ngraveio/bc-ur": "npm:^1.1.6" @@ -33709,11 +33709,11 @@ __metadata: linkType: hard "uint8arrays@npm:^2.1.3": - version: 2.1.10 - resolution: "uint8arrays@npm:2.1.10" + version: 2.1.5 + resolution: "uint8arrays@npm:2.1.5" dependencies: - multiformats: "npm:^9.4.2" - checksum: 63ceb5fecc09de69641531c847e0b435d15a73587e40d4db23ed9b8a1ebbe839ae39fe81a15ea6079cdf642fcf2583983f9a5d32726edc4bc5e87634f34e3bd5 + multibase: "npm:^4.0.1" + checksum: 521a120ad21250004a95330d0501c87344c5072376b5d5d9ef642721f7913cc1880b823715e5d8829307a9dda73c3064283cb3a7442f0e85fef781cfca4f0334 languageName: node linkType: hard