diff --git a/.changeset/cruel-beans-drop.md b/.changeset/cruel-beans-drop.md new file mode 100644 index 0000000000..4d53c2cb52 --- /dev/null +++ b/.changeset/cruel-beans-drop.md @@ -0,0 +1,24 @@ +--- +'@reown/appkit-adapter-bitcoin': patch +'@reown/appkit-utils': patch +'@reown/appkit-common': patch +'@reown/appkit-adapter-ethers': patch +'@reown/appkit-adapter-ethers5': patch +'@reown/appkit-adapter-solana': patch +'@reown/appkit-adapter-wagmi': patch +'@reown/appkit': patch +'@reown/appkit-cdn': patch +'@reown/appkit-cli': patch +'@reown/appkit-controllers': patch +'@reown/appkit-core': patch +'@reown/appkit-experimental': patch +'@reown/appkit-polyfills': patch +'@reown/appkit-scaffold-ui': patch +'@reown/appkit-siwe': patch +'@reown/appkit-siwx': patch +'@reown/appkit-ui': patch +'@reown/appkit-wallet': patch +'@reown/appkit-wallet-button': patch +--- + +Added bitget wallet connector diff --git a/packages/adapters/bitcoin/src/adapter.ts b/packages/adapters/bitcoin/src/adapter.ts index 7138bdd566..b813a35c34 100644 --- a/packages/adapters/bitcoin/src/adapter.ts +++ b/packages/adapters/bitcoin/src/adapter.ts @@ -7,6 +7,7 @@ import { AdapterBlueprint } from '@reown/appkit/adapters' import { bitcoin } from '@reown/appkit/networks' import { BitcoinWalletConnectConnector } from './connectors/BitcoinWalletConnectProvider.js' +import { BitgetConnector } from './connectors/BitgetConnector.js' import { LeatherConnector } from './connectors/LeatherConnector.js' import { OKXConnector } from './connectors/OKXConnector.js' import { SatsConnectConnector } from './connectors/SatsConnectConnector.js' @@ -118,6 +119,14 @@ export class BitcoinAdapter extends AdapterBlueprint { if (okxConnector) { this.addConnector(okxConnector) } + + const bitgetConnector = BitgetConnector.getWallet({ + requestedChains: this.networks, + getActiveNetwork + }) + if (bitgetConnector) { + this.addConnector(bitgetConnector) + } } override syncConnection( diff --git a/packages/adapters/bitcoin/src/connectors/BitgetConnector.ts b/packages/adapters/bitcoin/src/connectors/BitgetConnector.ts new file mode 100644 index 0000000000..eac0f0232e --- /dev/null +++ b/packages/adapters/bitcoin/src/connectors/BitgetConnector.ts @@ -0,0 +1,185 @@ +import { type CaipNetwork, ConstantsUtil as CommonConstantsUtil } from '@reown/appkit-common' +import { CoreHelperUtil, type RequestArguments } from '@reown/appkit-controllers' +import { PresetsUtil } from '@reown/appkit-utils' +import { bitcoin } from '@reown/appkit/networks' + +import { MethodNotSupportedError } from '../errors/MethodNotSupportedError.js' +import type { BitcoinConnector } from '../utils/BitcoinConnector.js' +import { ProviderEventEmitter } from '../utils/ProviderEventEmitter.js' + +export class BitgetConnector extends ProviderEventEmitter implements BitcoinConnector { + public readonly id = 'Bitget' + public readonly name = 'Bitget Wallet' + public readonly chain = 'bip122' + public readonly type = 'ANNOUNCED' + public readonly explorerId = + PresetsUtil.ConnectorExplorerIds[CommonConstantsUtil.CONNECTOR_ID.BITGET] + public readonly imageUrl: string + + public readonly provider = this + + private readonly wallet: BitgetConnector.Wallet + private readonly requestedChains: CaipNetwork[] = [] + private readonly getActiveNetwork: () => CaipNetwork | undefined + + constructor({ + wallet, + requestedChains, + getActiveNetwork, + imageUrl + }: BitgetConnector.ConstructorParams) { + super() + this.wallet = wallet + this.requestedChains = requestedChains + this.getActiveNetwork = getActiveNetwork + this.imageUrl = imageUrl + } + + public get chains() { + return this.requestedChains.filter(chain => chain.caipNetworkId === bitcoin.caipNetworkId) + } + + public async connect(): Promise { + const [address] = await this.wallet.requestAccounts() + + if (!address) { + throw new Error('No account available') + } + + this.bindEvents() + + return address + } + + public async disconnect(): Promise { + this.unbindEvents() + await this.wallet.disconnect() + } + + public async getAccountAddresses(): Promise { + const accounts = await this.wallet.getAccounts() + const publicKeyOfActiveAccount = await this.wallet.getPublicKey() + + const accountList = accounts.map(account => ({ + address: account, + purpose: 'payment' as const, + publicKey: publicKeyOfActiveAccount + })) + + return accountList + } + + public async signMessage(params: BitcoinConnector.SignMessageParams): Promise { + return this.wallet.signMessage(params.message) + } + + public async sendTransfer(params: BitcoinConnector.SendTransferParams): Promise { + const network = this.getActiveNetwork() + + if (!network) { + throw new Error('No active network available') + } + + const from = (await this.wallet.getAccounts())[0] + + if (!from) { + throw new Error('No account available') + } + + const txId = await this.wallet.sendBitcoin(params.recipient, params.amount) + + return txId + } + + public async signPSBT( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _params: BitcoinConnector.SignPSBTParams + ): Promise { + // There is an issue with signing psbt with bitget wallet + return Promise.reject(new MethodNotSupportedError(this.id, 'signPSBT')) + } + + public request(_args: RequestArguments): Promise { + return Promise.reject(new MethodNotSupportedError(this.id, 'request')) + } + + private bindEvents(): void { + this.unbindEvents() + + this.wallet.on('accountChanged', account => { + if (typeof account === 'object' && account && 'address' in account) { + this.emit('accountsChanged', [account.address]) + } + }) + this.wallet.on('disconnect', () => { + this.emit('disconnect') + }) + } + + private unbindEvents(): void { + this.wallet.removeAllListeners() + } + + public static getWallet(params: BitgetConnector.GetWalletParams): BitgetConnector | undefined { + if (!CoreHelperUtil.isClient()) { + return undefined + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const bitkeep = (window as any)?.bitkeep + const wallet = bitkeep?.unisat + + /** + * Bitget doesn't provide a way to get the image URL specifally for bitcoin + * so we use the icon for cardano as a fallback + */ + const imageUrl = bitkeep?.suiWallet?.icon || '' + + if (wallet) { + return new BitgetConnector({ wallet, imageUrl, ...params }) + } + + return undefined + } + + public async getPublicKey(): Promise { + return this.wallet.getPublicKey() + } +} + +export namespace BitgetConnector { + export type ConstructorParams = { + wallet: Wallet + requestedChains: CaipNetwork[] + getActiveNetwork: () => CaipNetwork | undefined + imageUrl: string + } + + export type Wallet = { + /* + * This interface doesn't include all available methods + * Reference: https://www.okx.com/web3/build/docs/sdks/chains/bitcoin/provider + */ + requestAccounts(): Promise + disconnect(): Promise + getAccounts(): Promise + pushPsbt(psbtHex: string): Promise + signMessage(signStr: string, type?: 'ecdsa' | 'bip322-simple'): Promise + signPsbt( + psbtHex: string, + params: { + toSignInputs: { + index: number + address: string + sighashTypes: number[] + }[] + } + ): Promise + sendBitcoin(toAddress: string, amount: string): Promise + on(event: string, listener: (param?: unknown) => void): void + removeAllListeners(): void + getPublicKey(): Promise + } + + export type GetWalletParams = Omit +} diff --git a/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts b/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts index d0a27aac01..04ee71d18e 100644 --- a/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts +++ b/packages/adapters/bitcoin/tests/BitcoinAdapter.test.ts @@ -15,6 +15,7 @@ import { bitcoin, bitcoinTestnet, mainnet } from '@reown/appkit/networks' import { BitcoinAdapter, type BitcoinConnector } from '../src' import { BitcoinWalletConnectConnector } from '../src/connectors/BitcoinWalletConnectProvider' +import { BitgetConnector } from '../src/connectors/BitgetConnector' import { LeatherConnector } from '../src/connectors/LeatherConnector' import { OKXConnector } from '../src/connectors/OKXConnector' import { SatsConnectConnector } from '../src/connectors/SatsConnectConnector' @@ -221,12 +222,14 @@ describe('BitcoinAdapter', () => { const walletStandardConnectorSpy = vi.spyOn(WalletStandardConnector, 'watchWallets') const satsConnectConnectorSpy = vi.spyOn(SatsConnectConnector, 'getWallets') const okxConnectorSpy = vi.spyOn(OKXConnector, 'getWallet') + const bitgetConnectorSpy = vi.spyOn(BitgetConnector, 'getWallet') adapter.syncConnectors(undefined, undefined) expect(walletStandardConnectorSpy).toHaveBeenCalled() expect(satsConnectConnectorSpy).toHaveBeenCalled() expect(okxConnectorSpy).toHaveBeenCalled() + expect(bitgetConnectorSpy).toHaveBeenCalled() }) it('should add connectors from SatsConnectConnector', () => { @@ -252,6 +255,15 @@ describe('BitcoinAdapter', () => { expect(adapter.connectors[0]).toBeInstanceOf(OKXConnector) }) + it('should add BitgetConnector', () => { + ;(window as any).bitkeep = {} + ;(window as any).bitkeep.unisat = { connect: vi.fn() } + + adapter.syncConnectors(undefined, undefined) + + expect(adapter.connectors[0]).toBeInstanceOf(BitgetConnector) + }) + it('should pass correct getActiveNetwork to SatsConnectConnector', () => { const mocks = mockSatsConnectProvider({ id: LeatherConnector.ProviderId, name: 'Leather' }) const getCaipNetwork = vi.fn(() => bitcoin) diff --git a/packages/adapters/bitcoin/tests/connectors/BitgetConnector.test.ts b/packages/adapters/bitcoin/tests/connectors/BitgetConnector.test.ts new file mode 100644 index 0000000000..8d7bbc50fc --- /dev/null +++ b/packages/adapters/bitcoin/tests/connectors/BitgetConnector.test.ts @@ -0,0 +1,200 @@ +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { CaipNetwork } from '@reown/appkit-common' +import { CoreHelperUtil } from '@reown/appkit-controllers' +import { bitcoin, bitcoinTestnet } from '@reown/appkit/networks' + +import { BitgetConnector } from '../../src/connectors/BitgetConnector' +import { MethodNotSupportedError } from '../../src/errors/MethodNotSupportedError' + +function mockBitgetWallet(): { + [K in keyof BitgetConnector.Wallet]: Mock +} { + return { + requestAccounts: vi.fn(() => Promise.resolve(['mock_address'])), + disconnect: vi.fn(), + getAccounts: vi.fn(() => Promise.resolve(['mock_address'])), + signMessage: vi.fn(() => Promise.resolve('mock_signature')), + signPsbt: vi.fn(() => Promise.resolve(Buffer.from('mock_psbt').toString('hex'))), + sendBitcoin: vi.fn(() => Promise.resolve('mock_txhash')), + on: vi.fn(), + removeAllListeners: vi.fn(), + getPublicKey: vi.fn(() => Promise.resolve('publicKey')), + pushPsbt: vi.fn(() => Promise.resolve('mock_txhash')) + } +} + +describe('BitgetConnector', () => { + let wallet: ReturnType + let requestedChains: CaipNetwork[] + let connector: BitgetConnector + let getActiveNetwork: Mock<() => CaipNetwork | undefined> + let imageUrl: string + + beforeEach(() => { + imageUrl = 'mock_image_url' + requestedChains = [bitcoin, bitcoinTestnet] + getActiveNetwork = vi.fn(() => bitcoin) + wallet = mockBitgetWallet() + connector = new BitgetConnector({ wallet, requestedChains, getActiveNetwork, imageUrl }) + }) + + it('should validate metadata', () => { + expect(connector.id).toBe('Bitget') + expect(connector.name).toBe('Bitget Wallet') + expect(connector.chain).toBe('bip122') + expect(connector.type).toBe('ANNOUNCED') + expect(connector.imageUrl).toBe('mock_image_url') + }) + + it('should return only mainnet chain', () => { + expect(connector.chains).toEqual([bitcoin]) + }) + + describe('connect', () => { + it('should connect the wallet', async () => { + const address = await connector.connect() + + expect(address).toBe('mock_address') + expect(wallet.requestAccounts).toHaveBeenCalled() + }) + + it('should bind events', async () => { + await connector.connect() + + expect(wallet.removeAllListeners).toHaveBeenCalled() + expect(wallet.on).toHaveBeenNthCalledWith(1, 'accountChanged', expect.any(Function)) + expect(wallet.on).toHaveBeenNthCalledWith(2, 'disconnect', expect.any(Function)) + }) + }) + + describe('disconnect', () => { + it('should disconnect the wallet', async () => { + await connector.disconnect() + + expect(wallet.disconnect).toHaveBeenCalled() + }) + + it('should unbind events', async () => { + await connector.disconnect() + + expect(wallet.removeAllListeners).toHaveBeenCalled() + }) + }) + + describe('getAccountAddresses', () => { + it('should get account addresses', async () => { + const accounts = await connector.getAccountAddresses() + + expect(accounts).toEqual([ + { address: 'mock_address', purpose: 'payment', publicKey: 'publicKey' } + ]) + expect(wallet.getAccounts).toHaveBeenCalled() + }) + }) + + describe('signMessage', () => { + it('should sign a message', async () => { + const signature = await connector.signMessage({ address: 'mock_address', message: 'message' }) + + expect(signature).toBe('mock_signature') + expect(wallet.signMessage).toHaveBeenCalledWith('message') + }) + }) + + describe('sendTransfer', () => { + it('should send a transfer', async () => { + const txid = await connector.sendTransfer({ amount: '1500', recipient: 'mock_to_address' }) + + expect(txid).toBe('mock_txhash') + expect(wallet.sendBitcoin).toHaveBeenCalledWith('mock_to_address', '1500') + }) + + it('should throw an error if the network is unavailable', async () => { + getActiveNetwork.mockReturnValueOnce(undefined) + + await expect( + connector.sendTransfer({ amount: '1500', recipient: 'mock_to_address' }) + ).rejects.toThrow('No active network available') + }) + + it('should throw an error if no account is available', async () => { + wallet.getAccounts.mockResolvedValueOnce([]) + + await expect( + connector.sendTransfer({ amount: '1500', recipient: 'mock_to_address' }) + ).rejects.toThrow('No account available') + }) + }) + + describe('signPSBT', () => { + it('should throw an error because signPSBT is not supported', async () => { + await expect(connector.signPSBT({} as any)).rejects.toThrow(MethodNotSupportedError) + }) + }) + + describe('request', () => { + it('should throw an error because request is not supported', async () => { + await expect(connector.request({} as any)).rejects.toThrow(MethodNotSupportedError) + }) + }) + + describe('events', () => { + it('should emit accountChanged event', async () => { + const listener = vi.fn(account => { + expect(account).toEqual(['mock_address']) + }) + connector.on('accountsChanged', listener) + await connector.connect() + + wallet.on.mock.calls[0]![1]({ address: 'mock_address' }) + + expect(listener).toHaveBeenCalled() + }) + + it('should emit disconnect event', async () => { + const listener = vi.fn() + connector.on('disconnect', listener) + await connector.connect() + + wallet.on.mock.calls[1]![1]() + + expect(listener).toHaveBeenCalled() + }) + }) + + describe('getWallet', () => { + it('should return undefined if there is no wallet', () => { + expect(BitgetConnector.getWallet({ getActiveNetwork, requestedChains: [] })).toBeUndefined() + }) + + it('should return the Connector if there is a wallet', () => { + ;(window as any).bitkeep = {} + ;(window as any).bitkeep.unisat = wallet + const connector = BitgetConnector.getWallet({ getActiveNetwork, requestedChains }) + expect(connector).toBeInstanceOf(BitgetConnector) + }) + + it('should get image url', () => { + ;(window as any).bitkeep = {} + ;(window as any).bitkeep.unisat = wallet + ;(window as any).bitkeep.suiWallet = { icon: 'mock_image' } + const connector = BitgetConnector.getWallet({ getActiveNetwork, requestedChains }) + expect(connector?.imageUrl).toBe('mock_image') + }) + + it('should return undefined if window is undefined (server-side)', () => { + vi.spyOn(CoreHelperUtil, 'isClient').mockReturnValue(false) + + expect(BitgetConnector.getWallet({ getActiveNetwork, requestedChains })).toBeUndefined() + }) + }) + + describe('getPublicKey', () => { + it('should return the public key', async () => { + const publicKey = await connector.getPublicKey() + expect(publicKey).toBe('publicKey') + expect(wallet.getPublicKey).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/appkit-utils/src/PresetsUtil.ts b/packages/appkit-utils/src/PresetsUtil.ts index 4b26c147b4..a89d49751d 100644 --- a/packages/appkit-utils/src/PresetsUtil.ts +++ b/packages/appkit-utils/src/PresetsUtil.ts @@ -15,6 +15,8 @@ export const PresetsUtil = { '19177a98252e07ddfc9af2083ba8e07ef627cb6103467ffebb3f8f4205fd7927', [CommonConstantsUtil.CONNECTOR_ID.OKX]: '971e689d0a5be527bac79629b4ee9b925e82208e5168b733496a09c0faed0709', + [CommonConstantsUtil.CONNECTOR_ID.BITGET]: + '38f5d18bd8522c244bdd70cb4a68e0e718865155811c043f052fb9f1c51de662', /* Connector names */ [ConstantsUtil.METMASK_CONNECTOR_NAME]: diff --git a/packages/common/src/utils/ConstantsUtil.ts b/packages/common/src/utils/ConstantsUtil.ts index fece6338b5..5b613098bc 100644 --- a/packages/common/src/utils/ConstantsUtil.ts +++ b/packages/common/src/utils/ConstantsUtil.ts @@ -17,7 +17,8 @@ export const ConstantsUtil = { LEDGER: 'ledger', OKX: 'okx', EIP6963: 'eip6963', - AUTH: 'ID_AUTH' + AUTH: 'ID_AUTH', + BITGET: 'bitget' }, CONNECTOR_NAMES: { AUTH: 'Auth'