From 6d5dfe653f81790908f78cdf794ef660c2d7e1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20T=C3=B3rz?= <93620601+torztomasz@users.noreply.github.com> Date: Tue, 15 Oct 2024 12:13:22 +0200 Subject: [PATCH] Add performUserActions query param to user page, with UI (#514) * add showAsMine query param * format * Add util for fetching USDC balance * WIP on adding actForUser message * Fix styling * Change wording and prepare warning panel * Show warning on escape page * Show panels and buttons depending on performUserActions flag * update designs * design update * use card * add icon * Fix formatting --------- Co-authored-by: Adrian Adamiak --- .../src/api/controllers/UserController.ts | 4 +- .../backend/src/api/routers/FrontendRouter.ts | 9 +- packages/escape-hatch-test/src/utils.ts | 13 ++ packages/frontend/src/preview/routes.ts | 68 +++++++++++ .../forced-actions/EscapeHatchActionPage.tsx | 111 ++++++++++-------- .../frontend/src/view/pages/user/UserPage.tsx | 39 ++++-- .../user/components/FinalizeEscapeForm.tsx | 6 +- .../components/PerformUserActionsPanel.tsx | 65 ++++++++++ .../user/components/UserQuickActionsTable.tsx | 19 ++- packages/frontend/tailwind.config.js | 1 + 10 files changed, 267 insertions(+), 68 deletions(-) create mode 100644 packages/frontend/src/view/pages/user/components/PerformUserActionsPanel.tsx diff --git a/packages/backend/src/api/controllers/UserController.ts b/packages/backend/src/api/controllers/UserController.ts index 7e0853e24..8e67da796 100644 --- a/packages/backend/src/api/controllers/UserController.ts +++ b/packages/backend/src/api/controllers/UserController.ts @@ -137,7 +137,8 @@ export class UserController { async getUserPage( givenUser: Partial, - starkKey: StarkKey + starkKey: StarkKey, + performUserActions: boolean | undefined ): Promise { const context = await this.pageContextService.getPageContext(givenUser) const collateralAsset = this.pageContextService.getCollateralAsset(context) @@ -308,6 +309,7 @@ export class UserController { totalTransactions, offers, totalOffers: forcedTradeOffersCount, + performUserActions, }) return { type: 'success', content } diff --git a/packages/backend/src/api/routers/FrontendRouter.ts b/packages/backend/src/api/routers/FrontendRouter.ts index bf313e0bd..a8634a264 100644 --- a/packages/backend/src/api/routers/FrontendRouter.ts +++ b/packages/backend/src/api/routers/FrontendRouter.ts @@ -220,12 +220,19 @@ export function createFrontendRouter( params: z.object({ starkKey: stringAs(StarkKey), }), + query: z.object({ + performUserActions: z + .string() + .transform((value) => value === 'true') + .optional(), + }), }), async (ctx) => { const givenUser = getGivenUser(ctx) const result = await userController.getUserPage( givenUser, - ctx.params.starkKey + ctx.params.starkKey, + ctx.query.performUserActions ) applyControllerResult(ctx, result) } diff --git a/packages/escape-hatch-test/src/utils.ts b/packages/escape-hatch-test/src/utils.ts index f5be74a9d..9c24e6a12 100644 --- a/packages/escape-hatch-test/src/utils.ts +++ b/packages/escape-hatch-test/src/utils.ts @@ -2,6 +2,10 @@ import * as helpers from '@nomicfoundation/hardhat-toolbox/network-helpers' import { ethers } from 'ethers' export class HardhatUtils { + USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + ERC20_ABI = [ + 'function balanceOf(address _owner) public view returns (uint256 balance)' + ] PERPETUAL_ABI = [ 'function isFrozen() public view returns (bool)', 'function forcedWithdrawalRequest(uint256,uint256,uint256,bool) external', @@ -61,6 +65,15 @@ export class HardhatUtils { return this.provider.getBalance(address) } + async getUSDCBalance(address: string) { + const usdc = new ethers.Contract( + this.USDC_ADDRESS, + this.ERC20_ABI, + this.provider + ) + return await usdc.balanceOf(address) + } + async triggerFreezable() { // Impersonate the user of position #1 await helpers.impersonateAccount(this.FORCED_WITHDRAWAL.ethereumAddress) diff --git a/packages/frontend/src/preview/routes.ts b/packages/frontend/src/preview/routes.ts index 3d9661c70..a68080949 100644 --- a/packages/frontend/src/preview/routes.ts +++ b/packages/frontend/src/preview/routes.ts @@ -1151,6 +1151,74 @@ const routes: Route[] = [ }) }, }, + { + path: '/users/someone/exchange-frozen', + description: 'Someone user page, the exchange is frozen.', + render: (ctx) => { + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) + context.freezeStatus = 'frozen' + + ctx.body = renderUserPage({ + context: context, + starkKey: StarkKey.fake(), + ethereumAddress: context.user.address, + exchangeAddress: EthereumAddress.fake(), + withdrawableAssets: [], + finalizableOffers: [], + assets: [ + randomUserAssetEntry('ESCAPE', { hashOrId: AssetId('USDC-6') }), + ...repeat(7, () => randomUserAssetEntry('USE_COLLATERAL_ESCAPE')), + ], + totalAssets: 18, + balanceChanges: repeat(10, randomUserBalanceChangeEntry), + totalBalanceChanges: 6999, + transactions: repeat(10, randomUserTransactionEntry), + totalTransactions: 48, + l2Transactions: [], + totalL2Transactions: 0, + offers: repeat(6, randomUserOfferEntry), + totalOffers: 6, + escapableAssets: [], + }) + }, + }, + { + path: '/users/someone/exchange-frozen/performUserActions', + description: + 'Someone user page, the exchange is frozen. You can perform actions for them.', + render: (ctx) => { + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) + context.freezeStatus = 'frozen' + + ctx.body = renderUserPage({ + context: context, + starkKey: StarkKey.fake(), + ethereumAddress: context.user.address, + exchangeAddress: EthereumAddress.fake(), + withdrawableAssets: [], + finalizableOffers: [], + assets: [ + randomUserAssetEntry('ESCAPE', { hashOrId: AssetId('USDC-6') }), + ...repeat(7, () => randomUserAssetEntry('USE_COLLATERAL_ESCAPE')), + ], + totalAssets: 18, + balanceChanges: repeat(10, randomUserBalanceChangeEntry), + totalBalanceChanges: 6999, + transactions: repeat(10, randomUserTransactionEntry), + totalTransactions: 48, + l2Transactions: [], + totalL2Transactions: 0, + offers: repeat(6, randomUserOfferEntry), + totalOffers: 6, + escapableAssets: [], + performUserActions: true, + }) + }, + }, { path: '/escape/:positionOrVaultId', link: '/escape/12345', diff --git a/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx b/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx index f5b4e922e..e2c5bf473 100644 --- a/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx +++ b/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx @@ -15,6 +15,7 @@ import { ContentWrapper } from '../../components/page/ContentWrapper' import { Page } from '../../components/page/Page' import { TermsOfServiceAck } from '../../components/TermsOfServiceAck' import { reactToHtml } from '../../reactToHtml' +import { PerformUserActionsPanel } from '../user/components/PerformUserActionsPanel' export const VERIFY_ESCAPE_REQUEST_FORM_ID = 'verify-escape-request-form' @@ -62,59 +63,67 @@ function EscapeHatchActionPage(props: Props) { description="Withdraw funds via Escape Hatch" context={props.context} > - -
-
- Escape your funds + + +
+
+
+ Escape your funds +
+ + The exchange is frozen, preventing it from executing regular + operations or supporting standard actions. + + + You have the option to request a withdrawal of the entire value of + any position to position's owner address by activating an 'escape + hatch.' This process involves interacting with an Ethereum + contract, which calculates the total value of the position, + including any open trades and funding rates. + + + Ultimately the funds will be withdraw to the Ethereum address of + the position's owner, regardless of the wallet used to perform the + Escape Hatch transactions. + + The escape process consists of three steps: + + + Please note, the execution of an Escape can be expensive due to + the Ethereum gas cost. +
- - The exchange is frozen, preventing it from executing regular - operations or supporting standard actions. - - - You have the option to request a withdrawal of the entire value of - any position by activating an 'Escape Hatch.' This process involves - interacting with an Ethereum contract, which calculates the total - value of the position, including any open trades and funding rates. - - - Ultimately the funds will be withdraw to the Ethereum address of the - position's owner, regardless of the wallet used to perform the - Escape Hatch transactions. - - The escape process consists of three steps: - - - Please note, the execution of an Escape can be expensive due to the - Ethereum gas cost. - -
- -
-
- Escape - - - {props.context.tradingMode === 'perpetual' - ? 'Position' - : 'Vault'} - {' '} - - #{props.positionOrVaultId.toString()} + + +
+ Escape + + + {props.context.tradingMode === 'perpetual' + ? 'Position' + : 'Vault'} + {' '} + + #{props.positionOrVaultId.toString()} + - -
- - - -
+
+ + + +
+
) diff --git a/packages/frontend/src/view/pages/user/UserPage.tsx b/packages/frontend/src/view/pages/user/UserPage.tsx index 9eb11ec3f..a10aadbb2 100644 --- a/packages/frontend/src/view/pages/user/UserPage.tsx +++ b/packages/frontend/src/view/pages/user/UserPage.tsx @@ -25,6 +25,7 @@ import { getTransactionTableProps, getUserPageProps, } from './common' +import { PerformUserActionsPanel } from './components/PerformUserActionsPanel' import { UserAssetEntry, UserAssetsTable } from './components/UserAssetTable' import { UserBalanceChangeEntry, @@ -56,6 +57,7 @@ interface UserPageProps { totalL2Transactions: number offers?: OfferEntry[] totalOffers: number + performUserActions?: boolean } export function renderUserPage(props: UserPageProps) { @@ -64,7 +66,18 @@ export function renderUserPage(props: UserPageProps) { function UserPage(props: UserPageProps) { const common = getUserPageProps(props.starkKey) - const isMine = props.context.user?.starkKey === props.starkKey + let isMine = props.context.user?.starkKey === props.starkKey + + if (!isMine) { + // If exchange is frozen and flag is passed, let others perform these actions + if ( + props.context.user !== undefined && + props.context.freezeStatus === 'frozen' && + props.performUserActions + ) { + isMine = true + } + } const { title: assetsTableTitle, ...assetsTablePropsWithoutTitle } = getAssetsTableProps(props.starkKey) @@ -97,16 +110,20 @@ function UserPage(props: UserPageProps) { chainId={props.context.chainId} ethereumAddress={props.ethereumAddress} /> - {isMine && ( - - )} + +
export const FinalizeEscapeFormProps = z.intersection( z.object({ exchangeAddress: stringAs(EthereumAddress), + isMine: z.boolean().optional(), }), z.discriminatedUnion('tradingMode', [ z.object({ @@ -54,7 +55,10 @@ export function FinalizeEscapeForm(props: Props) { data-props={formPropsJson} data-user={userJson} > - diff --git a/packages/frontend/src/view/pages/user/components/PerformUserActionsPanel.tsx b/packages/frontend/src/view/pages/user/components/PerformUserActionsPanel.tsx new file mode 100644 index 000000000..ad135a14f --- /dev/null +++ b/packages/frontend/src/view/pages/user/components/PerformUserActionsPanel.tsx @@ -0,0 +1,65 @@ +import { PageContext } from '@explorer/shared' +import { StarkKey } from '@explorer/types' +import React from 'react' + +import { InfoIcon } from '../../../assets/icons/InfoIcon' +import { Button } from '../../../components/Button' +import { Card } from '../../../components/Card' + +interface PerformUserActionsPanelProps { + starkKey: StarkKey + performUserActions?: boolean + context: PageContext +} + +export function PerformUserActionsPanel(props: PerformUserActionsPanelProps) { + const ownerIsAlreadyConnected = + props.context.user?.starkKey === props.starkKey + if ( + props.context.freezeStatus !== 'frozen' || + props.context.user === undefined || + ownerIsAlreadyConnected + ) { + return null + } + return !props.performUserActions ? ( +
+ +

+ Do you want to enable actions for this user? +

+
+

+ You are not connected to this user's wallet. You can enable Escape + Hatch operations and pay their gas cost, but all withdrawals will go + to this user's address, not yours. +

+ +
+
+
+ ) : ( +
+ +
+ +

+ You have enabled performing actions for this user +

+
+

+ You are not connected to this user's wallet. You can still perform + Escape Hatch operations for this user (and pay their gas costs) but + all withdrawals will go to this user's address, not yours. +

+
+
+ ) +} diff --git a/packages/frontend/src/view/pages/user/components/UserQuickActionsTable.tsx b/packages/frontend/src/view/pages/user/components/UserQuickActionsTable.tsx index 330d645dc..1e9c1efd0 100644 --- a/packages/frontend/src/view/pages/user/components/UserQuickActionsTable.tsx +++ b/packages/frontend/src/view/pages/user/components/UserQuickActionsTable.tsx @@ -20,6 +20,7 @@ interface UserQuickActionsTableProps { readonly finalizableOffers: readonly FinalizableOfferEntry[] readonly starkKey: StarkKey readonly exchangeAddress: EthereumAddress + readonly isMine?: boolean } export interface EscapableAssetEntry { @@ -47,6 +48,11 @@ export function UserQuickActionsTable(props: UserQuickActionsTableProps) { return null } + // If exchange is frozen, show the panel even if I'm a different user. + if (!props.isMine && props.context.freezeStatus !== 'frozen') { + return null + } + return ( @@ -66,7 +72,7 @@ export function UserQuickActionsTable(props: UserQuickActionsTableProps) { function EscapableAssets( props: Pick< UserQuickActionsTableProps, - 'escapableAssets' | 'context' | 'starkKey' | 'exchangeAddress' + 'escapableAssets' | 'context' | 'starkKey' | 'exchangeAddress' | 'isMine' > ) { return ( @@ -102,6 +108,7 @@ function EscapableAssets( exchangeAddress={props.exchangeAddress} positionId={asset.positionOrVaultId} quantizedAmount={asset.amount} + isMine={props.isMine} /> )} {props.context.user && @@ -115,6 +122,7 @@ function EscapableAssets( vaultId={asset.positionOrVaultId} quantizedAmount={asset.amount} assetId={asset.asset.details.assetHash} + isMine={props.isMine} /> )} @@ -127,7 +135,7 @@ function EscapableAssets( function WithdrawableAssets( props: Pick< UserQuickActionsTableProps, - 'withdrawableAssets' | 'context' | 'starkKey' | 'exchangeAddress' + 'withdrawableAssets' | 'context' | 'starkKey' | 'exchangeAddress' | 'isMine' > ) { return ( @@ -161,7 +169,12 @@ function WithdrawableAssets( starkKey={props.starkKey} exchangeAddress={props.exchangeAddress} > - diff --git a/packages/frontend/tailwind.config.js b/packages/frontend/tailwind.config.js index c56ab1522..d392aabfa 100644 --- a/packages/frontend/tailwind.config.js +++ b/packages/frontend/tailwind.config.js @@ -80,6 +80,7 @@ module.exports = { black: '#060606', brand: '#5F5CFF', 'brand-darker': '#4F4CD7', + warning: '#B8860B', }, }, plugins: [