diff --git a/packages/backend/src/Application.ts b/packages/backend/src/Application.ts index f0f788669..105612d16 100644 --- a/packages/backend/src/Application.ts +++ b/packages/backend/src/Application.ts @@ -621,7 +621,9 @@ export class Application { preprocessedUserL2TransactionsStatisticsRepository, vaultRepository, config.starkex.l2Transactions.excludeTypes, - config.starkex.contracts.perpetual + config.starkex.contracts.perpetual, + stateUpdater, + stateUpdateRepository ) const stateUpdateController = new StateUpdateController( pageContextService, diff --git a/packages/backend/src/api/controllers/EscapeHatchController.ts b/packages/backend/src/api/controllers/EscapeHatchController.ts index 4c8b22d0a..eb2488e6f 100644 --- a/packages/backend/src/api/controllers/EscapeHatchController.ts +++ b/packages/backend/src/api/controllers/EscapeHatchController.ts @@ -8,6 +8,7 @@ import { PageContextWithUser, UserDetails, } from '@explorer/shared' +import { MerkleProof, PositionLeaf } from '@explorer/state' import { EthereumAddress } from '@explorer/types' import { FreezeCheckService } from '../../core/FreezeCheckService' @@ -15,6 +16,7 @@ import { PageContextService } from '../../core/PageContextService' import { StateUpdater } from '../../core/StateUpdater' import { StateUpdateRepository } from '../../peripherals/database/StateUpdateRepository' import { UserTransactionRecord } from '../../peripherals/database/transactions/UserTransactionRepository' +import { calculatePositionValue } from '../../utils/calculatePositionValue' import { ControllerResult } from './ControllerResult' import { serializeMerkleProofForEscape } from './serializeMerkleProofForEscape' @@ -157,6 +159,13 @@ export class EscapeHatchController { const serializedState = encodeStateAsInt256Array( latestStateUpdate.perpetualState ) + const positionValues = + context.tradingMode === 'perpetual' + ? calculatePositionValue( + merkleProof as MerkleProof, + latestStateUpdate.perpetualState + ) + : undefined let content: string switch (context.tradingMode) { case 'perpetual': @@ -166,6 +175,9 @@ export class EscapeHatchController { starkKey: merkleProof.starkKey, escapeVerifierAddress: this.escapeVerifierAddress, positionOrVaultId, + positionValue: positionValues?.positionValue + ? positionValues.positionValue / 10000n + : undefined, serializedMerkleProof, assetCount: merkleProof.perpetualAssetCount, serializedState, @@ -177,6 +189,7 @@ export class EscapeHatchController { tradingMode: context.tradingMode, starkKey: merkleProof.starkKey, escapeVerifierAddress: this.escapeVerifierAddress, + positionValue: undefined, positionOrVaultId, serializedEscapeProof: serializedMerkleProof, }) diff --git a/packages/backend/src/api/controllers/UserController.ts b/packages/backend/src/api/controllers/UserController.ts index 51bf2b2fd..7015cf34a 100644 --- a/packages/backend/src/api/controllers/UserController.ts +++ b/packages/backend/src/api/controllers/UserController.ts @@ -20,6 +20,7 @@ import { TradingMode, UserDetails, } from '@explorer/shared' +import { MerkleProof, PositionLeaf } from '@explorer/state' import { AssetHash, AssetId, EthereumAddress, StarkKey } from '@explorer/types' import { L2TransactionTypesToExclude } from '../../config/starkex/StarkexConfig' @@ -27,6 +28,7 @@ import { AssetDetailsMap } from '../../core/AssetDetailsMap' import { AssetDetailsService } from '../../core/AssetDetailsService' import { ForcedTradeOfferViewService } from '../../core/ForcedTradeOfferViewService' import { PageContextService } from '../../core/PageContextService' +import { StateUpdater } from '../../core/StateUpdater' import { PaginationOptions } from '../../model/PaginationOptions' import { ForcedTradeOfferRepository } from '../../peripherals/database/ForcedTradeOfferRepository' import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' @@ -41,6 +43,7 @@ import { PricesRecord, PricesRepository, } from '../../peripherals/database/PricesRepository' +import { StateUpdateRepository } from '../../peripherals/database/StateUpdateRepository' import { SentTransactionRecord, SentTransactionRepository, @@ -53,6 +56,10 @@ import { UserRegistrationEventRepository } from '../../peripherals/database/User import { VaultRepository } from '../../peripherals/database/VaultRepository' import { WithdrawableAssetRepository } from '../../peripherals/database/WithdrawableAssetRepository' import { getAssetValueUSDCents } from '../../utils/assets' +import { + calculatePositionValue, + PositionValue, +} from '../../utils/calculatePositionValue' import { ControllerResult } from './ControllerResult' import { getCollateralAssetDetails } from './getCollateralAssetDetails' import { EscapableMap, getEscapableAssets } from './getEscapableAssets' @@ -79,7 +86,9 @@ export class UserController { private readonly excludeL2TransactionTypes: | L2TransactionTypesToExclude | undefined, - private readonly exchangeAddress: EthereumAddress + private readonly exchangeAddress: EthereumAddress, + private readonly stateUpdater: StateUpdater, + private readonly stateUpdateRepository: StateUpdateRepository ) {} async getUserRegisterPage( @@ -248,6 +257,27 @@ export class UserController { escapableAssetHashes, }) + // If escape process has started on perpetuals, hide all assets + const hideAllAssets = + context.freezeStatus === 'frozen' && + context.tradingMode === 'perpetual' && + escapableAssetHashes.length > 0 + + let positionValues: PositionValue | undefined + if ( + !hideAllAssets && + context.tradingMode === 'perpetual' && + userAssets.length > 0 + ) { + const firstAsset = userAssets[0] + if (firstAsset !== undefined) { + positionValues = await this.getPositionValue( + firstAsset.positionOrVaultId, + context + ) + } + } + const assetEntries = userAssets.map((a) => toUserAssetEntry( a, @@ -255,6 +285,7 @@ export class UserController { escapableMap, context.freezeStatus, assetPrices, + positionValues, collateralAsset?.assetId, assetDetailsMap ) @@ -287,12 +318,6 @@ export class UserController { this.excludeL2TransactionTypes ) - // If escape process has started on perpetuals, hide all assets - const hideAllAssets = - context.freezeStatus === 'frozen' && - context.tradingMode === 'perpetual' && - escapableAssetHashes.length > 0 - const content = renderUserPage({ context, starkKey, @@ -318,6 +343,9 @@ export class UserController { this.forcedTradeOfferViewService.toFinalizableOfferEntry(offer) ), assets: hideAllAssets ? [] : assetEntries, // When frozen and escaped, don't show assets + positionValue: positionValues?.positionValue + ? positionValues.positionValue / 10000n + : undefined, totalAssets: hideAllAssets ? 0 : userStatistics?.assetCount ?? 0, balanceChanges: balanceChangesEntries, totalBalanceChanges: userStatistics?.balanceChangeCount ?? 0, @@ -331,6 +359,32 @@ export class UserController { return { type: 'success', content } } + async getPositionValue(positionOrVaultId: bigint, context: PageContext) { + if (context.tradingMode !== 'perpetual') { + return { fundingPayments: {}, positionValue: undefined } + } + + const merkleProof = await this.stateUpdater.generateMerkleProof( + positionOrVaultId + ) + + const latestStateUpdate = await this.stateUpdateRepository.findLast() + if (!latestStateUpdate) { + throw new Error('No state update found') + } + if (!latestStateUpdate.perpetualState) { + throw new Error('No perpetual state found') + } + + if (!(merkleProof.leaf instanceof PositionLeaf)) { + throw new Error('Merkle proof is not for a position') + } + return calculatePositionValue( + merkleProof as MerkleProof, + latestStateUpdate.perpetualState + ) + } + async getUserAssetsPage( givenUser: Partial, starkKey: StarkKey, @@ -377,6 +431,26 @@ export class UserController { escapableAssetHashes, }) + const hideAllAssets = + context.freezeStatus === 'frozen' && + context.tradingMode === 'perpetual' && + escapableAssetHashes.length > 0 + + let postionValues: PositionValue | undefined + if ( + !hideAllAssets && + context.tradingMode === 'perpetual' && + userAssets.length > 0 + ) { + const firstAsset = userAssets[0] + if (firstAsset !== undefined) { + postionValues = await this.getPositionValue( + firstAsset.positionOrVaultId, + context + ) + } + } + const assets = userAssets.map((a) => toUserAssetEntry( a, @@ -384,16 +458,12 @@ export class UserController { escapableMap, context.freezeStatus, assetPrices, + postionValues, collateralAsset?.assetId, assetDetailsMap ) ) - const hideAllAssets = - context.freezeStatus === 'frozen' && - context.tradingMode === 'perpetual' && - escapableAssetHashes.length > 0 - const content = renderUserAssetsPage({ context, starkKey, @@ -573,6 +643,7 @@ function toUserAssetEntry( escapableMap: EscapableMap, freezeStatus: FreezeStatus, assetPrices: PricesRecord[], + positionValues: PositionValue | undefined, collateralAssetId?: AssetId, assetDetailsMap?: AssetDetailsMap ): UserAssetEntry { @@ -597,6 +668,8 @@ function toUserAssetEntry( // We need to use the latest price for the asset from PricesRepository. const assetPrice = assetPrices.find((p) => p.assetId === asset.assetHashOrId) + const positionValue = + positionValues?.fundingPayments[asset.assetHashOrId.toString()] return { asset: { hashOrId: asset.assetHashOrId, @@ -611,7 +684,8 @@ function toUserAssetEntry( : assetPrice !== undefined ? getAssetValueUSDCents(asset.balance, assetPrice.price) : undefined, - + fundingPayment: + positionValue !== undefined ? positionValue / 10000n : undefined, vaultOrPositionId: asset.positionOrVaultId.toString(), action, } diff --git a/packages/backend/src/utils/calculatePositionValue.ts b/packages/backend/src/utils/calculatePositionValue.ts new file mode 100644 index 000000000..7e8ab1a75 --- /dev/null +++ b/packages/backend/src/utils/calculatePositionValue.ts @@ -0,0 +1,54 @@ +import { State } from '@explorer/encoding' +import { MerkleProof, PositionLeaf } from '@explorer/state' + +const FXP_BITS = 32n + +export interface PositionValue { + fundingPayments: Record + positionValue: bigint | undefined +} + +export function calculatePositionValue( + merkleProof: MerkleProof, + state: State +): PositionValue { + const position = merkleProof.leaf + const fundingPayments: Record = {} + let fxpBalance = position.collateralBalance << FXP_BITS + + // For each asset in the position + for (const asset of position.assets) { + // Find the current funding index for this asset + const fundingIndex = state.indices.find( + (idx) => idx.assetId === asset.assetId + ) + if (!fundingIndex) { + throw new Error( + `Funding index not found for asset ${asset.assetId.toString()}` + ) + } + + // Calculate funding payment + const fundingPayment = + asset.balance * (asset.fundingIndex - fundingIndex.value) + + // Find the current price for this asset + const priceData = state.oraclePrices.find( + (price) => price.assetId === asset.assetId + ) + if (!priceData) { + throw new Error(`Price not found for asset ${asset.assetId.toString()}`) + } + + // Update the balance based on asset value and funding + fxpBalance += asset.balance * priceData.price + fundingPayment + + // Store funding payment for this asset + fundingPayments[asset.assetId.toString()] = fundingPayment >> FXP_BITS + } + + return { + fundingPayments, + positionValue: fxpBalance >> FXP_BITS, + } +} diff --git a/packages/frontend/src/preview/data/user.ts b/packages/frontend/src/preview/data/user.ts index 9d606bd6c..d4ad4d277 100644 --- a/packages/frontend/src/preview/data/user.ts +++ b/packages/frontend/src/preview/data/user.ts @@ -81,6 +81,7 @@ export function randomUserAssetEntry( asset: asset ?? assetBucket.pick(), balance: amountBucket.pick(), value: amountBucket.pick(), + fundingPayment: amountBucket.pick(), action: action ?? actionBucket.pick(), vaultOrPositionId: randomId(), } diff --git a/packages/frontend/src/preview/routes.ts b/packages/frontend/src/preview/routes.ts index a6fb0ecf0..2d2af5adf 100644 --- a/packages/frontend/src/preview/routes.ts +++ b/packages/frontend/src/preview/routes.ts @@ -490,6 +490,7 @@ const routes: Route[] = [ exchangeAddress: EthereumAddress.fake(), withdrawableAssets: repeat(3, randomWithdrawableAssetEntry), finalizableOffers: [], + positionValue: 123456n, assets: repeat(7, randomUserAssetEntry), totalAssets: 18, balanceChanges: repeat(10, randomUserBalanceChangeEntry), @@ -526,6 +527,7 @@ const routes: Route[] = [ exchangeAddress: EthereumAddress.fake(), withdrawableAssets: repeat(3, randomWithdrawableAssetEntry), finalizableOffers: repeat(2, randomUserOfferEntry), + positionValue: 123456n, assets: repeat(7, randomUserAssetEntry), totalAssets: 18, balanceChanges: repeat(10, randomUserBalanceChangeEntry), @@ -555,6 +557,7 @@ const routes: Route[] = [ exchangeAddress: EthereumAddress.fake(), withdrawableAssets: repeat(3, randomWithdrawableAssetEntry), finalizableOffers: repeat(2, randomUserOfferEntry), + positionValue: 123456n, assets: repeat(7, randomUserAssetEntry), totalAssets: 18, balanceChanges: repeat(10, randomUserBalanceChangeEntry), @@ -593,6 +596,7 @@ const routes: Route[] = [ transactions: repeat(10, randomUserTransactionEntry), totalTransactions: 99, l2Transactions: repeat(6, randomPerpetualUserL2TransactionEntry), + positionValue: 123456n, totalL2Transactions: 123000000, offers: repeat(6, () => randomUserOfferEntry(true)), totalOffers: 12, @@ -1171,6 +1175,7 @@ const routes: Route[] = [ balanceChanges: repeat(10, randomUserBalanceChangeEntry), totalBalanceChanges: 6999, transactions: repeat(10, randomUserTransactionEntry), + positionValue: 123456n, totalTransactions: 48, l2Transactions: [], totalL2Transactions: 0, @@ -1205,6 +1210,7 @@ const routes: Route[] = [ totalBalanceChanges: 6999, transactions: repeat(10, randomUserTransactionEntry), totalTransactions: 48, + positionValue: 123456n, l2Transactions: [], totalL2Transactions: 0, offers: repeat(6, randomUserOfferEntry), @@ -1242,6 +1248,7 @@ const routes: Route[] = [ l2Transactions: [], totalL2Transactions: 0, offers: repeat(6, randomUserOfferEntry), + positionValue: 123456n, totalOffers: 6, escapableAssets: [], performUserActions: true, @@ -1264,6 +1271,7 @@ const routes: Route[] = [ tradingMode: 'perpetual', escapeVerifierAddress: EthereumAddress.fake(), positionOrVaultId: 12345n, + positionValue: 123456n, serializedMerkleProof: [], assetCount: 0, serializedState: [], @@ -1299,6 +1307,7 @@ const routes: Route[] = [ totalBalanceChanges: 6999, transactions: repeat(10, randomUserTransactionEntry), totalTransactions: 48, + positionValue: 123456n, l2Transactions: [], totalL2Transactions: 0, offers: repeat(6, randomUserOfferEntry), diff --git a/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx b/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx index 6d7e486cb..062a63fca 100644 --- a/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx +++ b/packages/frontend/src/view/pages/forced-actions/EscapeHatchActionPage.tsx @@ -8,6 +8,9 @@ import { EthereumAddress, StarkKey } from '@explorer/types' import React from 'react' import { z } from 'zod' +import { assetToInfo } from '../../../utils/assets' +import { formatWithDecimals } from '../../../utils/formatting/formatAmount' +import { AssetWithLogo } from '../../components/AssetWithLogo' import { Button } from '../../components/Button' import { Card } from '../../components/Card' import { Link } from '../../components/Link' @@ -17,11 +20,13 @@ import { Page } from '../../components/page/Page' import { TermsOfServiceAck } from '../../components/TermsOfServiceAck' import { reactToHtml } from '../../reactToHtml' import { PerformUserActionsPanel } from '../user/components/PerformUserActionsPanel' +import { ForcedActionCard } from './components/ForcedActionCard' export const VERIFY_ESCAPE_REQUEST_FORM_ID = 'verify-escape-request-form' type Props = { context: PageContextWithUser + positionValue: bigint | undefined } & VerifyEscapeFormProps export type VerifyEscapeFormProps = z.infer @@ -124,6 +129,31 @@ function EscapeHatchActionPage(props: Props) { + {props.positionValue !== undefined && + context.tradingMode === 'perpetual' ? ( + +
+
+ + Estimated value + + + {formatWithDecimals(props.positionValue, 2)} + +
+
+ + Asset + + +
+
+
+ ) : null} + + State of assets (proven on Ethereum) is updated every few hours. + , content: ( <> - - State of assets (proven on Ethereum), updated every few - hours + + State of assets (proven on Ethereum) is updated every few + hours. a.fundingPayment !== undefined + ) return ( Name }, { header: 'Balance' }, + ...(showFundingPayment ? [{ header: 'Funding' }] : []), { header: props.tradingMode === 'perpetual' ? 'Position' : 'Vault' }, ...(props.isMine ? [{ header: 'Action' }] : []), ]} @@ -68,6 +72,17 @@ export function UserAssetsTable(props: UserAssetsTableProps) { )} , + ...(showFundingPayment + ? [ + + {entry.fundingPayment !== undefined + ? formatWithDecimals(entry.fundingPayment, 2, { + prefix: '$', + }) + : '-'} + , + ] + : []), #{entry.vaultOrPositionId}{' '} {props.tradingMode === 'spot' && ( diff --git a/packages/frontend/src/view/pages/user/components/UserProfile.tsx b/packages/frontend/src/view/pages/user/components/UserProfile.tsx index 4572c6142..3147dc929 100644 --- a/packages/frontend/src/view/pages/user/components/UserProfile.tsx +++ b/packages/frontend/src/view/pages/user/components/UserProfile.tsx @@ -1,18 +1,25 @@ -import { UserDetails } from '@explorer/shared' +import { CollateralAsset, UserDetails } from '@explorer/shared' import { EthereumAddress, StarkKey } from '@explorer/types' import React from 'react' +import { assetToInfo } from '../../../../utils/assets' +import { formatWithDecimals } from '../../../../utils/formatting/formatAmount' +import { InfoIcon } from '../../../assets/icons/InfoIcon' +import { AssetWithLogo } from '../../../components/AssetWithLogo' import { Button } from '../../../components/Button' import { Card } from '../../../components/Card' import { EtherscanLink } from '../../../components/EtherscanLink' import { InfoBanner } from '../../../components/InfoBanner' import { InlineEllipsis } from '../../../components/InlineEllipsis' import { LongHash } from '../../../components/LongHash' +import { TooltipWrapper } from '../../../components/Tooltip' interface UserProfileProps { user: Partial | undefined starkKey: StarkKey chainId: number + collateralAsset: CollateralAsset | undefined + positionValue: bigint | undefined ethereumAddress?: EthereumAddress } @@ -21,12 +28,14 @@ export function UserProfile({ starkKey, chainId, ethereumAddress, + positionValue, + collateralAsset, }: UserProfileProps) { const isMine = user?.starkKey === starkKey return (

Stark key

- + {starkKey.toString()}

@@ -79,6 +88,23 @@ export function UserProfile({ )} )} + {positionValue && collateralAsset ? ( + <> +

+ Estimated position value{' '} + + + +

+
+ {formatWithDecimals(positionValue, 2)}{' '} + +
+ + ) : null}
) }