diff --git a/browser/src/hooks/useWalletConnect.test.ts b/browser/src/hooks/useWalletConnect.test.ts index f4f0f68..c813db9 100644 --- a/browser/src/hooks/useWalletConnect.test.ts +++ b/browser/src/hooks/useWalletConnect.test.ts @@ -464,3 +464,33 @@ describe('useWalletConnect — a dApp reload must not reload the wallet', () => expect(opened[0]).toBe(''); }); }); + +describe('useWalletConnect — a refused handshake says which version to move to', () => { + it('surfaces the SDK floor the wallet compared, not just its bare message', async () => { + // Exactly what a 0.13 wallet sends a 0.11 app: the numbers are in `data`, the message + // (from a wallet on an older SDK) names none of them. + FakeConnectClient.nextConnectError = new ConnectError( + 'SDK version below the required minimum', + ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION, + { reason: 'protocol_incompatible', requiredSdk: '0.12.0-0', actualSdk: '0.11.9' }, + ); + + const hook = renderHook(() => useWalletConnect()); + await waitFor(() => expect(hook.result.current.isAutoConnecting).toBe(false)); + await connectPopup(hook.result); + + expect(hook.result.current.isConnected).toBe(false); + expect(hook.result.current.error).toContain('0.11.9'); + expect(hook.result.current.error).toContain('0.12.0-0'); + }); + + it('leaves an ordinary failure message alone', async () => { + FakeConnectClient.nextConnectError = new Error('Connection rejected by wallet'); + + const hook = renderHook(() => useWalletConnect()); + await waitFor(() => expect(hook.result.current.isAutoConnecting).toBe(false)); + await connectPopup(hook.result); + + expect(hook.result.current.error).toBe('Connection rejected by wallet'); + }); +}); diff --git a/browser/src/hooks/useWalletConnect.ts b/browser/src/hooks/useWalletConnect.ts index c689f11..e965151 100644 --- a/browser/src/hooks/useWalletConnect.ts +++ b/browser/src/hooks/useWalletConnect.ts @@ -4,7 +4,7 @@ import { PostMessageTransport, ExtensionTransport } from '@unicitylabs/sphere-sd import type { ConnectTransport, PublicIdentity, RpcMethod, IntentAction } from '@unicitylabs/sphere-sdk/connect'; import type { PermissionScope } from '@unicitylabs/sphere-sdk/connect'; import { isInIframe, hasExtension } from '../lib/detection'; -import { classifyRequestError } from '../lib/connectErrors'; +import { classifyRequestError, describeConnectFailure } from '../lib/connectErrors'; import { supportsGracefulLock } from '../lib/walletProtocol'; export interface WalletConnectState { @@ -378,7 +378,7 @@ export function useWalletConnect(): UseWalletConnect { transportRef.current = transport; await handshake(transport); } catch (err) { - setState((s) => ({ ...s, isConnecting: false, error: err instanceof Error ? err.message : 'Connection failed' })); + setState((s) => ({ ...s, isConnecting: false, error: describeConnectFailure(err) })); } }, [handshake]); @@ -399,7 +399,7 @@ export function useWalletConnect(): UseWalletConnect { await openPopupAndConnect(); } } catch (err) { - setState((s) => ({ ...s, isConnecting: false, error: err instanceof Error ? err.message : 'Connection failed' })); + setState((s) => ({ ...s, isConnecting: false, error: describeConnectFailure(err) })); } }, [openPopupAndConnect, handshake]); @@ -425,7 +425,7 @@ export function useWalletConnect(): UseWalletConnect { setState((s) => ({ ...s, isConnecting: false, - error: err instanceof Error ? err.message : 'Connection failed', + error: describeConnectFailure(err), })); } }, [connectViaExtension, connectViaPopup, handshake]); diff --git a/browser/src/lib/connectErrors.test.ts b/browser/src/lib/connectErrors.test.ts index d64e7a9..c750efe 100644 --- a/browser/src/lib/connectErrors.test.ts +++ b/browser/src/lib/connectErrors.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ConnectError, ERROR_CODES } from '@unicitylabs/sphere-sdk/connect'; -import { classifyRequestError, connectErrorCode, lockedData } from './connectErrors'; +import { classifyRequestError, connectErrorCode, lockedData, describeConnectFailure } from './connectErrors'; /** Exactly what ConnectHost sends on a 4009 in Release 1. */ const locked = () => new ConnectError('Wallet is locked', ERROR_CODES.WALLET_LOCKED, { reason: 'locked' }); @@ -100,3 +100,55 @@ describe('INTENT_OUTCOME_UNKNOWN is its own kind', () => { expect(classifyRequestError(err)).not.toBe('teardown'); }); }); + +/** + * The connect screen's red banner is the only place a developer learns their app was + * turned away. `error.message` from an older wallet says "SDK version below the required + * minimum" and names nothing; the version floor it compared is right there in + * `error.data`. Read it — a refusal that does not say which version to move to is a + * bug report the developer cannot act on. + */ +describe('describeConnectFailure', () => { + const gate = (data: Record) => + new ConnectError('SDK version below the required minimum', ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION, data); + + it('names the SDK version this app has and the one the wallet wants', () => { + const s = describeConnectFailure(gate({ reason: 'protocol_incompatible', requiredSdk: '0.12.0-0', actualSdk: '0.11.9' })); + expect(s).toContain('0.11.9'); + expect(s).toContain('0.12.0-0'); + }); + + it('still names the required version when this app reported none', () => { + const s = describeConnectFailure(gate({ reason: 'protocol_incompatible', requiredSdk: '0.12.0-0', actualSdk: null })); + expect(s).toContain('0.12.0-0'); + expect(s).not.toContain('null'); + }); + + it('names both protocol versions on a protocol floor', () => { + const s = describeConnectFailure(gate({ reason: 'protocol_incompatible', clientProtocol: '2.0', requiredProtocol: '2.1' })); + expect(s).toContain('2.0'); + expect(s).toContain('2.1'); + }); + + it('names both networks on a network mismatch', () => { + const err = new ConnectError('dApp targets a different network', ERROR_CODES.INCOMPATIBLE_NETWORK, { + reason: 'network_incompatible', + walletNetwork: { id: 4 }, + clientNetwork: { id: 1, name: 'mainnet' }, + }); + const s = describeConnectFailure(err); + expect(s).toContain('mainnet'); + expect(s).toContain('4'); + }); + + it('falls back to the wallet message when the gate sent no versions', () => { + // A newer wallet already names them in the message — do not second-guess it. + const s = describeConnectFailure(gate({ reason: 'protocol_incompatible' })); + expect(s).toBe('SDK version below the required minimum'); + }); + + it('passes non-gate failures through untouched', () => { + expect(describeConnectFailure(new Error('Wallet popup was closed'))).toBe('Wallet popup was closed'); + expect(describeConnectFailure('nope')).toBe('Connection failed'); + }); +}); diff --git a/browser/src/lib/connectErrors.ts b/browser/src/lib/connectErrors.ts index ea72662..b7b4e11 100644 --- a/browser/src/lib/connectErrors.ts +++ b/browser/src/lib/connectErrors.ts @@ -68,6 +68,71 @@ export function lockedData(err: unknown): WalletLockedData | undefined { const CODELESS_TEARDOWN = /\b(not connected|disconnected|connection timeout|query timeout|intent timeout|popup was closed)\b/i; +/** A non-empty string field of an untrusted `data` bag, or null. */ +function text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +/** `mainnet (1)` / `network 4`, or null when the peer sent no usable descriptor. */ +function describeNetwork(value: unknown): string | null { + if (typeof value !== 'object' || value === null) return null; + const { id, name } = value as { id?: unknown; name?: unknown }; + if (typeof id !== 'number') return null; + const label = text(name); + return label ? `${label} (${id})` : `network ${id}`; +} + +/** + * Connect-screen copy for a failed handshake. + * + * The compatibility gate publishes the versions it compared in `error.data` — + * `requiredSdk`/`actualSdk` for the npm floor, `clientProtocol`/`requiredProtocol` for the + * protocol floor, `clientNetwork`/`walletNetwork` for the network check. A wallet on an older + * SDK sends a `message` that names NONE of them ("SDK version below the required minimum"), + * so rendering `err.message` alone tells a developer to upgrade without saying to what. + * Read `data` and say it. When the gate sent no versions the wallet's own message is already + * the best available text — pass it through rather than inventing worse copy. + * + * Every field is read defensively: `data` crosses postMessage from a peer on an SDK version + * this app does not control. + */ +export function describeConnectFailure(err: unknown): string { + const code = connectErrorCode(err); + const raw = err instanceof Error ? err.message : null; + const fallback = raw ?? 'Connection failed'; + + if (code !== ERROR_CODES.UNSUPPORTED_PROTOCOL_VERSION && code !== ERROR_CODES.INCOMPATIBLE_NETWORK) { + return fallback; + } + + const data = (err as { data?: unknown }).data; + if (typeof data !== 'object' || data === null) return fallback; + const bag = data as Record; + + if (code === ERROR_CODES.INCOMPATIBLE_NETWORK) { + const client = describeNetwork(bag.clientNetwork); + const wallet = describeNetwork(bag.walletNetwork); + return client && wallet + ? `This app targets ${client}, but the wallet is on ${wallet}.` + : fallback; + } + + const requiredSdk = text(bag.requiredSdk); + if (requiredSdk) { + const actualSdk = text(bag.actualSdk); + const has = actualSdk ? `is built on sphere-sdk ${actualSdk}` : 'reported no sphere-sdk version'; + return `This app ${has} — the wallet requires ${requiredSdk} or newer. Upgrade @unicitylabs/sphere-sdk and rebuild.`; + } + + const clientProtocol = text(bag.clientProtocol); + const requiredProtocol = text(bag.requiredProtocol); + if (clientProtocol && requiredProtocol) { + return `This app speaks Connect protocol ${clientProtocol} — the wallet requires ${requiredProtocol} or newer. Upgrade @unicitylabs/sphere-sdk and rebuild.`; + } + + return fallback; +} + export function classifyRequestError(err: unknown): RequestErrorKind { const code = connectErrorCode(err);