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

[WIP] Native WalletConnect #2529

Closed
wants to merge 19 commits into from
Closed
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
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
"@sentry/react": "^7.28.1",
"@sentry/tracing": "^7.28.1",
"@truffle/hdwallet-provider": "^2.1.4",
"@walletconnect/core": "^2.10.0",
"@walletconnect/web3wallet": "^1.9.0",
"@web3-onboard/coinbase": "^2.2.4",
"@web3-onboard/core": "^2.21.0",
"@web3-onboard/injected-wallets": "^2.10.0",
Expand Down Expand Up @@ -126,6 +128,7 @@
"ts-prune": "^0.10.3",
"typechain": "^8.0.0",
"typescript": "4.9.4",
"typescript-plugin-css-modules": "^4.2.2"
"typescript-plugin-css-modules": "^4.2.2",
"walletconnect-v2-types": "npm:@walletconnect/types@^2.10.0"
}
}
11 changes: 11 additions & 0 deletions public/images/apps/wallet-connect.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions src/components/common/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import SafeLogo from '@/public/images/logo.svg'
import Link from 'next/link'
import useSafeAddress from '@/hooks/useSafeAddress'
import BatchIndicator from '@/components/batch/BatchIndicator'
import { ConnectWC } from '@/components/wallet-connect'

type HeaderProps = {
onMenuToggle?: Dispatch<SetStateAction<boolean>>
Expand Down Expand Up @@ -70,6 +71,12 @@ const Header = ({ onMenuToggle, onBatchToggle }: HeaderProps): ReactElement => {
</div>
)}

{safeAddress && (
<div className={classnames(css.element, css.hideMobile)}>
<ConnectWC />
</div>
)}

<div className={css.element}>
<NotificationCenter />
</div>
Expand Down
226 changes: 226 additions & 0 deletions src/components/wallet-connect/hooks/WalletConnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { IS_PRODUCTION, WC_PROJECT_ID } from '@/config/constants'
import { Core } from '@walletconnect/core'
import { Web3Wallet, type Web3WalletTypes } from '@walletconnect/web3wallet'
import { type JsonRpcResponse } from '@walletconnect/jsonrpc-utils'

import type Web3WalletType from '@walletconnect/web3wallet'
import { type SafeInfo } from '@safe-global/safe-gateway-typescript-sdk'
import { type SessionTypes } from 'walletconnect-v2-types'
import { EVMBasedNamespaces, SAFE_COMPATIBLE_METHODS, SAFE_WALLET_METADATA, WC_ERRORS } from './constants'

const logger = IS_PRODUCTION ? undefined : 'debug'

export class WalletConnect {
private web3Wallet: Web3WalletType | undefined
private safe: SafeInfo
private currentSession: SessionTypes.Struct | undefined

constructor(safe: SafeInfo) {
this.safe = safe
this.initializeWalletConnect()
}

private async initializeWalletConnect() {
const core = new Core({
projectId: WC_PROJECT_ID,
logger,
})

const web3wallet = await Web3Wallet.init({
core,
metadata: SAFE_WALLET_METADATA,
})

this.web3Wallet = web3wallet
}

isConnected() {
return !!this.currentSession
}

getMetadata() {
return this.currentSession?.peer.metadata
}

getSessionTopic() {
return this.currentSession?.topic
}

restoreExistingConnection() {
console.log('WC trying to restore for safe', this.safe)

if (!this.web3Wallet) {
return
}
// we try to find a compatible active session
const activeSessions = this.web3Wallet.getActiveSessions()
console.log('Active Sessions', activeSessions)

const compatibleSession = Object.keys(activeSessions)
.map((topic) => activeSessions[topic])
.find((session) =>
session.namespaces[EVMBasedNamespaces].accounts[0].includes(
`${EVMBasedNamespaces}:${this.safe.chainId}:${this.safe.address.value}`,
),
)

if (compatibleSession) {
this.currentSession = compatibleSession
}
}

async connect(uri: string) {
const isValidWalletConnectUri = uri && uri.startsWith('wc')

if (isValidWalletConnectUri && this.web3Wallet) {
await this.web3Wallet.core.pairing.pair({ uri })
}
}

async disconnect() {
if (!this.web3Wallet || !this.currentSession) {
throw Error('Cannot disconnect if no session is active')
}

await this.web3Wallet.disconnectSession({
topic: this.currentSession.topic,
reason: {
code: WC_ERRORS.USER_DISCONNECTED_CODE,
message: 'User disconnected. Safe Wallet Session ended by the user',
},
})

this.currentSession = undefined
}

resetSession() {
this.currentSession = undefined
}

onSessionProposal(handler: (proposal: Web3WalletTypes.SessionProposal) => void): () => void {
// events
this.web3Wallet?.on('session_proposal', handler)
return () => this.web3Wallet?.off('session_proposal', handler)
}

onSessionDelete(handler: () => void): () => void {
this.web3Wallet?.on('session_delete', handler)
return () => this.web3Wallet?.off('session_delete', handler)
}

onSessionRequest(handler: (event: Web3WalletTypes.SessionRequest) => void): () => void {
console.log('Registering session request handler')
this.web3Wallet?.on('session_request', handler)
return () => this.web3Wallet?.off('session_request', handler)
}

async approveSessionProposal(sessionProposal: Web3WalletTypes.SessionProposal, onMismatchingNamespaces: () => void) {
if (!this.web3Wallet) {
throw new Error('Web3Wallet needs to be initialized first')
}

const { id, params } = sessionProposal
const { requiredNamespaces } = params
const requiredNamespace = requiredNamespaces[EVMBasedNamespaces]

const safeChain = `${EVMBasedNamespaces}:${this.safe.chainId}`
const safeEvents = requiredNamespace?.events || [] // we accept all events like chainChanged & accountsChanged (even if they are not compatible with the Safe)

const requiredChains = [...(requiredNamespace.chains ?? [])]
// If the user accepts we always return all required namespaces and add the safe chain to it
const safeAccount = `${EVMBasedNamespaces}:${this.safe.chainId}:${this.safe.address.value}`

// We first pretend that our Safe is available on all required networks
const safeOnRequiredChains = requiredChains.map(
(requiredChain) => `${requiredChain ?? safeChain}:${this.safe.address.value}`,
)

let wcSession: SessionTypes.Struct
try {
wcSession = await this.web3Wallet.approveSession({
id,
namespaces: {
eip155: {
accounts: [safeAccount], // only the Safe account
chains: [safeChain], // only the Safe chain
methods: SAFE_COMPATIBLE_METHODS, // only the Safe methods
events: safeEvents,
},
},
})
} catch (error) {
wcSession = await this.web3Wallet.approveSession({
id,
namespaces: {
eip155: {
accounts: safeOnRequiredChains.includes(safeAccount)
? safeOnRequiredChains
: [safeAccount, ...safeOnRequiredChains], // Add all required chains on top
chains: requiredChains, // return the required Safes
methods: SAFE_COMPATIBLE_METHODS, // only the Safe methods
events: safeEvents,
},
},
})
}

this.currentSession = wcSession

// Then we update the session and reduce the Safe to the requested network only
if (!safeOnRequiredChains.includes(safeAccount) || safeOnRequiredChains.length > 1) {
if (!requiredChains.includes(safeChain)) {
requiredChains.push(safeChain)
}

// Emit accountsChanged and chainChanged event
try {
await this.web3Wallet.updateSession({
topic: wcSession.topic,
namespaces: {
eip155: {
accounts: [safeAccount],
chains: requiredChains,
methods: SAFE_COMPATIBLE_METHODS,
events: safeEvents,
},
},
})
} catch (error) {
onMismatchingNamespaces()
}
}
}

async sendSessionResponse(params: { topic: string; response: JsonRpcResponse<any> }) {
if (!this.web3Wallet) {
throw new Error('Web3Wallet needs to be initialized first')
}

await this.web3Wallet.respondSessionRequest(params)
}

async updateSafeInfo(safe: SafeInfo) {
this.safe = safe

if (!this.currentSession || !this.web3Wallet) {
return
}

// We have to update the active session
const safeAccount = `${EVMBasedNamespaces}:${safe.chainId}:${safe.address.value}`
const safeChain = `${EVMBasedNamespaces}:${safe.chainId}`
const currentNamespace = this.currentSession.namespaces[EVMBasedNamespaces]
const chainIsSet = currentNamespace.chains?.includes(safeChain)

await this.web3Wallet.updateSession({
topic: this.currentSession.topic,
namespaces: {
eip155: {
...currentNamespace,
accounts: [safeAccount],
chains: chainIsSet ? currentNamespace.chains : [...(currentNamespace.chains ?? []), safeChain],
},
},
})
}
}
42 changes: 42 additions & 0 deletions src/components/wallet-connect/hooks/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
export const SAFE_COMPATIBLE_METHODS = [
'eth_accounts',
'net_version',
'eth_chainId',
'personal_sign',
'eth_sign',
'eth_signTypedData',
'eth_signTypedData_v4',
'eth_sendTransaction',
'eth_blockNumber',
'eth_getBalance',
'eth_getCode',
'eth_getTransactionCount',
'eth_getStorageAt',
'eth_getBlockByNumber',
'eth_getBlockByHash',
'eth_getTransactionByHash',
'eth_getTransactionReceipt',
'eth_estimateGas',
'eth_call',
'eth_getLogs',
'eth_gasPrice',
'wallet_getPermissions',
'wallet_requestPermissions',
'safe_setSettings',
]

export enum WC_ERRORS {
UNSUPPORTED_CHAIN_ERROR_CODE = 5100,
INVALID_METHOD_ERROR_CODE = 1001,
USER_REJECTED_REQUEST_CODE = 4001,
USER_DISCONNECTED_CODE = 6000,
}

export const SAFE_WALLET_METADATA = {
name: 'Safe Wallet',
description: 'The most trusted platform to manage digital assets on Ethereum',
url: 'https://app.safe.global',
icons: ['https://app.safe.global/favicons/mstile-150x150.png', 'https://app.safe.global/favicons/logo_120x120.png'],
}

export const EVMBasedNamespaces = 'eip155'
Loading