Skip to content

Commit

Permalink
feat(connectors): ledger and safe
Browse files Browse the repository at this point in the history
  • Loading branch information
tmm committed Aug 8, 2023
1 parent 4e7d457 commit b18a2c4
Show file tree
Hide file tree
Showing 18 changed files with 542 additions and 291 deletions.
3 changes: 3 additions & 0 deletions packages/connectors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
},
"dependencies": {
"@coinbase/wallet-sdk": "3.8.0-beta.0",
"@ledgerhq/connect-kit-loader": "^1.1.1",
"@safe-global/safe-apps-provider": "^0.17.1",
"@safe-global/safe-apps-sdk": "^8.0.0",
"@walletconnect/ethereum-provider": "^2.8.5",
"@walletconnect/modal": "^2.5.6"
},
Expand Down
8 changes: 4 additions & 4 deletions packages/connectors/src/coinbaseWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,17 @@ export type CoinbaseWalletParameters = Evaluate<
* Fallback Ethereum JSON RPC URL
* @default ""
*/
jsonRpcUrl?: string
jsonRpcUrl?: string | undefined
/**
* Fallback Ethereum Chain ID
* @default 1
*/
chainId?: number
chainId?: number | undefined
/**
* Whether or not to reload dapp automatically after disconnect.
* @default false
*/
reloadOnDisconnect?: boolean
reloadOnDisconnect?: boolean | undefined
}
>

Expand Down Expand Up @@ -68,7 +68,7 @@ export function coinbaseWallet(parameters: CoinbaseWalletParameters) {
// Switch to chain if provided
let currentChainId = await this.getChainId()
if (chainId && currentChainId !== chainId) {
const chain = await this.switchChain?.({ chainId }).catch(() => ({
const chain = await this.switchChain!({ chainId }).catch(() => ({
id: currentChainId,
}))
currentChainId = chain?.id ?? currentChainId
Expand Down
2 changes: 2 additions & 0 deletions packages/connectors/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ test('exports', () => {
{
"coinbaseWallet": [Function],
"injected": [Function],
"ledger": [Function],
"safe": [Function],
"version": "2.0.0",
"walletConnect": [Function],
}
Expand Down
4 changes: 4 additions & 0 deletions packages/connectors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export {

export { type InjectedParameters, injected } from './injected.js'

export { type LedgerParameters, ledger } from './ledger.js'

export { type SafeParameters, safe } from './safe.js'

export {
type WalletConnectParameters,
walletConnect,
Expand Down
7 changes: 3 additions & 4 deletions packages/connectors/src/injected.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
RpcError,
SwitchChainError,
UserRejectedRequestError,
fromHex,
getAddress,
numberToHex,
} from 'viem'
Expand All @@ -25,8 +24,8 @@ export type InjectedParameters = {
* This flag simulates the disconnect behavior by keeping track of connection status in storage. See [GitHub issue](https://github.com/MetaMask/metamask-extension/issues/10353) for more info.
* @default true
*/
shimDisconnect?: boolean
unstable_shimAsyncInject?: boolean | number
shimDisconnect?: boolean | undefined
unstable_shimAsyncInject?: boolean | number | undefined
/**
* [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) Ethereum Provider to target
*/
Expand Down Expand Up @@ -238,7 +237,7 @@ export function injected(parameters: InjectedParameters = {}) {
const provider = await this.getProvider()
if (!provider) throw new ProviderNotFoundError()
const hexChainId = await provider.request({ method: 'eth_chainId' })
return fromHex(hexChainId, 'number')
return normalizeChainId(hexChainId)
},
async getProvider() {
if (typeof window === 'undefined') return undefined
Expand Down
69 changes: 69 additions & 0 deletions packages/connectors/src/ledger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { config } from '@wagmi/test'
import { expect, test } from 'vitest'

import { ledger } from './ledger.js'

/*
* 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
*/

test('setup', () => {
const connectorFn = ledger()
const connector = config._internal.setup(connectorFn)
expect(connector.name).toEqual('Ledger')
})

test.todo('behavior: connects')

test.todo('behavior: disconnects via dapp (wagmi Connector)')

test.todo('behavior: disconnects via wallet (Ledger Live)')

test.todo('behavior: switch chains via dapp (wagmi Connector)')

test.todo('behavior: switch chains via wallet (Ledger Live)')

test.todo('behavior: switch accounts via wallet (Ledger Live)')

test.todo('behavior: sends a transaction')

test.todo('behavior: signs a message')
213 changes: 213 additions & 0 deletions packages/connectors/src/ledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import {
type EthereumProvider,
SupportedProviders,
loadConnectKit,
} from '@ledgerhq/connect-kit-loader'
import {
ChainNotConfiguredError,
createConnector,
normalizeChainId,
} from '@wagmi/core'
import {
type ProviderConnectInfo,
type ProviderRpcError,
SwitchChainError,
UserRejectedRequestError,
getAddress,
numberToHex,
} from 'viem'

export type LedgerParameters = {
enableDebugLogs?: boolean | undefined
optionalEvents?: string[] | undefined
optionalMethods?: string[] | undefined
projectId?: string | undefined
requiredChains?: number[] | undefined
requiredEvents?: string[] | undefined
requiredMethods?: string[] | undefined
}

export function ledger(parameters: LedgerParameters = {}) {
type Provider = EthereumProvider
type Properties = {
onConnect(connectInfo: ProviderConnectInfo): void
onSessionDelete(data: { topic: string }): void
}

let provider_: Provider | undefined
let providerPromise: Promise<typeof provider_>

return createConnector<Provider, Properties>((config) => ({
id: 'ledger',
name: 'Ledger',
async setup() {
const provider = await this.getProvider()
if (!provider) return
provider.on('connect', this.onConnect.bind(this))
provider.on('session_delete', this.onSessionDelete.bind(this))
},
async connect({ chainId } = {}) {
try {
const provider = await this.getProvider()

// TODO: Update this logic to be more stable (a la WalletConnect connector)
// Don't request accounts if we have a session, like when reloading with
// an active WC v2 session
if (!provider.session)
await provider.request({
method: 'eth_requestAccounts',
})

const accounts = await this.getAccounts()

provider.removeListener('connect', this.onConnect.bind(this))
provider.on('accountsChanged', this.onAccountsChanged.bind(this))
provider.on('chainChanged', this.onChainChanged)
provider.on('disconnect', this.onDisconnect.bind(this))
provider.on('session_delete', this.onSessionDelete.bind(this))

// Switch to chain if provided
let currentChainId = await this.getChainId()
if (chainId && currentChainId !== chainId) {
const chain = await this.switchChain!({ chainId }).catch(() => ({
id: currentChainId,
}))
currentChainId = chain?.id ?? currentChainId
}

return { accounts, chainId: currentChainId }
} catch (error) {
if (/user rejected/i.test((error as ProviderRpcError)?.message))
throw new UserRejectedRequestError(error as Error)
throw error
}
},
async disconnect() {
const provider = await this.getProvider()
try {
await provider?.disconnect?.()
} catch (error) {
console.log({ error })
if (!/No matching key/i.test((error as Error).message)) throw error
} finally {
provider.removeListener(
'accountsChanged',
this.onAccountsChanged.bind(this),
)
provider.removeListener('chainChanged', this.onChainChanged)
provider.removeListener('disconnect', this.onDisconnect.bind(this))
provider.removeListener(
'session_delete',
this.onSessionDelete.bind(this),
)
provider.on('connect', this.onConnect.bind(this))
}
},
async getAccounts() {
const provider = await this.getProvider()
return (await provider.request<string[]>({ method: 'eth_accounts' })).map(
getAddress,
)
},
async getProvider({ chainId } = {}) {
async function initProvider() {
const connectKit = await loadConnectKit()
if (parameters.enableDebugLogs) connectKit.enableDebugLogs()

const {
optionalEvents,
optionalMethods,
projectId,
requiredChains,
requiredEvents,
requiredMethods,
} = parameters
connectKit.checkSupport({
chains: requiredChains,
events: requiredEvents,
methods: requiredMethods,
optionalChains: config.chains.map(({ id }) => id),
optionalEvents,
optionalMethods,
projectId,
providerType: SupportedProviders.Ethereum,
rpcMap: Object.fromEntries(
config.chains.map((chain) => [
chain.id,
chain.rpcUrls.default.http[0]!,
]),
),
walletConnectVersion: 2,
})

return (await connectKit.getProvider()) as unknown as EthereumProvider
}

if (!provider_) {
if (!providerPromise) providerPromise = initProvider()
provider_ = await providerPromise
}
if (chainId) await this.switchChain!({ chainId })
return provider_!
},
async getChainId() {
const provider = await this.getProvider()
const chainId = await provider.request({ method: 'eth_chainId' })
return normalizeChainId(chainId)
},
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())

try {
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))
throw new UserRejectedRequestError(error as Error)

throw new SwitchChainError(error as Error)
}
},
onAccountsChanged(accounts) {
if (accounts.length === 0) config.emitter.emit('disconnect')
else config.emitter.emit('change', { accounts: accounts.map(getAddress) })
},
onChainChanged(chain) {
const chainId = normalizeChainId(chain)
config.emitter.emit('change', { chainId })
},
async onConnect(connectInfo) {
const chainId = normalizeChainId(connectInfo.chainId)
const accounts = await this.getAccounts()
config.emitter.emit('connect', { accounts, chainId })
},
async onDisconnect(_error) {
config.emitter.emit('disconnect')

const provider = await this.getProvider()
provider.removeListener('accountsChanged', this.onAccountsChanged)
provider.removeListener('chainChanged', this.onChainChanged)
provider.removeListener('disconnect', this.onDisconnect.bind(this))
},
onSessionDelete() {
this.onDisconnect()
},
}))
}
23 changes: 23 additions & 0 deletions packages/connectors/src/safe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { config } from '@wagmi/test'
import { expect, test } from 'vitest'

import { safe } from './safe.js'

/*
* To manually test the Safe connector:
*
* 1. Run the wagmi playground app (`pnpm dev`)
* 2. Add a custom Safe App with App URL set to `http://localhost:5173` (make sure there is a `manifest.json` file served by the playground)
* 3. Open the playground app at `https://app.safe.global/eth:0x4557B18E779944BFE9d78A672452331C186a9f48/apps?appUrl=http%3A%2F%2Flocalhost%3A5173`
*
* See https://docs.gnosis-safe.io/learn/safe-tools/sdks/safe-apps/releasing-your-safe-app for more info.
*/

test('setup', () => {
const connectorFn = safe({
allowedDomains: [/gnosis-safe.io$/, /app.safe.global$/],
debug: false,
})
const connector = config._internal.setup(connectorFn)
expect(connector.name).toEqual('Safe')
})
Loading

1 comment on commit b18a2c4

@vercel
Copy link

@vercel vercel bot commented on b18a2c4 Aug 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

wagmi-v2 – ./docs

wagmi-v2-wagmi-dev.vercel.app
wagmi-v2-git-alpha-wagmi-dev.vercel.app
wagmi-v2.vercel.app
alpha.wagmi.sh

Please sign in to comment.