From eda038093a5341bd92d7c991d00f7a936988adaf Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Mon, 18 Mar 2024 14:54:00 -0700 Subject: [PATCH] Revert "Remove deprecated properties, networkChanged event, and offline send() net_version support (#306)" This reverts commit 25b7e4b52b8e4c8b7a28d9a99c50878fb5cadf53. --- jest.config.js | 8 +- src/BaseProvider.ts | 55 +++++-- src/MetaMaskInpageProvider.test.ts | 155 ++++++++++++++++-- src/MetaMaskInpageProvider.ts | 91 +++++++++- src/StreamProvider.test.ts | 82 ++++++++- src/StreamProvider.ts | 29 +++- .../createExternalExtensionProvider.test.ts | 8 +- src/initializeInpageProvider.ts | 5 + src/messages.ts | 13 +- src/utils.test.ts | 21 ++- src/utils.ts | 12 ++ 11 files changed, 425 insertions(+), 54 deletions(-) diff --git a/jest.config.js b/jest.config.js index 67f21b34..677079b5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -45,10 +45,10 @@ const baseConfig = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 61.45, - functions: 63.91, - lines: 63.59, - statements: 63.65, + branches: 65.43, + functions: 65.65, + lines: 66.74, + statements: 66.81, }, }, diff --git a/src/BaseProvider.ts b/src/BaseProvider.ts index 21526389..5dfcda5e 100644 --- a/src/BaseProvider.ts +++ b/src/BaseProvider.ts @@ -230,10 +230,14 @@ export abstract class BaseProvider extends SafeEventEmitter { * Sets initial state if provided and marks this provider as initialized. * Throws if called more than once. * + * Permits the `networkVersion` field in the parameter object for + * compatibility with child classes that use this value. + * * @param initialState - The provider's initial state. * @param initialState.accounts - The user's accounts. * @param initialState.chainId - The chain ID. * @param initialState.isUnlocked - Whether the user has unlocked MetaMask. + * @param initialState.networkVersion - The network version. * @fires BaseProvider#_initialized - If `initialState` is defined. * @fires BaseProvider#connect - If `initialState` is defined. */ @@ -241,17 +245,18 @@ export abstract class BaseProvider extends SafeEventEmitter { accounts: string[]; chainId: string; isUnlocked: boolean; + networkVersion?: string; }) { if (this._state.initialized) { throw new Error('Provider already initialized.'); } if (initialState) { - const { accounts, chainId, isUnlocked } = initialState; + const { accounts, chainId, isUnlocked, networkVersion } = initialState; // EIP-1193 connect this._handleConnect(chainId); - this._handleChainChanged({ chainId }); + this._handleChainChanged({ chainId, networkVersion }); this._handleUnlockStateChanged({ accounts, isUnlocked }); this._handleAccountsChanged(accounts); } @@ -324,23 +329,36 @@ export abstract class BaseProvider extends SafeEventEmitter { * Error codes per the CloseEvent status codes as required by EIP-1193: * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes. * + * @param isRecoverable - Whether the disconnection is recoverable. * @param errorMessage - A custom error message. - * @fires BaseProvider#disconnect + * @fires BaseProvider#disconnect - If the disconnection is not recoverable. */ - protected _handleDisconnect(errorMessage?: string) { - if (this._state.isConnected || !this._state.isPermanentlyDisconnected) { + protected _handleDisconnect(isRecoverable: boolean, errorMessage?: string) { + if ( + this._state.isConnected || + (!this._state.isPermanentlyDisconnected && !isRecoverable) + ) { this._state.isConnected = false; - const error = new JsonRpcError( - 1011, // Internal error - errorMessage ?? messages.errors.permanentlyDisconnected(), - ); - this._log.error(error); - this.#chainId = null; - this._state.accounts = null; - this.#selectedAddress = null; - this._state.isUnlocked = false; - this._state.isPermanentlyDisconnected = true; + let error; + if (isRecoverable) { + error = new JsonRpcError( + 1013, // Try again later + errorMessage ?? messages.errors.disconnected(), + ); + this._log.debug(error); + } else { + error = new JsonRpcError( + 1011, // Internal error + errorMessage ?? messages.errors.permanentlyDisconnected(), + ); + this._log.error(error); + this.#chainId = null; + this._state.accounts = null; + this.#selectedAddress = null; + this._state.isUnlocked = false; + this._state.isPermanentlyDisconnected = true; + } this.emit('disconnect', error); } @@ -351,13 +369,18 @@ export abstract class BaseProvider extends SafeEventEmitter { * and sets relevant public state. Does nothing if the given `chainId` is * equivalent to the existing value. * + * Permits the `networkVersion` field in the parameter object for + * compatibility with child classes that use this value. + * * @fires BaseProvider#chainChanged * @param networkInfo - An object with network info. * @param networkInfo.chainId - The latest chain ID. */ protected _handleChainChanged({ chainId, - }: { chainId?: string | undefined } | undefined = {}) { + }: + | { chainId?: string | undefined; networkVersion?: string | undefined } + | undefined = {}) { if (!isValidChainId(chainId)) { this._log.error(messages.errors.invalidNetworkParams(), { chainId }); return; diff --git a/src/MetaMaskInpageProvider.test.ts b/src/MetaMaskInpageProvider.test.ts index 045ea2dc..22001831 100644 --- a/src/MetaMaskInpageProvider.test.ts +++ b/src/MetaMaskInpageProvider.test.ts @@ -44,7 +44,12 @@ type InitializedProviderDetails = { * can be used to inspect message sent by the provider. */ async function getInitializedProvider({ - initialState: { accounts = [], chainId = '0x0', isUnlocked = true } = {}, + initialState: { + accounts = [], + chainId = '0x0', + isUnlocked = true, + networkVersion = '0', + } = {}, onMethodCalled = [], }: { initialState?: Partial< @@ -73,6 +78,7 @@ async function getInitializedProvider({ accounts, chainId, isUnlocked, + networkVersion, }, }), ); @@ -707,6 +713,13 @@ describe('MetaMaskInpageProvider: RPC', () => { expect.any(Function), ); }); + + it('net_version', () => { + const result = provider.send({ method: 'net_version' }); + expect(result).toMatchObject({ + result: null, + }); + }); }); it('throws on unsupported sync method', () => { @@ -735,9 +748,84 @@ describe('MetaMaskInpageProvider: RPC', () => { connectionStream.notify(MetaMaskInpageProviderStreamName, { jsonrpc: '2.0', method: 'metamask_chainChanged', - params: { chainId: '0x1' }, + params: { chainId: '0x1', networkVersion: '1' }, + }); + }); + }); + + it('calls networkChanged when receiving a new networkVersion', async () => { + const { provider, connectionStream } = await getInitializedProvider(); + + await new Promise((resolve) => { + provider.once('networkChanged', (newNetworkId) => { + expect(newNetworkId).toBe('1'); + resolve(undefined); + }); + + connectionStream.notify(MetaMaskInpageProviderStreamName, { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + params: { chainId: '0x1', networkVersion: '1' }, + }); + }); + }); + + it('handles chain changes with intermittent disconnection', async () => { + const { provider, connectionStream } = await getInitializedProvider(); + + // We check this mostly for the readability of this test. + expect(provider.isConnected()).toBe(true); + expect(provider.chainId).toBe('0x0'); + expect(provider.networkVersion).toBe('0'); + + const emitSpy = jest.spyOn(provider, 'emit'); + + await new Promise((resolve) => { + provider.once('disconnect', (error) => { + expect((error as any).code).toBe(1013); + resolve(); + }); + + connectionStream.notify(MetaMaskInpageProviderStreamName, { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + // A "loading" networkVersion indicates the network is changing. + // Although the chainId is different, chainChanged should not be + // emitted in this case. + params: { chainId: '0x1', networkVersion: 'loading' }, + }); + }); + + // Only once, for "disconnect". + expect(emitSpy).toHaveBeenCalledTimes(1); + emitSpy.mockClear(); // Clear the mock to avoid keeping a count. + + expect(provider.isConnected()).toBe(false); + // These should be unchanged. + expect(provider.chainId).toBe('0x0'); + expect(provider.networkVersion).toBe('0'); + + await new Promise((resolve) => { + provider.once('chainChanged', (newChainId) => { + expect(newChainId).toBe('0x1'); + resolve(); + }); + + connectionStream.notify(MetaMaskInpageProviderStreamName, { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + params: { chainId: '0x1', networkVersion: '1' }, }); }); + + expect(emitSpy).toHaveBeenCalledTimes(3); + expect(emitSpy).toHaveBeenNthCalledWith(1, 'connect', { chainId: '0x1' }); + expect(emitSpy).toHaveBeenCalledWith('chainChanged', '0x1'); + expect(emitSpy).toHaveBeenCalledWith('networkChanged', '1'); + + expect(provider.isConnected()).toBe(true); + expect(provider.chainId).toBe('0x1'); + expect(provider.networkVersion).toBe('1'); }); }); @@ -940,6 +1028,7 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { accounts: ['0xabc'], chainId: '0x0', isUnlocked: true, + networkVersion: '0', }; }); @@ -948,6 +1037,9 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { await new Promise((resolve) => setTimeout(() => resolve(), 1)); expect(requestMock).toHaveBeenCalledTimes(1); + expect(inpageProvider.chainId).toBe('0x0'); + expect(inpageProvider.networkVersion).toBe('0'); + expect(inpageProvider.selectedAddress).toBe('0xabc'); expect(inpageProvider.isConnected()).toBe(true); }); }); @@ -992,10 +1084,21 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { ).provider; }); - it('should throw an error when accessing chainId', () => { - expect(() => provider.chainId).toThrow( - `'ethereum.chainId' has been removed`, + it('should warn the first time chainId is accessed', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + + expect(provider.chainId).toBe('0x5'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + messages.warnings.chainIdDeprecation, ); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should not allow chainId to be modified', () => { + expect(() => (provider.chainId = '0x539')).toThrow( + 'Cannot set property chainId', + ); + expect(provider.chainId).toBe('0x5'); }); }); @@ -1003,13 +1106,30 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { let provider: any | MetaMaskInpageProvider; beforeEach(async () => { - provider = (await getInitializedProvider()).provider; + provider = ( + await getInitializedProvider({ + initialState: { + networkVersion: '5', + }, + }) + ).provider; }); - it('should throw an error when accessing networkVersion', () => { - expect(() => provider.networkVersion).toThrow( - `'ethereum.networkVersion' has been removed`, + it('should warn the first time networkVersion is accessed', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + + expect(provider.networkVersion).toBe('5'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + messages.warnings.networkVersionDeprecation, + ); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should not allow networkVersion to be modified', () => { + expect(() => (provider.networkVersion = '1337')).toThrow( + 'Cannot set property networkVersion', ); + expect(provider.networkVersion).toBe('5'); }); }); @@ -1026,10 +1146,21 @@ describe('MetaMaskInpageProvider: Miscellanea', () => { ).provider; }); - it('should throw an error when accessing selectedAddress', () => { - expect(() => provider.selectedAddress).toThrow( - `'ethereum.selectedAddress' has been removed`, + it('should warn the first time selectedAddress is accessed', async () => { + const consoleWarnSpy = jest.spyOn(globalThis.console, 'warn'); + + expect(provider.selectedAddress).toBe('0xdeadbeef'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + messages.warnings.selectedAddressDeprecation, + ); + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should not allow selectedAddress to be modified', () => { + expect(() => (provider.selectedAddress = '0x12345678')).toThrow( + 'Cannot set property selectedAddress', ); + expect(provider.selectedAddress).toBe('0xdeadbeef'); }); }); }); diff --git a/src/MetaMaskInpageProvider.ts b/src/MetaMaskInpageProvider.ts index 1fbba239..f3b3aa6a 100644 --- a/src/MetaMaskInpageProvider.ts +++ b/src/MetaMaskInpageProvider.ts @@ -15,7 +15,11 @@ import { } from './utils'; export type SendSyncJsonRpcRequest = { - method: 'eth_accounts' | 'eth_coinbase' | 'eth_uninstallFilter'; + method: + | 'eth_accounts' + | 'eth_coinbase' + | 'eth_uninstallFilter' + | 'net_version'; } & JsonRpcRequest; type WarningEventName = keyof SentWarningsState['events']; @@ -30,6 +34,10 @@ export type MetaMaskInpageProviderOptions = { } & Partial>; type SentWarningsState = { + // properties + chainId: boolean; + networkVersion: boolean; + selectedAddress: boolean; // methods enable: boolean; experimentalMethods: boolean; @@ -38,6 +46,7 @@ type SentWarningsState = { events: { close: boolean; data: boolean; + networkChanged: boolean; notification: boolean; }; }; @@ -49,6 +58,10 @@ export const MetaMaskInpageProviderStreamName = 'metamask-provider'; export class MetaMaskInpageProvider extends AbstractStreamProvider { protected _sentWarnings: SentWarningsState = { + // properties + chainId: false, + networkVersion: false, + selectedAddress: false, // methods enable: false, experimentalMethods: false, @@ -57,6 +70,7 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { events: { close: false, data: false, + networkChanged: false, notification: false, }, }; @@ -68,6 +82,8 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { MetaMaskInpageProvider['_getExperimentalApi'] >; + #networkVersion: string | null; + /** * Indicating that this provider is a MetaMask provider. */ @@ -108,6 +124,7 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { // eslint-disable-next-line @typescript-eslint/no-floating-promises this._initializeStateAsync(); + this.#networkVersion = null; this.isMetaMask = true; this._sendSync = this._sendSync.bind(this); @@ -150,19 +167,31 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { } //==================== - // Private Properties + // Deprecated Properties //==================== get chainId(): string | null { - throw new Error(messages.errors.invalidPropertyChainId()); + if (!this._sentWarnings.chainId) { + this._log.warn(messages.warnings.chainIdDeprecation); + this._sentWarnings.chainId = true; + } + return super.chainId; } get networkVersion(): string | null { - throw new Error(messages.errors.invalidPropertyNetworkVersion()); + if (!this._sentWarnings.networkVersion) { + this._log.warn(messages.warnings.networkVersionDeprecation); + this._sentWarnings.networkVersion = true; + } + return this.#networkVersion; } get selectedAddress(): string | null { - throw new Error(messages.errors.invalidPropertySelectedAddress()); + if (!this._sentWarnings.selectedAddress) { + this._log.warn(messages.warnings.selectedAddressDeprecation); + this._sentWarnings.selectedAddress = true; + } + return super.selectedAddress; } //==================== @@ -220,6 +249,24 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { // Private Methods //==================== + /** + * When the provider becomes disconnected, updates internal state and emits + * required events. Idempotent with respect to the isRecoverable parameter. + * + * Error codes per the CloseEvent status codes as required by EIP-1193: + * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Status_codes. + * + * @param isRecoverable - Whether the disconnection is recoverable. + * @param errorMessage - A custom error message. + * @fires BaseProvider#disconnect - If the disconnection is not recoverable. + */ + protected _handleDisconnect(isRecoverable: boolean, errorMessage?: string) { + super._handleDisconnect(isRecoverable, errorMessage); + if (this.#networkVersion && !isRecoverable) { + this.#networkVersion = null; + } + } + /** * Warns of deprecation for the given event, if applicable. * @@ -345,11 +392,11 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { let result; switch (payload.method) { case 'eth_accounts': - result = super.selectedAddress ? [super.selectedAddress] : []; + result = this.selectedAddress ? [this.selectedAddress] : []; break; case 'eth_coinbase': - result = super.selectedAddress ?? null; + result = this.selectedAddress ?? null; break; case 'eth_uninstallFilter': @@ -357,6 +404,10 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { result = true; break; + case 'net_version': + result = this.#networkVersion ?? null; + break; + default: throw new Error(messages.errors.unsupportedSync(payload.method)); } @@ -423,4 +474,30 @@ export class MetaMaskInpageProvider extends AbstractStreamProvider { }, ); } + + /** + * Upon receipt of a new chainId and networkVersion, emits corresponding + * events and sets relevant public state. Does nothing if neither the chainId + * nor the networkVersion are different from existing values. + * + * @fires MetamaskInpageProvider#networkChanged + * @param networkInfo - An object with network info. + * @param networkInfo.chainId - The latest chain ID. + * @param networkInfo.networkVersion - The latest network ID. + */ + protected _handleChainChanged({ + chainId, + networkVersion, + }: { chainId?: string; networkVersion?: string } = {}) { + // This will validate the params and disconnect the provider if the + // networkVersion is 'loading'. + super._handleChainChanged({ chainId, networkVersion }); + + if (this._state.isConnected && networkVersion !== this.#networkVersion) { + this.#networkVersion = networkVersion as string; + if (this._state.initialized) { + this.emit('networkChanged', this.#networkVersion); + } + } + } } diff --git a/src/StreamProvider.test.ts b/src/StreamProvider.test.ts index ec16f273..a2a7c06f 100644 --- a/src/StreamProvider.test.ts +++ b/src/StreamProvider.test.ts @@ -35,6 +35,7 @@ describe('StreamProvider', () => { it('initializes state and emits events', async () => { const accounts = ['0xabc']; const chainId = '0x1'; + const networkVersion = '1'; const isUnlocked = true; const streamProvider = new StreamProvider(new MockConnectionStream(), { @@ -48,11 +49,14 @@ describe('StreamProvider', () => { accounts, chainId, isUnlocked, + networkVersion, }; }); await streamProvider.initialize(); + expect(streamProvider.chainId).toBe(chainId); + expect(streamProvider.selectedAddress).toBe(accounts[0]); expect(streamProvider.isConnected()).toBe(true); expect(requestMock).toHaveBeenCalledTimes(1); @@ -377,6 +381,7 @@ describe('StreamProvider', () => { accounts: [], chainId: '0x0', isUnlocked: true, + networkVersion: '0', }; }); @@ -392,10 +397,85 @@ describe('StreamProvider', () => { mockStream.notify(mockStreamName, { jsonrpc: '2.0', method: 'metamask_chainChanged', - params: { chainId: '0x1' }, + params: { chainId: '0x1', networkVersion: '0x1' }, }); }); }); + + it('handles chain changes with intermittent disconnection', async () => { + const mockStream = new MockConnectionStream(); + const streamProvider = new StreamProvider(mockStream, { + jsonRpcStreamName: mockStreamName, + }); + + const requestMock = jest + .spyOn(streamProvider, 'request') + .mockImplementationOnce(async () => { + return { + accounts: [], + chainId: '0x0', + isUnlocked: true, + networkVersion: '0', + }; + }); + + await streamProvider.initialize(); + expect(requestMock).toHaveBeenCalledTimes(1); + + // We check this mostly for the readability of this test. + expect(streamProvider.isConnected()).toBe(true); + expect(streamProvider.chainId).toBe('0x0'); + + const emitSpy = jest.spyOn(streamProvider, 'emit'); + + await new Promise((resolve) => { + streamProvider.once('disconnect', (error) => { + expect(error.code).toBe(1013); + resolve(); + }); + + mockStream.notify(mockStreamName, { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + // A "loading" networkVersion indicates the network is changing. + // Although the chainId is different, chainChanged should not be + // emitted in this case. + params: { chainId: '0x1', networkVersion: 'loading' }, + }); + }); + + // Only once, for "disconnect". + expect(emitSpy).toHaveBeenCalledTimes(1); + emitSpy.mockClear(); // Clear the mock to avoid keeping a count. + + expect(streamProvider.isConnected()).toBe(false); + // These should be unchanged. + expect(streamProvider.chainId).toBe('0x0'); + + await new Promise((resolve) => { + streamProvider.once('chainChanged', (newChainId) => { + expect(newChainId).toBe('0x1'); + resolve(); + }); + + mockStream.notify(mockStreamName, { + jsonrpc: '2.0', + method: 'metamask_chainChanged', + // The networkVersion will be ignored here, we're just setting it + // to something other than 'loading'. + params: { chainId: '0x1', networkVersion: '1' }, + }); + }); + + expect(emitSpy).toHaveBeenCalledTimes(2); + expect(emitSpy).toHaveBeenNthCalledWith(1, 'connect', { + chainId: '0x1', + }); + expect(emitSpy).toHaveBeenCalledWith('chainChanged', '0x1'); + + expect(streamProvider.isConnected()).toBe(true); + expect(streamProvider.chainId).toBe('0x1'); + }); }); }); }); diff --git a/src/StreamProvider.ts b/src/StreamProvider.ts index c76c665c..60afc8df 100644 --- a/src/StreamProvider.ts +++ b/src/StreamProvider.ts @@ -10,7 +10,11 @@ import type { Duplex } from 'readable-stream'; import type { BaseProviderOptions } from './BaseProvider'; import { BaseProvider } from './BaseProvider'; import messages from './messages'; -import { EMITTED_NOTIFICATIONS, isValidChainId } from './utils'; +import { + EMITTED_NOTIFICATIONS, + isValidChainId, + isValidNetworkVersion, +} from './utils'; export type StreamProviderOptions = { /** @@ -159,30 +163,43 @@ export abstract class AbstractStreamProvider extends BaseProvider { this.emit('error', warningMsg); } - this._handleDisconnect(error ? error.message : undefined); + this._handleDisconnect(false, error ? error.message : undefined); } /** - * Upon receipt of a new chainId, emits corresponding - * events and sets relevant public state. + * Upon receipt of a new chainId and networkVersion, emits corresponding + * events and sets relevant public state. This class does not have a + * `networkVersion` property, but we rely on receiving a `networkVersion` + * with the value of `loading` to detect when the network is changing and + * a recoverable `disconnect` even has occurred. Child classes that use the + * `networkVersion` for other purposes must implement additional handling + * therefore. * * @fires BaseProvider#chainChanged * @param networkInfo - An object with network info. * @param networkInfo.chainId - The latest chain ID. + * @param networkInfo.networkVersion - The latest network ID. */ protected _handleChainChanged({ chainId, + networkVersion, }: { chainId?: string | undefined; + networkVersion?: string | undefined; } = {}) { - if (!isValidChainId(chainId)) { + if (!isValidChainId(chainId) || !isValidNetworkVersion(networkVersion)) { this._log.error(messages.errors.invalidNetworkParams(), { chainId, + networkVersion, }); return; } - super._handleChainChanged({ chainId }); + if (networkVersion === 'loading') { + this._handleDisconnect(true); + } else { + super._handleChainChanged({ chainId }); + } } } diff --git a/src/extension-provider/createExternalExtensionProvider.test.ts b/src/extension-provider/createExternalExtensionProvider.test.ts index f1f518d8..87ce1489 100644 --- a/src/extension-provider/createExternalExtensionProvider.test.ts +++ b/src/extension-provider/createExternalExtensionProvider.test.ts @@ -37,7 +37,12 @@ type InitializedExtensionProviderDetails = { * "onWrite" stub that can be used to inspect message sent by the provider. */ async function getInitializedProvider({ - initialState: { accounts = [], chainId = '0x0', isUnlocked = true } = {}, + initialState: { + accounts = [], + chainId = '0x0', + isUnlocked = true, + networkVersion = '0', + } = {}, onMethodCalled = [], }: { initialState?: Partial[0]>; @@ -64,6 +69,7 @@ async function getInitializedProvider({ accounts, chainId, isUnlocked, + networkVersion, }, }), ); diff --git a/src/initializeInpageProvider.ts b/src/initializeInpageProvider.ts index c31bfc41..3fba9600 100644 --- a/src/initializeInpageProvider.ts +++ b/src/initializeInpageProvider.ts @@ -62,6 +62,11 @@ export function initializeProvider({ const proxiedProvider = new Proxy(provider, { // some common libraries, e.g. web3@1.x, mess with our API deleteProperty: () => true, + // fix issue with Proxy unable to access private variables from getters + // https://stackoverflow.com/a/73051482 + get(target, propName: 'chainId' | 'networkVersion' | 'selectedAddress') { + return target[propName]; + }, }); if (providerInfo) { diff --git a/src/messages.ts b/src/messages.ts index e43db063..c17778c1 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -1,5 +1,7 @@ const messages = { errors: { + disconnected: () => + 'MetaMask: Disconnected from chain. Attempting to connect.', permanentlyDisconnected: () => 'MetaMask: Disconnected from MetaMask background. Page reload required.', sendSiteMetadata: () => @@ -16,18 +18,16 @@ const messages = { invalidLoggerObject: () => `'args.logger' must be an object if provided.`, invalidLoggerMethod: (method: string) => `'args.logger' must include required method '${method}'.`, - invalidPropertyChainId: () => - `MetaMask: 'ethereum.chainId' has been removed. Please use the 'eth_chainId' RPC method instead.\nFor more information, see: https://github.com/MetaMask/metamask-improvement-proposals/discussions/23`, - invalidPropertyNetworkVersion: () => - `MetaMask: 'ethereum.networkVersion' has been removed. Please use the 'net_version' RPC method instead.\nFor more information, see: https://github.com/MetaMask/metamask-improvement-proposals/discussions/23`, - invalidPropertySelectedAddress: () => - `MetaMask: 'ethereum.selectedAddress' has been removed. Please use the 'eth_accounts' RPC method instead.\nFor more information, see: https://github.com/MetaMask/metamask-improvement-proposals/discussions/23`, }, info: { connected: (chainId: string) => `MetaMask: Connected to chain with ID "${chainId}".`, }, warnings: { + // deprecated properties + chainIdDeprecation: `MetaMask: 'ethereum.chainId' is deprecated and may be removed in the future. Please use the 'eth_chainId' RPC method instead.\nFor more information, see: https://github.com/MetaMask/metamask-improvement-proposals/discussions/23`, + networkVersionDeprecation: `MetaMask: 'ethereum.networkVersion' is deprecated and may be removed in the future. Please use the 'net_version' RPC method instead.\nFor more information, see: https://github.com/MetaMask/metamask-improvement-proposals/discussions/23`, + selectedAddressDeprecation: `MetaMask: 'ethereum.selectedAddress' is deprecated and may be removed in the future. Please use the 'eth_accounts' RPC method instead.\nFor more information, see: https://github.com/MetaMask/metamask-improvement-proposals/discussions/23`, // deprecated methods enableDeprecation: `MetaMask: 'ethereum.enable()' is deprecated and may be removed in the future. Please use the 'eth_requestAccounts' RPC method instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1102`, sendDeprecation: `MetaMask: 'ethereum.send(...)' is deprecated and may be removed in the future. Please use 'ethereum.sendAsync(...)' or 'ethereum.request(...)' instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1193`, @@ -35,6 +35,7 @@ const messages = { events: { close: `MetaMask: The event 'close' is deprecated and may be removed in the future. Please use 'disconnect' instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1193#disconnect`, data: `MetaMask: The event 'data' is deprecated and will be removed in the future. Use 'message' instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1193#message`, + networkChanged: `MetaMask: The event 'networkChanged' is deprecated and may be removed in the future. Use 'chainChanged' instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1193#chainchanged`, notification: `MetaMask: The event 'notification' is deprecated and may be removed in the future. Use 'message' instead.\nFor more information, see: https://eips.ethereum.org/EIPS/eip-1193#message`, }, rpc: { diff --git a/src/utils.test.ts b/src/utils.test.ts index af82f852..a8a5ef87 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,4 +1,4 @@ -import { isValidChainId } from './utils'; +import { isValidChainId, isValidNetworkVersion } from './utils'; describe('utils', () => { describe('isValidChainId', () => { @@ -16,4 +16,23 @@ describe('utils', () => { ); }); }); + + describe('isValidNetworkVersion', () => { + it('returns `true` for valid values', () => { + [ + '1', + '10', + '999', + 'loading', // this is a hack that we use + ].forEach((value) => { + expect(isValidNetworkVersion(value)).toBe(true); + }); + }); + + it('returns `false` for invalid values', () => { + ['', null, undefined, true, 2, 0x1, {}].forEach((value) => { + expect(isValidNetworkVersion(value)).toBe(false); + }); + }); + }); }); diff --git a/src/utils.ts b/src/utils.ts index 0e54015b..deb35d98 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -94,4 +94,16 @@ export const getRpcPromiseCallback = export const isValidChainId = (chainId: unknown): chainId is string => Boolean(chainId) && typeof chainId === 'string' && chainId.startsWith('0x'); +/** + * Checks whether the given network version is valid, meaning if it is non-empty + * string. + * + * @param networkVersion - The network version to validate. + * @returns Whether the given network version is valid. + */ +export const isValidNetworkVersion = ( + networkVersion: unknown, +): networkVersion is string => + Boolean(networkVersion) && typeof networkVersion === 'string'; + export const NOOP = () => undefined;