Skip to content

Commit

Permalink
📡 Add support for WalletConnect (#4759)
Browse files Browse the repository at this point in the history
* Do not rely on `injectweb3-connect` for the "supportedWallets" list

* Remove Polkadot.js from the recommended wallets
See https://discord.com/channels/811216481340751934/943152333427191859/1192881817653620786

* Replace `Wallet.signer` by `Wallet.getSigner(address)`

* Expose `genesisHash` and `runtimeVersion` on the `ProxyApi`

* Install WalletConnect dependencies

* Integrate WalletConnect

* Fix types in stories and tests

* Fix wallet connection

* Fix transactions

* Fix with the proxy api

* Add the WalletConnect logo

* Fix legacy tests

* Persist session

* Remove "getSigner" method

* Implement "signRaw" with wallet connect

* Handle disconnections

* Properly disconnect accounts

* Fix wallet connection

* Fix interaction tests

* Name the accounts

* Fix the wallet reject app case

* Load wallet connect in parallel to the RPC node

* Add some metadata

* Fix wallet disconnection

* Address change requests
  • Loading branch information
thesan authored Feb 22, 2024
1 parent eeff43d commit 571291c
Show file tree
Hide file tree
Showing 34 changed files with 2,060 additions and 226 deletions.
3 changes: 3 additions & 0 deletions packages/ui/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ REACT_APP_CAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001
# Member avatar upload
REACT_APP_AVATAR_UPLOAD_URL=https://atlas-services.joystream.org/avatars

# WalletConnect project id
REACT_APP_WALLET_CONNECT_PROJECT_ID="2ea3f3ghubh32b8ie2f2"

# Image reporting

## Manual blacklist:
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@
"@types/react-router-dom": "^5.3.1",
"@types/react-transition-group": "^4.4.3",
"@types/styled-components": "^5.1.15",
"@walletconnect/modal": "^2.6.2",
"@walletconnect/universal-provider": "^2.11.0",
"@xstate/react": "^1.6.1",
"chart.js": "^4.4.1",
"copy-webpack-plugin": "^9.0.1",
Expand Down Expand Up @@ -120,6 +122,7 @@
"@types/yargs": "^17.0.3",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"@walletconnect/types": "^2.11.0",
"babel-jest": "^27.2.5",
"babel-loader": "^8.2.2",
"babel-plugin-import-graphql": "^2.8.1",
Expand Down
188 changes: 188 additions & 0 deletions packages/ui/src/accounts/model/walletConnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { Signer } from '@polkadot/api/types'
import { WalletConnectModal } from '@walletconnect/modal'
import { SessionTypes } from '@walletconnect/types'
import Provider from '@walletconnect/universal-provider'
import { BaseDotsamaWallet, MetadataDef, SubscriptionFn, WalletAccount } from 'injectweb3-connect'
import { Observable } from 'rxjs'

import PioneerLogo from '@/app/assets/images/logos/Pioneer.png'
import WalletConnectLogo from '@/app/assets/images/logos/WalletConnect.svg'

export class WalletConnect extends BaseDotsamaWallet {
public static source = 'WalletConnect'

protected _projectId: string
protected _genesisHash: Promise<string>
protected _chainCAIP: string | undefined
protected _provider: Provider | undefined
protected _accounts: WalletAccount[] | undefined

protected _disconnection$: Observable<void>
protected _disconnect: () => void

constructor(
projectId: string,
genesisHash: Promise<string>,
disconnection$: Observable<void>,
disconnect: () => void
) {
super({
extensionName: 'WalletConnect',
title: 'WalletConnect',
logo: { src: WalletConnectLogo, alt: 'WalletConnect Logo' },
})

this._projectId = projectId
this._genesisHash = genesisHash
this._disconnection$ = disconnection$
this._disconnect = disconnect
}

public enable = async (): Promise<void> => {
this._provider =
this._provider ??
(await Provider.init({
projectId: this._projectId,
relayUrl: 'wss://relay.walletconnect.com',
metadata: {
name: 'Pioneer',
description: 'Joystream Governance App',
icons: [PioneerLogo],
url: window.location.origin + window.location.pathname,
},
}))

this._chainCAIP = await this._genesisHash.then((hash) => `polkadot:${hash.slice(2, 34)}`)

this._provider.session = await this._getSession(this._provider, this._chainCAIP as string)

if (!this._provider.session) {
throw Error('The connection failed or was cancelled.')
}

this._handleDisconnection(this._provider)

const { session } = this._provider
this._accounts = Object.values(session.namespaces)
.flatMap((namespace) => namespace.accounts)
.map((account, index): WalletAccount => {
const peerWalletName = session.peer.metadata.name
return {
name: `${peerWalletName} account ${index + 1}`,
address: account.split(':')[2],
source: this.extensionName,
}
})
}

protected async _getSession(provider: Provider, chainCAIP: string): Promise<SessionTypes.Struct | undefined> {
if (provider.session) return provider.session

const lastSession = provider.client.session.getAll().at(-1)
if (lastSession) return lastSession

const requiredNamespaces = {
polkadot: {
methods: ['polkadot_signTransaction', 'polkadot_signMessage'],
chains: [chainCAIP],
events: ['chainChanged", "accountsChanged'],
},
}

const { uri, approval } = await provider.client.connect({ requiredNamespaces })

const wcModal = new WalletConnectModal({ projectId: this._projectId })

if (!uri) return

// if there is a URI from the client connect step open the modal
wcModal.openModal({ uri })

const modalClosedP = new Promise<undefined>((resolve) => {
const unsubscribe = wcModal.subscribeModal((state) => {
if (state.open) return

unsubscribe()
resolve(undefined)
})
})

// await session approval from the wallet app or the modal getting closed
return Promise.race([approval(), modalClosedP]).finally(wcModal.closeModal)
}

protected _handleDisconnection(provider: Provider): void {
const appDisconnectHandler = async () => {
if (!this._provider?.session) return

await provider.client.disconnect({
topic: this._provider.session.topic,
reason: { code: -1, message: 'Disconnected by client!' },
})

reset()
}
const disconnectSubscription = this._disconnection$.subscribe(appDisconnectHandler)

const walletDisconnectHandler = () => {
reset()
this._disconnect()
}
provider.client.once('session_delete', walletDisconnectHandler)
provider.client.once('session_expire', walletDisconnectHandler)

const reset = () => {
if (!this._provider?.session) return

disconnectSubscription.unsubscribe()
this._provider?.client.off('session_delete', walletDisconnectHandler)
this._provider?.client.off('session_expire', walletDisconnectHandler)
this._provider.session = undefined
}
}

public getAccounts = async (): Promise<WalletAccount[]> => {
return this._accounts ?? []
}

public subscribeAccounts: (callback: SubscriptionFn) => Promise<() => void> = (callback) => {
callback(this._accounts ?? [])
return Promise.resolve(() => undefined)
}

public updateMetadata: (chainInfo: MetadataDef) => Promise<boolean> = () => Promise.resolve(true)

public get signer(): Signer {
return {
signPayload: (transactionPayload) => {
if (!this._provider?.session || !this._chainCAIP) {
throw Error('The WalletConnect was accessed before it was enabled.')
}

return this._provider.client.request({
chainId: this._chainCAIP,
topic: this._provider.session.topic,
request: {
method: 'polkadot_signTransaction',
params: { address: transactionPayload.address, transactionPayload },
},
})
},

signRaw: (raw) => {
if (!this._provider?.session || !this._chainCAIP) {
throw Error('The WalletConnect was accessed before it was enabled.')
}

return this._provider.client.request({
chainId: this._chainCAIP,
topic: this._provider.session.topic,
request: {
method: 'polkadot_signMessage',
params: { address: raw.address, message: raw.data },
},
})
},
}
}
}
27 changes: 27 additions & 0 deletions packages/ui/src/accounts/model/wallets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { BaseDotsamaWallet, PolkadotLogo, SubwalletLogo, TalismanLogo, Wallet } from 'injectweb3-connect'

export const DefaultWalletIcon = PolkadotLogo

export const RecommendedWallets: Wallet[] = [
{
extensionName: 'talisman',
title: 'Talisman',
noExtensionMessage: 'You can use any Polkadot compatible wallet but we recommend using Talisman',
installUrl: 'https://talisman.xyz/download',
logo: { src: TalismanLogo, alt: 'Talisman Logo' },
},
{
extensionName: 'subwallet-js',
title: 'SubWallet',
noExtensionMessage: 'You can use any Polkadot compatible wallet but we recommend using Talisman',
installUrl: 'https://www.subwallet.app/download.html',
logo: { src: SubwalletLogo, alt: 'Subwallet Logo' },
},
].map((data) => new BaseDotsamaWallet(data))

export const RecommendedWalletsNames = RecommendedWallets.map((wallet) => wallet.extensionName)

export const asWallet = (source: string): Wallet =>
new BaseDotsamaWallet({ extensionName: source, logo: walletLogos.get(source) })

export const walletLogos = new Map([['polkadot-js', { src: DefaultWalletIcon, alt: 'Polkadotjs Logo' }]])
1 change: 1 addition & 0 deletions packages/ui/src/accounts/providers/accounts/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export const AccountsContext = createContext<UseAccounts>({
isLoading: true,
hasAccounts: false,
allAccounts: [],
allWallets: [],
})
Loading

0 comments on commit 571291c

Please sign in to comment.