Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/binance-unisat-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bigmi/client': patch
---

Detect Binance when `window.binancew3w` carries no bitcoin provider but `window.unisat` reports `isBinance`. The check returned early on `binancew3w` alone, so that build fell through and Binance was reported unavailable.
5 changes: 5 additions & 0 deletions .changeset/disconnect-state-consistency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@bigmi/client': patch
---

Clear a connection even when the connector's `disconnect()` throws. `xverse`, `oyl` and `leather` throw `ProviderNotFoundError` once their provider is gone, which skipped the delete and left the connection to be promoted to `current` — so the store still reported an account that could never sign, and the next disconnect failed too. The state is now consistent and the error still reaches the caller. The connector's own storage shim is cleared on that path too, so the store and storage cannot disagree and a later reconnect cannot silently restore what the user disconnected.
13 changes: 13 additions & 0 deletions .changeset/metamask-passive-reconnect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@bigmi/client': patch
---

Stop MetaMask Bitcoin from opening the extension on page load. `connect()` ignored the `isReconnecting` flag and read accounts through the interactive `bitcoin:connect`, so `reconnect()` prompted a returning user whose wallet was locked. It now reads the session the wallet already restored, matching `unisat`, `okx`, `binance`, `bitget` and `onekey`.

The wallet fills its `accounts` from a session lookup it does not await, so that read is polled briefly rather than taken as a first snapshot — otherwise a wallet that registers after the app mounts reports no session and never recovers. An absent session now rejects with `ConnectorNotConnectedError` instead of `UserRejectedRequestError`, since nothing was shown to the user.

`isAuthorized()` deliberately gates on the storage shim and the wallet's presence, not on its accounts: the wallet fills its `accounts` from a lookup it does not await, so reading them there would race the restore, report false on every reload and skip reconnect entirely. `connect({ isReconnecting: true })` is what waits for the session and rejects when it never arrives. A failure while reconnecting keeps its own error type rather than becoming `UserRejectedRequestError`, and an account that cannot be parsed is skipped instead of ending the poll.

A `change` event no longer throws out of MetaMask's emitter when one account cannot be parsed, and a batch where nothing parses is treated as the transient half-initialized state rather than a disconnect, and a selection that leaves no payment address now disconnects instead of reporting a connected wallet with no usable address. The connected shim is cleared when the wallet itself disconnects, so revoking the site inside MetaMask is not retried on every load.

An empty selection — a user with no Bitcoin account — now rejects with `ConnectorNotConnectedError` on the interactive path too, rather than a `TypeError` relabelled as a rejection. A reconnect that finds no session keeps the connected shim, because the poll cannot tell an absent session from a wallet that has not answered yet and dropping it would permanently sign out a user whose session is valid; a genuine revoke still clears it through `onDisconnect`. Only the interactive `bitcoin:connect` maps to `UserRejectedRequestError`, so a blocked storage write no longer reports a successful connect as a rejection, and `onDisconnect` releases the events subscription so a later connect rebinds to the current wallet object.
110 changes: 110 additions & 0 deletions packages/client/src/actions/disconnect.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { bitcoin, ChainId } from '@bigmi/core'
import { describe, expect, it } from 'vitest'
import { createConfig } from '../factories/createConfig.js'
import { createStorage } from '../factories/createStorage.js'
import { disconnect } from './disconnect.js'

const address = 'bc1q8h8s4zd9y0lkrx334aqnj4ykqs220ss735a3gh'

const stubConnector =
(id: string, disconnectImpl: () => Promise<void>) => (config: any) => ({
id,
name: id,
type: 'UTXO' as const,
connect: async () => ({
accounts: [
{ address, addressType: 'p2wpkh', publicKey: '00', purpose: 'payment' },
],
chainId: ChainId.BITCOIN_MAINNET,
}),
disconnect: disconnectImpl,
getAccounts: async () => [],
getChainId: async () => ChainId.BITCOIN_MAINNET,
getProvider: async () => ({}),
isAuthorized: async () => false,
onAccountsChanged: async () => {},
onChainChanged: () => {},
onDisconnect: async () => {},
emitter: config.emitter,
})

function setup(disconnectImpl: () => Promise<void>) {
// The default storage is a no-op outside the browser, so the shim
// assertions below would pass vacuously.
const store = new Map<string, string>()
const config = createConfig({
chains: [bitcoin],
connectors: [stubConnector('xverse', disconnectImpl) as any],
client: () => ({}) as any,
storage: createStorage({
storage: {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => {
store.set(key, value)
},
removeItem: (key) => {
store.delete(key)
},
},
}),
}) as any
const connector = config.connectors[0]
config.setState((x: any) => ({
...x,
connections: new Map([
[
connector.uid,
{
accounts: [{ address }],
chainId: ChainId.BITCOIN_MAINNET,
connector,
},
],
]),
current: connector.uid,
status: 'connected',
}))
return { config, connector }
}

describe('disconnect', () => {
it('clears the connection when the connector disconnects', async () => {
const { config, connector } = setup(async () => {})
await disconnect(config, { connector })
expect(config.state.status).toBe('disconnected')
expect(config.state.connections.size).toBe(0)
})

it('clears the connection even when the connector throws', async () => {
// A provider that has gone away cannot be kept connected, or it is
// promoted to `current` and the account is reported as usable.
const { config, connector } = setup(async () => {
throw new Error('ProviderNotFoundError')
})
await expect(disconnect(config, { connector })).rejects.toThrow(
'ProviderNotFoundError'
)
expect(config.state.connections.size).toBe(0)
expect(config.state.status).toBe('disconnected')
})

it('clears the connector shim when its disconnect throws', async () => {
// The connector throws before writing its own shim, so the store would
// say disconnected while storage still said connected — and the next
// reload would silently restore what the user disconnected.
const { config, connector } = setup(async () => {
throw new Error('ProviderNotFoundError')
})
await config.storage?.setItem(`${connector.id}.connected`, true)

await expect(disconnect(config, { connector })).rejects.toThrow(
'ProviderNotFoundError'
)
await expect(
config.storage?.getItem(`${connector.id}.connected`)
).resolves.toBeFalsy()
await expect(
config.storage?.getItem(`${connector.id}.disconnected`)
).resolves.toBe(true)
})
})
36 changes: 28 additions & 8 deletions packages/client/src/actions/disconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,26 @@ export async function disconnect(

const connections = config.state.connections

let disconnectError: unknown
if (connector) {
await connector.disconnect()
// A provider that has gone away cannot be kept connected: leaving its
// connection in place promotes it to `current`, so the store still reports
// an account that can never sign. Detach it either way, and surface the
// failure to the caller once the state is consistent.
try {
await connector.disconnect()
} catch (error) {
disconnectError = error
// The connector threw before writing its own shim, so storage would
// still say connected while the store says otherwise — and the next
// reconnect would silently restore what the user disconnected.
try {
await Promise.all([
config.storage?.setItem(`${connector.id}.disconnected`, true),
config.storage?.removeItem(`${connector.id}.connected`),
])
} catch {}
}
connector.emitter.off('change', config._internal.events.change)
connector.emitter.off('disconnect', config._internal.events.disconnect)
connector.emitter.on('connect', config._internal.events.connect)
Expand Down Expand Up @@ -56,13 +74,15 @@ export async function disconnect(
// Set recent connector if exists
{
const current = config.state.current
if (!current) {
return
}
const connector = config.state.connections.get(current)?.connector
if (!connector) {
return
const recent = current
? config.state.connections.get(current)?.connector
: undefined
if (recent) {
await config.storage?.setItem('recentConnectorId', recent.id)
}
await config.storage?.setItem('recentConnectorId', connector.id)
}

if (disconnectError) {
throw disconnectError
}
}
35 changes: 35 additions & 0 deletions packages/client/src/connectors/binance.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from 'vitest'
import { binance } from './binance.js'

afterEach(() => {
;(globalThis as any).window = undefined
})

describe('binance getInternalProvider', () => {
it('resolves the Binance Web3 Wallet bitcoin provider', async () => {
const provider = { requestAccounts: async () => [] }
;(globalThis as any).window = { binancew3w: { bitcoin: provider } }
const connector: any = binance()({} as any)
await expect(connector.getInternalProvider()).resolves.toBe(provider)
})

it('falls back to window.unisat when binancew3w carries no bitcoin provider', async () => {
const provider = { isBinance: true, requestAccounts: async () => [] }
;(globalThis as any).window = { binancew3w: {}, unisat: provider }
const connector: any = binance()({} as any)
await expect(connector.getInternalProvider()).resolves.toBe(provider)
})

it('resolves window.unisat when only that is injected', async () => {
const provider = { isBinance: true, requestAccounts: async () => [] }
;(globalThis as any).window = { unisat: provider }
const connector: any = binance()({} as any)
await expect(connector.getInternalProvider()).resolves.toBe(provider)
})

it('ignores a window.unisat that is not Binance', async () => {
;(globalThis as any).window = { unisat: { isBitKeep: true } }
const connector: any = binance()({} as any)
await expect(connector.getInternalProvider()).resolves.toBeUndefined()
})
})
13 changes: 6 additions & 7 deletions packages/client/src/connectors/binance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,14 @@ export function binance(
if (typeof window === 'undefined') {
return
}
if ('binancew3w' in window) {
const anyWindow: any = window
const anyWindow: any = window
if ('binancew3w' in window && anyWindow.binancew3w?.bitcoin) {
return anyWindow.binancew3w.bitcoin
}
if ('unisat' in window) {
const anyWindow: any = window
if (anyWindow.unisat.isBinance) {
return anyWindow.unisat
}
// Binance also ships as the sole `window.unisat` injection on some builds,
// and on others alongside a `binancew3w` that carries no bitcoin provider.
if ('unisat' in window && anyWindow.unisat?.isBinance) {
return anyWindow.unisat
}
},
async getProvider() {
Expand Down
Loading
Loading