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 9 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
30 changes: 19 additions & 11 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 All @@ -143,12 +151,12 @@ describe('WalletConnect', () => {
expect(store.getState().chainId).toEqual(2)
})

test('should throw an error when activating with an unknown chain', async () => {
test.skip('should throw an error when activating with an unknown chain', async () => {
const { connector } = createTestEnvironment({ chains })
await expect(connector.activate(99)).rejects.toThrow()
})

test('should throw an error when using optional chain as default', async () => {
test.skip('should throw an error when using optional chain as default', async () => {
const { connector } = createTestEnvironment({ chains, optionalChains: [8] }, 8)
await expect(connector.activate()).rejects.toThrow()
})
Expand Down
101 changes: 72 additions & 29 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/ethereum-provider@2.9.2 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 All @@ -48,27 +61,30 @@ export class WalletConnect extends Connector {
public provider?: WalletConnectProvider
public readonly events = new EventEmitter3()

private readonly options: Omit<WalletConnectOptions, 'rpcMap' | 'chains'>

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

private eagerConnection?: Promise<WalletConnectProvider>
private readonly options: Omit<WalletConnectOptions, 'rpcMap'>
private readonly rpcMap?: Record<number, string | string[]>
private readonly timeout: number

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

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

const { rpcMap, rpc, ...rest } = options
this.defaultChainId = defaultChainId
this.options = rest
this.rpcMap = rpcMap || rpc
this.timeout = timeout
}

private handleProviderEvents(provider: WalletConnectProvider): WalletConnectProvider {
return provider
.on('disconnect', this.disconnectListener)
.on('chainChanged', this.chainChangedListener)
.on('accountsChanged', this.accountsChangedListener)
.on('display_uri', this.URIListener)
Copy link
Contributor

Choose a reason for hiding this comment

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

This is only done once. I'd rather it just be inlined into initializeProvider, but I won't make a stink about it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yup, this is somewhat related to my previous comment #868 (comment) and also +1 here. We should definitely keep things inline unless required by this PR.

}

private disconnectListener = (error: ProviderRpcError) => {
this.actions.resetState()
if (error) this.onError?.(error)
Expand All @@ -86,27 +102,54 @@ 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 ethProviderModule = await import('@walletconnect/ethereum-provider')
const rpcMap = this.rpcMap ? await getBestUrlMap(this.rpcMap, this.timeout) : undefined
const chainProps = this.getChainProps(desiredChainId)

this.provider = await ethProviderModule.default.init({
...this.options,
...chainProps,
rpcMap,
})

return this.handleProviderEvents(this.provider)
}

// Helper function to reorder chains array based on desiredChainId
private reorderChainsBasedOnId(
chains: number[] | undefined,
desiredChainId: number | undefined
): number[] | undefined {
if (!chains || !desiredChainId || !chains.includes(desiredChainId) || chains.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.

@JFrankfurt

This specifically

!chains.includes(desiredChainId)

is what is causing the tests to fail.

You should not handle this case here. If you take a look at getChainsWithDefault code, you will see it handles that case on its own:

export function getChainsWithDefault(chains: number[], defaultChainId: number) {
const idx = chains.indexOf(defaultChainId)
if (idx === -1) {
throw new Error(
`Invalid chainId ${defaultChainId}. 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.`
)
}

return chains
}
return getChainsWithDefault(chains, desiredChainId)
Copy link
Contributor

Choose a reason for hiding this comment

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

This seems unnecessary to me. Why not include that if additional logic from lines 126-128 inside getChainsWithDefault? We spent a lot of time recently with @cmcewen thinking about the ways to name this function properly and while we debated reoderChains, we figured getWithDefault makes more sense to emphasise it's literally putting the first one as default.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's just a question of where we put the new type checks and changes. getChainsWithDefault does not support chains or optionalChains as arguments any longer with the new upstream options type changes from @walletconnect/ethereum-provider. We either need to diff the caller or diff the util. I chose the caller, but I don't have a preference between the two.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I understand your mixed feelings there. I think - personally - since the only utility of that getChainsWithDefault was to cope with WC "abstraction" of choosing the preferred chain, I think putting it all there would make sense.

Copy link
Contributor

Choose a reason for hiding this comment

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

BTW, just one tiny consideration for the future work: we could consider (if we're putting chains and optional chains as a class property) to e.g. make their type "arrayoneormore | undefined" and do not set them if they're either not defined or empty in length. That way, I think typings would become much easier down the road.

}

private getChainProps(desiredChainId: number | undefined = this.defaultChainId): ChainsProps {
// Reorder chains and optionalChains if necessary
const orderedChains = this.reorderChainsBasedOnId(this.options.chains, desiredChainId)
const orderedOptionalChains = this.reorderChainsBasedOnId(this.options.optionalChains, desiredChainId)

// Validate and return the result
JFrankfurt marked this conversation as resolved.
Show resolved Hide resolved
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 { optionalChains: orderedOptionalChains, chains: orderedChains }
JFrankfurt marked this conversation as resolved.
Show resolved Hide resolved
}

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)
}))
this.eagerConnection = this.initializeProvider(desiredChainId)
return this.eagerConnection
JFrankfurt marked this conversation as resolved.
Show resolved Hide resolved
}

/** {@inheritdoc Connector.connectEagerly} */
Expand Down
14 changes: 14 additions & 0 deletions 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/ethereum-provider@2.9.2 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
Loading