Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: upgrades @walletconnect/ethereum-provider and @walletconnect/modal #868

Merged
merged 17 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions example/components/ConnectWithSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,12 @@ function ChainSelect({
}}
disabled={switchChain === undefined}
>
<option hidden disabled selected={activeChainId === undefined}>
<option hidden disabled>
Select chain
</option>
<option value={-1} selected={activeChainId === -1}>
Default
</option>
<option value={-1}>Default</option>
{chainIds.map((chainId) => (
<option key={chainId} value={chainId} selected={chainId === activeChainId}>
<option key={chainId} value={chainId}>
{CHAINS[chainId]?.name ?? chainId}
</option>
Comment on lines +29 to 36
Copy link
Contributor Author

Choose a reason for hiding this comment

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

React was complaining about this invalid syntax in the example, but it's not related to the core diff here.

))}
Expand Down
2 changes: 0 additions & 2 deletions example/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import CoinbaseWalletCard from '../components/connectorCards/CoinbaseWalletCard'
import GnosisSafeCard from '../components/connectorCards/GnosisSafeCard'
import MetaMaskCard from '../components/connectorCards/MetaMaskCard'
import NetworkCard from '../components/connectorCards/NetworkCard'
import WalletConnectCard from '../components/connectorCards/WalletConnectCard'
import WalletConnectV2Card from '../components/connectorCards/WalletConnectV2Card'
import ProviderExample from '../components/ProviderExample'

Expand All @@ -13,7 +12,6 @@ export default function Home() {
<div style={{ display: 'flex', flexFlow: 'wrap', fontFamily: 'sans-serif' }}>
<MetaMaskCard />
<WalletConnectV2Card />
<WalletConnectCard />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

removing broken example

<CoinbaseWalletCard />
<NetworkCard />
<GnosisSafeCard />
Expand Down
4 changes: 2 additions & 2 deletions packages/walletconnect-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
"start": "tsc --watch"
},
"dependencies": {
"@walletconnect/ethereum-provider": "^2.8.6",
"@walletconnect/modal": "^2.5.9",
"@walletconnect/ethereum-provider": "^2.9.2",
"@walletconnect/modal": "^2.6.1",
"@web3-react/types": "^8.2.0",
"eventemitter3": "^4.0.7"
},
Expand Down
32 changes: 20 additions & 12 deletions packages/walletconnect-v2/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ class MockWalletConnectProvider extends MockEIP1193Provider<number> {

constructor(opts: EthereumProviderOptions) {
super()
this.chainId = opts.chains[0]
if (opts.chains && opts.chains.length > 0) {
this.chainId = opts.chains[0]
} else if (opts.optionalChains && opts.optionalChains.length > 0) {
this.chainId = opts.optionalChains[0]
}
this.opts = opts
}

Expand Down Expand Up @@ -68,7 +72,7 @@ class MockWalletConnectProvider extends MockEIP1193Provider<number> {
* We mock this method later in the test suite to test behavior when optional chains are not supported.
*/
public getConnectedChains() {
return this.opts.chains.concat(this.opts.optionalChains || [])
return (this.opts.chains || []).concat(this.opts.optionalChains || [])
Copy link
Contributor Author

Choose a reason for hiding this comment

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

since chains can be undefined now we need a default value

}

// session is an object when connected, undefined otherwise
Expand All @@ -91,21 +95,17 @@ class MockWalletConnectProvider extends MockEIP1193Provider<number> {
}

describe('WalletConnect', () => {
let wc2InitMock: jest.Mock
let wc2InitMock: jest.SpyInstance<ReturnType<typeof EthereumProvider.init>, Parameters<typeof EthereumProvider.init>>;

beforeEach(() => {
/*
* TypeScript error is expected here. We're mocking a factory `init` method
* to only define a subset of `EthereumProvider` that we use internally
*/
// @ts-ignore
wc2InitMock = jest
.spyOn(EthereumProvider, 'init')
// @ts-ignore
.mockImplementation(async (opts) => {
const provider = new MockWalletConnectProvider(opts)
return provider
})
// @ts-expect-error
.mockImplementation((opts) => Promise.resolve(new MockWalletConnectProvider(opts)))
})

describe('#connectEagerly', () => {
Expand All @@ -121,6 +121,14 @@ describe('WalletConnect', () => {
connector.activate()
connector.activate()
expect(wc2InitMock).toHaveBeenCalledTimes(1)
wc2InitMock.mockClear()
})
test('should be able to initialize with only optionalChains', async () => {
const { connector } = createTestEnvironment({ chains: undefined, optionalChains: chains })
connector.activate()
connector.activate()
expect(wc2InitMock).toHaveBeenCalledTimes(1)
wc2InitMock.mockClear()
})
})

Expand Down Expand Up @@ -148,11 +156,11 @@ describe('WalletConnect', () => {
await expect(connector.activate(99)).rejects.toThrow()
})

test('should throw an error when using optional chain as default', async () => {
const { connector } = createTestEnvironment({ chains, optionalChains: [8] }, 8)
await expect(connector.activate()).rejects.toThrow()
test('should throw an error when using optional chain as default', () => {
expect(() => createTestEnvironment({ chains, optionalChains: [8] }, 8)).toThrow('Invalid chainId 8. Make sure default chain is included in "chains" - chains specified in "optionalChains" may not be selected as the default, as they may not be supported by the wallet.')
})


test('should switch to an optional chain', async () => {
const { connector, store } = createTestEnvironment({
chains,
Expand Down
85 changes: 63 additions & 22 deletions packages/walletconnect-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Actions, ProviderRpcError } from '@web3-react/types'
import { Connector } from '@web3-react/types'
import EventEmitter3 from 'eventemitter3'

import { getBestUrlMap, getChainsWithDefault } from './utils'
import { ArrayOneOrMore, getBestUrlMap, getChainsWithDefault, isArrayOneOrMore } from './utils'

export const URI_AVAILABLE = 'URI_AVAILABLE'
const DEFAULT_TIMEOUT = 5000
Expand All @@ -23,6 +23,19 @@ export type WalletConnectOptions = Omit<Parameters<typeof WalletConnectProvider.
rpc?: { [chainId: number]: string | string[] }
}

/**
* Necessary type to interface with @walletconnect/[email protected] which is currently unexported
Copy link
Contributor

Choose a reason for hiding this comment

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

You should make an upstream issue/PR to have that exported.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Talked to them about it when I first started working on this PR and they said they would take care of exporting it. May be worth checking in again.

*/
type ChainsProps =
| {
chains: ArrayOneOrMore<number>
optionalChains?: number[]
}
| {
chains?: number[]
optionalChains: ArrayOneOrMore<number>
}

/**
* Options to configure the WalletConnect connector.
*/
Expand Down Expand Up @@ -51,22 +64,26 @@ export class WalletConnect extends Connector {
private readonly options: Omit<WalletConnectOptions, 'rpcMap' | 'chains'>

private readonly rpcMap?: Record<number, string | string[]>
private readonly chains: number[]
private readonly chains: number[] | ArrayOneOrMore<number> | undefined
private readonly optionalChains: number[] | ArrayOneOrMore<number> | undefined
private readonly defaultChainId?: number
private readonly timeout: number

private eagerConnection?: Promise<WalletConnectProvider>

constructor({ actions, options, defaultChainId, timeout = DEFAULT_TIMEOUT, onError }: WalletConnectConstructorArgs) {
constructor({ actions, defaultChainId, options, timeout = DEFAULT_TIMEOUT, onError }: WalletConnectConstructorArgs) {
super(actions, onError)

const { rpcMap, rpc, chains, ...rest } = options
const { rpcMap, rpc, ...rest } = options

this.options = rest
this.chains = chains
Copy link
Contributor

Choose a reason for hiding this comment

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

IMO this should not be removed. The principle was that all the props that are custom modified / not passed straight to WalletConnect should be kept outside to avoid confusion.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was taking the opposite approach. If this is intended, we should at least include a comment explaining that we're intentionally not trying to be a thin pass-through layer.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this is exactly what we're trying to do - to be a thin pass-through layer. It's just that internally we have made this decision to differentiate between properties that we transform vs the ones we pass-through to make it easier to maintain.

That way, you can e.g. check for arrays during initialisation time and put them on class as a property for further reference.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also, I believe that, once this line of code is back, you should put the if statement here to check for number of chains (and optional chains) to ensure initialisation is valid and throw an error during the runtime.

Note that this can't be changed once created, so doing if condition here makes definitely more sense instead of at the lazy init time.

Copy link
Contributor

Choose a reason for hiding this comment

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

I would also recommend putting optionalChains outside so that we can perform necessary reordering/check for presence.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this makes sense given the approach you outlined above

this.defaultChainId = defaultChainId
this.rpcMap = rpcMap || rpc
this.timeout = timeout

const { chains, optionalChains } = this.getChainProps(rest.chains, rest.optionalChains, defaultChainId)
this.chains = chains
this.optionalChains = optionalChains
}

private disconnectListener = (error: ProviderRpcError) => {
Expand All @@ -86,27 +103,51 @@ export class WalletConnect extends Connector {
this.events.emit(URI_AVAILABLE, uri)
}

private async initializeProvider(
Copy link
Contributor

Choose a reason for hiding this comment

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

I am wondering if this PR could be broken down into a smaller diff - the one that actually fixes the issues in PR title. I see a lot of changes like this one - which seem to be more of "refactoring" that are not related to the described issues, unless I am mistaken?

Would definitely understand the reason behind creating this utility function or reordering private class properties.

Copy link
Contributor

Choose a reason for hiding this comment

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

I am also not aware of recent changes in other files, but one thing to keep in mind is that, in principle, we always tried to keep providers similar as far as top-level methods and private APIs they have. If you would look at other existing providers, you would notice they all have isomorphicInitialize, but not necessarily have a lot of private/non-standard methods. Something to keep in mind for future readability/ease of maintenance.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Upgrading the package should (according to the wc team) get us the improvements mentioned in the title/description. All changes here are made to accommodate the new requirements from @walletconnect/ethereum-provider. i.e., enforcing ArrayOfOneOrMore<T> for ChainProps

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@grabbou Are you saying here that you'd rather a class import a utility function from another file rather than define its own private utility method?

Copy link
Contributor

Choose a reason for hiding this comment

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

l changes here are made to accommodate the new requirements from @walletconnect/ethereum-provider. i.e., enforcing ArrayOfOneOrMore for ChainProps

@grabbou Are you saying here that you'd rather a class import a utility function from another file rather than define its own private utility method?

What are the utility functions that are relevant to the PR apart from the array check? I was referring to the private methods defined on the class, that, to me, look like just extracted from their original places and still being referred only in those places.

I would like to get a full picture - why those changes were necessary and how they relate to the PR

desiredChainId: number | undefined = this.defaultChainId
): Promise<WalletConnectProvider> {
const rpcMap = this.rpcMap ? getBestUrlMap(this.rpcMap, this.timeout) : undefined
const chainProps = this.getChainProps(this.chains, this.optionalChains, desiredChainId)

const ethProviderModule = await import('@walletconnect/ethereum-provider')
this.provider = await ethProviderModule.default.init({
...this.options,
...chainProps,
rpcMap: await rpcMap,
})

return this.provider
.on('disconnect', this.disconnectListener)
.on('chainChanged', this.chainChangedListener)
.on('accountsChanged', this.accountsChangedListener)
.on('display_uri', this.URIListener)
}

private getChainProps(
chains: number[] | ArrayOneOrMore<number> | undefined,
optionalChains: number[] | ArrayOneOrMore<number> | undefined,
desiredChainId: number | undefined = this.defaultChainId
): ChainsProps {
// Reorder chains and optionalChains if necessary
const orderedChains = getChainsWithDefault(chains, desiredChainId)
const orderedOptionalChains = getChainsWithDefault(optionalChains, desiredChainId)

// Validate and return the result.
// Type discrimination requires that we use these typeguard checks to guarantee a valid return type.
if (isArrayOneOrMore(orderedChains)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this function necessary? Can't we just do orderedChains.length > 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

unfortunately no. This is a type guard that explicitly returns the named type that WC requires. If we did orderedChains.length > 0 we would still get number[] which TS interprets as a variable length array and therefore not compliant with the WC requirement that at least one of the arrays is length > 0

Copy link
Contributor

Choose a reason for hiding this comment

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

Makes sense!

return { chains: orderedChains, optionalChains: orderedOptionalChains }
} else if (isArrayOneOrMore(orderedOptionalChains)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the difference between this branch of code and previous branch of code? Looks like you're literally executing same code. Could this be (if necessary) inlined into a single conditional, such as if (isArrayOneOrMore(orderedChains) || isArrayOneOrMore(orderedOptionalChains)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

unfortunately this is not compatible with typescript's type discrimination system.

    if (isArrayOneOrMore(orderedChains) || isArrayOneOrMore(orderedOptionalChains)) {
      return { chains: orderedChains, optionalChains: orderedOptionalChains }
    }

gives the pictured error

image

return { chains: orderedChains, optionalChains: orderedOptionalChains }
}

throw new Error('Either chains or optionalChains must have at least one item.')
}

private isomorphicInitialize(
desiredChainId: number | undefined = this.defaultChainId
): Promise<WalletConnectProvider> {
if (this.eagerConnection) return this.eagerConnection

const rpcMap = this.rpcMap ? getBestUrlMap(this.rpcMap, this.timeout) : undefined
const chains = desiredChainId ? getChainsWithDefault(this.chains, desiredChainId) : this.chains

return (this.eagerConnection = import('@walletconnect/ethereum-provider').then(async (ethProviderModule) => {
const provider = (this.provider = await ethProviderModule.default.init({
...this.options,
chains,
rpcMap: await rpcMap,
}))

return provider
.on('disconnect', this.disconnectListener)
.on('chainChanged', this.chainChangedListener)
.on('accountsChanged', this.accountsChangedListener)
.on('display_uri', this.URIListener)
}))
return (this.eagerConnection = this.initializeProvider(desiredChainId))
}

/** {@inheritdoc Connector.connectEagerly} */
Expand Down
22 changes: 21 additions & 1 deletion packages/walletconnect-v2/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
/**
* Necessary type to interface with @walletconnect/[email protected] which is currently unexported
*/
export type ArrayOneOrMore<T> = {
0: T
} & Array<T>

/**
* This is a type guard for ArrayOneOrMore
*/
export function isArrayOneOrMore<T>(input: T[] = []): input is ArrayOneOrMore<T> {
return input.length > 0
}

/**
* @param rpcMap - Map of chainIds to rpc url(s).
* @param timeout - Timeout, in milliseconds, after which to consider network calls failed.
Expand Down Expand Up @@ -85,7 +99,13 @@ async function getBestUrl(urls: string | string[], timeout: number): Promise<str
* @param chains - An array of chain IDs.
* @param defaultChainId - The chain ID to treat as the default (it will be the first element in the returned array).
*/
export function getChainsWithDefault(chains: number[], defaultChainId: number) {
export function getChainsWithDefault(
chains: number[] | ArrayOneOrMore<number> | undefined,
defaultChainId: number | undefined
) {
if (!chains || !defaultChainId || chains.length === 0) {
return chains
}
const idx = chains.indexOf(defaultChainId)
if (idx === -1) {
throw new Error(
Expand Down
Loading
Loading