From 4e67d8cb2c28bd9379df369a53fceaab0a877640 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 14:25:53 +0200 Subject: [PATCH] Show current price in user assets (#526) * show current price in user assets * fix condition --------- Co-authored-by: Adrian Adamiak --- packages/backend/src/Application.ts | 3 + .../src/api/controllers/UserController.ts | 45 +++++++---- .../database/PricesRepository.test.ts | 76 +++++++++++++++++++ .../peripherals/database/PricesRepository.ts | 75 ++++++++++++++++++ .../pages/user/components/UserAssetTable.tsx | 6 +- 5 files changed, 190 insertions(+), 15 deletions(-) create mode 100644 packages/backend/src/peripherals/database/PricesRepository.test.ts create mode 100644 packages/backend/src/peripherals/database/PricesRepository.ts diff --git a/packages/backend/src/Application.ts b/packages/backend/src/Application.ts index cb3ce1a13..e70aae764 100644 --- a/packages/backend/src/Application.ts +++ b/packages/backend/src/Application.ts @@ -82,6 +82,7 @@ import { PreprocessedStateDetailsRepository } from './peripherals/database/Prepr import { PreprocessedStateUpdateRepository } from './peripherals/database/PreprocessedStateUpdateRepository' import { PreprocessedUserL2TransactionsStatisticsRepository } from './peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository' import { PreprocessedUserStatisticsRepository } from './peripherals/database/PreprocessedUserStatisticsRepository' +import { PricesRepository } from './peripherals/database/PricesRepository' import { Database } from './peripherals/database/shared/Database' import { StateTransitionRepository } from './peripherals/database/StateTransitionRepository' import { StateUpdateRepository } from './peripherals/database/StateUpdateRepository' @@ -129,6 +130,7 @@ export class Application { const kvStore = new KeyValueStore(database, logger) + const pricesRepository = new PricesRepository(database, logger) const verifierEventRepository = new VerifierEventRepository( database, logger @@ -607,6 +609,7 @@ export class Application { pageContextService, assetDetailsService, preprocessedAssetHistoryRepository, + pricesRepository, sentTransactionRepository, userTransactionRepository, forcedTradeOfferRepository, diff --git a/packages/backend/src/api/controllers/UserController.ts b/packages/backend/src/api/controllers/UserController.ts index 8e67da796..8073a68e7 100644 --- a/packages/backend/src/api/controllers/UserController.ts +++ b/packages/backend/src/api/controllers/UserController.ts @@ -37,6 +37,10 @@ import { import { sumUpTransactionCount } from '../../peripherals/database/PreprocessedL2TransactionsStatistics' import { PreprocessedUserL2TransactionsStatisticsRepository } from '../../peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository' import { PreprocessedUserStatisticsRepository } from '../../peripherals/database/PreprocessedUserStatisticsRepository' +import { + PricesRecord, + PricesRepository, +} from '../../peripherals/database/PricesRepository' import { SentTransactionRecord, SentTransactionRepository, @@ -61,6 +65,7 @@ export class UserController { private readonly pageContextService: PageContextService, private readonly assetDetailsService: AssetDetailsService, private readonly preprocessedAssetHistoryRepository: PreprocessedAssetHistoryRepository, + private readonly pricesRepository: PricesRepository, private readonly sentTransactionRepository: SentTransactionRepository, private readonly userTransactionRepository: UserTransactionRepository, private readonly forcedTradeOfferRepository: ForcedTradeOfferRepository, @@ -150,6 +155,7 @@ export class UserController { const [ registeredUser, userAssets, + assetPrices, history, l2Transactions, preprocessedUserL2TransactionsStatistics, @@ -169,6 +175,7 @@ export class UserController { paginationOpts, collateralAsset?.assetId ), + this.pricesRepository.getAllLatest(), this.preprocessedAssetHistoryRepository.getByStarkKeyPaginated( starkKey, paginationOpts @@ -239,6 +246,7 @@ export class UserController { context.tradingMode, escapableMap, context.freezeStatus, + assetPrices, collateralAsset?.assetId, assetDetailsMap ) @@ -322,15 +330,19 @@ export class UserController { ): Promise { const context = await this.pageContextService.getPageContext(givenUser) const collateralAsset = this.pageContextService.getCollateralAsset(context) - const [registeredUser, userAssets, userStatistics] = await Promise.all([ - this.userRegistrationEventRepository.findByStarkKey(starkKey), - this.preprocessedAssetHistoryRepository.getCurrentByStarkKeyPaginated( - starkKey, - pagination, - collateralAsset?.assetId - ), - this.preprocessedUserStatisticsRepository.findCurrentByStarkKey(starkKey), - ]) + const [registeredUser, userAssets, assetPrices, userStatistics] = + await Promise.all([ + this.userRegistrationEventRepository.findByStarkKey(starkKey), + this.preprocessedAssetHistoryRepository.getCurrentByStarkKeyPaginated( + starkKey, + pagination, + collateralAsset?.assetId + ), + this.pricesRepository.getAllLatest(), + this.preprocessedUserStatisticsRepository.findCurrentByStarkKey( + starkKey + ), + ]) if (!userStatistics) { return { @@ -363,6 +375,7 @@ export class UserController { context.tradingMode, escapableMap, context.freezeStatus, + assetPrices, collateralAsset?.assetId, assetDetailsMap ) @@ -551,6 +564,7 @@ function toUserAssetEntry( tradingMode: TradingMode, escapableMap: EscapableMap, freezeStatus: FreezeStatus, + assetPrices: PricesRecord[], collateralAssetId?: AssetId, assetDetailsMap?: AssetDetailsMap ): UserAssetEntry { @@ -571,6 +585,10 @@ function toUserAssetEntry( } } + // Price from preprocessedAssetHistory is a price from the moment when the position was opened. + // We need to use the latest price for the asset from PricesRepository. + const assetPrice = assetPrices.find((p) => p.assetId === asset.assetHashOrId) + return { asset: { hashOrId: asset.assetHashOrId, @@ -580,11 +598,12 @@ function toUserAssetEntry( }, balance: asset.balance, value: - asset.price === undefined - ? 0n - : asset.assetHashOrId === collateralAssetId + asset.assetHashOrId === collateralAssetId ? asset.balance / 10000n // TODO: use the correct decimals - : getAssetValueUSDCents(asset.balance, asset.price), + : assetPrice !== undefined + ? getAssetValueUSDCents(asset.balance, assetPrice.price) + : undefined, + vaultOrPositionId: asset.positionOrVaultId.toString(), action, } diff --git a/packages/backend/src/peripherals/database/PricesRepository.test.ts b/packages/backend/src/peripherals/database/PricesRepository.test.ts new file mode 100644 index 000000000..f26f568d4 --- /dev/null +++ b/packages/backend/src/peripherals/database/PricesRepository.test.ts @@ -0,0 +1,76 @@ +import { AssetId, Hash256, PedersenHash, Timestamp } from '@explorer/types' +import { Logger } from '@l2beat/backend-tools' +import { expect } from 'earl' +import { it } from 'mocha' + +import { setupDatabaseTestSuite } from '../../test/database' +import { PricesRepository } from './PricesRepository' +import { StateUpdateRepository } from './StateUpdateRepository' + +describe(PricesRepository.name, () => { + const { database } = setupDatabaseTestSuite() + const stateUpdateRepository = new StateUpdateRepository( + database, + Logger.SILENT + ) + const repository = new PricesRepository(database, Logger.SILENT) + + afterEach(() => repository.deleteAll()) + + describe(PricesRepository.prototype.getAllLatest.name, () => { + it('returns prices for all assets for the latest state update', async () => { + await stateUpdateRepository.add(mockStateUpdate(1)) + await stateUpdateRepository.add(mockStateUpdate(199)) + await stateUpdateRepository.add(mockStateUpdate(200)) + + await repository.add({ + stateUpdateId: 200, + assetId: AssetId('MATIC-6'), + price: 100n, + }) + await repository.add({ + stateUpdateId: 200, + assetId: AssetId('LTC-8'), + price: 100n, + }) + await repository.add({ + stateUpdateId: 200, + assetId: AssetId('ETH-9'), + price: 100n, + }) + await repository.add({ + stateUpdateId: 199, + assetId: AssetId('USDC-6'), + price: 100n, + }) + await repository.add({ + stateUpdateId: 1, + assetId: AssetId('ETH-9'), + price: 100n, + }) + + const results = await repository.getAllLatest() + expect(results).toEqualUnsorted([ + { stateUpdateId: 200, assetId: AssetId('MATIC-6'), price: 100n }, + { stateUpdateId: 200, assetId: AssetId('LTC-8'), price: 100n }, + { stateUpdateId: 200, assetId: AssetId('ETH-9'), price: 100n }, + ]) + }) + }) +}) + +function mockStateUpdate(id: number) { + return { + stateUpdate: { + id, + batchId: id - 1, + blockNumber: id, + rootHash: PedersenHash.fake(), + stateTransitionHash: Hash256.fake(), + timestamp: Timestamp(0), + }, + positions: [], + prices: [], + transactionHashes: [], + } +} diff --git a/packages/backend/src/peripherals/database/PricesRepository.ts b/packages/backend/src/peripherals/database/PricesRepository.ts new file mode 100644 index 000000000..75f4d3095 --- /dev/null +++ b/packages/backend/src/peripherals/database/PricesRepository.ts @@ -0,0 +1,75 @@ +import { AssetId } from '@explorer/types' +import { Logger } from '@l2beat/backend-tools' +import { PriceRow } from 'knex/types/tables' + +import { BaseRepository } from './shared/BaseRepository' +import { Database } from './shared/Database' + +export interface PricesRecord { + stateUpdateId: number + assetId: AssetId + price: bigint +} + +export class PricesRepository extends BaseRepository { + constructor(database: Database, logger: Logger) { + super(database, logger) + + /* eslint-disable @typescript-eslint/unbound-method */ + this.add = this.wrapAdd(this.add) + this.getAllLatest = this.wrapGet(this.getAllLatest) + this.deleteAll = this.wrapDelete(this.deleteAll) + /* eslint-enable @typescript-eslint/unbound-method */ + } + + async add(record: PricesRecord): Promise { + const knex = await this.knex() + const results = await knex('prices') + .insert(toPriceRow(record)) + .returning('state_update_id') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return results[0]!.state_update_id + } + + async getAllLatest() { + const knex = await this.knex() + + const rows = await knex('prices') + .select('*') + .innerJoin( + knex('prices') + .max('state_update_id as max_state_update_id') + .as('latest_prices'), + (join) => { + join.on( + 'prices.state_update_id', + '=', + 'latest_prices.max_state_update_id' + ) + } + ) + + return rows.map(toPricesRecord) + } + + async deleteAll() { + const knex = await this.knex() + return await knex('prices').delete() + } +} + +function toPricesRecord(row: PriceRow): PricesRecord { + return { + stateUpdateId: row.state_update_id, + assetId: AssetId(row.asset_id), + price: BigInt(row.price), + } +} + +function toPriceRow(record: PricesRecord): PriceRow { + return { + state_update_id: record.stateUpdateId, + asset_id: record.assetId.toString(), + price: record.price, + } +} diff --git a/packages/frontend/src/view/pages/user/components/UserAssetTable.tsx b/packages/frontend/src/view/pages/user/components/UserAssetTable.tsx index e11b67c25..9809686d7 100644 --- a/packages/frontend/src/view/pages/user/components/UserAssetTable.tsx +++ b/packages/frontend/src/view/pages/user/components/UserAssetTable.tsx @@ -24,7 +24,7 @@ interface UserAssetsTableProps { export interface UserAssetEntry { asset: Asset balance: bigint - value: bigint + value: bigint | undefined vaultOrPositionId: string action: | 'WITHDRAW' @@ -62,7 +62,9 @@ export function UserAssetsTable(props: UserAssetsTableProps) { {props.tradingMode === 'perpetual' && ( - {formatWithDecimals(entry.value, 2, { prefix: '$' })} + {entry.value + ? formatWithDecimals(entry.value, 2, { prefix: '$' }) + : 'Unknown price'} )} ,