Skip to content

fix(client): Bitcoin connector detection and reconnect correctness - #80

Merged
chybisov merged 9 commits into
mainfrom
fix/binance-unisat-injection
Sep 21, 2026
Merged

chybisov merged 9 commits into
mainfrom
fix/binance-unisat-injection

Conversation

@chybisov

Copy link
Copy Markdown
Member

What

binance()'s getInternalProvider returned as soon as window.binancew3w existed:

if ('binancew3w' in window) {
  return anyWindow.binancew3w.bitcoin   // may be undefined
}
if ('unisat' in window) { /* never reached in that case */ }

A build that injects binancew3w without a bitcoin provider therefore resolved undefined and never reached the window.unisat.isBinance fallback.

Why it matters

The LI.FI widget's own detection accepted both paths (binancew3w?.bitcoin || unisat?.isBinance), so the two disagreed: the widget listed a Binance wallet this connector could not resolve, and connecting bounced with no message.

lifinance/widget#878 removes that duplicated detection and asks the connector instead, so this narrowing would otherwise become a visible regression — Binance disappearing from the list on those builds. Same shape as the BitKeep fix in #77.

unisat() continues to reject isBinance, so the two connectors still cannot claim the same injection — covered by a test.

Verified

  • New binance.spec.ts, 4 tests, red before the change on the binancew3w-without-bitcoin case
  • unisat.spec.ts 5 tests and bitget.spec.ts 2 tests still pass, confirming no connector collision
  • pnpm check, check:types, check:circular-deps, knip:check, test all clean

`getInternalProvider` returned as soon as `binancew3w` existed, so a build
that injects `binancew3w` without a `bitcoin` provider resolved undefined and
never reached the `window.unisat.isBinance` fallback. The LI.FI widget's own
detection accepted both paths, so the two disagreed and Binance could be
listed while this connector could not drive it.

Same shape as the BitKeep fix in #77, and the widget is removing its
duplicate detection in favour of asking the connector.

`unisat()` still rejects `isBinance`, so the two cannot claim the same
injection.
@changeset-bot

changeset-bot Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f509fbf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@bigmi/client Patch
@bigmi/react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

`connect()` took no parameters, so it ignored `isReconnecting`, and it read
accounts through `getAccounts()` — which calls the interactive
`bitcoin:connect`. `reconnect()` runs on every mount and `isAuthorized()`
returns true whenever the connected shim is set, so a returning user with a
locked MetaMask got an unsolicited prompt on page load, and a rejection
surfaced as `UserRejectedRequestError`.

Read the session the wallet already restored when reconnecting, and keep the
interactive path for a user-initiated connect. This is the same treatment
`unisat`, `okx`, `binance`, `bitget` and `onekey` already have.

This matters now because lifinance/widget#878 makes `metamask()` a default
Bitcoin connector, which would have exposed every integrator to the prompt.
…bsence

Two problems with the passive reconnect path.

`wallet.accounts` is filled by a session lookup the wallet does not await, so
it reads empty from registration until that round trip resolves. `reconnect`
polls only for the provider, which appears the moment the wallet enters the
registry, so a wallet registering after the app mounts reported no session
and nothing recovered it — the `bitcoin:events` subscription is created after
the length check, so the later change event reached no listener. Poll the
accounts too, for up to a second: one extension round trip, and `reconnect`
already spends up to five seconds finding the provider.

`ConnectorNotConnectedError` was thrown inside the `try` whose `catch`
rethrows everything as `UserRejectedRequestError`, so a returning user with
no session was told they had rejected a request they never saw. Throw it
outside, as `unisat` and `okx` do.

The earlier tests could not catch either: the fake wallet hardcoded
`accounts`, which is the field whose timing is the bug. It now restores
asynchronously, and a fourth case covers the absent session.
@chybisov chybisov changed the title fix(client): detect Binance injected only as window.unisat fix(client): Bitcoin connector detection and reconnect correctness Sep 21, 2026
`disconnect()` awaited `connector.disconnect()` before deleting the
connection, so a throw skipped the delete, the listener cleanup and the state
update alike. `xverse`, `oyl` and `leather` all throw `ProviderNotFoundError`
once their provider is gone, and the surviving connection was then promoted
to `current`: the store reported an account that could never sign, and every
later disconnect hit the same dead entry. Detach it regardless and rethrow
after the state settles, so the caller still learns it failed.

Three follow-ups in the MetaMask connector, all on the reconnect path:

- An account that cannot be parsed now drops out of the poll instead of
  ending it, which is the half-initialized case the poll exists to survive.
- A failure while reconnecting keeps its own error type. Only the interactive
  path may report `UserRejectedRequestError`, since nothing is shown to the
  user otherwise.
- `isAuthorized()` requires a restored account, as `unisat`, `okx`, `binance`,
  `bitget` and `onekey` do. The storage shim alone sent every returning user
  through the poll, even after revoking the site or locking the wallet. It
  reads the session rather than calling `getAccounts`, so it stays passive.
`isAuthorized()` had started requiring a restored account, to match the other
connectors. For MetaMask that is wrong: the wallet fills `accounts` from a
session lookup it does not await, and `reconnect` calls `isAuthorized()` in
the same tick the wallet registers. It returned false on every reload, so
`reconnect` skipped MetaMask and the wait added to `connect()` was never
reached — reconnect was broken for the one wallet this work is about. Gate on
the shim and the provider, and let `connect({ isReconnecting: true })` decide
by waiting for the session.

The test that was supposed to cover that branch never reached it: the storage
stub resolved `undefined`, so `isAuthorized()` returned at the shim gate, and
it passed identically with the branch deleted. The stub now reports the shim,
and the restoring-session case fails if the accounts read comes back.

`disconnect()` also clears the connector's shim when its `disconnect()`
throws. The connector throws before writing its own, so the store said
disconnected while storage still said connected, and the next reconnect would
have restored what the user disconnected.

The poll window moves to 1500ms with an accurate rationale: the provider
lookup in `reconnect` returns as soon as the wallet registers, so this is the
only meaningful wait inside the 5s `connect` budget.
Three defects that `metamask()` becoming a default connector makes reachable
for every widget user.

A `change` event mapped accounts through `toAccount` unguarded, while the
reconnect poll next to it already skips what it cannot parse. `toAccount`
throws on a missing `publicKey` or an address it cannot classify, and the
throw escaped into MetaMask's own emitter: bigmi never learned the account
had changed and kept signing against the previous address.

`onAccountsChanged` treated only an empty raw list as a disconnect, then
emitted `change` with the payment-filtered list. A Taproot-only selection
filters to nothing, and an empty array is not nullish, so the connection kept
`status: 'connected'` with no usable address and every route request failed.
Filter first, and disconnect when nothing is left.

`onDisconnect` left the connected shim in storage. `isAuthorized` gates on
that shim, so a user who revoked the site inside MetaMask was reported
authorized on every later load and paid for a session poll that could never
succeed. Clear it.
The parse guard and the payment-filter disconnect interacted badly. Skipping
accounts `toAccount` cannot read left an empty list, which the disconnect
rule then took to mean the wallet had reported nothing — so a single `change`
event carrying only a half-initialized account disconnected the user and
deleted the connected shim, leaving the next page load unable to reconnect
either. That is the exact state the reconnect poll exists to ride out.

Treat a non-empty batch that parses to nothing as transient and ignore the
event. An empty batch, or one that parses to no payment address, still
disconnects.

`onDisconnect` also no longer depends on its storage write: a blocked
`localStorage` threw before `emit('disconnect')`, leaving the store connected
with no usable address — the state the payment-filter fix targets.
An empty interactive selection read `accounts[0].address` and threw a
TypeError, which the catch relabelled as `UserRejectedRequestError` — so a
user who simply holds no Bitcoin account saw raw JavaScript text and was told
they had rejected something. The reconnect path already classified that state
correctly; the interactive one now does too.

A reconnect that finds no session left the connected shim in place, and
`reconnect` swallows the error, so a site revoked while the tab was closed
never self-healed: every later load paid the full poll and sat in
`reconnecting`. Clear the shim there.

`onDisconnect` never released the events subscription — only `disconnect`
did, and this connector now reaches `onDisconnect` from `onAccountsChanged`
too. A later connect would see a truthy `unsubscribe`, skip reattaching, and
stay bound to a wallet object MetaMask had replaced, so account switches
stopped reaching the widget.

The poll guarded each account but not the `accounts` getter itself. A getter
that throws while the wallet initializes ended the poll on its first tick,
which is exactly the case the poll exists to survive.
…ow load

Two regressions from the previous commit.

Moving `getAccounts()` out of the `try` dropped the `UserRejectedRequestError`
mapping from the one call a user can actually reject, so a decline surfaced as
MetaMask's raw error and consumers lost the documented contract. The
surviving `try` then wrapped only the subscribe and the storage writes, so a
blocked `localStorage` in an embedded iframe told the user they had rejected a
connection that had in fact succeeded. Wrap just the interactive call, as
`binance` does, and let the storage write fail quietly.

Clearing the connected shim when a reconnect found no session assumed the
poll could tell "no session" from "no answer yet". It cannot: the wallet
restores once per load, un-awaited, and swallows a failure, and a cold MV3
service worker takes seconds. A returning user with a valid session was
signed out for good. Keep the shim — a genuine revoke still clears it through
`onDisconnect` — and spend more of the budget `reconnect` already allows.

No test covered the interactive rejection mapping, which is why the first
regression went unnoticed; there is one now, and it fails without the fix.
@chybisov chybisov added the release-preview Publish a 0.0.0-preview-<sha> build of this PR to npm label Sep 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📦 Preview published under the preview dist-tag.

Install the exact version(s) — @preview moves with the newest preview across PRs:

npm i @bigmi/client@0.0.0-preview-f509fbf
npm i @bigmi/react@0.0.0-preview-f509fbf

@github-actions github-actions Bot removed the release-preview Publish a 0.0.0-preview-<sha> build of this PR to npm label Sep 21, 2026
@chybisov
chybisov merged commit c3bff6a into main Sep 21, 2026
11 checks passed
@chybisov
chybisov deleted the fix/binance-unisat-injection branch September 21, 2026 11:46
@github-actions github-actions Bot mentioned this pull request Sep 21, 2026
chybisov added a commit to lifinance/widget that referenced this pull request Sep 21, 2026
Moves to `@bigmi/client` 0.10.4 and `@bigmi/react` 0.9.4, now that
lifinance/bigmi#80 is merged and published.

`0.10.4` carries what this branch depends on. The BitKeep and Binance
detection fall-throughs matter most: asking the connector instead of
re-deriving from `window` would otherwise narrow those two wallets rather
than preserve them. It also stops a default `metamask()` connector prompting
on page load, and keeps the store consistent when a wallet's own
`disconnect()` throws.

`@bigmi/core` stays on 0.9.2, which already carries the rejection mapping.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant