Skip to content

Commit

Permalink
Feat: private key signer (#3784)
Browse files Browse the repository at this point in the history
  • Loading branch information
katspaugh committed Jun 14, 2024
1 parent 6d2f6be commit 010d629
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 861 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
"@safe-global/safe-modules-deployments": "^1.2.0",
"@sentry/react": "^7.91.0",
"@spindl-xyz/attribution-lite": "^1.4.0",
"@truffle/hdwallet-provider": "^2.1.4",
"@walletconnect/utils": "^2.13.1",
"@walletconnect/web3wallet": "^1.12.1",
"@web3-onboard/coinbase": "^2.2.6",
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/wallets/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const enum WALLET_KEYS {
LEDGER = 'LEDGER',
TREZOR = 'TREZOR',
KEYSTONE = 'KEYSTONE',
PK = 'PK',
}

// TODO: Check if undefined is needed as a return type, possibly couple this with WALLET_MODULES
Expand All @@ -15,4 +16,5 @@ export const CGW_NAMES: { [key in WALLET_KEYS]: string | undefined } = {
[WALLET_KEYS.LEDGER]: 'ledger',
[WALLET_KEYS.TREZOR]: 'trezor',
[WALLET_KEYS.KEYSTONE]: 'keystone',
[WALLET_KEYS.PK]: 'pk',
}
14 changes: 10 additions & 4 deletions src/hooks/wallets/wallets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CYPRESS_MNEMONIC, TREZOR_APP_URL, TREZOR_EMAIL, WC_PROJECT_ID } from '@/config/constants'
import { CYPRESS_MNEMONIC, IS_PRODUCTION, TREZOR_APP_URL, TREZOR_EMAIL, WC_PROJECT_ID } from '@/config/constants'
import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
import type { InitOptions } from '@web3-onboard/core'
import coinbaseModule from '@web3-onboard/coinbase'
Expand All @@ -7,6 +7,7 @@ import keystoneModule from '@web3-onboard/keystone/dist/index'
import ledgerModule from '@web3-onboard/ledger/dist/index'
import trezorModule from '@web3-onboard/trezor'
import walletConnect from '@web3-onboard/walletconnect'
import pkModule from '@/services/private-key-module'

import e2eWalletModule from '@/tests/e2e-wallet'
import { CGW_NAMES, WALLET_KEYS } from './consts'
Expand Down Expand Up @@ -38,7 +39,7 @@ const walletConnectV2 = (chain: ChainInfo) => {
})
}

const WALLET_MODULES: { [key in WALLET_KEYS]: (chain: ChainInfo) => WalletInit } = {
const WALLET_MODULES: Partial<{ [key in WALLET_KEYS]: (chain: ChainInfo) => WalletInit }> = {
[WALLET_KEYS.INJECTED]: () => injectedWalletModule() as WalletInit,
[WALLET_KEYS.WALLETCONNECT_V2]: (chain) => walletConnectV2(chain) as WalletInit,
[WALLET_KEYS.COINBASE]: () => coinbaseModule({ darkMode: prefersDarkMode() }) as WalletInit,
Expand All @@ -47,6 +48,11 @@ const WALLET_MODULES: { [key in WALLET_KEYS]: (chain: ChainInfo) => WalletInit }
[WALLET_KEYS.KEYSTONE]: () => keystoneModule() as WalletInit,
}

// Testing wallet module
if (!IS_PRODUCTION) {
WALLET_MODULES[WALLET_KEYS.PK] = (chain) => pkModule(chain.chainId, chain.rpcUri) as WalletInit
}

export const getAllWallets = (chain: ChainInfo): WalletInits => {
return Object.values(WALLET_MODULES).map((module) => module(chain))
}
Expand All @@ -58,12 +64,12 @@ export const isWalletSupported = (disabledWallets: string[], walletLabel: string

export const getSupportedWallets = (chain: ChainInfo): WalletInits => {
if (window.Cypress && CYPRESS_MNEMONIC) {
return [e2eWalletModule(chain.rpcUri) as WalletInit]
return [e2eWalletModule(chain.chainId, chain.rpcUri) as WalletInit]
}
const enabledWallets = Object.entries(WALLET_MODULES).filter(([key]) => isWalletSupported(chain.disabledWallets, key))

if (enabledWallets.length === 0) {
return [WALLET_MODULES.INJECTED(chain)]
return [injectedWalletModule()]
}

return enabledWallets.map(([, module]) => module(chain))
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/wallets/web3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const getRpcServiceUrl = (rpcUri: RpcUri): string => {
export const createWeb3ReadOnly = (chain: ChainInfo, customRpc?: string): JsonRpcProvider | undefined => {
const url = customRpc || getRpcServiceUrl(chain.rpcUri)
if (!url) return
return new JsonRpcProvider(url, undefined, {
return new JsonRpcProvider(url, Number(chain.chainId), {
staticNetwork: true,
batchMaxCount: BATCH_MAX_COUNT,
})
Expand Down
3 changes: 3 additions & 0 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { useNotificationTracking } from '@/components/settings/PushNotifications
import Recovery from '@/features/recovery/components/Recovery'
import WalletProvider from '@/components/common/WalletProvider'
import CounterfactualHooks from '@/features/counterfactual/CounterfactualHooks'
import PkModulePopup from '@/services/private-key-module/PkModulePopup'

const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING

Expand Down Expand Up @@ -128,6 +129,8 @@ const WebCoreApp = ({
<Recovery />

<CounterfactualHooks />

<PkModulePopup />
</AppProviders>
</CacheProvider>
</Provider>
Expand Down
8 changes: 0 additions & 8 deletions src/pages/licenses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,14 +482,6 @@ const SafeLicenses = () => (
</ExternalLink>
</TableCell>
</TableRow>
<TableRow>
<TableCell>@truffle/hdwallet-provider</TableCell>
<TableCell>
<ExternalLink href="https://github.com/trufflesuite/truffle/blob/develop/LICENSE">
https://github.com/trufflesuite/truffle/blob/develop/LICENSE
</ExternalLink>
</TableCell>
</TableRow>
<TableRow>
<TableCell>@web3-onboard/coinbase</TableCell>
<TableCell>
Expand Down
2 changes: 1 addition & 1 deletion src/services/ExternalStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class ExternalStore<T extends unknown> {
}
}

private readonly subscribe = (listener: Listener): (() => void) => {
public readonly subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
Expand Down
6 changes: 6 additions & 0 deletions src/services/local-storage/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,10 @@ import Storage from './Storage'

const session = new Storage(typeof window !== 'undefined' ? window.sessionStorage : undefined)

export const sessionItem = <T>(key: string) => ({
get: () => session.getItem<T>(key),
set: (value: T) => session.setItem<T>(key, value),
remove: () => session.removeItem(key),
})

export default session
43 changes: 43 additions & 0 deletions src/services/private-key-module/PkModulePopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { FormEvent } from 'react'
import { Button, TextField, Typography, Box } from '@mui/material'
import ModalDialog from '@/components/common/ModalDialog'
import pkStore from './pk-popup-store'
const { useStore, setStore } = pkStore

const PkModulePopup = () => {
const { isOpen, privateKey } = useStore() ?? { isOpen: false, privateKey: '' }

const onClose = () => {
setStore({ isOpen: false, privateKey })
}

const onSubmit = (e: FormEvent) => {
e.preventDefault()
const privateKey = (e.target as unknown as { 'private-key': HTMLInputElement })['private-key'].value

setStore({
isOpen: false,
privateKey,
})
}

return (
<ModalDialog dialogTitle="Connect with Private Key" onClose={onClose} open={isOpen} sx={{ zIndex: 1400 }}>
<Box p={2}>
<Typography variant="body1" gutterBottom mb={3}>
Enter your signer private key. The key will be saved for the duration of this browser session.
</Typography>

<form onSubmit={onSubmit} action="#" method="post">
<TextField type="password" label="Private key" fullWidth required name="private-key" sx={{ mb: 3 }} />

<Button variant="contained" color="primary" fullWidth type="submit">
Connect
</Button>
</form>
</Box>
</ModalDialog>
)
}

export default PkModulePopup
5 changes: 5 additions & 0 deletions src/services/private-key-module/icon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const icon = `<svg width="100%" height="100%" viewBox="0 0 65 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3337 7C18.4255 7 17.6893 7.73621 17.6893 8.64436V11.9999H14.334C13.4258 11.9999 12.6896 12.7361 12.6896 13.6443V16.9999H16.0452C16.9534 16.9999 17.6896 16.2637 17.6896 15.3556V12H47.6893V15.3556C47.6893 16.2637 48.4255 16.9999 49.3337 16.9999H52.689V46.9999H56.0447C56.9528 46.9999 57.689 46.2637 57.689 45.3555V18.6442C57.689 17.7361 56.9528 16.9999 56.0447 16.9999H52.6893V13.6443C52.6893 12.7361 51.9531 11.9999 51.0449 11.9999H47.6894V8.64436C47.6894 7.73621 46.9532 7 46.045 7H19.3337ZM47.6893 48.6444C47.6893 47.7363 48.4255 47.0001 49.3337 47.0001H52.6893V50.3557C52.6893 51.2639 51.9531 52.0001 51.0449 52.0001H47.6894V55.3556C47.6894 56.2638 46.9532 57 46.045 57H19.3337C18.4255 57 17.6893 56.2638 17.6893 55.3556V52.0001H14.334C13.4258 52.0001 12.6896 51.2639 12.6896 50.3557V47.0001H16.0452C16.9534 47.0001 17.6896 47.7363 17.6896 48.6444V52H47.6893V48.6444ZM9.33382 16.9999C8.42566 16.9999 7.68945 17.7361 7.68945 18.6442V45.3555C7.68945 46.2637 8.42566 46.9999 9.33382 46.9999H12.6895V16.9999H9.33382ZM36.8004 27.248C36.8004 28.9337 35.7858 30.3824 34.3339 31.0168V40.403C34.3339 40.857 33.9658 41.2252 33.5117 41.2252H31.8673C31.4133 41.2252 31.0452 40.857 31.0452 40.403V31.0168C29.5932 30.3825 28.5786 28.9337 28.5786 27.248C28.5786 24.9776 30.4191 23.1371 32.6895 23.1371C34.9599 23.1371 36.8004 24.9776 36.8004 27.248Z" style="fill: var(--color-text-primary, #000)"/>
</svg>`

export default icon
126 changes: 126 additions & 0 deletions src/services/private-key-module/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { JsonRpcProvider, Wallet } from 'ethers'
import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'
import { type WalletInit, createEIP1193Provider } from '@web3-onboard/common'
import { getRpcServiceUrl } from '@/hooks/wallets/web3'
import pkPopupStore from './pk-popup-store'
import { numberToHex } from '@/utils/hex'

export const PRIVATE_KEY_MODULE_LABEL = 'Private key'

async function getPrivateKey() {
const savedKey = pkPopupStore.getStore()?.privateKey
if (savedKey) return savedKey

pkPopupStore.setStore({
isOpen: true,
privateKey: '',
})

return new Promise<string>((resolve) => {
const unsubscribe = pkPopupStore.subscribe(() => {
unsubscribe()
resolve(pkPopupStore.getStore()?.privateKey ?? '')
})
})
}

let currentChainId = ''
let currentRpcUri = ''

const PrivateKeyModule = (chainId: ChainInfo['chainId'], rpcUri: ChainInfo['rpcUri']): WalletInit => {
currentChainId = chainId
currentRpcUri = getRpcServiceUrl(rpcUri)

return () => {
return {
label: PRIVATE_KEY_MODULE_LABEL,
getIcon: async () => (await import('./icon')).default,
getInterface: async () => {
const privateKey = await getPrivateKey()
if (!privateKey) {
throw new Error('You rejected the connection')
}

let provider: JsonRpcProvider
let wallet: Wallet
let lastChainId = ''
const chainChangedListeners = new Set<(chainId: string) => void>()

const updateProvider = () => {
console.log('[Private key signer] Updating provider to chainId', currentChainId, currentRpcUri)
provider?.destroy()
provider = new JsonRpcProvider(currentRpcUri, Number(currentChainId), { staticNetwork: true })
wallet = new Wallet(privateKey, provider)
lastChainId = currentChainId
chainChangedListeners.forEach((listener) => listener(numberToHex(Number(currentChainId))))
}

updateProvider()

return {
provider: createEIP1193Provider(
{
on: (event: string, listener: (...args: any[]) => void) => {
if (event === 'accountsChanged') {
return
} else if (event === 'chainChanged') {
chainChangedListeners.add(listener)
} else {
provider.on(event, listener)
}
},

request: async (request: { method: string; params: any[] }) => {
if (currentChainId !== lastChainId) {
updateProvider()
}
return provider.send(request.method, request.params)
},

disconnect: () => {
pkPopupStore.setStore({
isOpen: false,
privateKey: '',
})
},
},
{
eth_chainId: async () => currentChainId,

// @ts-ignore
eth_getCode: async ({ params }) => provider.getCode(params[0], params[1]),

eth_accounts: async () => [wallet.address],
eth_requestAccounts: async () => [wallet.address],

eth_call: async ({ params }: { params: any }) => wallet.call(params[0]),

eth_sendTransaction: async ({ params }) => {
const tx = await wallet.sendTransaction(params[0] as any)
return tx.hash // return transaction hash
},

personal_sign: async ({ params }) => {
const signedMessage = wallet.signingKey.sign(params[0])
return signedMessage.serialized
},

eth_signTypedData: async ({ params }) => {
return await wallet.signTypedData(params[1].domain, params[1].data, params[1].value)
},

// @ts-ignore
wallet_switchEthereumChain: async ({ params }) => {
console.log('[Private key signer] Switching chain', params)
updateProvider()
},
},
),
}
},
platforms: ['desktop'],
}
}
}

export default PrivateKeyModule
23 changes: 23 additions & 0 deletions src/services/private-key-module/pk-popup-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import ExternalStore from '@/services/ExternalStore'
import { sessionItem } from '@/services/local-storage/session'

type PkModulePopupStore = {
isOpen: boolean
privateKey: string
}

const defaultValue = {
isOpen: false,
privateKey: '',
}

const STORAGE_KEY = 'privateKeyModulePK'
const pkStorage = sessionItem<PkModulePopupStore>(STORAGE_KEY)

const popupStore = new ExternalStore<PkModulePopupStore>(pkStorage.get() || defaultValue)

popupStore.subscribe(() => {
pkStorage.set(popupStore.getStore() || defaultValue)
})

export default popupStore
Loading

0 comments on commit 010d629

Please sign in to comment.