diff --git a/.changeset/cuddly-sheep-grab.md b/.changeset/cuddly-sheep-grab.md new file mode 100644 index 00000000..fba916e0 --- /dev/null +++ b/.changeset/cuddly-sheep-grab.md @@ -0,0 +1,5 @@ +--- +"@wagmi/connectors": patch +--- + +Added WalletConnect v2 sipport to Ledger connector. diff --git a/packages/connectors/package.json b/packages/connectors/package.json index 220ea135..1cda0bde 100644 --- a/packages/connectors/package.json +++ b/packages/connectors/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@coinbase/wallet-sdk": "^3.6.6", - "@ledgerhq/connect-kit-loader": "^1.0.1", + "@ledgerhq/connect-kit-loader": "1.1.0-beta.1", "@walletconnect/ethereum-provider": "2.8.0", "@walletconnect/legacy-provider": "^2.0.0", "@walletconnect/modal": "^2.4.5", diff --git a/packages/connectors/src/ledger.ts b/packages/connectors/src/ledger.ts index 77686310..c8217024 100644 --- a/packages/connectors/src/ledger.ts +++ b/packages/connectors/src/ledger.ts @@ -1,9 +1,10 @@ import { + EthereumProvider, SupportedProviders, loadConnectKit, } from '@ledgerhq/connect-kit-loader' -import type { EthereumProvider } from '@ledgerhq/connect-kit-loader' import type { Chain } from '@wagmi/chains' +import { EthereumProviderOptions } from '@walletconnect/ethereum-provider/dist/types/EthereumProvider' import { ProviderRpcError, SwitchChainError, @@ -14,17 +15,35 @@ import { numberToHex, } from 'viem' -import type { ConnectorData } from './base' import { Connector } from './base' import { normalizeChainId } from './utils/normalizeChainId' type LedgerConnectorOptions = { + enableDebugLogs?: boolean + version?: 1 | 2 + + // WalletConnect v2 init parameters + projectId?: EthereumProviderOptions['projectId'] + requiredChains?: number[] + requiredMethods?: string[] + optionalMethods?: string[] + requiredEvents?: string[] + optionalEvents?: string[] + metadata?: EthereumProviderOptions['metadata'] + + // WalletConnect v1 init parameters bridge?: string chainId?: number - enableDebugLogs?: boolean rpc?: { [chainId: number]: string } } +type ConnectConfig = { + /** Target chain to connect to. */ + chainId?: number + /** If provided, will attempt to connect to an existing pairing. */ + pairingTopic?: string +} + export class LedgerConnector extends Connector< EthereumProvider, LedgerConnectorOptions @@ -34,70 +53,75 @@ export class LedgerConnector extends Connector< readonly ready = true #provider?: EthereumProvider + #initProviderPromise?: Promise + #isV1: boolean + + get version(): 1 | 2 { + if (this.options.version) return this.options.version + else if (this.options.projectId) return 2 + return 1 + } - constructor({ - chains, - options = { enableDebugLogs: false }, - }: { - chains?: Chain[] - options?: LedgerConnectorOptions - } = {}) { - super({ chains, options }) + constructor(config: { chains?: Chain[]; options: LedgerConnectorOptions }) { + super({ + ...config, + options: { ...config.options }, + }) + + this.#isV1 = this.version === 1 } - async connect(): Promise> { + async connect({ chainId }: ConnectConfig = {}) { try { const provider = await this.getProvider({ create: true }) + this.#setupListeners() - if (provider.on) { - provider.on('accountsChanged', this.onAccountsChanged) - provider.on('chainChanged', this.onChainChanged) - provider.on('disconnect', this.onDisconnect) - } + // Don't request accounts if we have a session, like when reloading with + // an active WC v2 session + if (!provider.session) { + this.emit('message', { type: 'connecting' }) - this.emit('message', { type: 'connecting' }) + await provider.request({ + method: 'eth_requestAccounts', + }) + } - const accounts = (await provider.request({ - method: 'eth_requestAccounts', - })) as string[] - const account = getAddress(accounts[0] as string) - const id = await this.getChainId() - const unsupported = this.isChainUnsupported(id) + const account = await this.getAccount() + let id = await this.getChainId() + let unsupported = this.isChainUnsupported(id) - // Enable support for programmatic chain switching - this.switchChain = this.#switchChain + if (chainId && id !== chainId) { + const chain = await this.switchChain(chainId) + id = chain.id + unsupported = this.isChainUnsupported(id) + } return { account, chain: { id, unsupported }, + provider, } } catch (error) { - if ((error as ProviderRpcError).code === 4001) { + if (/user rejected/i.test((error as ProviderRpcError)?.message)) { throw new UserRejectedRequestError(error as Error) } - if ((error as ProviderRpcError).code === -32002) { - throw error instanceof Error ? error : new Error(String(error)) - } - throw error } } async disconnect() { const provider = await this.getProvider() + try { + if (provider.disconnect) await provider.disconnect() + } catch (error) { + if (!/No matching key/i.test((error as Error).message)) throw error + } finally { + this.#removeListeners() - if (provider?.disconnect) { - await provider.disconnect() - } - - if (provider?.removeListener) { - provider.removeListener('accountsChanged', this.onAccountsChanged) - provider.removeListener('chainChanged', this.onChainChanged) - provider.removeListener('disconnect', this.onDisconnect) + this.#isV1 && + typeof localStorage !== 'undefined' && + localStorage.removeItem('walletconnect') } - - typeof localStorage !== 'undefined' && - localStorage.removeItem('walletconnect') } async getAccount() { @@ -124,31 +148,11 @@ export class LedgerConnector extends Connector< create: false, }, ) { - if (!this.#provider || chainId || create) { - const connectKit = await loadConnectKit() - - if (this.options.enableDebugLogs) { - connectKit.enableDebugLogs() - } - - const rpc = this.chains.reduce( - (rpc, chain) => ({ - ...rpc, - [chain.id]: chain.rpcUrls.default.http[0], - }), - {}, - ) - - connectKit.checkSupport({ - bridge: this.options.bridge, - providerType: SupportedProviders.Ethereum, - chainId: chainId || this.options.chainId, - rpc: { ...rpc, ...this.options?.rpc }, - }) - - this.#provider = (await connectKit.getProvider()) as EthereumProvider + if (!this.#provider || (this.#isV1 && create)) { + await this.#createProvider() } - return this.#provider + if (chainId) await this.switchChain(chainId) + return this.#provider! } async getWalletClient({ chainId }: { chainId?: number } = {}) { @@ -157,65 +161,123 @@ export class LedgerConnector extends Connector< this.getAccount(), ]) const chain = this.chains.find((x) => x.id === chainId) + if (!provider) throw new Error('provider is required.') - return createWalletClient({ - account, - chain, - transport: custom(provider), - }) + return createWalletClient({ account, chain, transport: custom(provider) }) } async isAuthorized() { try { const account = await this.getAccount() + return !!account } catch { return false } } - async #switchChain(chainId: number) { - const provider = await this.getProvider() - const id = numberToHex(chainId) + async switchChain(chainId: number) { + const chain = this.chains.find((chain) => chain.id === chainId) + if (!chain) + throw new SwitchChainError(new Error('chain not found on connector.')) try { - // Set up a race between `wallet_switchEthereumChain` & the `chainChanged` event - // to ensure the chain has been switched. This is because there could be a case - // where a wallet may not resolve the `wallet_switchEthereumChain` method, or - // resolves slower than `chainChanged`. - await Promise.race([ - provider.request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: id }], - }), - new Promise((res) => - this.on('change', ({ chain }) => { - if (chain?.id === chainId) res(chainId) - }), - ), - ]) - return ( - this.chains.find((x) => x.id === chainId) ?? - ({ - id: chainId, - name: `Chain ${id}`, - network: `${id}`, - nativeCurrency: { name: 'Ether', decimals: 18, symbol: 'ETH' }, - rpcUrls: { default: { http: [''] }, public: { http: [''] } }, - } as Chain) - ) + const provider = await this.getProvider() + + await provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: numberToHex(chainId) }], + }) + + return chain } catch (error) { const message = typeof error === 'string' ? error : (error as ProviderRpcError)?.message - if (/user rejected request/i.test(message)) + if (/user rejected request/i.test(message)) { throw new UserRejectedRequestError(error as Error) + } throw new SwitchChainError(error as Error) } } + async #createProvider() { + if (!this.#initProviderPromise && typeof window !== 'undefined') { + this.#initProviderPromise = this.#initProvider() + } + return this.#initProviderPromise + } + + async #initProvider() { + const optionalChains = this.chains.map(({ id }) => id) + const connectKit = await loadConnectKit() + + if (this.options.enableDebugLogs) { + connectKit.enableDebugLogs() + } + + let checkSupportOptions + + if (this.#isV1) { + checkSupportOptions = { + providerType: SupportedProviders.Ethereum, + version: 1, + chainId: this.options.chainId, + bridge: this.options.bridge, + rpc: Object.fromEntries( + this.chains.map((chain) => [ + chain.id, + chain.rpcUrls.default.http[0]!, + ]), + ), + } + } else { + checkSupportOptions = { + providerType: SupportedProviders.Ethereum, + version: 2, + projectId: this.options.projectId, + chains: this.options.requiredChains, + optionalChains: optionalChains, + methods: this.options.requiredMethods, + optionalMethods: this.options.optionalMethods, + events: this.options.requiredEvents, + optionalEvents: this.options.optionalEvents, + metadata: this.options.metadata, + rpcMap: Object.fromEntries( + this.chains.map((chain) => [ + chain.id, + chain.rpcUrls.default.http[0]!, + ]), + ), + } + } + connectKit.checkSupport(checkSupportOptions) + + this.#provider = + (await connectKit.getProvider()) as unknown as EthereumProvider + } + + #setupListeners() { + if (!this.#provider) return + this.#removeListeners() + this.#provider.on('accountsChanged', this.onAccountsChanged) + this.#provider.on('chainChanged', this.onChainChanged) + this.#provider.on('disconnect', this.onDisconnect) + this.#provider.on('session_delete', this.onDisconnect) + this.#provider.on('connect', this.onConnect) + } + + #removeListeners() { + if (!this.#provider) return + this.#provider.removeListener('accountsChanged', this.onAccountsChanged) + this.#provider.removeListener('chainChanged', this.onChainChanged) + this.#provider.removeListener('disconnect', this.onDisconnect) + this.#provider.removeListener('session_delete', this.onDisconnect) + this.#provider.removeListener('connect', this.onConnect) + } + protected onAccountsChanged = (accounts: string[]) => { if (accounts.length === 0) this.emit('disconnect') - else this.emit('change', { account: getAddress(accounts[0] as string) }) + else this.emit('change', { account: getAddress(accounts[0]!) }) } protected onChainChanged = (chainId: number | string) => { @@ -227,4 +289,8 @@ export class LedgerConnector extends Connector< protected onDisconnect = () => { this.emit('disconnect') } + + protected onConnect = () => { + this.emit('connect', {}) + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ab84948..6e7fa13c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,8 +87,8 @@ importers: specifier: ^3.6.6 version: 3.6.6 '@ledgerhq/connect-kit-loader': - specifier: ^1.0.1 - version: 1.0.1 + specifier: 1.1.0-beta.1 + version: 1.1.0-beta.1 '@safe-global/safe-apps-provider': specifier: ^0.15.2 version: 0.15.2 @@ -772,8 +772,8 @@ packages: '@pedrouid/environment': 1.0.1 dev: false - /@ledgerhq/connect-kit-loader@1.0.1: - resolution: {integrity: sha512-OAJh9rMaypS1ttrSMwPznXqglJGcP3WPTTgz9YAKfkaMyUtZcHx7hCj4d6f7DdSVZOgWcyEYfZ8M2QrA2gtvgQ==} + /@ledgerhq/connect-kit-loader@1.1.0-beta.1: + resolution: {integrity: sha512-dk0XM8Z4YVkZfnfnOGXu/yCROY2KaFzularAfwdK36JtBb0BKSj6O6k2RJ5MAHH3os3JX69TNIIolM3yv6qAxA==} dev: false /@lit-labs/ssr-dom-shim@1.1.1: