Skip to content

Commit

Permalink
feat: impl wallet_switchEthereumChain (#11662)
Browse files Browse the repository at this point in the history
Co-authored-by: Jack-Works <Jack-Works@users.noreply.github.com>
  • Loading branch information
Jack-Works and Jack-Works authored Jun 4, 2024
1 parent 2d94c69 commit fdfdcca
Show file tree
Hide file tree
Showing 9 changed files with 186 additions and 25 deletions.
2 changes: 1 addition & 1 deletion packages/mask/entry-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ This section excludes PoW era methods and methods listed in <https://ethereum.gi

- [x] wallet_watchAsset ([EIP-747](https://eips.ethereum.org/EIPS/eip-747))
- [ ] wallet_addEthereumChain ([EIP-3085](https://eips.ethereum.org/EIPS/eip-3085))
- [ ] wallet_switchEthereumChain ([EIP-3326](https://eips.ethereum.org/EIPS/eip-3326))
- [x] wallet_switchEthereumChain ([EIP-3326](https://eips.ethereum.org/EIPS/eip-3326))

### [EIP-1102: Opt-in account exposure](https://eips.ethereum.org/EIPS/eip-1102)

Expand Down
36 changes: 28 additions & 8 deletions packages/mask/entry-sdk/bridge/eth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,19 @@ const methods: Methods = {
})
},
wallet_revokePermissions: null!,
wallet_switchEthereumChain: null!,
async wallet_switchEthereumChain(request) {
const p = providers.EVMWeb3.getWeb3Provider({
providerType: ProviderType.MaskWallet,
silent: false,
readonly: false,
})
const current = await Services.Wallet.sdk_eth_chainId()
if (current === Number.parseInt(request.chainId, 16)) return null
return p.request({
method: EthereumMethodType.wallet_switchEthereumChain,
params: [request],
})
},
async wallet_watchAsset({ type, options: { address, decimals, image, symbol, tokenId } }) {
// TODO: throw error if chainId is unknown (https://eips.ethereum.org/EIPS/eip-747#erc1046-type)
if (!isValidChecksumAddress(address)) return err.invalid_address()
Expand Down Expand Up @@ -319,7 +331,7 @@ export async function eth_request(request: unknown): Promise<{ e?: MaskEthereumP

// assert argument & return value validator exists
if (!(_method in methodValidate)) {
console.error(`Missing schema for method ${_method}`)
console.error(`[Mask wallet] Missing schema for method ${_method}`)
return { e: err.internal_error() }
}
const method = _method as keyof Methods
Expand All @@ -337,13 +349,18 @@ export async function eth_request(request: unknown): Promise<{ e?: MaskEthereumP
const paramsValidated = paramsSchema.safeParse(paramsArr)
if (!paramsValidated.success) {
if (process.env.NODE_ENV === 'development') {
console.debug('[Mask Wallet] Failed', request, 'received params', paramsArr)
console.debug(
'[Mask Wallet] The request failed to pass the validation',
request,
'received params',
paramsArr,
)
}
return { e: fromZodError(paramsValidated.error) }
}

if (process.env.NODE_ENV === 'development') {
console.debug('[Mask Wallet]', request, paramsValidated.data)
console.debug('[Mask Wallet] Received raw request', request, 'after validation', paramsValidated.data)
}
// call the method
const fn: (...args: any[]) => any = Reflect.get(methods, method)!
Expand All @@ -357,16 +374,19 @@ export async function eth_request(request: unknown): Promise<{ e?: MaskEthereumP
if (error instanceof MaskEthereumProviderRpcError) return { e: error }
if (error.message === 'User rejected the message.') return { e: err.user_rejected_the_request() }

console.error(error)
throw new Error('internal error')
console.error('[Mask wallet] Internal error when handling request', requestValidate.data, error)
return { e: err.internal_error() }
}

// validate return value
const returnSchema = methodValidate[method].return
const resultValidate = returnSchema.safeParse(result)
if (!resultValidate.success) {
console.error('Mask wallet returns invalid result', result)
return { e: fromZodError(resultValidate.error) }
console.debug('[Mask wallet] Return value invalid', result)
throw fromZodError(resultValidate.error)
}
if (process.env.NODE_ENV === 'development') {
console.debug('[Mask wallet] Request success', requestValidate.data, resultValidate.data)
}
return { d: resultValidate.data }
} catch (error) {
Expand Down
13 changes: 9 additions & 4 deletions packages/mask/entry-sdk/bridge/eth/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,15 @@ namespace _ {
const block_number = unpadded_hex.describe('block_number')
export const block = z.union([block_tag, block_number, block_hash]).describe('Block')
export const block_number_or_tag = z.union([block_tag, block_number]).describe('Block')
export const chainId = z
.string()
.regex(/^0x([1-9a-f]+[\da-f]*|0)$/g)
.refine((val) => Number.parseInt(val.slice(2), 16) <= Number.MAX_SAFE_INTEGER)
export const chainId = z.preprocess(
// number to string is not defined in the spec
(x) => (typeof x === 'number' || typeof x === 'bigint' ? '0x' + x.toString(16) : x),
z
.string()
.regex(/^0x([1-9a-f]+[\da-f]*|0)$/g)
.refine((val) => Number.parseInt(val.slice(2), 16) <= Number.MAX_SAFE_INTEGER),
)

export const decimal = z.number().min(0).max(36)
export const filter = z
.object({
Expand Down
125 changes: 125 additions & 0 deletions packages/mask/popups/pages/Wallet/Interaction/SwitchChainRequest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { makeStyles } from '@masknet/theme'
import type { InteractionItemProps } from './interaction.js'
import { Alert, IconButton, Link, Typography } from '@mui/material'
import { useMaskSharedTrans } from '../../../../shared-ui/index.js'
import { useChainId, useNetwork, useWeb3State } from '@masknet/web3-hooks-base'
import { NetworkPluginID } from '@masknet/shared-base'
import { useTitle } from 'react-use'
import { NetworkIcon } from '@masknet/shared'
import { KeyboardArrowRightRounded } from '@mui/icons-material'
import { EVMWeb3 } from '@masknet/web3-providers'
import { ProviderType } from '@masknet/web3-shared-evm'
import { useEffect } from 'react'

const useStyle = makeStyles()({
title: { fontSize: 28, marginTop: 16 },
origin: {
border: '1px solid gray',
textAlign: 'center',
borderRadius: 10,
fontSize: 'large',
padding: '0.25em',
margin: '1em 0.5em',
},
container: { display: 'flex', alignItems: 'center' },
icon: { width: 72, height: 72 },
network: { flex: 2, display: 'flex', alignItems: 'center', flexDirection: 'column' },
arrow: { flex: 1 },
})
export function SwitchChainRequest(props: InteractionItemProps) {
const { setConfirmAction } = props
const { classes } = useStyle()
const t = useMaskSharedTrans()
const origin = props.currentRequest.origin
const { Network, Message } = useWeb3State()
const currentChainId = useChainId()
const currentNetwork = useNetwork()
const nextChainId = Number.parseInt(props.currentRequest.request.arguments.params[0].chainId, 16)
const nextNetwork = useNetwork(NetworkPluginID.PLUGIN_EVM, nextChainId)

useTitle(t.wallet_sdk_connect_title())
useEffect(() => {
props.setConfirmDisabled(!nextNetwork)
}, [!nextNetwork])

if (!origin) return null
setConfirmAction(async () => {
if (!nextNetwork) return
await Network!.switchNetwork(nextNetwork.ID)
await EVMWeb3.switchChain(nextNetwork.chainId, {
providerType: ProviderType.MaskWallet,
})

await Message!.approveRequestWithResult(props.currentRequest.ID, { result: null, jsonrpc: '2.0', id: 0 })
// After a chain switch, old requests should be dropped according to https://eips.ethereum.org/EIPS/eip-3326
await Message!.denyAllRequests()
})

return (
<>
<Typography variant="h1" className={classes.title}>
{t.wallet_sdk_switch_chain_title()}
</Typography>
<Typography variant="h2" className={classes.origin}>
{origin.startsWith('https://') ? origin.slice('https://'.length) : origin}
</Typography>
{nextNetwork ? null : (
<Alert sx={{ marginBottom: 1 }} severity="error">
{t.wallet_sdk_switch_chain_error()}
</Alert>
)}
<div className={classes.container}>
<div className={classes.network}>
<IconButton
component={Link}
href={currentNetwork?.explorerUrl.url}
target="_blank"
rel="noopener noreferrer">
<NetworkIcon
className={classes.icon}
pluginID={NetworkPluginID.PLUGIN_EVM}
chainId={currentChainId}
network={currentNetwork}
size={16}
/>
</IconButton>
</div>
<KeyboardArrowRightRounded fontSize="large" className={classes.arrow} />
<div className={classes.network}>
<IconButton
component={Link}
href={nextNetwork?.explorerUrl.url}
target="_blank"
rel="noopener noreferrer">
<NetworkIcon
className={classes.icon}
pluginID={NetworkPluginID.PLUGIN_EVM}
chainId={nextChainId}
network={nextNetwork}
size={16}
/>
</IconButton>
</div>
</div>
<div className={classes.container}>
<div className={classes.network}>
<Link href={currentNetwork?.explorerUrl.url} target="_blank" rel="noopener noreferrer">
{currentNetwork?.fullName ?? 'Unknown Network'}
</Link>
<Typography>
{t.chain_id()}: {currentNetwork?.chainId ?? currentChainId}
</Typography>
</div>
<div className={classes.arrow} />
<div className={classes.network}>
<Link href={nextNetwork?.explorerUrl.url} target="_blank" rel="noopener noreferrer">
{nextNetwork?.fullName ?? 'Unknown Network'}
</Link>
<Typography>
{t.chain_id()}: {nextNetwork?.chainId ?? nextChainId}
</Typography>
</div>
</div>
</>
)
}
19 changes: 11 additions & 8 deletions packages/mask/popups/pages/Wallet/Interaction/interaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useNavigate } from 'react-router-dom'
import { WalletAssetTabs } from '../type.js'
import urlcat from 'urlcat'
import { PermissionRequest } from './PermissionRequest.js'
import { SwitchChainRequest } from './SwitchChainRequest.js'

const useStyles = makeStyles()({
left: {
Expand Down Expand Up @@ -110,6 +111,7 @@ export const Interaction = memo((props: InteractionProps) => {
{confirmVerb}
</ActionButton>
)
const InteractionItem = getInteractionComponent(props.currentRequest.request.arguments.method)

return (
<Box flex={1} display="flex" flexDirection="column">
Expand Down Expand Up @@ -148,21 +150,22 @@ export interface InteractionItemProps {
paymentToken: string
setPaymentToken: (paymentToken: string) => void
}
const InteractionItem = memo((props: InteractionItemProps) => {
switch (props.currentRequest.request.arguments.method) {
function getInteractionComponent(type: EthereumMethodType) {
switch (type) {
case EthereumMethodType.wallet_watchAsset:
return <WatchTokenRequest {...props} />
return WatchTokenRequest
case EthereumMethodType.wallet_requestPermissions:
return <PermissionRequest {...props} />
return PermissionRequest
case EthereumMethodType.wallet_switchEthereumChain:
return SwitchChainRequest
case EthereumMethodType.eth_sign:
case EthereumMethodType.eth_signTypedData_v4:
case EthereumMethodType.personal_sign:
return <WalletSignRequest {...props} />
return WalletSignRequest
default:
return <TransactionRequest {...props} />
return TransactionRequest
}
})
InteractionItem.displayName = 'InteractionItem'
}

const Pager = memo((props: InteractionProps) => {
const { currentMessageIndex, currentRequest, setMessageIndex, totalMessages } = props
Expand Down
4 changes: 3 additions & 1 deletion packages/mask/shared-ui/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1210,5 +1210,7 @@
"wallet_sdk_connect_warning_1": "See your address",
"wallet_sdk_connect_warning_2": "See your account balance and history",
"wallet_sdk_connect_warning_3": "See your Tokens and NFTs",
"wallet_sdk_connect_warning_4": "Suggest to send transactions and sign messages"
"wallet_sdk_connect_warning_4": "Suggest to send transactions and sign messages",
"wallet_sdk_switch_chain_title": "Allow this site to switch the network?",
"wallet_sdk_switch_chain_error": "Cannot switch to a unknown network."
}
5 changes: 4 additions & 1 deletion packages/web3-hooks/base/src/useChainId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { UNDEFINED, type NetworkPluginID } from '@masknet/shared-base'
import type { Web3Helper } from '@masknet/web3-helpers'
import { useWeb3State } from './useWeb3State.js'
import { useDefaultChainId } from './useDefaultChainId.js'
import { useDebugValue } from 'react'

export function useChainId<S extends 'all' | void = void, T extends NetworkPluginID = NetworkPluginID>(
pluginID?: T,
Expand All @@ -12,5 +13,7 @@ export function useChainId<S extends 'all' | void = void, T extends NetworkPlugi
const defaultChainId = useDefaultChainId(pluginID)
const actualChainId = useSubscription(Provider?.chainId ?? UNDEFINED)

return (expectedChainId ?? actualChainId ?? defaultChainId) as Web3Helper.ChainIdScope<S, T>
const chainId = (expectedChainId ?? actualChainId ?? defaultChainId) as Web3Helper.ChainIdScope<S, T>
useDebugValue(chainId)
return chainId
}
6 changes: 4 additions & 2 deletions packages/web3-hooks/base/src/useNetwork.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { useDebugValue, useMemo } from 'react'
import { useSubscription } from 'use-subscription'
import type { Web3Helper } from '@masknet/web3-helpers'
import { EMPTY_STRING, type NetworkPluginID } from '@masknet/shared-base'
Expand All @@ -13,8 +13,10 @@ export function useNetwork<T extends NetworkPluginID = NetworkPluginID>(
const networks = useNetworks(pluginID)
const networkID = useSubscription(Network?.networkID ?? EMPTY_STRING)

return useMemo(() => {
const network = useMemo(() => {
if (chainId) return networks.find((x) => x.chainId === chainId)
return networks.find((x) => x.ID === networkID)
}, [chainId, networkID, networks])
useDebugValue(network)
return network
}
1 change: 1 addition & 0 deletions packages/web3-shared/evm/src/helpers/isRiskyMethodType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function isRiskyMethodType(type: EthereumMethodType) {
EthereumMethodType.personal_sign,
EthereumMethodType.wallet_watchAsset,
EthereumMethodType.wallet_requestPermissions,
EthereumMethodType.wallet_switchEthereumChain,
EthereumMethodType.eth_signTypedData_v4,
EthereumMethodType.eth_sendTransaction,
EthereumMethodType.eth_signTransaction,
Expand Down

0 comments on commit fdfdcca

Please sign in to comment.