diff --git a/packages/analytics/CHANGELOG.md b/packages/analytics/CHANGELOG.md index 3c49524d..8d28e9f4 100644 --- a/packages/analytics/CHANGELOG.md +++ b/packages/analytics/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `failure_reason` property to `MMConnectProperties` in `schema.ts`, attached by producers on `mmconnect_wallet_action_failed` and `mmconnect_connection_failed`. Mirrors [`metamask-sdk-analytics-api#31`](https://github.com/consensys-vertical-apps/metamask-sdk-analytics-api/pull/31). ([#290](https://github.com/MetaMask/connect-monorepo/pull/290)) + ### Removed - Removed `MobileSDKConnectV2Payload` / `MobileSDKConnectV2Properties` from `schema.ts` and the `EventV2` oneOf; the `mobile/sdk-connect-v2` namespace has no emitters after [`metamask-mobile#27864`](https://github.com/MetaMask/metamask-mobile/pull/27864) and [`metamask-mobile#28322`](https://github.com/MetaMask/metamask-mobile/pull/28322), and the V2 endpoint is being updated to reject the namespace in [`metamask-sdk-analytics-api#29`](https://github.com/consensys-vertical-apps/metamask-sdk-analytics-api/pull/29). Internal types only — no change to the public API. diff --git a/packages/analytics/src/schema.ts b/packages/analytics/src/schema.ts index 166d3c71..1344f3fe 100644 --- a/packages/analytics/src/schema.ts +++ b/packages/analytics/src/schema.ts @@ -559,6 +559,15 @@ export type components = { dapp_requested_chains?: string[]; /** @description Array of CAIP-2 chain IDs that the user has permissioned */ user_permissioned_chains?: string[]; + /** + * @description Short tag describing why a failed event fired (e.g. + * `transport_timeout`, `wallet_internal_error`, `wallet_unauthorized`). + * Only set on `mmconnect_connection_failed` and + * `mmconnect_wallet_action_failed`. Open string for now — once we have + * enough data we may convert this to a closed enum. Mirrors the field + * in `api.spec.yml` of the analytics-api repo. + */ + failure_reason?: string; }; }; responses: never; diff --git a/packages/connect-evm/CHANGELOG.md b/packages/connect-evm/CHANGELOG.md index b46e043c..2282c7de 100644 --- a/packages/connect-evm/CHANGELOG.md +++ b/packages/connect-evm/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove `@metamask/chain-agnostic-permission` dependency. The two helpers used from it (`getEthAccounts`, `getPermittedEthChainIds`) and the `parseScopeString` utility are now implemented locally on top of `@metamask/utils` primitives. This drops the transitive `@metamask/controller-utils` / `lodash` / `bn.js` / `eth-ens-namehash` / `fast-deep-equal` / `@metamask/ethjs-unit` chain from the `connect-evm` bundle. +### Fixed + +- Stopped emitting `mmconnect_wallet_action_failed` for the `wallet_switchEthereumChain` attempt on the `"Unrecognized chain ID" → wallet_addEthereumChain` recovery path. The shim now only emits the failed event when there's no recovery to attempt (no `chainConfiguration` provided) or when the error is unrelated to a missing chain. A successful recovery used to produce four Mixpanel events for what is logically one chain change (`switch _requested`, `switch _failed`, `add _requested`, `add _succeeded`); it now produces three (`switch _requested`, `add _requested`, `add _succeeded`). If the recovery's own `wallet_addEthereumChain` call also fails, that failure is still tracked by `#addEthereumChain`'s own `_failed` event, so the overall flow still surfaces exactly one `_failed` per logical chain change. ([#294](https://github.com/MetaMask/connect-monorepo/pull/294)) + ## [1.2.0] ### Added diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index eb80cff2..dd797010 100644 --- a/packages/connect-evm/src/connect.test.ts +++ b/packages/connect-evm/src/connect.test.ts @@ -1,4 +1,7 @@ /* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ + +/* eslint-disable @typescript-eslint/naming-convention -- analytics event names are snake_case by schema convention */ +import { analytics } from '@metamask/analytics'; import type { SessionData, MultichainCore } from '@metamask/connect-multichain'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; @@ -59,7 +62,6 @@ function createMockCore(): MockCore { const storageSet = vi.fn().mockResolvedValue(undefined); const mockCore = { - // eslint-disable-next-line @typescript-eslint/naming-convention -- mock mirrors real class _status _status: _status as ConnectEvmStatus, get status(): ConnectEvmStatus { return this._status; @@ -97,7 +99,13 @@ function createMockCore(): MockCore { get: storageGet, set: storageSet, }, + getAnonId: vi.fn().mockResolvedValue('test-anon-id'), + }, + options: { + dapp: { url: 'https://example.com' }, + versions: { 'connect-evm': '0.0.0-test' }, }, + transportType: 'mwp', }; mockCore._status = _status; @@ -154,7 +162,7 @@ describe('MetamaskConnectEVM', () => { await client.getProvider().request({ method: 'wallet_requestPermissions', - // eslint-disable-next-line @typescript-eslint/naming-convention + params: [{ eth_accounts: {} }], }); @@ -757,6 +765,7 @@ describe('MetamaskConnectEVM', () => { describe('switchChain', () => { let mockCore: MockCore; let client: Awaited>; + let trackSpy: ReturnType; const polygonChainConfiguration = { chainId: '0x89', @@ -765,6 +774,11 @@ describe('MetamaskConnectEVM', () => { rpcUrls: ['https://polygon-rpc.com'], }; + const trackedEventNames = (): string[] => + (trackSpy.mock.calls as [string, unknown][]).map( + ([eventName]) => eventName, + ); + beforeEach(async () => { mockCore = createMockCore(); mockCore.storage.adapter.get.mockResolvedValue(JSON.stringify('0x1')); @@ -787,6 +801,12 @@ describe('MetamaskConnectEVM', () => { // Ignore the eth_accounts call made during the connect flow above so // each test can assert against a clean call log. mockCore.transport.sendEip1193Message.mockClear(); + + // Spy on analytics.track after the connect-time events have already + // fired so test assertions only see calls made during the test body. + trackSpy = vi.spyOn(analytics, 'track').mockImplementation(() => { + // intentionally noop — we only want the spy's call record + }); }); it('falls back to wallet_addEthereumChain when wallet_switchEthereumChain fails with "Unrecognized chain ID" and chainConfiguration is provided', async () => { @@ -815,9 +835,21 @@ describe('MetamaskConnectEVM', () => { 'wallet_switchEthereumChain', 'wallet_addEthereumChain', ]); + + // The recovery succeeded — Mixpanel should not see a `_failed` event + // for the swallowed switch attempt. From the user's perspective this + // is one successful chain change. + expect(trackedEventNames()).toEqual([ + 'mmconnect_wallet_action_requested', // switch + 'mmconnect_wallet_action_requested', // add + 'mmconnect_wallet_action_succeeded', // add + ]); + expect(trackedEventNames()).not.toContain( + 'mmconnect_wallet_action_failed', + ); }); - it('rethrows the original "Unrecognized chain ID" error (preserving code 4902) when no chainConfiguration is provided', async () => { + it('still emits `_failed` for the switch when recovery is impossible (no chainConfiguration provided)', async () => { mockCore.transport.sendEip1193Message.mockImplementation( async (request: { method: string }) => { if (request.method === 'wallet_switchEthereumChain') { @@ -843,6 +875,13 @@ describe('MetamaskConnectEVM', () => { ); expect(calls).toEqual(['wallet_switchEthereumChain']); expect(calls).not.toContain('wallet_addEthereumChain'); + + // No recovery is going to happen, so the switch failure must surface + // to Mixpanel — otherwise the failure is invisible. + expect(trackedEventNames()).toEqual([ + 'mmconnect_wallet_action_requested', + 'mmconnect_wallet_action_failed', + ]); }); it('rethrows non "Unrecognized chain ID" errors without falling back, even when chainConfiguration is provided', async () => { @@ -867,6 +906,60 @@ describe('MetamaskConnectEVM', () => { ); expect(calls).toEqual(['wallet_switchEthereumChain']); expect(calls).not.toContain('wallet_addEthereumChain'); + + // The catch's recovery branch only fires for "Unrecognized chain ID" + // errors; everything else must still emit `_failed` (or `_rejected` + // post the rejection classifier — irrelevant here, both are tracked + // events that must fire). + const eventNames = trackedEventNames(); + expect(eventNames).toContain('mmconnect_wallet_action_requested'); + expect( + eventNames.some( + (name) => + name === 'mmconnect_wallet_action_failed' || + name === 'mmconnect_wallet_action_rejected', + ), + ).toBe(true); + }); + + it('emits `_failed` for the add when recovery itself fails (suppression only applies on successful recovery)', async () => { + mockCore.transport.sendEip1193Message.mockImplementation( + async (request: { method: string }) => { + if (request.method === 'wallet_switchEthereumChain') { + const error = new Error('Unrecognized chain ID 0x89') as Error & { + code: number; + }; + error.code = 4902; + throw error; + } + if (request.method === 'wallet_addEthereumChain') { + throw new Error('Wallet refused to add the chain'); + } + return { result: null, id: 1, jsonrpc: '2.0' as const }; + }, + ); + + await expect( + client.switchChain({ + chainId: '0x89', + chainConfiguration: polygonChainConfiguration, + }), + ).rejects.toThrow('Wallet refused to add the chain'); + + // The switch attempt's `_failed` is still suppressed (the user's + // intent was a chain change, not specifically a switch). The + // overall failure surfaces through the add's own `_failed` event. + // Net effect: one `_failed` per logical chain change, regardless of + // which RPC method the wallet rejected. + const eventNames = trackedEventNames(); + expect(eventNames).toEqual([ + 'mmconnect_wallet_action_requested', // switch + 'mmconnect_wallet_action_requested', // add + 'mmconnect_wallet_action_failed', // add + ]); + expect( + eventNames.filter((name) => name === 'mmconnect_wallet_action_failed'), + ).toHaveLength(1); }); }); @@ -897,7 +990,7 @@ describe('MetamaskConnectEVM', () => { // Call wallet_requestPermissions — should force a new request await client.getProvider().request({ method: 'wallet_requestPermissions', - // eslint-disable-next-line @typescript-eslint/naming-convention + params: [{ eth_accounts: {} }], }); @@ -937,7 +1030,7 @@ describe('MetamaskConnectEVM', () => { await client.getProvider().request({ method: 'wallet_requestPermissions', - // eslint-disable-next-line @typescript-eslint/naming-convention + params: [{ eth_accounts: {} }], }); diff --git a/packages/connect-evm/src/connect.ts b/packages/connect-evm/src/connect.ts index 77500eef..d45448d9 100644 --- a/packages/connect-evm/src/connect.ts +++ b/packages/connect-evm/src/connect.ts @@ -581,13 +581,22 @@ export class MetamaskConnectEVM { } return Promise.resolve(); } catch (error) { - await this.#trackWalletActionFailed(method, scope, params, error); + // "Unrecognized chain ID" + a chainConfiguration is the recovery path: + // the wallet doesn't know this chain, so we silently fall through to + // `wallet_addEthereumChain`. From the dapp/user's perspective this is + // still one logical chain change with one outcome, so we let the add's + // own `_succeeded` / `_failed` event represent the whole flow rather + // than emitting a `_failed` here for an implementation detail the + // user never sees. Without this suppression a successful recovery + // produces four events in Mixpanel (switch _requested, switch _failed, + // add _requested, add _succeeded) instead of the three it should. const isChainMissingInWallet = (error as Error).message.includes( 'Unrecognized chain ID', ); if (isChainMissingInWallet && chainConfiguration) { return this.#addEthereumChain(chainConfiguration); } + await this.#trackWalletActionFailed(method, scope, params, error); throw error; } } diff --git a/packages/connect-multichain/CHANGELOG.md b/packages/connect-multichain/CHANGELOG.md index 154c2c20..71a2af63 100644 --- a/packages/connect-multichain/CHANGELOG.md +++ b/packages/connect-multichain/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Attach a `failure_reason` tag to `mmconnect_wallet_action_failed` and `mmconnect_connection_failed` events via a new `classifyFailureReason` helper, distinguishing transport timeouts, transport disconnects, EIP-1193 wallet errors (`4100 wallet_unauthorized`, `4200 wallet_method_unsupported`, `4902 unrecognised_chain`), and JSON-RPC wallet errors (`-32601`, `-32602`, `-32603`, plus the `-32000…-32099` server-error range), with an `unknown` fallback. Schema-side: [`metamask-sdk-analytics-api#31`](https://github.com/consensys-vertical-apps/metamask-sdk-analytics-api/pull/31). ([#290](https://github.com/MetaMask/connect-monorepo/pull/290)) + ## [0.13.0] ### Uncategorized diff --git a/packages/connect-multichain/src/domain/utils/index.ts b/packages/connect-multichain/src/domain/utils/index.ts index c3c5ff80..99d76a9a 100644 --- a/packages/connect-multichain/src/domain/utils/index.ts +++ b/packages/connect-multichain/src/domain/utils/index.ts @@ -8,6 +8,8 @@ export function getVersion(): string { } export { + classifyFailureReason, getWalletActionAnalyticsProperties, isRejectionError, } from '../../multichain/utils/analytics'; +export type { FailureReason } from '../../multichain/utils/analytics'; diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index c2619a39..cf2e51fe 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -29,6 +29,7 @@ import { TransportType, } from '../domain'; import { + classifyFailureReason, getBaseAnalyticsProperties, isRejectionError, } from './utils/analytics'; @@ -757,6 +758,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { analytics.track('mmconnect_connection_failed', { ...baseProps, transport_type: transportType, + failure_reason: classifyFailureReason(error), }); } } catch { diff --git a/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts b/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts index ab598a94..f7c5b4e1 100644 --- a/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts +++ b/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts @@ -1,5 +1,7 @@ /* eslint-disable id-length -- vitest alias */ /* eslint-disable no-empty-function -- Empty mock functions */ +/* eslint-disable @typescript-eslint/unbound-method -- referencing the mocked `analytics.track` is intentional in spy assertions */ +/* eslint-disable @typescript-eslint/naming-convention -- analytics event properties are snake_case by schema convention */ import { analytics } from '@metamask/analytics'; import * as t from 'vitest'; @@ -226,4 +228,47 @@ t.describe('RequestRouter', () => { }, ); }); + + t.describe('failure_reason classification on wallet actions', () => { + t.it( + 'attaches `failure_reason: wallet_internal_error` when the wallet returns code -32603', + async () => { + mockTransport.request.mockResolvedValue({ + error: { code: -32603, message: 'Internal error' }, + }); + + await t + .expect(requestRouter.invokeMethod(baseOptions)) + .rejects.toBeInstanceOf(RPCInvokeMethodErr); + + t.expect(analytics.track).toHaveBeenCalledWith( + 'mmconnect_wallet_action_failed', + t.expect.objectContaining({ + failure_reason: 'wallet_internal_error', + method: 'eth_sendTransaction', + }), + ); + }, + ); + + t.it( + 'attaches `failure_reason: transport_timeout` when transport throws a timeout', + async () => { + const timeoutErr = new Error('Transport request timed out'); + timeoutErr.name = 'TransportTimeoutError'; + mockTransport.request.mockRejectedValue(timeoutErr); + + await t + .expect(requestRouter.invokeMethod(baseOptions)) + .rejects.toThrow(); + + t.expect(analytics.track).toHaveBeenCalledWith( + 'mmconnect_wallet_action_failed', + t.expect.objectContaining({ + failure_reason: 'transport_timeout', + }), + ); + }, + ); + }); }); diff --git a/packages/connect-multichain/src/multichain/rpc/requestRouter.ts b/packages/connect-multichain/src/multichain/rpc/requestRouter.ts index cdf3e9f7..576bdc7d 100644 --- a/packages/connect-multichain/src/multichain/rpc/requestRouter.ts +++ b/packages/connect-multichain/src/multichain/rpc/requestRouter.ts @@ -22,6 +22,7 @@ import { } from '../../domain'; import { openDeeplink } from '../utils'; import { + classifyFailureReason, getWalletActionAnalyticsProperties, isRejectionError, } from '../utils/analytics'; @@ -138,7 +139,7 @@ export class RequestRouter { if (isRejection) { await this.#trackWalletActionRejected(options); } else { - await this.#trackWalletActionFailed(options); + await this.#trackWalletActionFailed(options, error); } if (error instanceof RPCInvokeMethodErr) { throw error; @@ -188,14 +189,21 @@ export class RequestRouter { /** * Tracks wallet action failed event. * - * @param options + * @param options - The invoke method options. + * @param error - The error that caused the failure (used to classify the + * `failure_reason` property on the event). */ - async #trackWalletActionFailed(options: InvokeMethodOptions): Promise { + async #trackWalletActionFailed( + options: InvokeMethodOptions, + error: unknown, + ): Promise { const props = await getWalletActionAnalyticsProperties( this.config, this.config.storage, options, this.transportType, + // eslint-disable-next-line @typescript-eslint/naming-convention -- analytics property is snake_case by schema convention + { failure_reason: classifyFailureReason(error) }, ); analytics.track('mmconnect_wallet_action_failed', props); } diff --git a/packages/connect-multichain/src/multichain/utils/analytics.test.ts b/packages/connect-multichain/src/multichain/utils/analytics.test.ts new file mode 100644 index 00000000..278de7f7 --- /dev/null +++ b/packages/connect-multichain/src/multichain/utils/analytics.test.ts @@ -0,0 +1,238 @@ +/* eslint-disable id-length -- vitest alias */ +import * as t from 'vitest'; + +import { + classifyFailureReason, + getWalletActionAnalyticsProperties, +} from './analytics'; +import { + type InvokeMethodOptions, + type MultichainOptions, + RPCInvokeMethodErr, + type Scope, + type StoreClient, + TransportType, +} from '../../domain'; + +t.describe('classifyFailureReason', () => { + t.it('classifies transport timeout from TransportTimeoutError name', () => { + const error = new Error('Transport request timed out'); + error.name = 'TransportTimeoutError'; + t.expect(classifyFailureReason(error)).toBe('transport_timeout'); + }); + + t.it( + 'classifies transport timeout from DefaultTransport plain message', + () => { + // DefaultTransport throws `new Error('Request timeout')` (not a + // TransportTimeoutError) on its own setTimeout path — we still want to + // classify this as a timeout. + t.expect(classifyFailureReason(new Error('Request timeout'))).toBe( + 'transport_timeout', + ); + }, + ); + + t.it('classifies transport disconnect from TransportError name', () => { + const error = new Error('Chrome port not connected'); + error.name = 'TransportError'; + t.expect(classifyFailureReason(error)).toBe('transport_disconnect'); + }); + + t.it( + 'classifies transport disconnect from narrow substring matches (not bare "disconnect")', + () => { + t.expect( + classifyFailureReason(new Error('Transport disconnect during call')), + ).toBe('transport_disconnect'); + t.expect(classifyFailureReason(new Error('Connection lost'))).toBe( + 'transport_disconnect', + ); + t.expect( + classifyFailureReason(new Error('Socket closed unexpectedly')), + ).toBe('transport_disconnect'); + }, + ); + + t.it('classifies the "Unrecognized chain" message', () => { + t.expect( + classifyFailureReason(new Error('Unrecognized chain ID "0xfa"')), + ).toBe('unrecognised_chain'); + }); + + t.it('classifies wallet JSON-RPC method-not-found', () => { + const error = new RPCInvokeMethodErr('inner', -32601, 'Method not found'); + t.expect(classifyFailureReason(error)).toBe('wallet_method_unsupported'); + }); + + t.it('classifies wallet JSON-RPC invalid params', () => { + const error = new RPCInvokeMethodErr('inner', -32602, 'Invalid params'); + t.expect(classifyFailureReason(error)).toBe('wallet_invalid_params'); + }); + + t.it('classifies wallet JSON-RPC internal error', () => { + const error = new RPCInvokeMethodErr('inner', -32603, 'Internal error'); + t.expect(classifyFailureReason(error)).toBe('wallet_internal_error'); + }); + + t.it( + 'classifies wallet 4100 unauthorized (e.g. CAIP-25 scope did not grant the method)', + () => { + const error = new RPCInvokeMethodErr( + 'inner', + 4100, + 'The requested account and/or method has not been authorized by the user.', + ); + t.expect(classifyFailureReason(error)).toBe('wallet_unauthorized'); + }, + ); + + t.it('classifies wallet 4200 unsupported method', () => { + const error = new RPCInvokeMethodErr('inner', 4200, 'Unsupported method'); + t.expect(classifyFailureReason(error)).toBe('wallet_method_unsupported'); + }); + + t.it( + 'classifies wallet 4902 unrecognised chain (mobile switchEthereumChain)', + () => { + const error = new RPCInvokeMethodErr( + 'inner', + 4902, + 'Unrecognized chain ID "0xfa". Try adding the chain using wallet_addEthereumChain first.', + ); + t.expect(classifyFailureReason(error)).toBe('unrecognised_chain'); + }, + ); + + t.it( + 'falls back to "unknown" for unrecognised provider-defined codes (no wallet_custom_error bucket)', + () => { + // 4900 "Disconnected" — real EIP-1193 code, but we don't surface it + // separately today. Lives in `unknown` until/unless usage justifies + // its own bucket (this is the policy described in the source comment). + // Regression test: this also exercises the ordering of the classifier — + // wallet codes are checked BEFORE the transport-disconnect substring + // heuristic, so the "Disconnected" message must not leak into + // `transport_disconnect`. + const error = new RPCInvokeMethodErr('inner', 4900, 'Disconnected'); + t.expect(classifyFailureReason(error)).toBe('unknown'); + }, + ); + + t.it( + 'classifies JSON-RPC server-error range (-32000..-32099) as wallet_internal_error', + () => { + t.expect( + classifyFailureReason( + new RPCInvokeMethodErr('inner', -32000, 'Server error'), + ), + ).toBe('wallet_internal_error'); + t.expect( + classifyFailureReason( + new RPCInvokeMethodErr('inner', -32099, 'Reserved server error'), + ), + ).toBe('wallet_internal_error'); + }, + ); + + t.it('falls back to "unknown" for unrecognised errors', () => { + t.expect( + classifyFailureReason(new Error('Something exploded somewhere')), + ).toBe('unknown'); + t.expect(classifyFailureReason(null)).toBe('unknown'); + t.expect(classifyFailureReason('a string')).toBe('unknown'); + }); + + // Documents how the classifier behaves on the *connection*-side error + // surface (the `.catch` of `transport.connect()` in `multichain/index.ts`). + // Most of these errors are plain `new Error(...)` strings rather than + // `RPCInvokeMethodErr`s — they have no wallet code to inspect, so they + // currently land in `unknown` unless the message happens to match a + // transport heuristic. This is a known gap (see PR description / audit); + // tests pin the current behaviour so future improvements have a baseline. + t.describe('connection-side error shapes', () => { + t.it( + 'classifies "Transport not initialized" as unknown (no message heuristic match)', + () => { + t.expect( + classifyFailureReason( + new Error('Transport not initialized, establish connection first'), + ), + ).toBe('unknown'); + }, + ); + + t.it( + 'classifies "Existing connection is pending" as unknown (concurrent connect race)', + () => { + t.expect( + classifyFailureReason( + new Error( + 'Existing connection is pending. Please check your MetaMask Mobile app to continue.', + ), + ), + ).toBe('unknown'); + }, + ); + + t.it( + 'classifies the deeplink sentinel "No active session found" as unknown', + () => { + // Note: this error is thrown inside a `setTimeout` callback for + // deeplink opening and is fire-and-forget — in practice the analytics + // try/catch never sees it. Kept here so the classifier's behaviour is + // documented if/when we instrument that path. + t.expect( + classifyFailureReason(new Error('No active session found')), + ).toBe('unknown'); + }, + ); + + t.it( + 'classifies a TransportTimeoutError reaching the connect() catch', + () => { + const error = new Error('connect timed out after 60000ms'); + error.name = 'TransportTimeoutError'; + t.expect(classifyFailureReason(error)).toBe('transport_timeout'); + }, + ); + }); +}); + +t.describe('getWalletActionAnalyticsProperties', () => { + const mockOptions: MultichainOptions = { + dapp: { name: 'Test', url: 'https://test.com' }, + versions: { 'connect-multichain': '1.2.3' }, + } as unknown as MultichainOptions; + + const mockStorage = { + getAnonId: async () => Promise.resolve('anon-id-123'), + } as unknown as StoreClient; + + const invokeOptions: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { method: 'personal_sign', params: [] }, + }; + + t.it('does not attach failure_reason by default', async () => { + const props = await getWalletActionAnalyticsProperties( + mockOptions, + mockStorage, + invokeOptions, + TransportType.Browser, + ); + t.expect(props).not.toHaveProperty('failure_reason'); + }); + + t.it('attaches failure_reason when passed via extra', async () => { + const props = await getWalletActionAnalyticsProperties( + mockOptions, + mockStorage, + invokeOptions, + TransportType.Browser, + // eslint-disable-next-line @typescript-eslint/naming-convention -- analytics property is snake_case by schema convention + { failure_reason: 'transport_timeout' }, + ); + t.expect(props.failure_reason).toBe('transport_timeout'); + }); +}); diff --git a/packages/connect-multichain/src/multichain/utils/analytics.ts b/packages/connect-multichain/src/multichain/utils/analytics.ts index 9fcd4be5..0e56eaf1 100644 --- a/packages/connect-multichain/src/multichain/utils/analytics.ts +++ b/packages/connect-multichain/src/multichain/utils/analytics.ts @@ -9,7 +9,58 @@ import type { StoreClient, TransportType, } from '../../domain'; -import { getPlatformType } from '../../domain'; +import { getPlatformType, RPCInvokeMethodErr } from '../../domain'; + +/** + * Tag describing the cause of a failed wallet action / connection. Surfaced + * as the `failure_reason` property on `mmconnect_wallet_action_failed` and + * `mmconnect_connection_failed` events so we can distinguish e.g. a transport + * timeout from a wallet-side internal error in Mixpanel. + * + * Intentionally a string union (not a const enum) so callers stay free to + * pass through a new bucket; the schema-side property is an open string for + * the same reason. + */ +export type FailureReason = + | 'transport_timeout' + | 'transport_disconnect' + | 'wallet_method_unsupported' + | 'wallet_invalid_params' + | 'wallet_internal_error' + | 'wallet_unauthorized' + | 'unrecognised_chain' + | 'unknown'; + +/** + * Pulls the most informative `code` / `message` pair out of an error, + * unwrapping `RPCInvokeMethodErr` so the wallet-side code (e.g. 4001) is + * visible to classifiers instead of being hidden behind the SDK's static + * `code: 53`. Falls back to the outer error if there is no inner wallet code. + * + * @param error - The error object to inspect + * @returns The most relevant `{ code, message }` pair we can extract + */ +function getUnwrappedErrorDetails(error: unknown): { + code: number | undefined; + message: string; +} { + if (typeof error !== 'object' || error === null) { + return { code: undefined, message: '' }; + } + + if (error instanceof RPCInvokeMethodErr) { + return { + code: error.rpcCode ?? error.code, + message: error.rpcMessage ?? error.message ?? '', + }; + } + + const errorObj = error as { code?: number; message?: string }; + return { + code: errorObj.code, + message: errorObj.message ?? '', + }; +} /** * Checks if an error represents a user rejection. @@ -36,6 +87,113 @@ export function isRejectionError(error: unknown): boolean { ); } +/** + * Classifies a failed wallet action / connection error into a short tag for + * the `failure_reason` analytics property. Caller is expected to have already + * established that the error is *not* a user rejection (use `isRejectionError` + * for that branching). + * + * The taxonomy is deliberately producer-side-only — the schema accepts any + * string — so we can add buckets here without an API migration. Once the + * distribution stabilises we may convert the schema field to a closed enum. + * + * @param error - The error to classify + * @returns A short, snake_case tag describing why the operation failed + */ +export function classifyFailureReason(error: unknown): FailureReason { + if (typeof error !== 'object' || error === null) { + return 'unknown'; + } + + const errorObj = error as { name?: string; message?: string }; + const errorName = errorObj.name ?? ''; + const errorMessageRaw = errorObj.message ?? ''; + const errorMessage = errorMessageRaw.toLowerCase(); + + // Wallet-side JSON-RPC / EIP-1193 code is the strongest signal we have — + // check it before any message-substring heuristics so a wallet error like + // `{ code: 4900, message: 'Disconnected' }` doesn't get caught by the + // transport-disconnect text match below. Unwraps `RPCInvokeMethodErr` so + // the wallet's actual error code is visible. + const { code } = getUnwrappedErrorDetails(error); + if (typeof code === 'number') { + // JSON-RPC 2.0 + EIP-1474 standard codes. + if (code === -32601) { + return 'wallet_method_unsupported'; + } + if (code === -32602) { + return 'wallet_invalid_params'; + } + if (code === -32603) { + return 'wallet_internal_error'; + } + // Standard JSON-RPC server error range. + if (code <= -32000 && code >= -32099) { + return 'wallet_internal_error'; + } + // EIP-1193 named provider codes — handled individually. Codes in the + // 1000–4999 range that aren't matched here fall through to `unknown`. + if (code === 4100) { + // Unauthorized — most commonly fires when a method isn't in the + // CAIP-25 scope's granted methods list (the multichain permission + // layer rejects it before the method handler runs). Distinct from + // a user rejection (4001) and worth tracking separately. + return 'wallet_unauthorized'; + } + if (code === 4200) { + // Unsupported method — wallet handler exists but explicitly refuses. + return 'wallet_method_unsupported'; + } + if (code === 4902) { + // Unrecognized chain ID — `wallet_switchEthereumChain` to a chain the + // wallet hasn't been told about. Same signal as the message heuristic + // below, but reaches us cleanly via code rather than substring. + return 'unrecognised_chain'; + } + // Anything else in the EIP-1193 / EIP-1474 provider-defined range + // (1000–4999) falls through to `unknown` — we can promote specific codes + // into their own buckets later as the distribution stabilises, without a + // schema migration. Two buckets for "we don't know what this is" adds + // noise without insight. + } + + // Transport-layer errors. Two shapes exist: + // - `TransportTimeoutError` from `@metamask/multichain-api-client` (used by + // MWP and the warmup paths of the default extension transport). It's a + // subclass of `TransportError` so we match on the name field rather than + // importing the symbol (the type lives in a runtime dependency that the + // analytics utils shouldn't pull in directly). + // - A plain `new Error('Request timeout')` thrown by `DefaultTransport`'s + // own setTimeout. Indistinguishable from other errors without the message. + if ( + errorName === 'TransportTimeoutError' || + errorMessageRaw === 'Request timeout' || + errorMessage.includes('timed out') || + errorMessage.includes('timeout') + ) { + return 'transport_timeout'; + } + // Transport disconnect. Narrowed substring set so we don't snag wallet + // error messages that happen to contain "disconnect" (e.g. EIP-1193 + // `4900 Disconnected`, which the wallet-code branch above already routed + // to `unknown` per policy). + if ( + errorName === 'TransportError' || + errorMessage.includes('not connected') || + errorMessage.includes('transport disconnect') || + errorMessage.includes('connection lost') || + errorMessage.includes('socket closed') + ) { + return 'transport_disconnect'; + } + + if (errorMessage.includes('unrecognized chain')) { + return 'unrecognised_chain'; + } + + return 'unknown'; +} + /** * Gets base analytics properties that are common across all events. * @@ -71,6 +229,10 @@ export async function getBaseAnalyticsProperties( * @param storage - Storage client for getting anonymous ID * @param invokeOptions - The invoke method options containing method and scope * @param transportType - The transport type to use for the analytics event + * @param extra - Optional event-specific properties. Today only used to + * attach a `failure_reason` tag to `mmconnect_wallet_action_failed` events. + * @param extra.failure_reason - A short tag describing why the operation + * failed; see `classifyFailureReason` and the `FailureReason` union. * @returns Wallet action analytics properties */ export async function getWalletActionAnalyticsProperties( @@ -78,6 +240,7 @@ export async function getWalletActionAnalyticsProperties( storage: StoreClient, invokeOptions: InvokeMethodOptions, transportType: TransportType, + extra?: { failure_reason?: FailureReason }, ): Promise<{ mmconnect_versions: Record; dapp_id: string; @@ -85,6 +248,7 @@ export async function getWalletActionAnalyticsProperties( caip_chain_id: string; anon_id: string; transport_type: TransportType; + failure_reason?: FailureReason; }> { const dappId = getDappId(options.dapp); const anonId = await storage.getAnonId(); @@ -96,5 +260,6 @@ export async function getWalletActionAnalyticsProperties( caip_chain_id: invokeOptions.scope, anon_id: anonId, transport_type: transportType, + ...(extra?.failure_reason ? { failure_reason: extra.failure_reason } : {}), }; } diff --git a/playground/browser-playground/CHANGELOG.md b/playground/browser-playground/CHANGELOG.md index 811f589c..9132208e 100644 --- a/playground/browser-playground/CHANGELOG.md +++ b/playground/browser-playground/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added an **Analytics test bench** collapsible panel with one button per `failure_reason` classifier branch, plus a local `yarn analytics:echo` server that stands in for the analytics endpoint. See the playground README for the manual-testing walkthrough. ([#290](https://github.com/MetaMask/connect-monorepo/pull/290)) + ## [0.7.2] ### Changed diff --git a/playground/browser-playground/README.md b/playground/browser-playground/README.md index aa511353..b6f3d79d 100644 --- a/playground/browser-playground/README.md +++ b/playground/browser-playground/README.md @@ -73,6 +73,66 @@ Toggle between multichain and legacy EVM modes to test backwards compatibility w Test the wagmi integration for React applications with persistent sessions and multichain support. +## Manually testing analytics events + +The playground emits analytics events via `@metamask/analytics` to whatever endpoint `METAMASK_ANALYTICS_ENDPOINT` resolves to (defaults to the production sink). For local verification — confirming a new property landed, a classifier picked the right bucket, a new event fired at all — you can point the playground at a local echo server and use the in-page **Analytics test bench** section to drive each code path. + +### One-time setup + +The pieces below are already wired up in this repo; this is just so you know what's involved if anything looks off: + +- **`scripts/analytics-echo-server.mjs`** — a tiny Node HTTP server that accepts `POST /v2/events` and pretty-prints every event with `event_name`, `failure_reason`, `method`, and `transport` highlighted. +- **`craco.config.js`** — `DefinePlugin` is patched to forward `process.env.METAMASK_ANALYTICS_ENDPOINT` into the browser bundle. CRA's default behaviour only exposes `REACT_APP_*` vars, so without this the override would silently do nothing. +- **`public/index.html`** — the `Content-Security-Policy` meta tag's `connect-src` allowlist includes `http://localhost:*` and `http://127.0.0.1:*` so the browser is allowed to reach a local sink. Without this the browser drops the request and the Network tab shows nothing — the only hint is a CSP refusal in the console. +- **`src/components/AnalyticsTestBench.tsx`** — the collapsible "Analytics test bench" section in the playground UI, with one button per `classifyFailureReason` branch. + +### Step-by-step + +1. **Start the echo server** in one terminal: + + ```bash + yarn analytics:echo + # → analytics echo server listening on http://localhost:8787 + ``` + +2. **Start the playground** in another terminal with the endpoint override: + + ```bash + METAMASK_ANALYTICS_ENDPOINT="http://localhost:8787/" yarn start + ``` + + (You can also put `METAMASK_ANALYTICS_ENDPOINT=http://localhost:8787/` in your `.env` — it goes through the same `DefinePlugin` path.) + +3. **Open** `http://localhost:3000` (or whatever port CRA picked), connect via the Multichain card. + +4. **Expand "Analytics test bench"** at the top of the page. Each button drives a request shape designed to land in a specific `failure_reason` bucket on `mmconnect_wallet_action_failed`. Watch the echo-server terminal — events arrive within a couple hundred ms. + + The bench keeps an in-page "Recent triggers" log showing the raw `name` / `code` / `msg` the wallet returned, so you can cross-reference what the wallet sent vs which bucket the classifier picked. + +### Triggering buckets that need manual setup + +Two buckets aren't deterministically reachable from a button: + +- **`transport_timeout`** — toggle DevTools → Network → "Offline", then click any wallet-bound trigger, then wait ~30s for the SDK timeout. +- **`transport_disconnect`** — click a wallet-bound trigger, then disable/quit the MetaMask extension before approving. + +Both buttons in the bench just print these instructions in an alert. + +### Gotcha: multichain scope rules + +On a CAIP-25 multichain session, the wallet's permission layer rejects any method not in the granted scope with EIP-1193 `4100 Unauthorized` **before** the method handler runs ([source](https://github.com/MetaMask/core/blob/main/packages/multichain-api-middleware/src/handlers/wallet-invokeMethod.ts)). So buttons like "bogus method" and "switch chain to 0xfa" all land in `wallet_unauthorized`, even though they'd produce different codes (`-32601` / `4902`) if the wallet got to run its handlers. The bench labels reflect this — see the in-page "Heads up" note. + +### Verifying the endpoint override took effect + +If events aren't arriving at the echo server, the env var probably didn't make it into the bundle. Quick check: + +```bash +curl -s http://localhost:3000/static/js/bundle.js | grep -o 'localhost:8787[^"]*' | head -1 +# → localhost:8787/ +``` + +If that prints nothing, the bundle is still pointing at the production endpoint — restart `yarn start` with the env var set in the same shell. + ## Project Structure ``` @@ -80,6 +140,7 @@ browser-playground/ ├── src/ │ ├── App.tsx # Main application component │ ├── components/ +│ │ ├── AnalyticsTestBench.tsx # Collapsible bench for driving each failure_reason branch │ │ ├── DynamicInputs.tsx # Checkbox selection UI │ │ ├── FeaturedNetworks.tsx # Network selection component │ │ ├── LegacyEVMCard.tsx # Legacy EVM connector card @@ -95,6 +156,7 @@ browser-playground/ │ ├── config.ts # Wagmi configuration │ └── metamask-connector.ts # Auto-generated connector ├── scripts/ +│ ├── analytics-echo-server.mjs # Local POST /v2/events sink for manual analytics testing │ ├── copy-wagmi-connector.js # Copies wagmi connector from integrations/ │ └── README.md # Script documentation └── public/ diff --git a/playground/browser-playground/craco.config.js b/playground/browser-playground/craco.config.js index d5d524e5..2c461762 100644 --- a/playground/browser-playground/craco.config.js +++ b/playground/browser-playground/craco.config.js @@ -53,6 +53,9 @@ module.exports = { 'process.env.INFURA_API_KEY': JSON.stringify( process.env.INFURA_API_KEY, ), + 'process.env.METAMASK_ANALYTICS_ENDPOINT': JSON.stringify( + process.env.METAMASK_ANALYTICS_ENDPOINT, + ), }), ); diff --git a/playground/browser-playground/package.json b/playground/browser-playground/package.json index e3baf9e0..c4d433d8 100644 --- a/playground/browser-playground/package.json +++ b/playground/browser-playground/package.json @@ -24,6 +24,7 @@ "build/" ], "scripts": { + "analytics:echo": "node scripts/analytics-echo-server.mjs", "build": "yarn copy-wagmi-connector && DISABLE_ESLINT_PLUGIN=true craco build", "build:docs": "typedoc", "changelog:format": "../../scripts/format-changelog.sh @metamask/browser-playground", diff --git a/playground/browser-playground/public/index.html b/playground/browser-playground/public/index.html index c72cabc1..cec55f64 100644 --- a/playground/browser-playground/public/index.html +++ b/playground/browser-playground/public/index.html @@ -14,7 +14,7 @@ script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; - connect-src 'self' wss://mm-sdk-relay.api.cx.metamask.io https://mm-sdk-analytics.api.cx.metamask.io https://*.infura.io; + connect-src 'self' wss://mm-sdk-relay.api.cx.metamask.io https://mm-sdk-analytics.api.cx.metamask.io https://*.infura.io http://localhost:* http://127.0.0.1:*; frame-src https://fwd.metamask.io; font-src 'self';" /> diff --git a/playground/browser-playground/scripts/README.md b/playground/browser-playground/scripts/README.md index fa5578c6..da5f4166 100644 --- a/playground/browser-playground/scripts/README.md +++ b/playground/browser-playground/scripts/README.md @@ -1,5 +1,31 @@ # Browser Playground Scripts +## analytics-echo-server.mjs + +### Purpose + +A tiny local HTTP server (`POST /v2/events`, default port `8787`) that stands in for `https://mm-sdk-analytics.api.cx.metamask.io` during manual testing. Pretty-prints every event it receives, with `event_name`, `failure_reason`, `method`, and `transport` highlighted, so you can verify the playground is emitting the analytics events you expect. + +Pair it with the **Analytics test bench** section in the playground UI (`src/components/AnalyticsTestBench.tsx`) — see [the playground README](../README.md#manually-testing-analytics-events) for the full walkthrough. + +### Usage + +```bash +yarn analytics:echo # listens on :8787 +PORT=9090 yarn analytics:echo # custom port +``` + +Then start the playground with the analytics endpoint pointed at it: + +```bash +METAMASK_ANALYTICS_ENDPOINT="http://localhost:8787/" yarn start +``` + +### Important Notes + +- The env var injection is wired through `craco.config.js`'s `DefinePlugin`; CRA only exposes `REACT_APP_*` vars to the bundle by default, so a plain `process.env.METAMASK_ANALYTICS_ENDPOINT` would be `undefined` at runtime without that patch. +- `public/index.html`'s `Content-Security-Policy` meta tag includes `http://localhost:*` and `http://127.0.0.1:*` in `connect-src` so local sinks are reachable — without this the browser silently blocks the request and the Network tab shows nothing. + ## copy-wagmi-connector.js ### Purpose diff --git a/playground/browser-playground/scripts/analytics-echo-server.mjs b/playground/browser-playground/scripts/analytics-echo-server.mjs new file mode 100644 index 00000000..b26c4b99 --- /dev/null +++ b/playground/browser-playground/scripts/analytics-echo-server.mjs @@ -0,0 +1,147 @@ +/** + * Local analytics echo server for the browser playground. + * + * Stands in for `https://mm-sdk-analytics.api.cx.metamask.io` when you want + * to manually inspect the analytics events the playground produces. Accepts + * `POST /v2/events` from `@metamask/analytics`, pretty-prints each event + * with `event_name`, `failure_reason`, `method`, and `transport` highlighted, + * and replies `200 {}` so the SDK's Sender batch loop is happy. + * + * See `playground/browser-playground/README.md` → "Manually testing + * analytics events" for the full setup. + * + * Usage: + * yarn analytics:echo # listens on :8787 + * PORT=9090 yarn analytics:echo # custom port + * node scripts/analytics-echo-server.mjs # standalone, no yarn + */ + +import http from 'node:http'; + +// eslint-disable-next-line n/no-process-env -- standalone CLI script: PORT env var is the documented way to override the default. +const PORT = Number(process.env.PORT ?? 8787); + +const COLOR = { + reset: '\x1b[0m', + dim: '\x1b[2m', + bold: '\x1b[1m', + cyan: '\x1b[36m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + magenta: '\x1b[35m', + blue: '\x1b[34m', +}; + +const colorForEvent = (name) => { + if (!name) { + return COLOR.dim; + } + if (name.endsWith('_failed')) { + return COLOR.red; + } + if (name.endsWith('_rejected')) { + return COLOR.yellow; + } + if (name.endsWith('_succeeded') || name === 'mmconnect_connected') { + return COLOR.green; + } + if (name.endsWith('_requested')) { + return COLOR.cyan; + } + return COLOR.magenta; +}; + +let eventCounter = 0; + +const printEvent = (event) => { + eventCounter += 1; + const name = event?.event_name ?? ''; + const props = event?.properties ?? {}; + const failureReason = props.failure_reason; + const { method } = props; + const transport = props.transport_type; + + const head = + `${COLOR.dim}#${eventCounter}${COLOR.reset} ` + + `${colorForEvent(name)}${COLOR.bold}${name}${COLOR.reset}`; + + const tags = []; + if (failureReason) { + tags.push( + `${COLOR.bold}${COLOR.red}failure_reason=${failureReason}${COLOR.reset}`, + ); + } + if (method) { + tags.push(`${COLOR.cyan}method=${method}${COLOR.reset}`); + } + if (transport) { + tags.push(`${COLOR.blue}transport=${transport}${COLOR.reset}`); + } + + console.log(`\n${head}${tags.length ? ` ${tags.join(' ')}` : ''}`); + console.log(`${COLOR.dim}${JSON.stringify(props, null, 2)}${COLOR.reset}`); +}; + +const server = http.createServer((req, res) => { + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }); + res.end(); + return; + } + + if (req.method !== 'POST') { + res.writeHead(404, { 'Access-Control-Allow-Origin': '*' }); + res.end('not found'); + return; + } + + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let parsed; + try { + parsed = JSON.parse(raw); + } catch (parseError) { + console.error( + `\n${COLOR.red}Failed to parse body${COLOR.reset}`, + parseError, + ); + console.error(raw); + res.writeHead(200, { 'Access-Control-Allow-Origin': '*' }); + res.end('{}'); + return; + } + + const events = Array.isArray(parsed) ? parsed : [parsed]; + console.log( + `\n${COLOR.dim}━━━ ${new Date().toISOString()} POST ${req.url} (${events.length} event${events.length === 1 ? '' : 's'}) ━━━${COLOR.reset}`, + ); + for (const ev of events) { + printEvent(ev); + } + + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + res.end('{}'); + }); +}); + +server.listen(PORT, () => { + console.log( + `${COLOR.bold}${COLOR.green}analytics echo server listening on http://localhost:${PORT}${COLOR.reset}`, + ); + console.log( + `${COLOR.dim} expecting POST /v2/events from @metamask/analytics${COLOR.reset}`, + ); + console.log( + `${COLOR.dim} start playground with: METAMASK_ANALYTICS_ENDPOINT=http://localhost:${PORT}/ yarn start${COLOR.reset}\n`, + ); +}); diff --git a/playground/browser-playground/src/App.tsx b/playground/browser-playground/src/App.tsx index 759f2919..b2f2b399 100644 --- a/playground/browser-playground/src/App.tsx +++ b/playground/browser-playground/src/App.tsx @@ -15,6 +15,7 @@ import { ScopeCard } from './components/ScopeCard'; import { LegacyEVMCard } from './components/LegacyEVMCard'; import { WagmiCard } from './components/WagmiCard'; import { SolanaWalletCard } from './components/SolanaWalletCard'; +import { AnalyticsTestBench } from './components/AnalyticsTestBench'; import { useSolanaSDK } from './sdk/SolanaProvider'; import { Buffer } from 'buffer'; @@ -403,6 +404,12 @@ function App() { )} + +
{ + entryId += 1; + return entryId; +}; + +export function AnalyticsTestBench({ + connectedScopes, +}: { + connectedScopes: Scope[]; +}) { + const { invokeMethod, session } = useSDK(); + const [results, setResults] = useState([]); + + // Pick the first connected EVM scope; fall back to mainnet so the buttons + // still render. Each button can override this if it needs a specific scope + // (e.g. unrecognised_chain wants a chain ID the wallet won't know). + const defaultScope: Scope = + (connectedScopes.find((s) => s.startsWith('eip155:')) as Scope) ?? + ('eip155:1' as Scope); + + // Grab the first EVM account from the session for triggers that need a + // real signer (e.g. `personal_sign`). If we hand the wallet the zero + // address, it returns -32602 before showing any prompt — which defeats + // the rejection sanity-check entirely. + const firstEvmAddress = useMemo<`0x${string}` | undefined>(() => { + const scopes = session?.sessionScopes ?? {}; + for (const [scope, value] of Object.entries(scopes)) { + if (!scope.startsWith('eip155:') && scope !== 'wallet') continue; + const accounts = (value as { accounts?: string[] })?.accounts ?? []; + for (const caipAccount of accounts) { + // `eip155:1:0xabc...` → take the trailing address portion + const addr = caipAccount.split(':').pop(); + if (addr?.startsWith('0x')) return addr as `0x${string}`; + } + } + return undefined; + }, [session]); + + const runTrigger = useCallback( + async ( + label: string, + expected: ExpectedBucket | string, + trigger: () => Promise, + ) => { + const id = nextId(); + setResults((prev) => [ + { id, label, expected, status: 'pending' }, + ...prev, + ]); + try { + await trigger(); + setResults((prev) => + prev.map((r): ResultEntry => + r.id === id ? { ...r, status: 'no-throw' } : r, + ), + ); + } catch (error) { + const e = error as { name?: string; message?: string; code?: unknown }; + const errorPatch: Partial = { status: 'threw' }; + if (e.name !== undefined) errorPatch.errorName = e.name; + if (e.message !== undefined) errorPatch.errorMessage = e.message; + if (typeof e.code === 'number' || typeof e.code === 'string') { + errorPatch.errorCode = e.code; + } + setResults((prev) => + prev.map((r): ResultEntry => + r.id === id ? { ...r, ...errorPatch } : r, + ), + ); + // Keep console verbose for cross-referencing with the echo server. + console.warn(`[analytics bench] "${label}":`, error); + } + }, + [], + ); + + /* -------------------------------------------------------------------- */ + /* Trigger definitions. Each one targets a specific classifier branch. */ + /* -------------------------------------------------------------------- */ + + const triggerBogusMethod = () => + runTrigger( + 'wallet_unauthorized (method not in scope)', + 'wallet_unauthorized', + () => + // Bogus method name — in a CAIP-25 multichain session, the wallet's + // permission layer rejects this with `4100 Unauthorized` BEFORE any + // method handler runs (see `wallet-invokeMethod.ts` in @metamask/core). + // So the practical "method unsupported" signal on multichain is 4100, + // not -32601. + invokeMethod({ + scope: defaultScope, + request: { + method: 'foo_bar_doesnt_exist', + params: [], + }, + }), + ); + + const triggerInvalidParams = () => + runTrigger( + 'wallet_invalid_params (−32602)', + 'wallet_invalid_params', + () => + invokeMethod({ + scope: defaultScope, + request: { + method: 'eth_sendTransaction', + params: [ + { + to: 'not-a-hex-address', + value: 'not-hex-either', + } as any, + ], + }, + }), + ); + + const triggerSwitchUnknownChain = () => + runTrigger( + 'wallet_unauthorized (switchEthereumChain not in scope)', + 'wallet_unauthorized', + () => + // CAIP-25 scopes don't typically include wallet_switchEthereumChain + // in the granted methods, so this hits the same 4100 path as the + // bogus method above. To actually reach `4902 unrecognised_chain` + // (or `-32603 wallet_internal_error`), the wallet would need to grant + // the method and then run its handler — not generally reachable from + // the playground. + invokeMethod({ + scope: defaultScope, + request: { + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0xdeadbe' }], + }, + }), + ); + + const triggerSwitchFantom = () => + runTrigger( + 'wallet_unauthorized (switchEthereumChain not in scope)', + 'wallet_unauthorized', + () => + // Same caveat as above — on a typical multichain session this lands + // in 4100 unauthorized, not 4902 unrecognised_chain. Kept as a button + // so we can see if any wallet build behaves differently. + invokeMethod({ + scope: defaultScope, + request: { + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0xfa' }], // Fantom — usually unknown + }, + }), + ); + + const triggerSignTypedDataMalformed = () => + runTrigger( + 'wallet_invalid_params (malformed signTypedData)', + 'wallet_invalid_params', + () => + // Wallets typically validate the typed-data payload before showing + // the confirmation UI; bad input produces -32602 or -32603. The + // exact code varies between builds. + invokeMethod({ + scope: defaultScope, + request: { + method: 'eth_signTypedData_v4', + params: ['0x0000000000000000000000000000000000000000', '{}'], + }, + }), + ); + + const triggerUnknown = () => + runTrigger( + 'unknown (fallback) — empty method', + 'unknown', + () => + invokeMethod({ + scope: defaultScope, + request: { + method: '', + params: [], + }, + }), + ); + + const triggerRejection = () => { + if (!firstEvmAddress) { + alert( + 'No EVM account in the current session — connect one first so personal_sign has a real signer to prompt for.', + ); + return; + } + return runTrigger( + '(rejection — should fire _rejected, NOT _failed)', + 'n/a — _rejected', + () => + // personal_sign params: [message_hex, signer_address]. Must be a real + // connected account or the wallet rejects with -32602 *before* it + // ever shows the prompt — which would defeat the sanity-check. + invokeMethod({ + scope: defaultScope, + request: { + method: 'personal_sign', + params: ['0x68656c6c6f', firstEvmAddress], + }, + }), + ); + }; + + /* -------------------------------------------------------------------- */ + /* Buttons that need manual repro */ + /* -------------------------------------------------------------------- */ + const triggerTransportTimeout = () => { + alert( + [ + 'transport_timeout requires a stall longer than the transport timeout.', + '', + 'Easiest repro:', + ' 1. Open DevTools → Network → set throttling to "Offline".', + ' 2. Click any wallet-bound trigger above (e.g. "wallet_invalid_params").', + ' 3. Wait ~30s for the SDK timeout to fire.', + '', + 'You should see mmconnect_wallet_action_failed with failure_reason=transport_timeout.', + ].join('\n'), + ); + }; + + const triggerTransportDisconnect = () => { + alert( + [ + 'transport_disconnect requires the wallet to drop the connection mid-request.', + '', + 'Easiest repro:', + ' 1. Click any wallet-bound trigger above.', + ' 2. Immediately disable or quit the MetaMask extension before approving.', + '', + 'You should see mmconnect_wallet_action_failed with failure_reason=transport_disconnect.', + ].join('\n'), + ); + }; + + const buttonClass = + 'text-left bg-slate-800 hover:bg-slate-700 text-white px-3 py-2 rounded text-sm transition-colors'; + const manualButtonClass = + 'text-left bg-amber-700 hover:bg-amber-600 text-white px-3 py-2 rounded text-sm transition-colors'; + + return ( +
+
+ +
+

+ Analytics test bench +

+

+ Drive each failure_reason classifier branch + from the dapp. Pair with the local analytics echo server (see + playground README). +

+
+ + ▶ + +
+ +
+

+ Each button drives a request shape designed to land in a specific{' '} + failure_reason{' '} + bucket on{' '} + + mmconnect_wallet_action_failed + + . Watch the analytics echo server terminal to see the tagged event. + Connect first — most of these need an active session. +

+

+ Heads up: on multichain (CAIP-25) sessions, the wallet + rejects any method not in the granted scope with{' '} + 4100 Unauthorized{' '} + BEFORE the method handler runs. That means buttons like "bogus + method" and "switch chain to 0xfa" both reach the same code path — + they all land in{' '} + wallet_unauthorized{' '} + because the wallet never gets to see the actual method. To reach{' '} + + wallet_method_unsupported + {' '} + (real -32601) or unrecognised_chain (real{' '} + 4902), the method has to be in scope first — that's a + separate experiment. +

+ +
+ + + + + + + + + +
+ + {results.length > 0 && ( +
+

+ Recent triggers +

+
+ {results.map((r) => ( +
+
+ {r.label} + + expected: {r.expected} + +
+ {r.status === 'pending' && ( +
…waiting
+ )} + {r.status === 'no-throw' && ( +
+ no error thrown — _failed event will NOT have fired +
+ )} + {r.status === 'threw' && ( +
+ {r.errorName && ( + + name= + {r.errorName} + + )} + {r.errorCode !== undefined && ( + + code= + {String(r.errorCode)} + + )} + {r.errorMessage && ( + + msg= + {r.errorMessage} + + )} +
+ )} +
+ ))} +
+
+ )} +
+
+
+ ); +}