Skip to content

XBullAdapter leaks an account-change listener on every reconnect, and disconnect() removes only the last one #840

Description

@woahwhattheheck

Description

XBullAdapter.setupAccountChangeListener() overwrites this.unsubscribe without calling the previous one, so every call to connect() registers an additional account-change listener with xBull and orphans the one before it. disconnect() then invokes only the most recent unsubscribe, leaving the earlier listeners live.

The sharp consequence is not the leak itself but what the orphans keep doing: each one still holds this and still runs this.currentPublicKey = publicKey. So after disconnect() has deliberately set currentPublicKey = null, the next account-change event from the wallet writes an address straight back into the adapter the app believes it has torn down.

connect() being called more than once is ordinary usage — reconnect after a dropped session, an account switch, or a component remount that re-runs connection setup.

The other two adapters already handle this

Both siblings state the intended behaviour, in two different ways:

FreighterAdapter.startAccountChangePolling() refuses to double-register:

private startAccountChangePolling(): void {
    if (this.pollInterval) return;

LobstrAdapter.setupAccountChangeListener() removes the previous registration before installing a new one:

if (this.accountChangedHandler && typeof window.lobstr.off === "function") {
    window.lobstr.off("accountChanged", this.accountChangedHandler);
}

XBullAdapter does neither, and it is the only one of the three that does not.

Reproduce

Driving the real XBullAdapter against a window.xbull double that counts its live listeners:

one connect                          -> 1 live listener        (expected 1)
three connects, no disconnect        -> 3 live listeners       (expected 1)
disconnect()                         -> 2 live listeners       (expected 0)
wallet emits accountChanged after disconnect
    adapter.currentPublicKey         -> "GC-SOME-OTHER-ADDRESS"  (expected null)

The last line is the one that matters: disconnect() nulls currentPublicKey, and a leaked listener puts an address back.

Suggested fix

Mirror LobstrAdapter — drop the previous registration before taking a new one, so at most one is ever live:

private setupAccountChangeListener(): void {
    if (!window.xbull) return;

    if (this.unsubscribe) {
      this.unsubscribe();
      this.unsubscribe = null;
    }

    this.unsubscribe = window.xbull.onAccountChange((publicKey: string) => {
      // unchanged
    });
}

disconnect() then needs no change: with one live listener its existing unsubscribe is sufficient.

Acceptance criteria

  • Repeated connect() calls leave at most one live xBull account-change listener
  • After disconnect(), no listener remains registered with the wallet
  • An account-change event delivered after disconnect() does not modify adapter state
  • FreighterAdapter and LobstrAdapter behaviour unchanged
  • Unit tests pass

Context

  • Target file: src/wallets/adapters/XBullAdapter.ts
  • Related: src/wallets/adapters/LobstrAdapter.ts and src/wallets/adapters/FreighterAdapter.ts, which both already avoid this

Per CONTRIBUTING I'm not starting on it unassigned — I'd like to work on this, and I have the fix and regression tests ready to push if a maintainer assigns it to me.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions