diff --git a/.changeset/cool-beds-pump.md b/.changeset/cool-beds-pump.md new file mode 100644 index 00000000..f4f7f9d6 --- /dev/null +++ b/.changeset/cool-beds-pump.md @@ -0,0 +1,5 @@ +--- +'@wagmi/connectors': minor +--- + +Added `LedgerConnector` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..781f5689 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +@tmm @jxom + +/packages/connectors/src/ledger @hlopes-ledger \ No newline at end of file diff --git a/packages/connectors/README.md b/packages/connectors/README.md index 22a5c654..6c669200 100644 --- a/packages/connectors/README.md +++ b/packages/connectors/README.md @@ -47,8 +47,9 @@ const client = createClient({ ## Connectors -- [`InjectedConnector`](/packages/connectors/src/injected.ts) - [`CoinbaseWalletConnector`](/packages/connectors/src/coinbaseWallet.ts) +- [`InjectedConnector`](/packages/connectors/src/injected.ts) +- [`LedgerConnector`](/packages/connectors/src/ledger.ts) - [`MetaMaskConnector`](/packages/connectors/src/metaMask.ts) - [`MockConnector`](/packages/connectors/src/mock.ts) - [`WalletConnectConnector`](/packages/connectors/src/walletConnect.ts) diff --git a/packages/connectors/ledger/package.json b/packages/connectors/ledger/package.json new file mode 100644 index 00000000..6773e46b --- /dev/null +++ b/packages/connectors/ledger/package.json @@ -0,0 +1,4 @@ +{ + "main": "../dist/ledger.js", + "type": "module" +} diff --git a/packages/connectors/package.json b/packages/connectors/package.json index 67726145..4b9f661a 100644 --- a/packages/connectors/package.json +++ b/packages/connectors/package.json @@ -19,6 +19,7 @@ "dependencies": { "@coinbase/wallet-sdk": "^3.5.4", "@walletconnect/ethereum-provider": "^1.8.0", + "@ledgerhq/connect-kit-loader": "^1.0.0", "abitype": "^0.1.8", "eventemitter3": "^4.0.7" }, @@ -42,6 +43,10 @@ "types": "./dist/injected.d.ts", "default": "./dist/injected.js" }, + "./ledger": { + "types": "./dist/ledger.d.ts", + "default": "./dist/ledger.js" + }, "./metaMask": { "types": "./dist/metaMask.d.ts", "default": "./dist/metaMask.js" @@ -61,7 +66,8 @@ "/injected", "/metaMask", "/mock", - "/walletConnect" + "/walletConnect", + "/ledger" ], "sideEffects": false, "contributors": [ diff --git a/packages/connectors/src/ledger.test.ts b/packages/connectors/src/ledger.test.ts new file mode 100644 index 00000000..41cfb715 --- /dev/null +++ b/packages/connectors/src/ledger.test.ts @@ -0,0 +1,74 @@ +import { testChains } from '@wagmi/core/internal/test' +import { describe, expect, it } from 'vitest' + +import { LedgerConnector } from './ledger' + +/* + * To manually test the Ledger connector: + * + * - install the Ledger Live app, https://www.ledger.com/ledger-live + * - install the Ledger Connect extension, currently in beta, it should soon be + * distributed with the Ledger Live app for iOS and macOS + * - run the wagmi playground app following the contributing docs on the main + * wagmi repository + * - open the playground app in a web browser + * - press the "Ledger" button + * - see below for the Ledger Connect and Ledger Live flows + * - after the account is selected on the wallet the dapp state should reflect + * the chosen account information + * + * Ledger Connect + * + * - if you are on a platform supported by the Ledger Connect extension + * (currently Safari on iOS and macOS) but don't have it installed or enabled + * you should see a modal explaining how you can do that + * - if you are on a platform supported by the Ledger Connect extension and + * have it installed and enabled it should pop up allowing you to select an + * account + * - when pressing the "Disconnect" button on the dapp, the dapp shows as + * disconnected but you are not actually disconnected until you press + * the "Disconnect" button on Ledger Connect + * - when pressing the "Disconnect" button on Ledger Connect (press the pill + * shaped button with the Ledger logo, then "Disconnect" on the popup), the + * dapp should also show as disconnected + * + * Testing Ledger Live + * + * - if you are on a platform not yet supported by the Connect extension you + * should see a modal allowing you to use the Ledger Live app; pressing + * "Use Ledger Live" or scanning the QR code should open the app and allow + * you to choose an account + * - when pressing the "Disconnect" button on the dapp, Ledger Live should + * also show as disconnected + * - when pressing the "Disconnect" button on Ledger Live, the dapp should + * also show as disconnected + * - when switching accounts on Ledger Live the dapp should reflect those + * changes + */ +describe('LedgerConnector', () => { + it('inits', () => { + const connector = new LedgerConnector({ + chains: testChains, + options: {}, + }) + expect(connector.name).toEqual('Ledger') + }) + + describe('behavior', () => { + it.todo('connects') + + it.todo('disconnects via dapp (wagmi Connector)') + + it.todo('disconnects via wallet (Ledger Live)') + + it.todo('switch chains via dapp (wagmi Connector)') + + it.todo('switch chains via wallet (Ledger Live)') + + it.todo('switch accounts via wallet (Ledger Live)') + + it.todo('sends a transaction') + + it.todo('signs a message') + }) +}) diff --git a/packages/connectors/src/ledger.ts b/packages/connectors/src/ledger.ts new file mode 100644 index 00000000..a8ce58cf --- /dev/null +++ b/packages/connectors/src/ledger.ts @@ -0,0 +1,189 @@ +import { + SupportedProviders, + loadConnectKit, +} from '@ledgerhq/connect-kit-loader' +import type { EthereumProvider } from '@ledgerhq/connect-kit-loader' +import { + Chain, + ProviderRpcError, + RpcError, + UserRejectedRequestError, + normalizeChainId, +} from '@wagmi/core' +import { providers } from 'ethers' +import { getAddress } from 'ethers/lib/utils.js' + +import type { ConnectorData } from './base' +import { Connector } from './base' + +type LedgerConnectorOptions = { + bridge?: string + chainId?: number + enableDebugLogs?: boolean + rpc?: { [chainId: number]: string } +} + +type LedgerSigner = providers.JsonRpcSigner + +export class LedgerConnector extends Connector< + EthereumProvider, + LedgerConnectorOptions, + LedgerSigner +> { + readonly id = 'ledger' + readonly name = 'Ledger' + readonly ready = true + + #provider?: EthereumProvider + + constructor({ + chains, + options = { enableDebugLogs: false }, + }: { + chains?: Chain[] + options?: LedgerConnectorOptions + } = {}) { + super({ chains, options }) + } + + async connect(): Promise> { + try { + const provider = await this.getProvider({ create: true }) + + if (provider.on) { + provider.on('accountsChanged', this.onAccountsChanged) + provider.on('chainChanged', this.onChainChanged) + provider.on('disconnect', this.onDisconnect) + } + + this.emit('message', { type: 'connecting' }) + + 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) + + return { + account, + chain: { id, unsupported }, + provider: new providers.Web3Provider( + provider as providers.ExternalProvider, + ), + } + } catch (error) { + if ((error as ProviderRpcError).code === 4001) { + throw new UserRejectedRequestError(error) + } + if ((error as RpcError).code === -32002) { + throw error instanceof Error ? error : new Error(String(error)) + } + + throw error + } + } + + async disconnect() { + const provider = await this.getProvider() + + if (provider?.disconnect) { + await provider.disconnect() + } + + if (provider?.removeListener) { + provider.removeListener('accountsChanged', this.onAccountsChanged) + provider.removeListener('chainChanged', this.onChainChanged) + provider.removeListener('disconnect', this.onDisconnect) + } + + typeof localStorage !== 'undefined' && + localStorage.removeItem('walletconnect') + } + + async getAccount() { + const provider = await this.getProvider() + const accounts = (await provider.request({ + method: 'eth_accounts', + })) as string[] + const account = getAddress(accounts[0] as string) + + return account + } + + async getChainId() { + const provider = await this.getProvider() + const chainId = (await provider.request({ + method: 'eth_chainId', + })) as number + + return normalizeChainId(chainId) + } + + async getProvider( + { chainId, create }: { chainId?: number; create?: boolean } = { + 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 + } + return this.#provider + } + + async getSigner({ chainId }: { chainId?: number } = {}) { + const [provider, account] = await Promise.all([ + this.getProvider({ chainId }), + this.getAccount(), + ]) + return new providers.Web3Provider( + provider as providers.ExternalProvider, + chainId, + ).getSigner(account) + } + + async isAuthorized() { + try { + const account = await this.getAccount() + return !!account + } catch { + return false + } + } + + protected onAccountsChanged = (accounts: string[]) => { + if (accounts.length === 0) this.emit('disconnect') + else this.emit('change', { account: getAddress(accounts[0] as string) }) + } + + protected onChainChanged = (chainId: number | string) => { + const id = normalizeChainId(chainId) + const unsupported = this.isChainUnsupported(id) + this.emit('change', { chain: { id, unsupported } }) + } + + protected onDisconnect = () => { + this.emit('disconnect') + } +} diff --git a/packages/connectors/tsup.config.ts b/packages/connectors/tsup.config.ts index 23d97e68..ff2fad31 100644 --- a/packages/connectors/tsup.config.ts +++ b/packages/connectors/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig( 'src/metaMask.ts', 'src/mock/index.ts', 'src/walletConnect.ts', + 'src/ledger.ts', ], platform: 'browser', }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a4d79f1..d370ba4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,7 @@ importers: packages/connectors: specifiers: '@coinbase/wallet-sdk': ^3.5.4 + '@ledgerhq/connect-kit-loader': ^1.0.0 '@wagmi/core': ^0.8.0 '@walletconnect/ethereum-provider': ^1.8.0 abitype: ^0.1.8 @@ -61,6 +62,7 @@ importers: eventemitter3: ^4.0.7 dependencies: '@coinbase/wallet-sdk': 3.6.2 + '@ledgerhq/connect-kit-loader': 1.0.0 '@walletconnect/ethereum-provider': 1.8.0 abitype: 0.1.8 eventemitter3: 4.0.7 @@ -785,6 +787,10 @@ packages: '@json-rpc-tools/types': 1.7.6 '@pedrouid/environment': 1.0.1 + /@ledgerhq/connect-kit-loader/1.0.0: + resolution: {integrity: sha512-TQnVSwg8WKUx5WUQtGKJurFpnpeaVHmJfKdElVMBgccfHska6+4XA+554NjHQ46GvI/V0wknFShnudTRqc5JXg==} + dev: false + /@manypkg/find-root/1.1.0: resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} dependencies: