From a175d6894ec03fdcfaca0991d16be0434302eeec Mon Sep 17 00:00:00 2001 From: blockgroot <170620375+blockgroot@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:01:24 +0530 Subject: [PATCH 1/2] [SDK] fix: implement removeListener on EIP1193 provider adapter EIP1193.toProvider()'s removeListener was a permanent no-op: it discarded the unsubscribe function returned by wallet.subscribe(), so listeners registered via provider.on(...) could never actually be detached. Consumers such as wagmi connectors (see packages/wagmi-adapter/src/connector.ts) that subscribe to accountsChanged/chainChanged/disconnect and later call removeListener at teardown would leak the listener, which kept firing after the caller believed it had unsubscribed. Track the unsubscribe function per (event, listener) pair and invoke it from removeListener, matching the EIP-1193 contract. --- .changeset/eip1193-remove-listener-noop.md | 5 ++++ .../src/adapters/eip1193/to-eip1193.test.ts | 19 ++++++++++++++ .../src/adapters/eip1193/to-eip1193.ts | 26 ++++++++++++++++--- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 .changeset/eip1193-remove-listener-noop.md diff --git a/.changeset/eip1193-remove-listener-noop.md b/.changeset/eip1193-remove-listener-noop.md new file mode 100644 index 00000000000..1e2cfde5ad0 --- /dev/null +++ b/.changeset/eip1193-remove-listener-noop.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Fix: `EIP1193.toProvider()`'s `removeListener` is no longer a no-op. Previously, `removeListener` discarded the unsubscribe function returned by `wallet.subscribe()`, so listeners registered via `provider.on(...)` (e.g. `accountsChanged`, `chainChanged`, `disconnect`) could never actually be detached — they kept firing after callers (such as wagmi connectors) believed they had unsubscribed. `removeListener` now tracks and invokes the correct unsubscribe function per `(event, listener)` pair. diff --git a/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts b/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts index 87f1a9ec561..483a99c5586 100644 --- a/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts +++ b/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts @@ -190,6 +190,25 @@ describe("toProvider", () => { ).resolves.toEqual([]); }); + test("removeListener should detach a listener registered via on", () => { + const provider = toProvider({ + chain: ANVIL_CHAIN, + client: TEST_CLIENT, + wallet: mockWallet, + }); + + const listener = vi.fn(); + provider.on("accountsChanged", listener); + + emitter.emit("accountsChanged", [mockAccount.address]); + expect(listener).toHaveBeenCalledTimes(1); + + provider.removeListener("accountsChanged", listener); + + emitter.emit("accountsChanged", [mockAccount.address]); + expect(listener).toHaveBeenCalledTimes(1); + }); + test("should use custom connect override when provided", async () => { const walletWithoutAccount = { ...mockWallet, diff --git a/packages/thirdweb/src/adapters/eip1193/to-eip1193.ts b/packages/thirdweb/src/adapters/eip1193/to-eip1193.ts index 1c831b35c1b..2692b30ee5e 100644 --- a/packages/thirdweb/src/adapters/eip1193/to-eip1193.ts +++ b/packages/thirdweb/src/adapters/eip1193/to-eip1193.ts @@ -58,10 +58,30 @@ export type ToEip1193ProviderOptions = { export function toProvider(options: ToEip1193ProviderOptions): EIP1193Provider { const { chain, client, wallet, connectOverride } = options; const rpcClient = getRpcClient({ chain, client }); + // tracks the unsubscribe fn returned by wallet.subscribe for each (event, listener) + // pair so removeListener can actually detach it, per the EIP-1193 contract. + const unsubscribes = new Map< + unknown, + // biome-ignore lint/suspicious/noExplicitAny: matches EIP1193Provider's loose typing + Map<(params: any) => any, () => void> + >(); return { - on: wallet.subscribe, - removeListener: () => { - // should invoke the return fn from subscribe instead + on: (event, listener) => { + const unsubscribe = wallet.subscribe(event, listener); + let listeners = unsubscribes.get(event); + if (!listeners) { + listeners = new Map(); + unsubscribes.set(event, listeners); + } + listeners.set(listener, unsubscribe); + }, + removeListener: (event, listener) => { + const listeners = unsubscribes.get(event); + const unsubscribe = listeners?.get(listener); + if (unsubscribe) { + unsubscribe(); + listeners?.delete(listener); + } }, request: async (request) => { switch (request.method) { From d13d14d69fd45a3209b72c85e395148b284b9e56 Mon Sep 17 00:00:00 2001 From: blockgroot <170620375+blockgroot@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:22:49 +0530 Subject: [PATCH 2/2] [SDK] test: cover duplicate on() registration for EIP1193 removeListener CodeRabbit flagged a theoretical risk on PR #8886: since removeListener looks up a Map keyed by listener reference, registering the same listener twice for an event and overwriting that map entry could in principle leave a stale subscription active after removeListener. Verified this isn't actually reachable: every Wallet in this repo uses createWalletEmitter() (packages/thirdweb/src/wallets/wallet-emitter.ts), backed by tiny-emitter.ts's Set per event, so subscribing the same callback reference twice is a no-op on the underlying Set and either returned unsubscribe closure removes the same single entry. Added a regression test asserting a single removeListener call after two identical on() registrations fully detaches the listener, so this guarantee stays enforced if the emitter implementation ever changes. --- .../src/adapters/eip1193/to-eip1193.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts b/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts index 483a99c5586..82106363ed6 100644 --- a/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts +++ b/packages/thirdweb/src/adapters/eip1193/to-eip1193.test.ts @@ -209,6 +209,30 @@ describe("toProvider", () => { expect(listener).toHaveBeenCalledTimes(1); }); + test("removeListener fully detaches a listener registered twice via on", () => { + const provider = toProvider({ + chain: ANVIL_CHAIN, + client: TEST_CLIENT, + wallet: mockWallet, + }); + + const listener = vi.fn(); + // register the same listener reference for the same event twice, then + // confirm a single removeListener call fully detaches it (the underlying + // wallet emitter dedupes by callback reference via a Set, so this must + // not require two removeListener calls). + provider.on("accountsChanged", listener); + provider.on("accountsChanged", listener); + + emitter.emit("accountsChanged", [mockAccount.address]); + expect(listener).toHaveBeenCalledTimes(1); + + provider.removeListener("accountsChanged", listener); + + emitter.emit("accountsChanged", [mockAccount.address]); + expect(listener).toHaveBeenCalledTimes(1); + }); + test("should use custom connect override when provided", async () => { const walletWithoutAccount = { ...mockWallet,