Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions browser/src/hooks/useWalletConnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
8 changes: 4 additions & 4 deletions browser/src/hooks/useWalletConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]);

Expand All @@ -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]);

Expand All @@ -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]);
Expand Down
54 changes: 53 additions & 1 deletion browser/src/lib/connectErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
Expand Down Expand Up @@ -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<string, unknown>) =>
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');
});
});
65 changes: 65 additions & 0 deletions browser/src/lib/connectErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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);

Expand Down
Loading