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
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.
Description
XBullAdapter.setupAccountChangeListener()overwritesthis.unsubscribewithout calling the previous one, so every call toconnect()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
thisand still runsthis.currentPublicKey = publicKey. So afterdisconnect()has deliberately setcurrentPublicKey = 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:LobstrAdapter.setupAccountChangeListener()removes the previous registration before installing a new one:XBullAdapterdoes neither, and it is the only one of the three that does not.Reproduce
Driving the real
XBullAdapteragainst awindow.xbulldouble that counts its live listeners:The last line is the one that matters:
disconnect()nullscurrentPublicKey, 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:disconnect()then needs no change: with one live listener its existing unsubscribe is sufficient.Acceptance criteria
connect()calls leave at most one live xBull account-change listenerdisconnect(), no listener remains registered with the walletdisconnect()does not modify adapter stateFreighterAdapterandLobstrAdapterbehaviour unchangedContext
src/wallets/adapters/XBullAdapter.tssrc/wallets/adapters/LobstrAdapter.tsandsrc/wallets/adapters/FreighterAdapter.ts, which both already avoid thisPer 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.