From a483840bba16af748e848f3a9550540acdada94c Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Tue, 28 Jul 2026 15:01:01 +0300 Subject: [PATCH] fix(connect): read the version floor out of a refused handshake The connect screen's red banner rendered err.message verbatim. A wallet on an older SDK sends "SDK version below the required minimum" -- which names neither the version this app has nor the one the wallet wants, even though both are sitting in error.data. The banner is the only place a developer learns their app was turned away, so it has to say what to upgrade to. describeConnectFailure reads the floor out of data and says it, for the SDK floor, the protocol floor and the network mismatch. When the gate sent no versions the wallet's own message is already the best text available, so it is passed through rather than replaced with worse copy. --- browser/src/hooks/useWalletConnect.test.ts | 30 ++++++++++ browser/src/hooks/useWalletConnect.ts | 8 +-- browser/src/lib/connectErrors.test.ts | 54 +++++++++++++++++- browser/src/lib/connectErrors.ts | 65 ++++++++++++++++++++++ 4 files changed, 152 insertions(+), 5 deletions(-) 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);