diff --git a/.changeset/odd-geckos-whisper.md b/.changeset/odd-geckos-whisper.md new file mode 100644 index 0000000000..abd1e508f7 --- /dev/null +++ b/.changeset/odd-geckos-whisper.md @@ -0,0 +1,5 @@ +--- +"@rainbow-me/rainbowkit": patch +--- + +Locked the dependencies for the `coinbaseWallet` wallet connector to Coinbase Wallet SDK v3 to temporarily mitigate breaking changes included an upcoming version of Wagmi. diff --git a/packages/rainbowkit/package.json b/packages/rainbowkit/package.json index 6fc20db111..2b31a27eb8 100644 --- a/packages/rainbowkit/package.json +++ b/packages/rainbowkit/package.json @@ -63,6 +63,7 @@ "@types/ua-parser-js": "^0.7.39" }, "dependencies": { + "@coinbase/wallet-sdk": "3.9.3", "@vanilla-extract/css": "1.14.0", "@vanilla-extract/dynamic": "2.1.0", "@vanilla-extract/sprinkles": "1.6.1", diff --git a/packages/rainbowkit/src/connectors/coinbaseWallet.test.ts b/packages/rainbowkit/src/connectors/coinbaseWallet.test.ts new file mode 100644 index 0000000000..591df8e2c1 --- /dev/null +++ b/packages/rainbowkit/src/connectors/coinbaseWallet.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; +import { http, createConfig } from 'wagmi'; +import { mainnet } from 'wagmi/chains'; +import { coinbaseWallet } from './coinbaseWallet.js'; + +const config = createConfig({ + chains: [mainnet], + transports: { + [mainnet.id]: http(), + }, +}); + +describe('coinbaseWallet', () => { + test('setup', () => { + const connectorFn = coinbaseWallet({ appName: 'wagmi' }); + const connector = config._internal.connectors.setup(connectorFn); + expect(connector.name).toEqual('Coinbase Wallet'); + }); +}); diff --git a/packages/rainbowkit/src/connectors/coinbaseWallet.ts b/packages/rainbowkit/src/connectors/coinbaseWallet.ts new file mode 100644 index 0000000000..19e01bb8ab --- /dev/null +++ b/packages/rainbowkit/src/connectors/coinbaseWallet.ts @@ -0,0 +1,254 @@ +import { + type CoinbaseWalletProvider, + type CoinbaseWalletSDK, +} from '@coinbase/wallet-sdk'; + +import { + type ProviderRpcError, + SwitchChainError, + UserRejectedRequestError, + getAddress, + numberToHex, +} from 'viem'; +import { ChainNotConfiguredError, Connector, createConnector } from 'wagmi'; +import { Evaluate, Mutable, Omit } from '../types/utils'; + +// Borrowed from wagmi@2.5.22 +// https://github.com/wevm/wagmi/blob/72a25ee00a7b6b1b41c1a4825d08440a852f9057/packages/connectors/src/coinbaseWallet.ts#L20 + +// TODO(@3): Set `enableMobileWalletLink` to `true` +export type CoinbaseWalletParameters = Evaluate< + Mutable< + Omit< + ConstructorParameters[0], + 'reloadOnDisconnect' // remove property since TSDoc says default is `true` + > + > & { + /** + * Fallback Ethereum JSON RPC URL + * @default "" + */ + jsonRpcUrl?: string | undefined; + /** + * Fallback Ethereum Chain ID + * @default 1 + */ + chainId?: number | undefined; + /** + * Whether or not to reload dapp automatically after disconnect. + * @default false + */ + reloadOnDisconnect?: boolean | undefined; + } +>; + +coinbaseWallet.type = 'coinbaseWallet' as const; +export function coinbaseWallet(parameters: CoinbaseWalletParameters) { + const reloadOnDisconnect = false; + + type Provider = CoinbaseWalletProvider; + type Properties = {}; + + let sdk: CoinbaseWalletSDK | undefined; + let walletProvider: Provider | undefined; + + let accountsChanged: Connector['onAccountsChanged'] | undefined; + let chainChanged: Connector['onChainChanged'] | undefined; + let disconnect: Connector['onDisconnect'] | undefined; + + return createConnector((config) => ({ + id: 'coinbaseWalletSDK', + name: 'Coinbase Wallet', + type: coinbaseWallet.type, + async connect({ chainId } = {}) { + try { + const provider = await this.getProvider(); + const accounts = ( + (await provider.request({ + method: 'eth_requestAccounts', + })) as string[] + ).map((x) => getAddress(x)); + + if (!accountsChanged) { + accountsChanged = this.onAccountsChanged.bind(this); + provider.on('accountsChanged', accountsChanged); + } + if (!chainChanged) { + chainChanged = this.onChainChanged.bind(this); + provider.on('chainChanged', chainChanged); + } + if (!disconnect) { + disconnect = this.onDisconnect.bind(this); + provider.on('disconnect', disconnect); + } + + // Switch to chain if provided + let currentChainId = await this.getChainId(); + if (chainId && currentChainId !== chainId) { + const chain = await this.switchChain!({ chainId }).catch((error) => { + if (error.code === UserRejectedRequestError.code) throw error; + return { id: currentChainId }; + }); + currentChainId = chain?.id ?? currentChainId; + } + + return { accounts, chainId: currentChainId }; + } catch (error) { + if ( + /(user closed modal|accounts received is empty|user denied account)/i.test( + (error as Error).message, + ) + ) + throw new UserRejectedRequestError(error as Error); + throw error; + } + }, + async disconnect() { + const provider = await this.getProvider(); + + if (accountsChanged) { + provider.removeListener('accountsChanged', accountsChanged); + accountsChanged = undefined; + } + if (chainChanged) { + provider.removeListener('chainChanged', chainChanged); + chainChanged = undefined; + } + if (disconnect) { + provider.removeListener('disconnect', disconnect); + disconnect = undefined; + } + + provider.disconnect(); + provider.close(); + }, + async getAccounts() { + const provider = await this.getProvider(); + return ( + await provider.request({ + method: 'eth_accounts', + }) + ).map((x) => getAddress(x)); + }, + async getChainId() { + const provider = await this.getProvider(); + const chainId = await provider.request({ method: 'eth_chainId' }); + return Number(chainId); + }, + async getProvider() { + if (!walletProvider) { + const { default: CoinbaseWalletSDK } = await import( + '@coinbase/wallet-sdk' + ); + let SDK: typeof CoinbaseWalletSDK; + if ( + typeof CoinbaseWalletSDK !== 'function' && + typeof (CoinbaseWalletSDK as { default: typeof CoinbaseWalletSDK }) + .default === 'function' + ) { + SDK = (CoinbaseWalletSDK as { default: typeof CoinbaseWalletSDK }) + .default; + } else { + SDK = CoinbaseWalletSDK as unknown as typeof CoinbaseWalletSDK; + } + + sdk = new SDK({ reloadOnDisconnect, ...parameters }); + + // Force types to retrieve private `walletExtension` method from the Coinbase Wallet SDK. + const walletExtensionChainId = ( + sdk as unknown as { + get walletExtension(): { getChainId(): number } | undefined; + } + ).walletExtension?.getChainId(); + + const chain = + config.chains.find((chain) => + parameters.chainId + ? chain.id === parameters.chainId + : chain.id === walletExtensionChainId, + ) || config.chains[0]; + const chainId = parameters.chainId || chain?.id; + const jsonRpcUrl = + parameters.jsonRpcUrl || chain?.rpcUrls.default.http[0]; + + walletProvider = sdk.makeWeb3Provider(jsonRpcUrl, chainId); + } + + return walletProvider; + }, + async isAuthorized() { + try { + const accounts = await this.getAccounts(); + return !!accounts.length; + } catch { + return false; + } + }, + async switchChain({ chainId }) { + const chain = config.chains.find((chain) => chain.id === chainId); + if (!chain) throw new SwitchChainError(new ChainNotConfiguredError()); + + const provider = await this.getProvider(); + const chainId_ = numberToHex(chain.id); + + try { + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chainId_ }], + }); + return chain; + } catch (error) { + // Indicates chain is not added to provider + if ((error as ProviderRpcError).code === 4902) { + try { + await provider.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId: chainId_, + chainName: chain.name, + nativeCurrency: chain.nativeCurrency, + rpcUrls: [chain.rpcUrls.default?.http[0] ?? ''], + blockExplorerUrls: [chain.blockExplorers?.default.url], + }, + ], + }); + return chain; + } catch (error) { + throw new UserRejectedRequestError(error as Error); + } + } + + throw new SwitchChainError(error as Error); + } + }, + onAccountsChanged(accounts) { + if (accounts.length === 0) this.onDisconnect(); + else + config.emitter.emit('change', { + accounts: accounts.map((x) => getAddress(x)), + }); + }, + onChainChanged(chain) { + const chainId = Number(chain); + config.emitter.emit('change', { chainId }); + }, + async onDisconnect(_error) { + config.emitter.emit('disconnect'); + + const provider = await this.getProvider(); + if (accountsChanged) { + provider.removeListener('accountsChanged', accountsChanged); + accountsChanged = undefined; + } + if (chainChanged) { + provider.removeListener('chainChanged', chainChanged); + chainChanged = undefined; + } + if (disconnect) { + provider.removeListener('disconnect', disconnect); + disconnect = undefined; + } + }, + })); +} diff --git a/packages/rainbowkit/src/types/utils.ts b/packages/rainbowkit/src/types/utils.ts new file mode 100644 index 0000000000..0808b93923 --- /dev/null +++ b/packages/rainbowkit/src/types/utils.ts @@ -0,0 +1,13 @@ +/** Combines members of an intersection into a readable type. */ +export type Evaluate = { [key in keyof type]: type[key] } & unknown; + +/** Removes `readonly` from all properties of an object. */ +export type Mutable = { + -readonly [key in keyof type]: type[key]; +}; + +/** Strict version of built-in Omit type */ +export type Omit = Pick< + type, + Exclude +>; diff --git a/packages/rainbowkit/src/wallets/walletConnectors/coinbaseWallet/coinbaseWallet.ts b/packages/rainbowkit/src/wallets/walletConnectors/coinbaseWallet/coinbaseWallet.ts index 69b580efbc..80eb357094 100644 --- a/packages/rainbowkit/src/wallets/walletConnectors/coinbaseWallet/coinbaseWallet.ts +++ b/packages/rainbowkit/src/wallets/walletConnectors/coinbaseWallet/coinbaseWallet.ts @@ -1,5 +1,5 @@ import { createConnector } from 'wagmi'; -import { coinbaseWallet as coinbaseWagmiWallet } from 'wagmi/connectors'; +import { coinbaseWallet as coinbaseWagmiWallet } from '../../../connectors/coinbaseWallet'; import { isIOS } from '../../../utils/isMobile'; import { Wallet, WalletDetailsParams } from '../../Wallet'; import { hasInjectedProvider } from '../../getInjectedConnector'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1661e7e37..6e49fbc7ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -427,6 +427,9 @@ importers: packages/rainbowkit: dependencies: + '@coinbase/wallet-sdk': + specifier: 3.9.3 + version: 3.9.3 '@vanilla-extract/css': specifier: 1.14.0 version: 1.14.0 @@ -3620,6 +3623,22 @@ packages: transitivePeerDependencies: - supports-color + /@coinbase/wallet-sdk@3.9.3: + resolution: {integrity: sha512-N/A2DRIf0Y3PHc1XAMvbBUu4zisna6qAdqABMZwBMNEfWrXpAwx16pZGkYCLGE+Rvv1edbcB2LYDRnACNcmCiw==} + dependencies: + bn.js: 5.2.1 + buffer: 6.0.3 + clsx: 1.2.1 + eth-block-tracker: 7.1.0 + eth-json-rpc-filters: 6.0.1 + eventemitter3: 5.0.1 + keccak: 3.0.3 + preact: 10.19.3 + sha.js: 2.4.11 + transitivePeerDependencies: + - supports-color + dev: false + /@commitlint/cli@18.4.3(typescript@5.4.2): resolution: {integrity: sha512-zop98yfB3A6NveYAZ3P1Mb6bIXuCeWgnUfVNkH4yhIMQpQfzFwseadazOuSn0OOfTt0lWuFauehpm9GcqM5lww==} engines: {node: '>=v18'} @@ -6152,7 +6171,7 @@ packages: engines: {node: '>=16.0.0'} dependencies: '@ethereumjs/tx': 4.2.0 - '@noble/hashes': 1.3.3 + '@noble/hashes': 1.4.0 '@scure/base': 1.1.5 '@types/debug': 4.1.7 debug: 4.3.4 @@ -6315,6 +6334,10 @@ packages: resolution: {integrity: sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==} engines: {node: '>= 16'} + /@noble/hashes@1.4.0: + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'}