From 3acd9645fb0a03e89f56d02259793aef53e11719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20T=C3=B3rz?= <93620601+torztomasz@users.noreply.github.com> Date: Mon, 10 Jul 2023 22:30:08 +0200 Subject: [PATCH] Implement ApeX StarkKey recovery (#415) * implement apex starkkey recovery Co-authored-by: Piotr Szlachciak * implement apex testnet starkkey recovery * pass isMainnet in context * change isMainnet flag to chainId * improve user experience regarding metamask * fix linter errors * fix failing test --------- Co-authored-by: Piotr Szlachciak --- .../ForcedTradeOfferController.test.ts | 2 + .../src/core/PageContextService.test.ts | 14 ++ .../backend/src/core/PageContextService.ts | 2 + packages/frontend/scripts/metamaskSpy.js | 26 +++- packages/frontend/src/preview/routes.ts | 2 + .../frontend/src/scripts/MetamaskClient.ts | 25 ++++ .../frontend/src/scripts/keys/keys.test.ts | 23 ++- packages/frontend/src/scripts/keys/keys.ts | 6 +- .../src/scripts/keys/recovery.test.ts | 4 +- .../frontend/src/scripts/keys/recovery.ts | 16 ++- .../src/scripts/keys/starkKeyRecovery.ts | 27 +++- packages/frontend/src/scripts/metamask.ts | 135 ++++++++++-------- .../src/scripts/peripherals/wallet.ts | 24 ++++ .../src/view/components/page/Navbar.tsx | 6 +- .../src/view/components/page/Page.tsx | 6 +- .../src/view/pages/user/UserRecoverPage.tsx | 1 + packages/shared/src/PageContext.ts | 2 + 17 files changed, 237 insertions(+), 84 deletions(-) create mode 100644 packages/frontend/src/scripts/MetamaskClient.ts diff --git a/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts b/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts index 1bf46eb93..32ad20340 100644 --- a/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts +++ b/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts @@ -54,9 +54,11 @@ describe(ForcedTradeOfferController.name, () => { const pageContext: PageContext = { user: undefined, tradingMode: 'perpetual', + chainId: 1, instanceName: 'dYdX', collateralAsset: fakeCollateralAsset, } + describe( ForcedTradeOfferController.prototype.getOfferDetailsPage.name, () => { diff --git a/packages/backend/src/core/PageContextService.test.ts b/packages/backend/src/core/PageContextService.test.ts index a1855499d..0d309aec6 100644 --- a/packages/backend/src/core/PageContextService.test.ts +++ b/packages/backend/src/core/PageContextService.test.ts @@ -12,12 +12,18 @@ describe(PageContextService.name, () => { tradingMode: 'perpetual', instanceName: 'dYdX', collateralAsset: fakeCollateralAsset, + blockchain: { + chainId: 1, + }, }, } as Config const spotConfig = { starkex: { tradingMode: 'spot', instanceName: 'Myria', + blockchain: { + chainId: 5, + }, }, } as const as Config @@ -39,6 +45,7 @@ describe(PageContextService.name, () => { expect(context).toEqual({ user: undefined, tradingMode: 'perpetual', + chainId: 1, instanceName: perpetualConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, }) @@ -62,6 +69,7 @@ describe(PageContextService.name, () => { expect(context).toEqual({ user: undefined, tradingMode: 'spot', + chainId: 5, instanceName: spotConfig.starkex.instanceName, }) expect(mockedUserService.getUserDetails).toHaveBeenCalledWith(givenUser) @@ -81,6 +89,7 @@ describe(PageContextService.name, () => { const pageContext = { user: givenUser, tradingMode: 'perpetual', + chainId: 1, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const @@ -101,6 +110,7 @@ describe(PageContextService.name, () => { ({ user: undefined, tradingMode: 'perpetual', + chainId: 1, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const) @@ -127,6 +137,7 @@ describe(PageContextService.name, () => { const pageContext = { user: givenUser, tradingMode: 'perpetual', + chainId: 1, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const @@ -166,6 +177,7 @@ describe(PageContextService.name, () => { const pageContext = { user: givenUser, tradingMode: 'perpetual', + chainId: 1, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const @@ -188,6 +200,7 @@ describe(PageContextService.name, () => { const pageContext = { user: undefined, tradingMode: 'perpetual', + chainId: 5, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const @@ -205,6 +218,7 @@ describe(PageContextService.name, () => { const pageContext = { user: undefined, tradingMode: 'spot', + chainId: 5, instanceName: spotConfig.starkex.instanceName, } as const diff --git a/packages/backend/src/core/PageContextService.ts b/packages/backend/src/core/PageContextService.ts index ebb2b3148..468964fe6 100644 --- a/packages/backend/src/core/PageContextService.ts +++ b/packages/backend/src/core/PageContextService.ts @@ -23,6 +23,7 @@ export class PageContextService { user, tradingMode: this.config.starkex.tradingMode, instanceName: this.config.starkex.instanceName, + chainId: this.config.starkex.blockchain.chainId, collateralAsset: this.config.starkex.collateralAsset, } } @@ -30,6 +31,7 @@ export class PageContextService { return { user, tradingMode: this.config.starkex.tradingMode, + chainId: this.config.starkex.blockchain.chainId, instanceName: this.config.starkex.instanceName, } } diff --git a/packages/frontend/scripts/metamaskSpy.js b/packages/frontend/scripts/metamaskSpy.js index 812cef984..e5045342c 100644 --- a/packages/frontend/scripts/metamaskSpy.js +++ b/packages/frontend/scripts/metamaskSpy.js @@ -5,11 +5,29 @@ const oldRequest = window.ethereum.request window.ethereum.request = function (...args) { - console.log('request.args', args) - return Promise.resolve(oldRequest.apply(this, args)).then((value) => { - console.log('request.result', value) + console.log('[REQUEST] args', args) + return Promise.resolve(olDRequest.apply(this, args)).then((value) => { + console.log('[REQUEST] result', value) return value }) } -// TODO: add send, sendAsync support for dYdX +const oldSend = window.ethereum.send +window.ethereum.send = function (...args) { + console.log('[SEND] args', args) + return Promise.resolve( + oldSend.apply(this, args).then((value) => { + console.log('[SEND] result', value) + return value + }) + ) +} + +const oldSendAsync = window.ethereum.sendAsync +window.ethereum.sendAsync = function (...args) { + console.log('[SEND ASYNC] args', args) + return Promise.resolve(oldSendAsync.apply(this, args)).then((value) => { + console.log('[SEND ASYNC] result', value) + return value + }) +} diff --git a/packages/frontend/src/preview/routes.ts b/packages/frontend/src/preview/routes.ts index d160c6ae5..0e2ea3891 100644 --- a/packages/frontend/src/preview/routes.ts +++ b/packages/frontend/src/preview/routes.ts @@ -1571,6 +1571,7 @@ function getPerpetualPageContext( return { user, instanceName: 'dYdX', + chainId: 1, tradingMode: 'perpetual', collateralAsset: fakeCollateralAsset, } as const @@ -1593,6 +1594,7 @@ function getSpotPageContext( return { user, instanceName: 'Myria', + chainId: 1, tradingMode: 'spot', } as const } diff --git a/packages/frontend/src/scripts/MetamaskClient.ts b/packages/frontend/src/scripts/MetamaskClient.ts new file mode 100644 index 000000000..7ef375038 --- /dev/null +++ b/packages/frontend/src/scripts/MetamaskClient.ts @@ -0,0 +1,25 @@ +export class MetamaskClient { + constructor( + private readonly provider: Provider, + private readonly instanceChainId: number + ) {} + + async getChainId(): Promise { + return (await this.provider.request({ method: 'eth_chainId' })) as string + } + + async switchToInstanceNetwork() { + return await this.switchToNetwork(`0x${this.instanceChainId.toString(16)}`) + } + + async switchToNetwork(chainId: `0x${string}`) { + return await this.provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId }], + }) + } + + async requestAccounts() { + return await this.provider.request({ method: 'eth_requestAccounts' }) + } +} diff --git a/packages/frontend/src/scripts/keys/keys.test.ts b/packages/frontend/src/scripts/keys/keys.test.ts index 8ed2d5568..8b08ead96 100644 --- a/packages/frontend/src/scripts/keys/keys.test.ts +++ b/packages/frontend/src/scripts/keys/keys.test.ts @@ -1,14 +1,14 @@ import { expect } from 'earl' import { - getDydxStarkExKeyPairFromData, + getGenericStarkExKeyPairFromData, getMyriaStarkExKeyPairFromData, } from './keys' -describe(getDydxStarkExKeyPairFromData.name, () => { - it('correctly calculates the keys', () => { +describe(getGenericStarkExKeyPairFromData.name, () => { + it('correctly calculates the keys for dydx', () => { const data = '0x12345678' - const pair = getDydxStarkExKeyPairFromData(data) + const pair = getGenericStarkExKeyPairFromData(data) // Derived using: // const x = require('@dydxprotocol/starkex-lib') @@ -22,6 +22,21 @@ describe(getDydxStarkExKeyPairFromData.name, () => { '0186532eaed1aa913e4bffc1b64e338cd7ce9c1afe35e395eee28777660dd959', }) }) + + it('correctly calculates the keys for apex', () => { + const signature = + '0xde864c207981b865c601a77cda6d669169a20a17980c38f151a4302432efb8013ec02c74c2bf4fb111dadcd9924ea023d6f690512cde22edbb5213487a43ae031c03' + const pair = getGenericStarkExKeyPairFromData(signature) + + expect(pair).toEqual({ + publicKey: + '07e6d96a3ed5152d2686edc1429b82404135698008d5722bd065df8d0020c447', + publicKeyYCoordinate: + '0356cfb50aa20e625c9b82e2fbadcf3c93dbd8ac8f5d738354f7dc53b522d105', + privateKey: + '0381ed01dd751a7a16f09802527fe85176aed3ab4ca22b1e19592bf64dfd05d6', + }) + }) }) describe('getMyriaStarkExKeyPairFromData', () => { diff --git a/packages/frontend/src/scripts/keys/keys.ts b/packages/frontend/src/scripts/keys/keys.ts index 34cf689b5..e36a2b36c 100644 --- a/packages/frontend/src/scripts/keys/keys.ts +++ b/packages/frontend/src/scripts/keys/keys.ts @@ -22,8 +22,10 @@ export const Registration = z.object({ }) // Follows the same logic as https://github.com/dydxprotocol/starkex-lib -export function getDydxStarkExKeyPairFromData(hexData: string): StarkKeyPair { - const hashedData = keccak256(hexData) +export function getGenericStarkExKeyPairFromData( + signature: string +): StarkKeyPair { + const hashedData = keccak256(signature) const privateKey = BigInt(hashedData) / 2n ** 5n const normalized = normalizeHex32(privateKey.toString(16)) diff --git a/packages/frontend/src/scripts/keys/recovery.test.ts b/packages/frontend/src/scripts/keys/recovery.test.ts index a2221619a..d0ead6bc4 100644 --- a/packages/frontend/src/scripts/keys/recovery.test.ts +++ b/packages/frontend/src/scripts/keys/recovery.test.ts @@ -1,12 +1,12 @@ import { EthereumAddress } from '@explorer/types' import { expect } from 'earl' -import { getDydxStarkExKeyPairFromData } from './keys' +import { getGenericStarkExKeyPairFromData } from './keys' import { signRegistration } from './recovery' describe(signRegistration.name, () => { it('returns correct data', () => { - const pair = getDydxStarkExKeyPairFromData('0x1234') + const pair = getGenericStarkExKeyPairFromData('0x1234') const ethKey = EthereumAddress('0xdeadbeef12345678deadbeef12345678deadbeef') const data = signRegistration(ethKey, pair) diff --git a/packages/frontend/src/scripts/keys/recovery.ts b/packages/frontend/src/scripts/keys/recovery.ts index 80c9c0b02..576c447c3 100644 --- a/packages/frontend/src/scripts/keys/recovery.ts +++ b/packages/frontend/src/scripts/keys/recovery.ts @@ -3,7 +3,7 @@ import { EthereumAddress, StarkKey } from '@explorer/types' import { Wallet } from '../peripherals/wallet' import { - getDydxStarkExKeyPairFromData, + getGenericStarkExKeyPairFromData, getMyriaStarkExKeyPairFromData, Registration, signStarkMessage, @@ -20,7 +20,7 @@ export async function recoverKeysDydx( account: EthereumAddress ): Promise { const ethSignature = await Wallet.signDydxKey(account) - const keyPair = getDydxStarkExKeyPairFromData(ethSignature + '00') + const keyPair = getGenericStarkExKeyPairFromData(ethSignature + '00') const registration = signRegistration(account, keyPair) return { account, starkKey: StarkKey(keyPair.publicKey), registration } } @@ -29,7 +29,7 @@ export async function recoverKeysDydxLegacy( account: EthereumAddress ): Promise { const ethSignature = await Wallet.signDydxKeyLegacy(account) - const keyPair = getDydxStarkExKeyPairFromData(ethSignature + '03') + const keyPair = getGenericStarkExKeyPairFromData(ethSignature + '03') const registration = signRegistration(account, keyPair) return { account, starkKey: StarkKey(keyPair.publicKey), registration } } @@ -43,6 +43,16 @@ export async function recoverKeysMyria( return { account, starkKey: StarkKey(keyPair.publicKey), registration } } +export async function recoverKeysApex( + account: EthereumAddress, + chainId: number +): Promise { + const ethSignature = await Wallet.signApexKey(account, chainId) + const keyPair = getGenericStarkExKeyPairFromData(ethSignature + '03') + const registration = signRegistration(account, keyPair) + return { account, starkKey: StarkKey(keyPair.publicKey), registration } +} + export function signRegistration( account: EthereumAddress, keyPair: StarkKeyPair diff --git a/packages/frontend/src/scripts/keys/starkKeyRecovery.ts b/packages/frontend/src/scripts/keys/starkKeyRecovery.ts index 23834f68b..49d59c3cc 100644 --- a/packages/frontend/src/scripts/keys/starkKeyRecovery.ts +++ b/packages/frontend/src/scripts/keys/starkKeyRecovery.ts @@ -5,7 +5,12 @@ import Cookie from 'js-cookie' import { RECOVER_STARK_KEY_BUTTON_ID } from '../../view' import { getUsersInfo } from '../metamask' import { makeQuery } from '../utils/query' -import { RecoveredKeys, recoverKeysDydx, recoverKeysMyria } from './recovery' +import { + RecoveredKeys, + recoverKeysApex, + recoverKeysDydx, + recoverKeysMyria, +} from './recovery' export function initStarkKeyRecovery() { const { $ } = makeQuery(document.body) @@ -17,17 +22,25 @@ export function initStarkKeyRecovery() { if (!registerButton || !account) { return } - const instanceName = InstanceName.parse(registerButton.dataset.instanceName) - + const { instanceName, chainId } = getDataFromButton(registerButton) // eslint-disable-next-line @typescript-eslint/no-misused-promises registerButton.addEventListener('click', async () => { - const keys = await recoverKeys(account, instanceName) + const keys = await recoverKeys(account, instanceName, chainId) Cookie.set('starkKey', keys.starkKey.toString()) setToLocalStorage(account, keys) window.location.href = `/users/${keys.starkKey.toString()}` }) } +const getDataFromButton = (button: HTMLElement) => { + const instanceName = InstanceName.parse(button.dataset.instanceName) + if (button.dataset.chainId === undefined) { + throw new Error('chainId not passed to stark key recovery button') + } + + return { instanceName, chainId: Number(button.dataset.chainId) } +} + const setToLocalStorage = (account: EthereumAddress, keys: RecoveredKeys) => { const accountsMap = getUsersInfo() @@ -41,15 +54,17 @@ const setToLocalStorage = (account: EthereumAddress, keys: RecoveredKeys) => { const recoverKeys = ( account: EthereumAddress, - instanceName: InstanceName + instanceName: InstanceName, + chainId: number ): Promise => { switch (instanceName) { case 'dYdX': return recoverKeysDydx(account) case 'Myria': return recoverKeysMyria(account) - case 'GammaX': case 'ApeX': + return recoverKeysApex(account, chainId) + case 'GammaX': //TODO: Implement throw new Error('NIY') default: diff --git a/packages/frontend/src/scripts/metamask.ts b/packages/frontend/src/scripts/metamask.ts index 6de5d27fe..35e98a125 100644 --- a/packages/frontend/src/scripts/metamask.ts +++ b/packages/frontend/src/scripts/metamask.ts @@ -4,6 +4,7 @@ import Cookie from 'js-cookie' import { z } from 'zod' import { Registration } from './keys/keys' +import { MetamaskClient } from './MetamaskClient' import { makeQuery } from './utils/query' type UsersInfo = z.infer @@ -26,81 +27,97 @@ export function initMetamask() { const { $ } = makeQuery(document.body) const connectButton = $.maybe('#connect-with-metamask') - if (connectButton) { - connectButton.addEventListener('click', () => { - if (provider) { - provider.request({ method: 'eth_requestAccounts' }).catch(console.error) - } else { - window.open('https://metamask.io/download/') - } - }) - } + const instanceChainId = getInstanceChainId() if (!provider) { + connectButton?.addEventListener('click', () => { + window.open('https://metamask.io/download/') + }) return } - provider.request({ method: 'eth_accounts' }).catch(console.error) - - provider - .request({ method: 'eth_chainId' }) - .then((chainId) => { - updateChainId(chainId as string) - }) - .catch(console.error) + const metamaskClient = new MetamaskClient(provider, instanceChainId) + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + connectButton?.addEventListener('click', async () => { + const chainId = await metamaskClient.getChainId() + if (instanceChainId !== Number(chainId)) { + metamaskClient + .switchToInstanceNetwork() + .then(() => metamaskClient.requestAccounts()) + .catch(console.error) + return + } - provider.on('accountsChanged', (accounts) => { - updateAccounts(accounts) + await metamaskClient.requestAccounts() }) - provider.on('chainChanged', (chainId) => { - updateChainId(chainId) - }) + provider.on('accountsChanged', (accounts) => updateAccounts(accounts)) + + provider.on('chainChanged', (chainId) => + updateChainId(chainId, instanceChainId) + ) +} + +function updateChainId(chainId: string, instanceChainId: number) { + const networkName = chainIdToNetworkName[instanceChainId] - function updateAccounts(accounts: string[]) { - deleteDisconnectedAccountsFromUsersInfo(accounts) - const connectedAccount = accounts.at(0) - const currentAccount = Cookie.get('account') - - const accountsMap = getUsersInfo() - - if (connectedAccount !== currentAccount) { - if (connectedAccount) { - Cookie.set('account', connectedAccount.toString()) - const accountMap = accountsMap[connectedAccount] - if (accountMap?.starkKey) { - Cookie.set('starkKey', accountMap.starkKey.toString()) - } else { - Cookie.remove('starkKey') - } + if (!networkName) { + throw new Error(`Unknown chainId: ${instanceChainId}`) + } + + if (Number(chainId) !== instanceChainId) { + alert(`Please change your metamask to ${networkName} network`) + } +} + +function updateAccounts(accounts: string[]) { + deleteDisconnectedAccountsFromUsersInfo(accounts) + const connectedAccount = accounts.at(0) + const currentAccount = Cookie.get('account') + + const accountsMap = getUsersInfo() + + if (connectedAccount !== currentAccount) { + if (connectedAccount) { + Cookie.set('account', connectedAccount.toString()) + const accountMap = accountsMap[connectedAccount] + if (accountMap?.starkKey) { + Cookie.set('starkKey', accountMap.starkKey.toString()) } else { - localStorage.removeItem('accountsMap') - Cookie.remove('account') Cookie.remove('starkKey') } - location.reload() + } else { + localStorage.removeItem('accountsMap') + Cookie.remove('account') + Cookie.remove('starkKey') } + location.reload() } +} - function deleteDisconnectedAccountsFromUsersInfo( - connectedAccounts: string[] - ) { - const usersInfo = getUsersInfo() - Object.keys(usersInfo).forEach((userAccount) => { - if (!connectedAccounts.includes(userAccount)) { - //eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete usersInfo[userAccount] - } - }) +function deleteDisconnectedAccountsFromUsersInfo(connectedAccounts: string[]) { + const usersInfo = getUsersInfo() + Object.keys(usersInfo).forEach((userAccount) => { + if (!connectedAccounts.includes(userAccount)) { + //eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete usersInfo[userAccount] + } + }) - localStorage.setItem('accountsMap', JSON.stringify(usersInfo)) - } + localStorage.setItem('accountsMap', JSON.stringify(usersInfo)) +} - const MAINNET_CHAIN_ID = '0x1' - const GANACHE_CHAIN_ID = '0x539' - function updateChainId(chainId: string) { - if (chainId !== MAINNET_CHAIN_ID && chainId !== GANACHE_CHAIN_ID) { - alert('Please change your metamask to mainnet') - } +const getInstanceChainId = () => { + const instanceChainId = document.querySelector('html')?.dataset.chainId + if (!instanceChainId) { + throw new Error('Chain id not found') } + return Number(instanceChainId) +} + +const chainIdToNetworkName: Record = { + 1: 'Mainnet', + 5: 'Goerli', + 539: 'Ganache', } diff --git a/packages/frontend/src/scripts/peripherals/wallet.ts b/packages/frontend/src/scripts/peripherals/wallet.ts index d45a52ca6..57abe1761 100644 --- a/packages/frontend/src/scripts/peripherals/wallet.ts +++ b/packages/frontend/src/scripts/peripherals/wallet.ts @@ -62,6 +62,30 @@ export const Wallet = { return result as string }, + async signApexKey( + account: EthereumAddress, + chainId: number + ): Promise { + const message = `name: ApeX\nversion: 1.0\nenvId: ${chainId}\naction: L2 Key\nonlySignOn: https://pro.apex.exchange` + + const result = await getProvider().request({ + method: 'personal_sign', + params: [message, account.toString()], + }) + return result as string + }, + + async signApexTestnetKey(account: EthereumAddress): Promise { + const message = + 'name: ApeX\nversion: 1.0\nenvId: 5\naction: L2 Key\nonlySignOn: https://pro.apex.exchange' + + const result = await getProvider().request({ + method: 'personal_sign', + params: [message, account.toString()], + }) + return result as string + }, + async sendRegistrationTransaction( account: EthereumAddress, starkKey: StarkKey, diff --git a/packages/frontend/src/view/components/page/Navbar.tsx b/packages/frontend/src/view/components/page/Navbar.tsx index 976755e95..5f6c63f21 100644 --- a/packages/frontend/src/view/components/page/Navbar.tsx +++ b/packages/frontend/src/view/components/page/Navbar.tsx @@ -13,8 +13,8 @@ interface NavbarProps { } export function Navbar({ searchBar = true, context }: NavbarProps) { - const { user, instanceName, tradingMode } = context - + const { user, instanceName, tradingMode, chainId } = context + const isMainnet = chainId === 1 return ( - {instanceName.toUpperCase()} EXPLORER + {instanceName.toUpperCase()} {isMainnet ? '' : 'TESTNET'} EXPLORER
diff --git a/packages/frontend/src/view/components/page/Page.tsx b/packages/frontend/src/view/components/page/Page.tsx index 2ea3c609d..ab07d84c4 100644 --- a/packages/frontend/src/view/components/page/Page.tsx +++ b/packages/frontend/src/view/components/page/Page.tsx @@ -22,7 +22,11 @@ interface Props { export function Page(props: Props) { return ( - + Recover diff --git a/packages/shared/src/PageContext.ts b/packages/shared/src/PageContext.ts index 3c09ff987..ae4713b28 100644 --- a/packages/shared/src/PageContext.ts +++ b/packages/shared/src/PageContext.ts @@ -13,6 +13,7 @@ type CheckTradingMode = Exclude< interface PerpetualPageContext { user: UserDetails | undefined instanceName: InstanceName + chainId: number tradingMode: 'perpetual' collateralAsset: CollateralAsset } @@ -20,6 +21,7 @@ interface PerpetualPageContext { interface SpotPageContext { user: UserDetails | undefined instanceName: InstanceName + chainId: number tradingMode: 'spot' }