Fix: WalletConnect Crypto.decode Silent Decryption Failure (undefined Payload)
When integrating @walletconnect/core or @walletconnect/web3modal into dApps, node services, or autonomous Web3 agents, developers often report mysterious RPC request hangs. The QR pairing completes, the WebSocket connection remains active, yet transaction signing requests or personal_sign calls stall indefinitely without emitting any user-facing error message or exception trace.
The root cause of this behavior stems from a critical error-handling flaw inside the WalletConnect core Crypto controller: when payload decryption fails due to a stale symmetric key or mismatched topic initialization, crypto.decode() catches the exception, logs it internally, and silently returns undefined. Callers expecting a parsed JSON-RPC payload fail silently without triggering fallback error handlers.
If you are also experiencing issue with dynamic ABI bindings during frontend compilation, consult our guide on Wagmi CLI Foundry Plugin Multiple Addresses with Same ABI.
Architectural Deep-Dive: Symmetric Key Decryption & Topic Lifecycle
WalletConnect v2 relies on ChaCha20-Poly1305 symmetric encryption for end-to-end communication between the dApp (or agent runtime) and the wallet endpoint over an untrusted relay network.
The Core Decryption Trap
In @walletconnect/core (located in packages/core/src/controllers/crypto.ts), the internal decode method processes incoming encrypted payloads:
// Core implementation pattern in WalletConnect Core Controller
public async decode(topic: string, encoded: string, opts?: CryptoTypes.DecodeOptions): Promise<any> {
try {
const symKey = this.getSymKey(topic);
const message = decrypt({ symKey, encoded, encoding: opts?.encoding });
const payload = safeJsonParse(message);
return payload;
} catch (error) {
this.logger.error(`Failed to decode message for topic ${topic}:`, error);
// CRITICAL ISSUE: Error is caught, logged, but NOT re-thrown!
// Returns undefined to the caller.
}
}
Why Silent undefined Breaks Web3 State Machines
- No Error Propagation: Because
decode()returnsundefinedinstead of throwing aDecryptionErrororKeyMismatchError, high-level event listeners receiving the socket frame processundefinedas a valid payload. - Hanging Promises: Downstream RPC request managers (such as Viem’s
walletClientor Ethers.jsJsonRpcProvider) wait for a specificidmatching their outbound request. Sinceundefinedhas noidfield, the request promise never resolves nor rejects. - Session Desynchronization: If local state in
localStorageorIndexedDBretains an expiredsymKeywhile the wallet peer updated its key during an out-of-band session update, every subsequent request on thattopicsilently fails forever.
Step-by-Step Resolution Protocol
To prevent silent failures in production dApps and AI agent runners, follow this three-stage remediation strategy.
1. Implement Defensive Decryption Wrapper
Wrap your SignClient message listener with an explicit validation layer that detects undefined payloads and converts them into actionable session resets.
import { SignClient } from '@walletconnect/sign-client';
import { getSdkError } from '@walletconnect/utils';
export function attachDefensiveWalletConnectListeners(signClient: InstanceType<typeof SignClient>) {
// Intercept incoming relay messages
signClient.core.relayer.on('relayer_message', async (event: { topic: string; message: string }) => {
const { topic, message } = event;
try {
// Attempt manual decode using internal crypto controller
const payload = await signClient.core.crypto.decode(topic, message);
if (payload === undefined) {
console.warn(`[WalletConnect Guard] Silent decryption failure detected on topic: ${topic}`);
// Purge invalid session state and disconnect peer
await handleDecryptionFailure(signClient, topic);
}
} catch (err) {
console.error('[WalletConnect Guard] Unhandled crypto decode error:', err);
}
});
}
async function handleDecryptionFailure(signClient: InstanceType<typeof SignClient>, topic: string) {
try {
// 1. Terminate the broken session
if (signClient.session.has(topic)) {
await signClient.disconnect({
topic,
reason: getSdkError('USER_DISCONNECTED'),
});
}
// 2. Clear key storage for the affected topic
await signClient.core.crypto.deleteSymKey(topic);
// 3. Dispatch global disconnect event for UI re-pairing
window.dispatchEvent(new CustomEvent('wc_decryption_failed', { detail: { topic } }));
} catch (purgeError) {
console.error('[WalletConnect Guard] Failed to purge corrupted session:', purgeError);
}
}
2. Flush Stale Local Storage Keys on App Mount
Corrupted symKey entries in browser storage or agent persistent stores are the primary trigger for crypto.decode failures. Implement a startup sanitization routine:
export async function sanitizeWalletConnectStorage() {
const KEY_PREFIX = 'wc@2:core:crypto:symKey';
// Inspect localStorage for dangling keys without matching active sessions
Object.keys(localStorage).forEach((key) => {
if (key.startsWith(KEY_PREFIX)) {
const topic = key.replace(`${KEY_PREFIX}:`, '');
const activeSession = localStorage.getItem('wc@2:client:0.3.0://session');
if (activeSession) {
const sessions = JSON.parse(activeSession);
const hasMatchingSession = sessions.some((s: any) => s.topic === topic);
if (!hasMatchingSession) {
console.info(`[WC Clean] Purging orphan symKey for topic: ${topic}`);
localStorage.removeItem(key);
}
}
}
});
}
3. Automated Re-Pairing Fallback Hook for React / Next.js
In client applications using @walletconnect/web3modal or Wagmi, register a custom event listener to trigger standard re-authentication UI:
import { useEffect } from 'react';
import { useDisconnect } from 'wagmi';
export function WalletConnectDecryptionGuard() {
const { disconnect } = useDisconnect();
useEffect(() => {
const onDecryptionFailure = (event: Event) => {
const customEvt = event as CustomEvent<{ topic: string }>;
console.error(`Decryption failed on session topic ${customEvt.detail.topic}. Forcing disconnect...`);
// Reset wagmi connection state
disconnect();
// Clear session artifacts
localStorage.removeItem('walletconnect');
};
window.addEventListener('wc_decryption_failed', onDecryptionFailure);
return () => window.removeEventListener('wc_decryption_failed', onDecryptionFailure);
}, [disconnect]);
return null;
}
Production Security & Recovery Checklist
| Audit Step | Operational Command / Code Check | Expected Result |
|---|---|---|
| Decode Interceptor | signClient.core.crypto.decode(topic, payload) | Returns valid JSON object or triggers defensive purge on undefined. |
| Storage Purge | sanitizeWalletConnectStorage() | Zero orphan symKey entries in localStorage/IndexedDB. |
| Timeout Guard | RPC Timeout Wrap (e.g. 15s max per request) | Promise rejects cleanly with RPC_TIMEOUT instead of hanging. |
Frequently Asked Questions
Q: Why does this error occur after refreshing the page or restarting the node process?
When the page reloads, the state manager rehydrates active sessions from local storage. If the remote wallet revoked or rotated the session key while the dApp was offline, the dApp attempts to decrypt incoming messages using the obsolete symKey cached before the reload, triggering the silent undefined failure.
Q: Can this bug be exploited for denial-of-service (DoS)?
Yes. An attacker on the relay network capable of injecting invalid ciphertext onto an active topic can trigger crypto.decode decryption failures. Because the client silently ignores the error and drops the packet, legitimate RPC requests may be suppressed without alerting the application operator.
Q: Does upgrading to the latest WalletConnect SDK fix this completely?
Recent releases add enhanced internal logging, but defensive code checks on the return value of crypto.decode() remain mandatory in production code bases to prevent downstream hanging promises when unparseable payloads reach client application handlers.