diff --git a/packages/backend/src/Application.ts b/packages/backend/src/Application.ts index a00d1fda6..ac78702cb 100644 --- a/packages/backend/src/Application.ts +++ b/packages/backend/src/Application.ts @@ -51,6 +51,7 @@ import { PerpetualHistoryPreprocessor } from './core/preprocessing/PerpetualHist import { Preprocessor } from './core/preprocessing/Preprocessor' import { SpotHistoryPreprocessor } from './core/preprocessing/SpotHistoryPreprocessor' import { StateDetailsPreprocessor } from './core/preprocessing/StateDetailsPreprocessor' +import { UserL2TransactionsStatisticsPreprocessor } from './core/preprocessing/UserL2TransactionsPreprocessor' import { UserStatisticsPreprocessor } from './core/preprocessing/UserStatisticsPreprocessor' import { SpotValidiumSyncService } from './core/SpotValidiumSyncService' import { SpotValidiumUpdater } from './core/SpotValidiumUpdater' @@ -71,6 +72,7 @@ import { PositionRepository } from './peripherals/database/PositionRepository' import { PreprocessedAssetHistoryRepository } from './peripherals/database/PreprocessedAssetHistoryRepository' import { PreprocessedStateDetailsRepository } from './peripherals/database/PreprocessedStateDetailsRepository' import { PreprocessedStateUpdateRepository } from './peripherals/database/PreprocessedStateUpdateRepository' +import { PreprocessedUserL2TransactionsStatisticsRepository } from './peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository' import { PreprocessedUserStatisticsRepository } from './peripherals/database/PreprocessedUserStatisticsRepository' import { Database } from './peripherals/database/shared/Database' import { StateTransitionRepository } from './peripherals/database/StateTransitionRepository' @@ -87,7 +89,6 @@ import { AvailabilityGatewayClient } from './peripherals/starkware/AvailabilityG import { FeederGatewayClient } from './peripherals/starkware/FeederGatewayClient' import { FetchClient } from './peripherals/starkware/FetchClient' import { handleServerError, reportError } from './tools/ErrorReporter' -import { shouldShowL2Transactions } from './utils/shouldShowL2Transactions' export class Application { start: () => Promise @@ -414,6 +415,16 @@ export class Application { const preprocessedUserStatisticsRepository = new PreprocessedUserStatisticsRepository(database, logger) + const preprocessedUserL2TransactionsStatisticsRepository = + new PreprocessedUserL2TransactionsStatisticsRepository(database, logger) + + const userL2TransactionsPreprocessor = + new UserL2TransactionsStatisticsPreprocessor( + preprocessedUserL2TransactionsStatisticsRepository, + l2TransactionRepository, + logger + ) + let preprocessor: Preprocessor | Preprocessor const isPreprocessorEnabled = config.enablePreprocessing @@ -443,13 +454,13 @@ export class Application { preprocessedStateDetailsRepository, preprocessedAssetHistoryRepository, userTransactionRepository, + l2TransactionRepository, logger ) const userStatisticsPreprocessor = new UserStatisticsPreprocessor( preprocessedUserStatisticsRepository, preprocessedAssetHistoryRepository, - preprocessedStateUpdateRepository, stateUpdateRepository, kvStore, logger @@ -462,6 +473,8 @@ export class Application { perpetualHistoryPreprocessor, stateDetailsPreprocessor, userStatisticsPreprocessor, + userL2TransactionsPreprocessor, + l2TransactionRepository, logger, isPreprocessorEnabled ) @@ -479,13 +492,13 @@ export class Application { preprocessedStateDetailsRepository, preprocessedAssetHistoryRepository, userTransactionRepository, + l2TransactionRepository, logger ) const userStatisticsPreprocessor = new UserStatisticsPreprocessor( preprocessedUserStatisticsRepository, preprocessedAssetHistoryRepository, - preprocessedStateUpdateRepository, stateUpdateRepository, kvStore, logger @@ -498,6 +511,8 @@ export class Application { spotHistoryPreprocessor, stateDetailsPreprocessor, userStatisticsPreprocessor, + userL2TransactionsPreprocessor, + l2TransactionRepository, logger, isPreprocessorEnabled ) @@ -517,7 +532,6 @@ export class Application { // #endregion core // #region api - const showL2Transactions = shouldShowL2Transactions(config) const homeController = new HomeController( pageContextService, assetDetailsService, @@ -525,8 +539,7 @@ export class Application { userTransactionRepository, forcedTradeOfferRepository, l2TransactionRepository, - preprocessedStateDetailsRepository, - showL2Transactions + preprocessedStateDetailsRepository ) const userController = new UserController( @@ -541,8 +554,8 @@ export class Application { forcedTradeOfferViewService, withdrawableAssetRepository, preprocessedUserStatisticsRepository, - config.starkex.contracts.perpetual, - showL2Transactions + preprocessedUserL2TransactionsStatisticsRepository, + config.starkex.contracts.perpetual ) const stateUpdateController = new StateUpdateController( pageContextService, @@ -551,7 +564,7 @@ export class Application { userTransactionRepository, l2TransactionRepository, preprocessedAssetHistoryRepository, - showL2Transactions + preprocessedStateDetailsRepository ) const transactionController = new TransactionController( pageContextService, @@ -629,7 +642,6 @@ export class Application { this.start = async () => { logger.for(this).info('Starting') - await apiServer.listen() if (config.freshStart) await database.rollbackAll() await database.migrateToLatest() await preprocessor.catchUp() @@ -641,6 +653,8 @@ export class Application { await stateUpdateWithBatchIdMigrator.migrate() await stateUpdater.initTree() + await apiServer.listen() + if (config.enableSync) { transactionStatusService.start() await syncScheduler.start() diff --git a/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts b/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts index 32ad20340..294e9325d 100644 --- a/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts +++ b/packages/backend/src/api/controllers/ForcedTradeOfferController.test.ts @@ -54,6 +54,7 @@ describe(ForcedTradeOfferController.name, () => { const pageContext: PageContext = { user: undefined, tradingMode: 'perpetual', + showL2Transactions: true, chainId: 1, instanceName: 'dYdX', collateralAsset: fakeCollateralAsset, diff --git a/packages/backend/src/api/controllers/HomeController.ts b/packages/backend/src/api/controllers/HomeController.ts index 42d6f3e91..00e1788e4 100644 --- a/packages/backend/src/api/controllers/HomeController.ts +++ b/packages/backend/src/api/controllers/HomeController.ts @@ -12,6 +12,7 @@ import { PageContextService } from '../../core/PageContextService' import { PaginationOptions } from '../../model/PaginationOptions' import { ForcedTradeOfferRepository } from '../../peripherals/database/ForcedTradeOfferRepository' import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' +import { sumUpTransactionCount } from '../../peripherals/database/PreprocessedL2TransactionsStatistics' import { PreprocessedStateDetailsRepository } from '../../peripherals/database/PreprocessedStateDetailsRepository' import { UserTransactionData } from '../../peripherals/database/transactions/UserTransaction' import { UserTransactionRepository } from '../../peripherals/database/transactions/UserTransactionRepository' @@ -33,8 +34,7 @@ export class HomeController { private readonly userTransactionRepository: UserTransactionRepository, private readonly forcedTradeOfferRepository: ForcedTradeOfferRepository, private readonly l2TransactionRepository: L2TransactionRepository, - private readonly preprocessedStateDetailsRepository: PreprocessedStateDetailsRepository, - private readonly showL2Transactions: boolean + private readonly preprocessedStateDetailsRepository: PreprocessedStateDetailsRepository ) {} async getHomePage( @@ -44,7 +44,7 @@ export class HomeController { const paginationOpts = { offset: 0, limit: 6 } const [ l2Transactions, - l2TransactionsCount, + lastStateDetailsWithL2TransactionsStatistics, stateUpdates, stateUpdatesCount, forcedUserTransactions, @@ -53,7 +53,7 @@ export class HomeController { availableOffersCount, ] = await Promise.all([ this.l2TransactionRepository.getPaginatedWithoutMulti(paginationOpts), - this.l2TransactionRepository.countAllDistinctTransactionIds(), + this.preprocessedStateDetailsRepository.findLastWithL2TransactionsStatistics(), this.preprocessedStateDetailsRepository.getPaginated(paginationOpts), this.preprocessedStateDetailsRepository.countAll(), this.userTransactionRepository.getPaginated({ @@ -85,12 +85,10 @@ export class HomeController { const content = renderHomePage({ context, tutorials: [], // explicitly no tutorials - l2Transactions: this.showL2Transactions - ? { - data: l2Transactions.map(l2TransactionToEntry), - total: l2TransactionsCount, - } - : undefined, + l2Transactions: l2Transactions.map(l2TransactionToEntry), + totalL2Transactions: sumUpTransactionCount( + lastStateDetailsWithL2TransactionsStatistics?.cumulativeL2TransactionsStatistics + ), stateUpdates: stateUpdateEntries, totalStateUpdates: stateUpdatesCount, forcedTransactions: forcedTransactionEntries, @@ -111,16 +109,19 @@ export class HomeController { ): Promise { const context = await this.pageContextService.getPageContext(givenUser) - const [total, l2Transactions] = await Promise.all([ - this.l2TransactionRepository.countAllDistinctTransactionIds(), - this.l2TransactionRepository.getPaginatedWithoutMulti(pagination), - ]) + const [l2Transactions, lastStateDetailsWithL2TransactionsStatistics] = + await Promise.all([ + this.l2TransactionRepository.getPaginatedWithoutMulti(pagination), + this.preprocessedStateDetailsRepository.findLastWithL2TransactionsStatistics(), + ]) const content = renderHomeL2TransactionsPage({ context, l2Transactions: l2Transactions.map(l2TransactionToEntry), + total: sumUpTransactionCount( + lastStateDetailsWithL2TransactionsStatistics?.cumulativeL2TransactionsStatistics + ), ...pagination, - total, }) return { type: 'success', content } } diff --git a/packages/backend/src/api/controllers/L2TransactionController.ts b/packages/backend/src/api/controllers/L2TransactionController.ts index 279c51da4..17b83a859 100644 --- a/packages/backend/src/api/controllers/L2TransactionController.ts +++ b/packages/backend/src/api/controllers/L2TransactionController.ts @@ -27,7 +27,9 @@ export class L2TransactionController { return { type: 'not found' } } const aggregatedL2Transaction = - await this.l2TransactionRepository.findByTransactionId(transactionId) + await this.l2TransactionRepository.findAggregatedByTransactionId( + transactionId + ) if (!aggregatedL2Transaction) { return { diff --git a/packages/backend/src/api/controllers/StateUpdateController.ts b/packages/backend/src/api/controllers/StateUpdateController.ts index e6823f617..83ae085db 100644 --- a/packages/backend/src/api/controllers/StateUpdateController.ts +++ b/packages/backend/src/api/controllers/StateUpdateController.ts @@ -16,6 +16,8 @@ import { PreprocessedAssetHistoryRecord, PreprocessedAssetHistoryRepository, } from '../../peripherals/database/PreprocessedAssetHistoryRepository' +import { sumUpTransactionCount } from '../../peripherals/database/PreprocessedL2TransactionsStatistics' +import { PreprocessedStateDetailsRepository } from '../../peripherals/database/PreprocessedStateDetailsRepository' import { StateUpdateRepository } from '../../peripherals/database/StateUpdateRepository' import { UserTransactionData } from '../../peripherals/database/transactions/UserTransaction' import { UserTransactionRepository } from '../../peripherals/database/transactions/UserTransactionRepository' @@ -38,7 +40,7 @@ export class StateUpdateController { private readonly userTransactionRepository: UserTransactionRepository, private readonly l2TransactionRepository: L2TransactionRepository, private readonly preprocessedAssetHistoryRepository: PreprocessedAssetHistoryRepository, - private readonly showL2Transactions: boolean + private readonly preprocessedStateDetailsRepository: PreprocessedStateDetailsRepository ) {} async getStateUpdatePage( @@ -56,7 +58,7 @@ export class StateUpdateController { forcedUserTransactions, totalForcedUserTransactions, l2Transactions, - totalL2Transactions, + preprocessedStateDetails, ] = await Promise.all([ this.stateUpdateRepository.findById(stateUpdateId), this.preprocessedAssetHistoryRepository.getByStateUpdateIdPaginated( @@ -82,7 +84,7 @@ export class StateUpdateController { limit: 6, } ), - this.l2TransactionRepository.countAllDistinctTransactionIdsByStateUpdateId( + this.preprocessedStateDetailsRepository.findByStateUpdateId( stateUpdateId ), ]) @@ -127,12 +129,12 @@ export class StateUpdateController { balanceChanges: balanceChangeEntries, totalBalanceChanges, priceChanges: priceEntries, - l2Transactions: this.showL2Transactions - ? { - data: l2Transactions.map(l2TransactionToEntry), - total: totalL2Transactions, - } - : undefined, + l2Transactions: l2Transactions.map(l2TransactionToEntry), + totalL2Transactions: preprocessedStateDetails?.l2TransactionsStatistics + ? sumUpTransactionCount( + preprocessedStateDetails.l2TransactionsStatistics + ) + : 'processing', transactions, totalTransactions: totalForcedUserTransactions, }) @@ -147,12 +149,12 @@ export class StateUpdateController { ): Promise { const context = await this.pageContextService.getPageContext(givenUser) - const [l2Transactions, total] = await Promise.all([ + const [l2Transactions, preprocessedStateDetails] = await Promise.all([ this.l2TransactionRepository.getPaginatedWithoutMultiByStateUpdateId( stateUpdateId, pagination ), - this.l2TransactionRepository.countAllDistinctTransactionIdsByStateUpdateId( + this.preprocessedStateDetailsRepository.findByStateUpdateId( stateUpdateId ), ]) @@ -162,7 +164,11 @@ export class StateUpdateController { id: stateUpdateId.toString(), l2Transactions: l2Transactions.map(l2TransactionToEntry), ...pagination, - total, + total: preprocessedStateDetails?.l2TransactionsStatistics + ? sumUpTransactionCount( + preprocessedStateDetails.l2TransactionsStatistics + ) + : 'processing', }) return { type: 'success', content } diff --git a/packages/backend/src/api/controllers/UserController.ts b/packages/backend/src/api/controllers/UserController.ts index ac63b3e34..430061e11 100644 --- a/packages/backend/src/api/controllers/UserController.ts +++ b/packages/backend/src/api/controllers/UserController.ts @@ -30,6 +30,8 @@ import { PreprocessedAssetHistoryRecord, PreprocessedAssetHistoryRepository, } from '../../peripherals/database/PreprocessedAssetHistoryRepository' +import { sumUpTransactionCount } from '../../peripherals/database/PreprocessedL2TransactionsStatistics' +import { PreprocessedUserL2TransactionsStatisticsRepository } from '../../peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository' import { PreprocessedUserStatisticsRepository } from '../../peripherals/database/PreprocessedUserStatisticsRepository' import { SentTransactionRecord, @@ -60,8 +62,8 @@ export class UserController { private readonly forcedTradeOfferViewService: ForcedTradeOfferViewService, private readonly withdrawableAssetRepository: WithdrawableAssetRepository, private readonly preprocessedUserStatisticsRepository: PreprocessedUserStatisticsRepository, - private readonly exchangeAddress: EthereumAddress, - private readonly showL2Transactions: boolean + private readonly preprocessedUserL2TransactionsStatisticsRepository: PreprocessedUserL2TransactionsStatisticsRepository, + private readonly exchangeAddress: EthereumAddress ) {} async getUserRegisterPage( @@ -126,7 +128,7 @@ export class UserController { userAssets, history, l2Transactions, - l2TransactionsCount, + preprocessedUserL2TransactionsStatistics, sentTransactions, userTransactions, userTransactionsCount, @@ -150,7 +152,9 @@ export class UserController { starkKey, paginationOpts ), - this.l2TransactionRepository.countAllUserSpecific(starkKey), + this.preprocessedUserL2TransactionsStatisticsRepository.findLatestByStarkKey( + starkKey + ), this.sentTransactionRepository.getByStarkKey(starkKey), this.userTransactionRepository.getByStarkKey( starkKey, @@ -214,12 +218,10 @@ export class UserController { context, starkKey, ethereumAddress: registeredUser?.ethAddress, - l2Transactions: this.showL2Transactions - ? { - data: l2Transactions.map(l2TransactionToEntry), - total: l2TransactionsCount, - } - : undefined, + l2Transactions: l2Transactions.map(l2TransactionToEntry), + totalL2Transactions: sumUpTransactionCount( + preprocessedUserL2TransactionsStatistics?.cumulativeL2TransactionsStatistics + ), withdrawableAssets: withdrawableAssets.map((asset) => ({ asset: { hashOrId: @@ -316,19 +318,24 @@ export class UserController { pagination: PaginationOptions ): Promise { const context = await this.pageContextService.getPageContext(givenUser) - const [l2Transactions, l2TransactionsCount] = await Promise.all([ - this.l2TransactionRepository.getUserSpecificPaginated( - starkKey, - pagination - ), - this.l2TransactionRepository.countAllUserSpecific(starkKey), - ]) + const [l2Transactions, preprocessedUserL2TransactionsStatistics] = + await Promise.all([ + this.l2TransactionRepository.getUserSpecificPaginated( + starkKey, + pagination + ), + this.preprocessedUserL2TransactionsStatisticsRepository.findLatestByStarkKey( + starkKey + ), + ]) const content = renderUserL2TransactionsPage({ context, starkKey, l2Transactions: l2Transactions.map(l2TransactionToEntry), - total: l2TransactionsCount, + total: sumUpTransactionCount( + preprocessedUserL2TransactionsStatistics?.cumulativeL2TransactionsStatistics + ), ...pagination, }) return { type: 'success', content } diff --git a/packages/backend/src/core/PageContextService.test.ts b/packages/backend/src/core/PageContextService.test.ts index 0d309aec6..d42411aef 100644 --- a/packages/backend/src/core/PageContextService.test.ts +++ b/packages/backend/src/core/PageContextService.test.ts @@ -45,6 +45,7 @@ describe(PageContextService.name, () => { expect(context).toEqual({ user: undefined, tradingMode: 'perpetual', + showL2Transactions: false, chainId: 1, instanceName: perpetualConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, @@ -69,6 +70,7 @@ describe(PageContextService.name, () => { expect(context).toEqual({ user: undefined, tradingMode: 'spot', + showL2Transactions: false, chainId: 5, instanceName: spotConfig.starkex.instanceName, }) @@ -90,6 +92,7 @@ describe(PageContextService.name, () => { user: givenUser, tradingMode: 'perpetual', chainId: 1, + showL2Transactions: false, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const @@ -110,6 +113,7 @@ describe(PageContextService.name, () => { ({ user: undefined, tradingMode: 'perpetual', + showL2Transactions: false, chainId: 1, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, @@ -138,6 +142,7 @@ describe(PageContextService.name, () => { user: givenUser, tradingMode: 'perpetual', chainId: 1, + showL2Transactions: false, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const @@ -178,6 +183,7 @@ describe(PageContextService.name, () => { user: givenUser, tradingMode: 'perpetual', chainId: 1, + showL2Transactions: false, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, } as const @@ -200,6 +206,7 @@ describe(PageContextService.name, () => { const pageContext = { user: undefined, tradingMode: 'perpetual', + showL2Transactions: false, chainId: 5, instanceName: spotConfig.starkex.instanceName, collateralAsset: fakeCollateralAsset, @@ -218,6 +225,7 @@ describe(PageContextService.name, () => { const pageContext = { user: undefined, tradingMode: 'spot', + showL2Transactions: false, 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 468964fe6..0d8e95f8f 100644 --- a/packages/backend/src/core/PageContextService.ts +++ b/packages/backend/src/core/PageContextService.ts @@ -7,6 +7,7 @@ import { } from '@explorer/shared' import { Config } from '../config' +import { shouldShowL2Transactions } from '../utils/shouldShowL2Transactions' import { UserService } from './UserService' export class PageContextService { @@ -25,6 +26,7 @@ export class PageContextService { instanceName: this.config.starkex.instanceName, chainId: this.config.starkex.blockchain.chainId, collateralAsset: this.config.starkex.collateralAsset, + showL2Transactions: shouldShowL2Transactions(this.config), } } @@ -33,6 +35,7 @@ export class PageContextService { tradingMode: this.config.starkex.tradingMode, chainId: this.config.starkex.blockchain.chainId, instanceName: this.config.starkex.instanceName, + showL2Transactions: shouldShowL2Transactions(this.config), } } diff --git a/packages/backend/src/core/preprocessing/Preprocessor.test.ts b/packages/backend/src/core/preprocessing/Preprocessor.test.ts index 2933c7d61..d76b21762 100644 --- a/packages/backend/src/core/preprocessing/Preprocessor.test.ts +++ b/packages/backend/src/core/preprocessing/Preprocessor.test.ts @@ -4,6 +4,7 @@ import { expect, mockFn, mockObject } from 'earl' import { Knex } from 'knex' import { KeyValueStore } from '../../peripherals/database/KeyValueStore' +import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' import { PreprocessedStateUpdateRepository } from '../../peripherals/database/PreprocessedStateUpdateRepository' import { StateUpdateRecord, @@ -12,6 +13,7 @@ import { import { PerpetualHistoryPreprocessor } from './PerpetualHistoryPreprocessor' import { Preprocessor, SyncDirection } from './Preprocessor' import { StateDetailsPreprocessor } from './StateDetailsPreprocessor' +import { UserL2TransactionsStatisticsPreprocessor } from './UserL2TransactionsPreprocessor' import { UserStatisticsPreprocessor } from './UserStatisticsPreprocessor' const generateFakeStateUpdate = ( @@ -35,6 +37,9 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + + mockObject(), Logger.SILENT ) @@ -64,6 +69,117 @@ describe(Preprocessor.name, () => { }) }) + describe(Preprocessor.prototype.catchUp.name, () => { + it('catches up', async () => { + const mockKnexTransaction = mockObject() + const lastPreprocessedStateUpdateId = 10 + const mockUserStatisticsPreprocessor = + mockObject({ + catchUp: mockFn().resolvesTo(undefined), + }) + const mockUserL2TransactionsPreprocessor = + mockObject({ + catchUp: mockFn().resolvesTo(undefined), + }) + const mockStateDetailsPreprocessor = mockObject( + { + catchUpL2Transactions: mockFn().resolvesTo(undefined), + } + ) + + const mockPreprocessedStateUpdateRepository = + mockObject({ + findLast: mockFn().resolvesTo({ + stateUpdateId: lastPreprocessedStateUpdateId, + }), + runInTransaction: mockFn(async (fn) => fn(mockKnexTransaction)), + }) + + const preprocessor = new Preprocessor( + mockObject(), + mockPreprocessedStateUpdateRepository, + mockObject(), + mockObject(), + mockStateDetailsPreprocessor, + mockUserStatisticsPreprocessor, + mockUserL2TransactionsPreprocessor, + mockObject(), + Logger.SILENT + ) + + await preprocessor.catchUp() + + expect( + mockPreprocessedStateUpdateRepository.runInTransaction + ).toHaveBeenCalled() + expect( + mockPreprocessedStateUpdateRepository.findLast + ).toHaveBeenCalledWith(mockKnexTransaction) + + expect(mockUserStatisticsPreprocessor.catchUp).toHaveBeenCalledWith( + mockKnexTransaction, + lastPreprocessedStateUpdateId + ) + expect( + mockStateDetailsPreprocessor.catchUpL2Transactions + ).toHaveBeenCalledWith(mockKnexTransaction, lastPreprocessedStateUpdateId) + expect(mockUserStatisticsPreprocessor.catchUp).toHaveBeenCalledWith( + mockKnexTransaction, + lastPreprocessedStateUpdateId + ) + }) + + it('does nothing when there is nothing to catch up', async () => { + const mockKnexTransaction = mockObject() + const mockPreprocessedStateUpdateRepository = + mockObject({ + findLast: mockFn().resolvesTo(undefined), + runInTransaction: mockFn(async (fn) => fn(mockKnexTransaction)), + }) + + const mockUserStatisticsPreprocessor = + mockObject({ + catchUp: mockFn(), + }) + const mockUserL2TransactionsPreprocessor = + mockObject({ + catchUp: mockFn(), + }) + const mockStateDetailsPreprocessor = mockObject( + { + catchUpL2Transactions: mockFn(), + } + ) + + const preprocessor = new Preprocessor( + mockObject(), + mockPreprocessedStateUpdateRepository, + mockObject(), + mockObject(), + mockStateDetailsPreprocessor, + mockUserStatisticsPreprocessor, + mockUserL2TransactionsPreprocessor, + mockObject(), + Logger.SILENT + ) + + await preprocessor.catchUp() + + expect( + mockPreprocessedStateUpdateRepository.runInTransaction + ).toHaveBeenCalled() + expect( + mockPreprocessedStateUpdateRepository.findLast + ).toHaveBeenCalledWith(mockKnexTransaction) + + expect(mockUserStatisticsPreprocessor.catchUp).not.toHaveBeenCalled() + expect( + mockStateDetailsPreprocessor.catchUpL2Transactions + ).not.toHaveBeenCalled() + expect(mockUserStatisticsPreprocessor.catchUp).not.toHaveBeenCalled() + }) + }) + describe(Preprocessor.prototype.getLastSyncedStateUpdate.name, () => { it('handles sync status returning undefined', async () => { const mockKeyValueStore = mockObject({ @@ -76,6 +192,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) @@ -100,6 +218,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const lastStateUpdate = await preprocessor.getLastSyncedStateUpdate() @@ -125,6 +245,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = mockFn().resolvesTo(undefined) @@ -149,6 +271,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = mockFn().resolvesTo(undefined) @@ -176,6 +300,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = mockFn().resolvesTo(fakeStateUpdate) @@ -205,6 +331,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = mockFn().resolvesTo(fakeStateUpdate) @@ -235,6 +363,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = mockFn().resolvesTo(fakeStateUpdate) @@ -267,6 +397,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = mockFn().resolvesTo(fakeStateUpdate) @@ -298,6 +430,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = mockFn().resolvesTo(fakeStateUpdate) @@ -330,6 +464,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = @@ -363,6 +499,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) const mockGetLastSyncedStateUpdate = @@ -378,6 +516,7 @@ describe(Preprocessor.name, () => { describe(Preprocessor.prototype.preprocessNextStateUpdate.name, () => { it('correctly updates preprocessedStateUpdateRepository in SQL transaction', async () => { + const preprocessL2TransactionTo = 200 const fakeStateUpdate1 = generateFakeStateUpdate(1) const fakeStateUpdate2 = generateFakeStateUpdate(2) const mockKnexTransaction = mockObject() @@ -388,12 +527,17 @@ describe(Preprocessor.name, () => { const mockStateDetailsPreprocessor = mockObject( { preprocessNextStateUpdate: async () => undefined, + catchUpL2Transactions: mockFn(async () => {}), } ) const mockUserStatisticsPreprocessor = mockObject({ preprocessNextStateUpdate: async () => undefined, }) + const mockUserL2TransactionsPreprocessor = + mockObject({ + catchUp: mockFn(async () => {}), + }) const stateUpdateRepo = mockObject({ findById: async (id: number) => ({ [2]: fakeStateUpdate2 }[id]), }) @@ -412,8 +556,16 @@ describe(Preprocessor.name, () => { mockPerpetualHistoryPreprocessor, mockStateDetailsPreprocessor, mockUserStatisticsPreprocessor, + mockUserL2TransactionsPreprocessor, + mockObject(), Logger.SILENT ) + + const mockedGetStateUpdateIdToCatchUpL2TransactionsTo = + mockFn().resolvesTo(preprocessL2TransactionTo) + preprocessor.getStateUpdateIdToCatchUpL2TransactionsTo = + mockedGetStateUpdateIdToCatchUpL2TransactionsTo + await preprocessor.preprocessNextStateUpdate() expect(preprocessedRepo.add).toHaveBeenOnlyCalledWith( { @@ -431,6 +583,15 @@ describe(Preprocessor.name, () => { expect( mockUserStatisticsPreprocessor.preprocessNextStateUpdate ).toHaveBeenOnlyCalledWith(mockKnexTransaction, fakeStateUpdate2) + expect( + mockedGetStateUpdateIdToCatchUpL2TransactionsTo + ).toHaveBeenCalledWith(mockKnexTransaction, fakeStateUpdate2.id) + expect( + mockStateDetailsPreprocessor.catchUpL2Transactions + ).toHaveBeenOnlyCalledWith(mockKnexTransaction, preprocessL2TransactionTo) + expect( + mockUserL2TransactionsPreprocessor.catchUp + ).toHaveBeenOnlyCalledWith(mockKnexTransaction, preprocessL2TransactionTo) }) it('throws when next state update is missing', async () => { @@ -454,6 +615,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) await expect(preprocessor.preprocessNextStateUpdate()).toBeRejectedWith( @@ -487,6 +650,10 @@ describe(Preprocessor.name, () => { runInTransaction: async (fn) => fn(mockKnexTransaction), deleteByStateUpdateId: async () => 1, }) + const mockUserL2TransactionsPreprocessor = + mockObject({ + rollbackOneStateUpdate: mockFn().resolvesTo(undefined), + }) const preprocessor = new Preprocessor( mockObject(), preprocessedRepo, @@ -494,6 +661,8 @@ describe(Preprocessor.name, () => { mockPerpetualHistoryPreprocessor, mockStateDetailsPreprocessor, mockUserStatisticsPreprocessor, + mockUserL2TransactionsPreprocessor, + mockObject(), Logger.SILENT ) await preprocessor.rollbackOneStateUpdate() @@ -510,6 +679,9 @@ describe(Preprocessor.name, () => { expect( mockUserStatisticsPreprocessor.rollbackOneStateUpdate ).toHaveBeenOnlyCalledWith(mockKnexTransaction, fakeStateUpdate.id) + expect( + mockUserL2TransactionsPreprocessor.rollbackOneStateUpdate + ).toHaveBeenOnlyCalledWith(mockKnexTransaction, fakeStateUpdate.id) }) it('throws when there are no preprocessings to roll back', async () => { @@ -525,6 +697,8 @@ describe(Preprocessor.name, () => { mockObject(), mockObject(), mockObject(), + mockObject(), + mockObject(), Logger.SILENT ) await expect(preprocessor.rollbackOneStateUpdate()).toBeRejectedWith( @@ -532,4 +706,57 @@ describe(Preprocessor.name, () => { ) }) }) + + describe( + Preprocessor.prototype.getStateUpdateIdToCatchUpL2TransactionsTo.name, + () => { + const trx = mockObject() + const lastL2TransactionStateUpdateId = 100 + const mockedL2TransactionRepository = mockObject( + { + findLatestStateUpdateId: mockFn().resolvesTo( + lastL2TransactionStateUpdateId + ), + } + ) + + const preprocessor = new Preprocessor( + mockObject(), + mockObject(), + mockObject(), + mockObject(), + mockObject(), + mockObject(), + mockObject(), + mockedL2TransactionRepository, + Logger.SILENT + ) + + it('returns the latest l2 transaction state update id if it is smaller than processed state update id', async () => { + const preprocessTo = + await preprocessor.getStateUpdateIdToCatchUpL2TransactionsTo( + trx, + lastL2TransactionStateUpdateId + 1 + ) + + expect( + mockedL2TransactionRepository.findLatestStateUpdateId + ).toHaveBeenCalledWith(trx) + expect(preprocessTo).toEqual(lastL2TransactionStateUpdateId) + }) + + it('returns the processed state update id if it is smaller than latest l2 transaction state update id', async () => { + const preprocessTo = + await preprocessor.getStateUpdateIdToCatchUpL2TransactionsTo( + trx, + lastL2TransactionStateUpdateId - 1 + ) + + expect( + mockedL2TransactionRepository.findLatestStateUpdateId + ).toHaveBeenCalledWith(trx) + expect(preprocessTo).toEqual(lastL2TransactionStateUpdateId - 1) + }) + } + ) }) diff --git a/packages/backend/src/core/preprocessing/Preprocessor.ts b/packages/backend/src/core/preprocessing/Preprocessor.ts index 5a2d81559..efcd6aaea 100644 --- a/packages/backend/src/core/preprocessing/Preprocessor.ts +++ b/packages/backend/src/core/preprocessing/Preprocessor.ts @@ -1,7 +1,9 @@ import { AssetHash, AssetId } from '@explorer/types' import { Logger } from '@l2beat/backend-tools' +import { Knex } from 'knex' import { KeyValueStore } from '../../peripherals/database/KeyValueStore' +import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' import { PreprocessedStateUpdateRepository } from '../../peripherals/database/PreprocessedStateUpdateRepository' import { StateUpdateRecord, @@ -9,6 +11,7 @@ import { } from '../../peripherals/database/StateUpdateRepository' import { HistoryPreprocessor } from './HistoryPreprocessor' import { StateDetailsPreprocessor } from './StateDetailsPreprocessor' +import { UserL2TransactionsStatisticsPreprocessor } from './UserL2TransactionsPreprocessor' import { UserStatisticsPreprocessor } from './UserStatisticsPreprocessor' export type SyncDirection = 'forward' | 'backward' | 'stop' @@ -21,6 +24,8 @@ export class Preprocessor { private historyPreprocessor: HistoryPreprocessor, private stateDetailsPreprocessor: StateDetailsPreprocessor, private userStatisticsPreprocessor: UserStatisticsPreprocessor, + private userL2TransactionsPreprocessor: UserL2TransactionsStatisticsPreprocessor, + private l2TransactionRepository: L2TransactionRepository, private logger: Logger, private isEnabled: boolean = true ) { @@ -115,7 +120,24 @@ export class Preprocessor { async catchUp() { await this.preprocessedStateUpdateRepository.runInTransaction( async (trx) => { - await this.userStatisticsPreprocessor.catchUp(trx) + const lastProcessedStateUpdate = + await this.preprocessedStateUpdateRepository.findLast(trx) + if (!lastProcessedStateUpdate) { + return + } + + await this.userStatisticsPreprocessor.catchUp( + trx, + lastProcessedStateUpdate.stateUpdateId + ) + await this.stateDetailsPreprocessor.catchUpL2Transactions( + trx, + lastProcessedStateUpdate.stateUpdateId + ) + await this.userL2TransactionsPreprocessor.catchUp( + trx, + lastProcessedStateUpdate.stateUpdateId + ) } ) } @@ -165,6 +187,24 @@ export class Preprocessor { nextStateUpdate ) + // We cannot assume that Feeder and Availability Gateway are in sync + // with the state updates. We need to catch up with L2 transactions + // after each state update to make sure it is preprocessed as far as possible. + const preprocessL2TransactionTo = + await this.getStateUpdateIdToCatchUpL2TransactionsTo( + trx, + nextStateUpdate.id + ) + + await this.stateDetailsPreprocessor.catchUpL2Transactions( + trx, + preprocessL2TransactionTo + ) + await this.userL2TransactionsPreprocessor.catchUp( + trx, + preprocessL2TransactionTo + ) + // END TRANSACTION } ) @@ -205,9 +245,24 @@ export class Preprocessor { lastProcessedStateUpdate.stateUpdateId, trx ) - + await this.userL2TransactionsPreprocessor.rollbackOneStateUpdate( + trx, + lastProcessedStateUpdate.stateUpdateId + ) // END TRANSACTION } ) } + + async getStateUpdateIdToCatchUpL2TransactionsTo( + trx: Knex.Transaction, + processedStateUpdateId: number + ) { + const lastL2TransactionStateUpdateId = + await this.l2TransactionRepository.findLatestStateUpdateId(trx) + + return lastL2TransactionStateUpdateId + ? Math.min(lastL2TransactionStateUpdateId, processedStateUpdateId) + : processedStateUpdateId + } } diff --git a/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.test.ts b/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.test.ts index d238a2459..ba909d4a4 100644 --- a/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.test.ts +++ b/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.test.ts @@ -3,10 +3,16 @@ import { Logger } from '@l2beat/backend-tools' import { expect, mockFn, mockObject } from 'earl' import { Knex } from 'knex' +import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' import { PreprocessedAssetHistoryRepository } from '../../peripherals/database/PreprocessedAssetHistoryRepository' -import { PreprocessedStateDetailsRepository } from '../../peripherals/database/PreprocessedStateDetailsRepository' +import { + PreprocessedStateDetailsRecord, + PreprocessedStateDetailsRepository, +} from '../../peripherals/database/PreprocessedStateDetailsRepository' import { StateUpdateRecord } from '../../peripherals/database/StateUpdateRepository' import { UserTransactionRepository } from '../../peripherals/database/transactions/UserTransactionRepository' +import { fakePreprocessedL2TransactionsStatistics } from '../../test/fakes' +import { sumNumericValuesByKey } from '../../utils/sumNumericValuesByKey' import { StateDetailsPreprocessor } from './StateDetailsPreprocessor' const stateUpdate: StateUpdateRecord = { @@ -24,6 +30,8 @@ describe(StateDetailsPreprocessor.name, () => { () => { it('should calculate assetUpdateCount and forcedTransactionCount', async () => { const trx = mockObject() + const preprocessedStateDetailsId = 15 + const mockPreprocessedAssetHistoryRepository = mockObject< PreprocessedAssetHistoryRepository >({ @@ -35,13 +43,17 @@ describe(StateDetailsPreprocessor.name, () => { }) const mockPreprocessedStateDetailsRepository = mockObject({ - add: mockFn().resolvesTo(undefined), + add: mockFn().resolvesTo(preprocessedStateDetailsId), }) + const mockL2TransactionRepository = mockObject( + {} + ) const stateDetailsPreprocessor = new StateDetailsPreprocessor( mockPreprocessedStateDetailsRepository, mockPreprocessedAssetHistoryRepository, mockUserTransactionRepository, + mockL2TransactionRepository, Logger.SILENT ) @@ -67,6 +79,7 @@ describe(StateDetailsPreprocessor.name, () => { }) } ) + describe( StateDetailsPreprocessor.prototype.rollbackOneStateUpdate.name, () => { @@ -81,6 +94,7 @@ describe(StateDetailsPreprocessor.name, () => { mockPreprocessedStateDetailsRepository, mockObject>(), mockObject(), + mockObject(), Logger.SILENT ) @@ -95,4 +109,213 @@ describe(StateDetailsPreprocessor.name, () => { }) } ) + + describe( + StateDetailsPreprocessor.prototype.catchUpL2Transactions.name, + () => { + const trx = mockObject() + const getStatisticsByStateUpdateIdResult = + fakePreprocessedL2TransactionsStatistics() + const findMostRecentWithL2TransactionStatisticsResult = { + l2TransactionsStatistics: fakePreprocessedL2TransactionsStatistics(), + cumulativeL2TransactionsStatistics: + fakePreprocessedL2TransactionsStatistics(), + } + + const recordsToUpdate: PreprocessedStateDetailsRecord[] = [ + { + id: 1, + stateUpdateId: 200, + } as PreprocessedStateDetailsRecord, + { + id: 3, + stateUpdateId: 250, + } as PreprocessedStateDetailsRecord, + { + id: 5, + stateUpdateId: 400, + } as PreprocessedStateDetailsRecord, + ] + const preprocessToStateUpdateId = 200 + + it('catches up using sum of latest preprocessed record statistics and current statistics as l2 transaction statistics', async () => { + const mockedPreprocessedStateDetailsRepository = + mockObject({ + getAllWithoutL2TransactionStatisticsUpToStateUpdateId: + mockFn().resolvesTo(recordsToUpdate), + findByStateUpdateId: mockFn().resolvesTo( + findMostRecentWithL2TransactionStatisticsResult + ), + update: mockFn().resolvesTo(1), + }) + const mockedL2TransactionRepository = + mockObject({ + getStatisticsByStateUpdateId: mockFn().resolvesTo( + getStatisticsByStateUpdateIdResult + ), + }) + const stateDetailsPreprocessor = new StateDetailsPreprocessor( + mockedPreprocessedStateDetailsRepository, + mockObject>(), + mockObject(), + mockedL2TransactionRepository, + Logger.SILENT + ) + + await stateDetailsPreprocessor.catchUpL2Transactions( + trx, + preprocessToStateUpdateId + ) + + expect( + mockedPreprocessedStateDetailsRepository.getAllWithoutL2TransactionStatisticsUpToStateUpdateId + ).toHaveBeenCalledWith(preprocessToStateUpdateId, trx) + + for (const recordToUpdate of recordsToUpdate) { + expect( + mockedL2TransactionRepository.getStatisticsByStateUpdateId + ).toHaveBeenCalledWith(recordToUpdate.stateUpdateId, trx) + + expect( + mockedPreprocessedStateDetailsRepository.findByStateUpdateId + ).toHaveBeenCalledWith(recordToUpdate.stateUpdateId - 1, trx) + + expect( + mockedPreprocessedStateDetailsRepository.update( + { + id: recordToUpdate.id, + l2TransactionsStatistics: getStatisticsByStateUpdateIdResult, + cumulativeL2TransactionsStatistics: sumNumericValuesByKey( + getStatisticsByStateUpdateIdResult, + findMostRecentWithL2TransactionStatisticsResult.l2TransactionsStatistics + ), + }, + trx + ) + ) + } + }) + + it('catches up using current statistics as l2 transaction statistics if no previous statistics and stateUpdateId = 1', async () => { + const recordsToUpdate: PreprocessedStateDetailsRecord[] = [ + { + id: 1, + stateUpdateId: 1, + } as PreprocessedStateDetailsRecord, + ] + + const mockedPreprocessedStateDetailsRepository = + mockObject({ + getAllWithoutL2TransactionStatisticsUpToStateUpdateId: + mockFn().resolvesTo(recordsToUpdate), + findByStateUpdateId: mockFn().resolvesTo(undefined), + update: mockFn().resolvesTo(1), + }) + const mockedL2TransactionRepository = + mockObject({ + getStatisticsByStateUpdateId: mockFn().resolvesTo( + getStatisticsByStateUpdateIdResult + ), + }) + const stateDetailsPreprocessor = new StateDetailsPreprocessor( + mockedPreprocessedStateDetailsRepository, + mockObject>(), + mockObject(), + mockedL2TransactionRepository, + Logger.SILENT + ) + + await stateDetailsPreprocessor.catchUpL2Transactions( + trx, + preprocessToStateUpdateId + ) + + expect( + mockedPreprocessedStateDetailsRepository.getAllWithoutL2TransactionStatisticsUpToStateUpdateId + ).toHaveBeenCalledWith(preprocessToStateUpdateId, trx) + + for (const recordToUpdate of recordsToUpdate) { + expect( + mockedL2TransactionRepository.getStatisticsByStateUpdateId + ).toHaveBeenCalledWith(recordToUpdate.stateUpdateId, trx) + + expect( + mockedPreprocessedStateDetailsRepository.findByStateUpdateId + ).toHaveBeenCalledWith(recordToUpdate.stateUpdateId - 1, trx) + + expect( + mockedPreprocessedStateDetailsRepository.update( + { + id: recordToUpdate.id, + l2TransactionsStatistics: getStatisticsByStateUpdateIdResult, + cumulativeL2TransactionsStatistics: + getStatisticsByStateUpdateIdResult, + }, + trx + ) + ) + } + }) + + it('throws an error if no previous state update statistics found and stateUpdateId > 1', async () => { + const mockedPreprocessedStateDetailsRepository = + mockObject({ + getAllWithoutL2TransactionStatisticsUpToStateUpdateId: + mockFn().resolvesTo(recordsToUpdate), + findByStateUpdateId: mockFn().resolvesTo(undefined), + }) + const mockedL2TransactionRepository = + mockObject({ + getStatisticsByStateUpdateId: mockFn().resolvesTo( + getStatisticsByStateUpdateIdResult + ), + }) + const stateDetailsPreprocessor = new StateDetailsPreprocessor( + mockedPreprocessedStateDetailsRepository, + mockObject>(), + mockObject(), + mockedL2TransactionRepository, + Logger.SILENT + ) + + await expect(() => + stateDetailsPreprocessor.catchUpL2Transactions( + trx, + preprocessToStateUpdateId + ) + ).toBeRejected() + }) + + it('throws an error if previous state update statistics found without statistics and stateUpdateId > 1', async () => { + const mockedPreprocessedStateDetailsRepository = + mockObject({ + getAllWithoutL2TransactionStatisticsUpToStateUpdateId: + mockFn().resolvesTo(recordsToUpdate), + findByStateUpdateId: mockFn().resolvesTo({ + cumulativeL2TransactionsStatistics: undefined, + }), + }) + const mockedL2TransactionRepository = + mockObject({ + getStatisticsByStateUpdateId: mockFn().resolvesTo( + getStatisticsByStateUpdateIdResult + ), + }) + const stateDetailsPreprocessor = new StateDetailsPreprocessor( + mockedPreprocessedStateDetailsRepository, + mockObject>(), + mockObject(), + mockedL2TransactionRepository, + Logger.SILENT + ) + + await expect(() => + stateDetailsPreprocessor.catchUpL2Transactions( + trx, + preprocessToStateUpdateId + ) + ).toBeRejected() + }) + } + ) }) diff --git a/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.ts b/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.ts index 9baeba5ba..6ec532501 100644 --- a/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.ts +++ b/packages/backend/src/core/preprocessing/StateDetailsPreprocessor.ts @@ -1,18 +1,23 @@ import { Logger } from '@l2beat/backend-tools' import { Knex } from 'knex' +import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' import { PreprocessedAssetHistoryRepository } from '../../peripherals/database/PreprocessedAssetHistoryRepository' import { PreprocessedStateDetailsRepository } from '../../peripherals/database/PreprocessedStateDetailsRepository' import { StateUpdateRecord } from '../../peripherals/database/StateUpdateRepository' import { UserTransactionRepository } from '../../peripherals/database/transactions/UserTransactionRepository' +import { sumNumericValuesByKey } from '../../utils/sumNumericValuesByKey' export class StateDetailsPreprocessor { constructor( - protected readonly preprocessedStateDetailsRepository: PreprocessedStateDetailsRepository, - protected readonly preprocessedAssetHistoryRepository: PreprocessedAssetHistoryRepository, - protected readonly userTransactionRepository: UserTransactionRepository, - protected readonly logger: Logger - ) {} + private readonly preprocessedStateDetailsRepository: PreprocessedStateDetailsRepository, + private readonly preprocessedAssetHistoryRepository: PreprocessedAssetHistoryRepository, + private readonly userTransactionRepository: UserTransactionRepository, + private readonly l2TransactionRepository: L2TransactionRepository, + private readonly logger: Logger + ) { + this.logger = logger.for(this) + } async preprocessNextStateUpdate( trx: Knex.Transaction, @@ -43,6 +48,59 @@ export class StateDetailsPreprocessor { ) } + async catchUpL2Transactions( + trx: Knex.Transaction, + preprocessToStateUpdateId: number + ) { + const recordsToUpdate = + await this.preprocessedStateDetailsRepository.getAllWithoutL2TransactionStatisticsUpToStateUpdateId( + preprocessToStateUpdateId, + trx + ) + + for (const recordToUpdate of recordsToUpdate) { + this.logger.info( + `Catching up L2 transaction statistics for state update ${recordToUpdate.stateUpdateId}` + ) + + const statisticsForStateUpdate = + await this.l2TransactionRepository.getStatisticsByStateUpdateId( + recordToUpdate.stateUpdateId, + trx + ) + + const previousStateUpdateDetails = + await this.preprocessedStateDetailsRepository.findByStateUpdateId( + recordToUpdate.stateUpdateId - 1, + trx + ) + + if ( + recordToUpdate.stateUpdateId > 1 && + !previousStateUpdateDetails?.cumulativeL2TransactionsStatistics + ) { + throw new Error( + 'Statistics for previous state update where not preprocessed. This should never happen.' + ) + } + + await this.preprocessedStateDetailsRepository.update( + { + id: recordToUpdate.id, + l2TransactionsStatistics: statisticsForStateUpdate, + cumulativeL2TransactionsStatistics: + previousStateUpdateDetails?.cumulativeL2TransactionsStatistics + ? sumNumericValuesByKey( + previousStateUpdateDetails.cumulativeL2TransactionsStatistics, + statisticsForStateUpdate + ) + : statisticsForStateUpdate, + }, + trx + ) + } + } + async rollbackOneStateUpdate( trx: Knex.Transaction, lastProcessedStateUpdateId: number diff --git a/packages/backend/src/core/preprocessing/UserL2TransactionsPreprocessor.test.ts b/packages/backend/src/core/preprocessing/UserL2TransactionsPreprocessor.test.ts new file mode 100644 index 000000000..2d550cdfa --- /dev/null +++ b/packages/backend/src/core/preprocessing/UserL2TransactionsPreprocessor.test.ts @@ -0,0 +1,222 @@ +import { StarkKey } from '@explorer/types' +import { Logger } from '@l2beat/backend-tools' +import { expect, mockFn, mockObject } from 'earl' +import { Knex } from 'knex' + +import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' +import { PreprocessedUserL2TransactionsStatisticsRepository } from '../../peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository' +import { fakePreprocessedL2TransactionsStatistics } from '../../test/fakes' +import { sumNumericValuesByKey } from '../../utils/sumNumericValuesByKey' +import { UserL2TransactionsStatisticsPreprocessor } from './UserL2TransactionsPreprocessor' + +describe(UserL2TransactionsStatisticsPreprocessor.name, () => { + describe( + UserL2TransactionsStatisticsPreprocessor.prototype.catchUp.name, + () => { + it('should catch up user L2 transactions from last preprocessed state update id', async () => { + const start = 98 + const preprocessTo = 100 + const starkKeys = [ + StarkKey.fake('a'), + StarkKey.fake('b'), + StarkKey.fake('c'), + ] as const + const fakeStatistics = fakePreprocessedL2TransactionsStatistics() + const starkKeyToStateUpdateHelper = [ + [starkKeys[0], start + 1], + [starkKeys[1], start + 1], + [starkKeys[2], start + 2], + ] as const + + const mockKnexTransaction = mockObject() + const mockPreprocessedUserL2TransactionsRepository = + mockObject({ + findLast: mockFn().resolvesTo({ + stateUpdateId: start, + }), + findLatestByStarkKey: mockFn().resolvesTo({ + cumulativeL2TransactionsStatistics: fakeStatistics, + }), + add: mockFn().resolvesTo(1), + }) + const mockL2TransactionRepository = mockObject( + { + getStarkKeysByStateUpdateId: mockFn() + .resolvesToOnce([starkKeys[0], starkKeys[1]]) + .resolvesToOnce([starkKeys[2]]), + getStatisticsByStateUpdateIdAndStarkKey: + mockFn().resolvesTo(fakeStatistics), + } + ) + + const userL2TransactionsPreprocessor = + new UserL2TransactionsStatisticsPreprocessor( + mockPreprocessedUserL2TransactionsRepository, + mockL2TransactionRepository, + Logger.SILENT + ) + + await userL2TransactionsPreprocessor.catchUp( + mockKnexTransaction, + preprocessTo + ) + + expect( + mockPreprocessedUserL2TransactionsRepository.findLast + ).toHaveBeenCalledTimes(1) + expect( + mockL2TransactionRepository.getStarkKeysByStateUpdateId + ).toHaveBeenNthCalledWith(1, start + 1, mockKnexTransaction) + expect( + mockL2TransactionRepository.getStarkKeysByStateUpdateId + ).toHaveBeenNthCalledWith(2, start + 2, mockKnexTransaction) + + starkKeyToStateUpdateHelper.forEach( + ([starkKey, stateUpdateId], index) => { + expect( + mockL2TransactionRepository.getStatisticsByStateUpdateIdAndStarkKey + ).toHaveBeenNthCalledWith( + index + 1, + stateUpdateId, + starkKey, + mockKnexTransaction + ) + expect( + mockPreprocessedUserL2TransactionsRepository.findLatestByStarkKey + ).toHaveBeenNthCalledWith(index + 1, starkKey, mockKnexTransaction) + expect( + mockPreprocessedUserL2TransactionsRepository.add + ).toHaveBeenNthCalledWith( + index + 1, + { + stateUpdateId, + starkKey, + l2TransactionsStatistics: fakeStatistics, + cumulativeL2TransactionsStatistics: sumNumericValuesByKey( + fakeStatistics, + fakeStatistics + ), + }, + mockKnexTransaction + ) + } + ) + }) + it('should catch up user L2 transactions from 1 if nothing was preprocessed before ', async () => { + const preprocessTo = 2 + const starkKeys = [ + StarkKey.fake('a'), + StarkKey.fake('b'), + StarkKey.fake('c'), + ] as const + const fakeStatistics = fakePreprocessedL2TransactionsStatistics() + const starkKeyToStateUpdateHelper = [ + [starkKeys[0], 1], + [starkKeys[1], 1], + [starkKeys[2], 2], + ] as const + + const mockKnexTransaction = mockObject() + const mockPreprocessedUserL2TransactionsRepository = + mockObject({ + findLast: mockFn().resolvesTo(undefined), + findLatestByStarkKey: mockFn().resolvesTo({ + cumulativeL2TransactionsStatistics: fakeStatistics, + }), + add: mockFn().resolvesTo(1), + }) + const mockL2TransactionRepository = mockObject( + { + getStarkKeysByStateUpdateId: mockFn() + .resolvesToOnce([starkKeys[0], starkKeys[1]]) + .resolvesToOnce([starkKeys[2]]), + getStatisticsByStateUpdateIdAndStarkKey: + mockFn().resolvesTo(fakeStatistics), + } + ) + + const userL2TransactionsPreprocessor = + new UserL2TransactionsStatisticsPreprocessor( + mockPreprocessedUserL2TransactionsRepository, + mockL2TransactionRepository, + Logger.SILENT + ) + + await userL2TransactionsPreprocessor.catchUp( + mockKnexTransaction, + preprocessTo + ) + + expect( + mockPreprocessedUserL2TransactionsRepository.findLast + ).toHaveBeenCalledTimes(1) + expect( + mockL2TransactionRepository.getStarkKeysByStateUpdateId + ).toHaveBeenNthCalledWith(1, 1, mockKnexTransaction) + expect( + mockL2TransactionRepository.getStarkKeysByStateUpdateId + ).toHaveBeenNthCalledWith(2, 2, mockKnexTransaction) + + starkKeyToStateUpdateHelper.forEach( + ([starkKey, stateUpdateId], index) => { + expect( + mockL2TransactionRepository.getStatisticsByStateUpdateIdAndStarkKey + ).toHaveBeenNthCalledWith( + index + 1, + stateUpdateId, + starkKey, + mockKnexTransaction + ) + expect( + mockPreprocessedUserL2TransactionsRepository.findLatestByStarkKey + ).toHaveBeenNthCalledWith(index + 1, starkKey, mockKnexTransaction) + expect( + mockPreprocessedUserL2TransactionsRepository.add + ).toHaveBeenNthCalledWith( + index + 1, + { + stateUpdateId, + starkKey, + l2TransactionsStatistics: fakeStatistics, + cumulativeL2TransactionsStatistics: sumNumericValuesByKey( + fakeStatistics, + fakeStatistics + ), + }, + mockKnexTransaction + ) + } + ) + }) + } + ) + describe( + UserL2TransactionsStatisticsPreprocessor.prototype.rollbackOneStateUpdate + .name, + () => { + it('should rollback one state update', async () => { + const stateUpdateId = 123 + const mockKnexTransaction = mockObject({}) + + const mockPreprocessedUserL2TransactionsRepository = + mockObject({ + deleteByStateUpdateId: mockFn().resolvesTo(1), + }) + const preprocessor = new UserL2TransactionsStatisticsPreprocessor( + mockPreprocessedUserL2TransactionsRepository, + mockObject(), + Logger.SILENT + ) + + await preprocessor.rollbackOneStateUpdate( + mockKnexTransaction, + stateUpdateId + ) + + expect( + mockPreprocessedUserL2TransactionsRepository.deleteByStateUpdateId + ).toHaveBeenCalledWith(stateUpdateId, mockKnexTransaction) + }) + } + ) +}) diff --git a/packages/backend/src/core/preprocessing/UserL2TransactionsPreprocessor.ts b/packages/backend/src/core/preprocessing/UserL2TransactionsPreprocessor.ts new file mode 100644 index 000000000..24a95fd76 --- /dev/null +++ b/packages/backend/src/core/preprocessing/UserL2TransactionsPreprocessor.ts @@ -0,0 +1,80 @@ +import { Logger } from '@l2beat/backend-tools' +import { Knex } from 'knex' + +import { L2TransactionRepository } from '../../peripherals/database/L2TransactionRepository' +import { PreprocessedUserL2TransactionsStatisticsRepository } from '../../peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository' +import { sumNumericValuesByKey } from '../../utils/sumNumericValuesByKey' + +export class UserL2TransactionsStatisticsPreprocessor { + constructor( + private readonly preprocessedUserL2TransactionsStatisticsRepository: PreprocessedUserL2TransactionsStatisticsRepository, + private readonly l2TransactionRepository: L2TransactionRepository, + private readonly logger: Logger + ) { + this.logger = logger.for(this) + } + + async catchUp(trx: Knex.Transaction, preprocessToStateUpdateId: number) { + const lastPreprocessed = + await this.preprocessedUserL2TransactionsStatisticsRepository.findLast() + + const start = lastPreprocessed ? lastPreprocessed.stateUpdateId + 1 : 1 + for ( + let stateUpdateId = start; + stateUpdateId <= preprocessToStateUpdateId; + stateUpdateId++ + ) { + this.logger.info( + `Preprocessing user l2 transactions statistics for state update ${stateUpdateId}` + ) + const starkKeys = + await this.l2TransactionRepository.getStarkKeysByStateUpdateId( + stateUpdateId, + trx + ) + + for (const starkKey of starkKeys) { + const l2TransactionsStatistics = + await this.l2TransactionRepository.getStatisticsByStateUpdateIdAndStarkKey( + stateUpdateId, + starkKey, + trx + ) + + const lastPreprocessedUserL2TransactionsStatistics = + await this.preprocessedUserL2TransactionsStatisticsRepository.findLatestByStarkKey( + starkKey, + trx + ) + + const cumulativeL2TransactionsStatistics = + lastPreprocessedUserL2TransactionsStatistics + ? sumNumericValuesByKey( + lastPreprocessedUserL2TransactionsStatistics.cumulativeL2TransactionsStatistics, + l2TransactionsStatistics + ) + : l2TransactionsStatistics + + await this.preprocessedUserL2TransactionsStatisticsRepository.add( + { + stateUpdateId, + starkKey, + l2TransactionsStatistics, + cumulativeL2TransactionsStatistics, + }, + trx + ) + } + } + } + + async rollbackOneStateUpdate( + trx: Knex.Transaction, + lastProcessedStateUpdateId: number + ) { + await this.preprocessedUserL2TransactionsStatisticsRepository.deleteByStateUpdateId( + lastProcessedStateUpdateId, + trx + ) + } +} diff --git a/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.test.ts b/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.test.ts index 4581d9827..86c6502b0 100644 --- a/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.test.ts +++ b/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.test.ts @@ -5,7 +5,6 @@ import { Knex } from 'knex' import { KeyValueStore } from '../../peripherals/database/KeyValueStore' import { PreprocessedAssetHistoryRepository } from '../../peripherals/database/PreprocessedAssetHistoryRepository' -import { PreprocessedStateUpdateRepository } from '../../peripherals/database/PreprocessedStateUpdateRepository' import { PreprocessedUserStatisticsRepository } from '../../peripherals/database/PreprocessedUserStatisticsRepository' import { StateUpdateRecord, @@ -95,7 +94,6 @@ describe(UserStatisticsPreprocessor.name, () => { const userStatisticsPreprocessor = new UserStatisticsPreprocessor( mockPreprocessedUserStatisticsRepository, mockPreprocessedAssetHistoryRepository, - mockObject(), mockObject(), mockObject(), Logger.SILENT @@ -182,7 +180,6 @@ describe(UserStatisticsPreprocessor.name, () => { const userStatisticsPreprocessor = new UserStatisticsPreprocessor( mockPreprocessedUserStatisticsRepository, mockObject(), - mockObject(), mockObject(), mockObject(), Logger.SILENT diff --git a/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.ts b/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.ts index ef38a2fb9..e88e35dfc 100644 --- a/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.ts +++ b/packages/backend/src/core/preprocessing/UserStatisticsPreprocessor.ts @@ -3,7 +3,6 @@ import { Knex } from 'knex' import { KeyValueStore } from '../../peripherals/database/KeyValueStore' import { PreprocessedAssetHistoryRepository } from '../../peripherals/database/PreprocessedAssetHistoryRepository' -import { PreprocessedStateUpdateRepository } from '../../peripherals/database/PreprocessedStateUpdateRepository' import { PreprocessedUserStatisticsRepository } from '../../peripherals/database/PreprocessedUserStatisticsRepository' import { StateUpdateRecord, @@ -12,15 +11,14 @@ import { export class UserStatisticsPreprocessor { constructor( - private preprocessedUserStatisticsRepository: PreprocessedUserStatisticsRepository, - private preprocessedAssetHistoryRepository: PreprocessedAssetHistoryRepository, - private preprocessedStateUpdateRepository: PreprocessedStateUpdateRepository, - private stateUpdateRepository: StateUpdateRepository, + private readonly preprocessedUserStatisticsRepository: PreprocessedUserStatisticsRepository, + private readonly preprocessedAssetHistoryRepository: PreprocessedAssetHistoryRepository, + private readonly stateUpdateRepository: StateUpdateRepository, private readonly kvStore: KeyValueStore, - protected logger: Logger + private readonly logger: Logger ) {} - async catchUp(trx: Knex.Transaction) { + async catchUp(trx: Knex.Transaction, lastProcessedStateUpdateId: number) { const kvKey = 'userStatisticsPreprocessorCaughtUp' const isCaughtUp = await this.kvStore.findByKey(kvKey, trx) if (isCaughtUp) { @@ -29,12 +27,10 @@ export class UserStatisticsPreprocessor { this.logger.info('Catching up UserStatisticsPreprocessor...') await this.preprocessedUserStatisticsRepository.deleteAll(trx) - const lastProcessedStateUpdate = - await this.preprocessedStateUpdateRepository.findLast(trx) for ( let stateUpdateId = 1; - stateUpdateId <= (lastProcessedStateUpdate?.stateUpdateId ?? 0); + stateUpdateId <= lastProcessedStateUpdateId; stateUpdateId++ ) { const stateUpdate = await this.stateUpdateRepository.findById( diff --git a/packages/backend/src/peripherals/database/L2TransactionRepository.test.ts b/packages/backend/src/peripherals/database/L2TransactionRepository.test.ts index 9857a71ea..ae2133930 100644 --- a/packages/backend/src/peripherals/database/L2TransactionRepository.test.ts +++ b/packages/backend/src/peripherals/database/L2TransactionRepository.test.ts @@ -1,4 +1,7 @@ -import { PerpetualL2MultiTransactionData } from '@explorer/shared' +import { + PerpetualL2MultiTransactionData, + PerpetualL2TransactionData, +} from '@explorer/shared' import { AssetHash, AssetId, @@ -9,11 +12,55 @@ import { } from '@explorer/types' import { Logger } from '@l2beat/backend-tools' import { expect } from 'earl' +import range from 'lodash/range' import { beforeEach, it } from 'mocha' import { setupDatabaseTestSuite } from '../../test/database' import { L2TransactionRepository } from './L2TransactionRepository' +const genericMultiTransaction = ( + transactions: PerpetualL2TransactionData[] +) => ({ + stateUpdateId: 1, + transactionId: 1234, + blockNumber: 12345, + data: { + type: 'MultiTransaction', + transactions, + } as PerpetualL2MultiTransactionData, +}) + +const genericDepositTransaction = { + stateUpdateId: 1, + transactionId: 1234, + blockNumber: 12345, + data: { + type: 'Deposit', + starkKey: StarkKey.fake(), + positionId: 1234n, + amount: 5000n, + }, +} as const + +const genericWithdrawalToAddressTransaction = { + stateUpdateId: 1, + transactionId: 1234, + blockNumber: 12345, + data: { + positionId: 1234n, + starkKey: StarkKey.fake('2'), + ethereumAddress: EthereumAddress.fake(), + amount: 12345n, + nonce: 10n, + expirationTimestamp: Timestamp(1234), + signature: { + r: Hash256.fake(), + s: Hash256.fake(), + }, + type: 'WithdrawalToAddress', + }, +} as const + describe(L2TransactionRepository.name, () => { const { database } = setupDatabaseTestSuite() @@ -23,56 +70,28 @@ describe(L2TransactionRepository.name, () => { describe(`${L2TransactionRepository.prototype.add.name} and ${L2TransactionRepository.prototype.findById.name}`, () => { it('can add a transaction', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - const id = await repository.add(record) + const id = await repository.add(genericDepositTransaction) const transaction = await repository.findById(id) expect(transaction).toEqual({ id, - stateUpdateId: record.stateUpdateId, - transactionId: record.transactionId, - blockNumber: record.blockNumber, - starkKeyA: record.data.starkKey, + stateUpdateId: genericDepositTransaction.stateUpdateId, + transactionId: genericDepositTransaction.transactionId, + blockNumber: genericDepositTransaction.blockNumber, + starkKeyA: genericDepositTransaction.data.starkKey, starkKeyB: undefined, - data: record.data, + data: genericDepositTransaction.data, state: undefined, parentId: undefined, }) }) it('can add an alternative transaction', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const + const record = genericDepositTransaction const alternativeRecord = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 12345n, - amount: 5000n, - }, + ...genericDepositTransaction, + data: { ...genericDepositTransaction.data, positionId: 12345n }, } as const const id = await repository.add(record) @@ -121,35 +140,13 @@ describe(L2TransactionRepository.name, () => { }) it('can add a multi transaction', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } + const record = genericMultiTransaction([ + { ...genericDepositTransaction.data, starkKey: StarkKey.fake('1') }, + { + ...genericWithdrawalToAddressTransaction.data, + starkKey: StarkKey.fake('2'), + }, + ]) const id = await repository.add(record) @@ -192,65 +189,21 @@ describe(L2TransactionRepository.name, () => { }) it('can add a multi transaction as an alternative transaction', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } + const record = genericMultiTransaction([ + { ...genericDepositTransaction.data, starkKey: StarkKey.fake('1') }, + { + ...genericWithdrawalToAddressTransaction.data, + starkKey: StarkKey.fake('2'), + }, + ]) - const alternativeRecord = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('3'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('4'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } + const alternativeRecord = genericMultiTransaction([ + { ...genericDepositTransaction.data, starkKey: StarkKey.fake('3') }, + { + ...genericWithdrawalToAddressTransaction.data, + starkKey: StarkKey.fake('4'), + }, + ]) const id = await repository.add(record) const altId = await repository.add(alternativeRecord) @@ -337,580 +290,320 @@ describe(L2TransactionRepository.name, () => { }) }) - describe( - L2TransactionRepository.prototype.countAllDistinctTransactionIds.name, - () => { - it('returns the number of distinct transactions', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - const multiAltForRecord = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } - const record2 = { - stateUpdateId: 2, - transactionId: 1235, - blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - const altForRecord2 = { - stateUpdateId: 2, - transactionId: 1235, - blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 2500n, - }, - } as const - const multiRecord = { - stateUpdateId: 1, - transactionId: 1237, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } + describe(L2TransactionRepository.prototype.countByTransactionId.name, () => { + it('returns the number of transactions', async () => { + const record = genericDepositTransaction + const record2 = { + ...genericDepositTransaction, + stateUpdateId: 2, + blockNumber: 123456, + } + await repository.add(record) + await repository.add(record2) - await repository.add(record) + const count = await repository.countByTransactionId(record.transactionId) - expect(await repository.countAllDistinctTransactionIds()).toEqual(1) + expect(count).toEqual(2) + }) - await repository.add(multiAltForRecord) + it('considers multi transactions as a single transaction', async () => { + await repository.add( + genericMultiTransaction([ + genericDepositTransaction.data, + genericWithdrawalToAddressTransaction.data, + ]) + ) + await repository.add(genericDepositTransaction) - expect(await repository.countAllDistinctTransactionIds()).toEqual(1) + const count = await repository.countByTransactionId(1234) + expect(count).toEqual(2) + }) - await repository.add(record2) + it('returns 0 if there are no transactions', async () => { + const count = await repository.countByTransactionId(1234) - expect(await repository.countAllDistinctTransactionIds()).toEqual(2) + expect(count).toEqual(0) + }) + }) - await repository.add(altForRecord2) + describe( + L2TransactionRepository.prototype.findAggregatedByTransactionId.name, + () => { + it('returns correct object for transaction', async () => { + const record = genericDepositTransaction - expect(await repository.countAllDistinctTransactionIds()).toEqual(2) + const id = await repository.add(record) - await repository.add(multiRecord) + const transaction = await repository.findAggregatedByTransactionId( + record.transactionId + ) - expect(await repository.countAllDistinctTransactionIds()).toEqual(3) + expect(transaction).toEqual({ + id, + stateUpdateId: record.stateUpdateId, + transactionId: record.transactionId, + blockNumber: record.blockNumber, + originalTransaction: record.data, + alternativeTransactions: [], + }) }) - it('returns 0 if there are no transactions', async () => { - const count = await repository.countAllDistinctTransactionIds() + it('returns correct object for multi transaction', async () => { + const record = genericMultiTransaction([ + genericDepositTransaction.data, + genericWithdrawalToAddressTransaction.data, + ]) + + const id = await repository.add(record) + + const transaction = await repository.findAggregatedByTransactionId( + record.transactionId + ) - expect(count).toEqual(0) + expect(transaction).toEqual({ + id, + stateUpdateId: record.stateUpdateId, + transactionId: record.transactionId, + blockNumber: record.blockNumber, + originalTransaction: record.data, + alternativeTransactions: [], + }) }) - } - ) - describe( - L2TransactionRepository.prototype - .countAllDistinctTransactionIdsByStateUpdateId.name, - () => { - it('returns the number of distinct transactions by stateUpdateId', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - const multiAltForRecord = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } - const record2 = { - stateUpdateId: 1, - transactionId: 1235, - blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - const altForRecord2 = { - stateUpdateId: 1, - transactionId: 1235, - blockNumber: 123456, + it('returns correct object for transaction with alts', async () => { + const record = genericDepositTransaction + const alt1 = { + ...record, data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, + ...record.data, amount: 2500n, }, } as const - const multiRecord = { - stateUpdateId: 1, - transactionId: 1237, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } - const recordForSecondStateUpdate = { - stateUpdateId: 2, - transactionId: 1234, - blockNumber: 12345, + const alt2 = genericMultiTransaction([ + genericDepositTransaction.data, + genericWithdrawalToAddressTransaction.data, + ]) + + const alt3 = { + ...record, data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, + ...record.data, + amount: 1000n, }, } as const - await repository.add(record) - - expect( - await repository.countAllDistinctTransactionIdsByStateUpdateId(1) - ).toEqual(1) - - await repository.add(multiAltForRecord) - - expect( - await repository.countAllDistinctTransactionIdsByStateUpdateId(1) - ).toEqual(1) - - await repository.add(record2) - - expect( - await repository.countAllDistinctTransactionIdsByStateUpdateId(1) - ).toEqual(2) - - await repository.add(altForRecord2) - - expect( - await repository.countAllDistinctTransactionIdsByStateUpdateId(1) - ).toEqual(2) - - await repository.add(multiRecord) + const id = await repository.add(record) + await repository.add(alt1) + await repository.add(alt2) + await repository.add(alt3) - expect( - await repository.countAllDistinctTransactionIdsByStateUpdateId(1) - ).toEqual(3) - - await repository.add(recordForSecondStateUpdate) - - expect( - await repository.countAllDistinctTransactionIdsByStateUpdateId(1) - ).toEqual(3) - expect( - await repository.countAllDistinctTransactionIdsByStateUpdateId(2) - ).toEqual(1) - }) - - it('returns 0 if there are no transactions', async () => { - const count = - await repository.countAllDistinctTransactionIdsByStateUpdateId(123) + const transaction = await repository.findAggregatedByTransactionId( + record.transactionId + ) - expect(count).toEqual(0) + expect(transaction).toEqual({ + id, + stateUpdateId: record.stateUpdateId, + transactionId: record.transactionId, + blockNumber: record.blockNumber, + originalTransaction: record.data, + alternativeTransactions: [alt1.data, alt2.data, alt3.data], + }) }) } ) - describe(L2TransactionRepository.prototype.countAllUserSpecific.name, () => { - const starkKey = StarkKey.fake() - - it('returns the number of transactions', async () => { - await repository.add({ - stateUpdateId: 2, - transactionId: 1234, - blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: starkKey, - positionId: 1234n, - amount: 5000n, - }, - }) - - expect(await repository.countAllUserSpecific(starkKey)).toEqual(1) - }) + describe( + L2TransactionRepository.prototype.getStarkKeysByStateUpdateId.name, + () => { + it('returns correct stark keys', async () => { + const stateUpdateId = 10 + for (const i of range(4)) { + await repository.add({ + ...genericDepositTransaction, + stateUpdateId, + transactionId: 123 + i, + data: { + ...genericDepositTransaction.data, + starkKey: StarkKey.fake(i.toString()), + }, + }) + } - it('returns 0 if there are no user specific transactions', async () => { - await repository.add({ - transactionId: 1235, - stateUpdateId: 1, - blockNumber: 12345, - data: { - type: 'FundingTick', - globalFundingIndices: { - indices: [ - { - syntheticAssetId: AssetId('BTC-10'), - quantizedFundingIndex: 137263953, - }, - ], - timestamp: Timestamp(1657926000), + await repository.add({ + transactionId: 12345, + stateUpdateId, + blockNumber: 12345, + data: { + type: 'ForcedTrade', + starkKeyA: StarkKey.fake('f1'), + starkKeyB: StarkKey.fake('f2'), + positionIdA: 123n, + positionIdB: 1234n, + collateralAssetId: AssetHash.fake(), + syntheticAssetId: AssetId('BTC-10'), + collateralAmount: 1234n, + syntheticAmount: 1234n, + isABuyingSynthetic: true, + nonce: 1222n, + isValid: true, }, - }, - }) - - const count = await repository.countAllUserSpecific(starkKey) - - expect(count).toEqual(0) - }) - }) - - describe(L2TransactionRepository.prototype.countByTransactionId.name, () => { - it('returns the number of transactions', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - const record2 = { - stateUpdateId: 2, - transactionId: 1234, - blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - await repository.add(record) - await repository.add(record2) - - const count = await repository.countByTransactionId(record.transactionId) + }) - expect(count).toEqual(2) - }) + const starkKeys = await repository.getStarkKeysByStateUpdateId( + stateUpdateId + ) - it('considers multi transactions as a single transaction', async () => { - await repository.add({ - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - }) - await repository.add({ - stateUpdateId: 2, - transactionId: 1234, - blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, + expect(starkKeys).toEqualUnsorted([ + StarkKey.fake('f1'), + StarkKey.fake('f2'), + StarkKey.fake('0'), + StarkKey.fake('1'), + StarkKey.fake('2'), + StarkKey.fake('3'), + ]) }) - const count = await repository.countByTransactionId(1234) - expect(count).toEqual(2) - }) - - it('returns 0 if there are no transactions', async () => { - const count = await repository.countByTransactionId(1234) - - expect(count).toEqual(0) - }) - }) - - describe(L2TransactionRepository.prototype.findByTransactionId.name, () => { - it('returns correct object for transaction', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - } as const - - const id = await repository.add(record) + it('returns empty array if there are no transactions', async () => { + const starkKeys = await repository.getStarkKeysByStateUpdateId(1234) - const transaction = await repository.findByTransactionId( - record.transactionId - ) - - expect(transaction).toEqual({ - id, - stateUpdateId: record.stateUpdateId, - transactionId: record.transactionId, - blockNumber: record.blockNumber, - originalTransaction: record.data, - alternativeTransactions: [], + expect(starkKeys).toEqual([]) }) - }) - - it('returns correct object for multi transaction', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } + } + ) - const id = await repository.add(record) + describe( + L2TransactionRepository.prototype.getStatisticsByStateUpdateId.name, + () => { + it('returns correct statistics', async () => { + const stateUpdateId = 1 + const transactionIds = [] + for (let i = 0; i < 10; i++) { + transactionIds.push( + await repository.add({ + ...genericDepositTransaction, + transactionId: 1234 + i, + stateUpdateId, + }) + ) + } + await repository.add({ + ...genericDepositTransaction, + transactionId: 1234, + stateUpdateId, + }) + await repository.add( + genericMultiTransaction([ + genericDepositTransaction.data, + genericWithdrawalToAddressTransaction.data, + ]) + ) - const transaction = await repository.findByTransactionId( - record.transactionId - ) + const statistics = await repository.getStatisticsByStateUpdateId( + stateUpdateId + ) - expect(transaction).toEqual({ - id, - stateUpdateId: record.stateUpdateId, - transactionId: record.transactionId, - blockNumber: record.blockNumber, - originalTransaction: record.data, - alternativeTransactions: [], + expect(statistics).toEqual({ + depositCount: 12, + withdrawalToAddressCount: 1, + forcedWithdrawalCount: 0, + tradeCount: 0, + forcedTradeCount: 0, + transferCount: 0, + conditionalTransferCount: 0, + liquidateCount: 0, + deleverageCount: 0, + fundingTickCount: 0, + oraclePricesTickCount: 0, + multiTransactionCount: 1, + replacedTransactionsCount: 1, + }) }) - }) - - it('returns correct object for transaction with alts', async () => { - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - } as const - const alt1 = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake('2'), - positionId: 1000n, - amount: 2500n, - }, - } as const + } + ) - const alt2 = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake('1'), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake('2'), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), + describe( + L2TransactionRepository.prototype.getStatisticsByStateUpdateIdAndStarkKey + .name, + () => { + it('returns correct statistics', async () => { + const stateUpdateId = 1 + const starkKeys = [StarkKey.fake(), StarkKey.fake()] as const + for (const [index, starkKey] of starkKeys.entries()) { + for (let i = 0; i < 10; i++) { + await repository.add({ + ...genericDepositTransaction, + data: { + ...genericDepositTransaction.data, + starkKey, }, - type: 'WithdrawalToAddress', + transactionId: (1234 + i) * (index + 1), + stateUpdateId, + }) + } + await repository.add({ + transactionId: 1234 * (index + 1), + stateUpdateId, + blockNumber: 12345, + data: { + type: 'ForcedTrade', + starkKeyA: StarkKey.fake(), + starkKeyB: starkKey, + positionIdA: 123n, + positionIdB: 1234n, + collateralAssetId: AssetHash.fake(), + syntheticAssetId: AssetId('BTC-10'), + collateralAmount: 1234n, + syntheticAmount: 1234n, + isABuyingSynthetic: true, + nonce: 1222n, + isValid: true, }, - ], - } as PerpetualL2MultiTransactionData, - } - - const alt3 = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake('3'), - positionId: 234n, - amount: 2500n, - }, - } as const + }) + await repository.add({ + ...genericDepositTransaction, + data: { + ...genericDepositTransaction.data, + starkKey, + }, + transactionId: 12, + stateUpdateId: 123, + }) - const id = await repository.add(record) - await repository.add(alt1) - await repository.add(alt2) - await repository.add(alt3) + await repository.add({ + ...genericMultiTransaction([ + { ...genericDepositTransaction.data, starkKey }, + { ...genericWithdrawalToAddressTransaction.data, starkKey }, + ]), + stateUpdateId: 1, + transactionId: 123456 * (index + 1), + }) + } - const transaction = await repository.findByTransactionId( - record.transactionId - ) + const statistics = + await repository.getStatisticsByStateUpdateIdAndStarkKey( + stateUpdateId, + starkKeys[0] + ) - expect(transaction).toEqual({ - id, - stateUpdateId: record.stateUpdateId, - transactionId: record.transactionId, - blockNumber: record.blockNumber, - originalTransaction: record.data, - alternativeTransactions: [alt1.data, alt2.data, alt3.data], + expect(statistics).toEqual({ + depositCount: 11, + withdrawalToAddressCount: 1, + forcedWithdrawalCount: 0, + tradeCount: 0, + forcedTradeCount: 1, + transferCount: 0, + conditionalTransferCount: 0, + liquidateCount: 0, + deleverageCount: 0, + fundingTickCount: 0, + oraclePricesTickCount: 0, + replacedTransactionsCount: 1, + }) }) - }) - }) + } + ) describe( L2TransactionRepository.prototype.getPaginatedWithoutMulti.name, @@ -920,15 +613,8 @@ describe(L2TransactionRepository.name, () => { for (let i = 0; i < 10; i++) { ids.push( await repository.add({ + ...genericDepositTransaction, transactionId: 1234 + i, - stateUpdateId: 1, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, }) ) } @@ -945,15 +631,8 @@ describe(L2TransactionRepository.name, () => { for (let i = 0; i < 10; i++) { ids.push( await repository.add({ + ...genericDepositTransaction, transactionId: 1234 + i, - stateUpdateId: 1, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, }) ) } @@ -970,47 +649,15 @@ describe(L2TransactionRepository.name, () => { for (let i = 0; i < 10; i++) { ids.push( await repository.add({ + ...genericDepositTransaction, transactionId: 1234 + i, - stateUpdateId: 1, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, }) ) } - const multiTransaction = { - transactionId: 1234, - stateUpdateId: 1, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake(), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, - } + const multiTransaction = genericMultiTransaction([ + genericDepositTransaction.data, + genericWithdrawalToAddressTransaction.data, + ]) const multiId = await repository.add(multiTransaction) multiTransaction.data.transactions.forEach((_, index) => ids.push(multiId + index + 1) @@ -1020,7 +667,6 @@ describe(L2TransactionRepository.name, () => { limit: 5, offset: 0, }) - console.log(records) expect(records.map((x) => x.id)).toEqual(ids.reverse().slice(0, 5)) }) @@ -1037,14 +683,12 @@ describe(L2TransactionRepository.name, () => { for (let i = 0; i < 5; i++) { ids.push( await repository.add({ + ...genericDepositTransaction, transactionId: 1239 + i, stateUpdateId: 2, - blockNumber: 12345, data: { - type: 'Deposit', + ...genericDepositTransaction.data, starkKey: starkKey, - positionId: 1234n, - amount: 5000n, }, }) ) @@ -1137,15 +781,9 @@ describe(L2TransactionRepository.name, () => { for (let i = 0; i < 20; i++) { ids.push( await repository.add({ + ...genericDepositTransaction, transactionId: 1234 + i, stateUpdateId: i < 10 ? 1 : 2, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, }) ) } @@ -1163,15 +801,9 @@ describe(L2TransactionRepository.name, () => { for (let i = 0; i < 20; i++) { ids.push( await repository.add({ + ...genericDepositTransaction, transactionId: 1234 + i, stateUpdateId: i < 10 ? 1 : 2, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, }) ) } @@ -1189,46 +821,18 @@ describe(L2TransactionRepository.name, () => { for (let i = 0; i < 20; i++) { ids.push( await repository.add({ + ...genericDepositTransaction, transactionId: 1234 + i, stateUpdateId: i < 10 ? 1 : 2, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, }) ) } const multiTransaction = { + ...genericMultiTransaction([ + genericDepositTransaction.data, + genericWithdrawalToAddressTransaction.data, + ]), transactionId: 1254, - stateUpdateId: 1, - blockNumber: 12345, - data: { - type: 'MultiTransaction', - transactions: [ - { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - { - positionId: 1234n, - starkKey: StarkKey.fake(), - ethereumAddress: EthereumAddress.fake(), - amount: 12345n, - nonce: 10n, - expirationTimestamp: Timestamp(1234), - signature: { - r: Hash256.fake(), - s: Hash256.fake(), - }, - type: 'WithdrawalToAddress', - }, - ], - } as PerpetualL2MultiTransactionData, } const multiId = await repository.add(multiTransaction) multiTransaction.data.transactions.forEach((_, index) => @@ -1261,27 +865,12 @@ describe(L2TransactionRepository.name, () => { it('returns the latest state update id', async () => { const latestStateUpdateRecord = { + ...genericDepositTransaction, stateUpdateId: 10, transactionId: 12345, blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const - const record = { - stateUpdateId: 1, - transactionId: 1234, - blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const + } + const record = genericDepositTransaction await repository.add(record) await repository.add(latestStateUpdateRecord) @@ -1297,27 +886,17 @@ describe(L2TransactionRepository.name, () => { describe(L2TransactionRepository.prototype.deleteAfterBlock.name, () => { it('deletes transactions after a block', async () => { const record = { + ...genericDepositTransaction, stateUpdateId: 1, transactionId: 1234, blockNumber: 12345, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const + } const recordToBeDeleted = { + ...genericDepositTransaction, stateUpdateId: 2, transactionId: 12345, blockNumber: 123456, - data: { - type: 'Deposit', - starkKey: StarkKey.fake(), - positionId: 1234n, - amount: 5000n, - }, - } as const + } const id = await repository.add(record) const deletedId = await repository.add(recordToBeDeleted) diff --git a/packages/backend/src/peripherals/database/L2TransactionRepository.ts b/packages/backend/src/peripherals/database/L2TransactionRepository.ts index 4a92c9279..ea4863cfd 100644 --- a/packages/backend/src/peripherals/database/L2TransactionRepository.ts +++ b/packages/backend/src/peripherals/database/L2TransactionRepository.ts @@ -6,12 +6,17 @@ import { StarkKey } from '@explorer/types' import { Logger } from '@l2beat/backend-tools' import { Knex } from 'knex' import { L2TransactionRow } from 'knex/types/tables' +import uniq from 'lodash/uniq' import { PaginationOptions } from '../../model/PaginationOptions' import { - decodeTransactionData, + decodeL2TransactionData, encodeL2TransactionData, } from './PerpetualL2Transaction' +import { + PreprocessedL2TransactionsStatistics, + PreprocessedUserL2TransactionsStatistics, +} from './PreprocessedL2TransactionsStatistics' import { BaseRepository } from './shared/BaseRepository' import { Database } from './shared/Database' @@ -48,15 +53,25 @@ export class L2TransactionRepository extends BaseRepository { super(database, logger) /* eslint-disable @typescript-eslint/unbound-method */ this.add = this.wrapAdd(this.add) - this.countAllDistinctTransactionIds = this.wrapAny( - this.countAllDistinctTransactionIds - ) - this.countAllUserSpecific = this.wrapAny(this.countAllUserSpecific) this.countByTransactionId = this.wrapAny(this.countByTransactionId) + this.getStarkKeysByStateUpdateId = this.wrapGet( + this.getStarkKeysByStateUpdateId + ) + this.getStatisticsByStateUpdateId = this.wrapAny( + this.getStatisticsByStateUpdateId + ) + this.getStatisticsByStateUpdateIdAndStarkKey = this.wrapAny( + this.getStatisticsByStateUpdateIdAndStarkKey + ) this.getPaginatedWithoutMulti = this.wrapGet(this.getPaginatedWithoutMulti) + this.getPaginatedWithoutMultiByStateUpdateId = this.wrapGet( + this.getPaginatedWithoutMultiByStateUpdateId + ) this.getUserSpecificPaginated = this.wrapGet(this.getUserSpecificPaginated) this.findById = this.wrapFind(this.findById) - this.findByTransactionId = this.wrapFind(this.findByTransactionId) + this.findAggregatedByTransactionId = this.wrapFind( + this.findAggregatedByTransactionId + ) this.findLatestStateUpdateId = this.wrapFind(this.findLatestStateUpdateId) this.deleteAfterBlock = this.wrapDelete(this.deleteAfterBlock) this.deleteAll = this.wrapDelete(this.deleteAll) @@ -165,47 +180,105 @@ export class L2TransactionRepository extends BaseRepository { return parentId } - async countAllDistinctTransactionIds() { + async countByTransactionId(transactionId: number): Promise { const knex = await this.knex() - const [result] = await knex('l2_transactions').countDistinct( - 'transaction_id' - ) + const [result] = await knex('l2_transactions') + // We filter out the child transactions because they should not be counted as separate transactions + .where({ transaction_id: transactionId, parent_id: null }) + .count() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return Number(result!.count) } - async countAllDistinctTransactionIdsByStateUpdateId(stateUpdateId: number) { - const knex = await this.knex() - const [result] = await knex('l2_transactions') - .where({ state_update_id: stateUpdateId }) - .countDistinct('transaction_id') - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return Number(result!.count) + async getStarkKeysByStateUpdateId( + stateUpdateId: number, + trx?: Knex.Transaction + ) { + const knex = await this.knex(trx) + const starkKeyARows = (await knex('l2_transactions') + .distinct('stark_key_a') + .whereNotNull('stark_key_a') + .andWhere({ state_update_id: stateUpdateId })) as { + stark_key_a: string + }[] + + const starkKeyBRows = (await knex('l2_transactions') + .distinct('stark_key_b') + .whereNotNull('stark_key_b') + .andWhere({ state_update_id: stateUpdateId })) as { + stark_key_b: string + }[] + + const uniqStarkKeys = uniq([ + ...starkKeyARows.map((row) => row.stark_key_a), + ...starkKeyBRows.map((row) => row.stark_key_b), + ]) + + return uniqStarkKeys.map((starkKey) => StarkKey(starkKey)) } - async countAllUserSpecific(starkKey: StarkKey) { - const knex = await this.knex() - const [result] = await knex('l2_transactions') - .where({ - stark_key_a: starkKey.toString(), - }) - .orWhere({ - stark_key_b: starkKey.toString(), - }) + async getStatisticsByStateUpdateId( + stateUpdateId: number, + trx?: Knex.Transaction + ): Promise { + const knex = await this.knex(trx) + + const countGroupedByType = (await knex('l2_transactions') + .select('type') + .where({ state_update_id: stateUpdateId }) .count() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return Number(result!.count) - } + .groupBy('type')) as { + type: PerpetualL2TransactionData['type'] + count: number + }[] - async countByTransactionId(transactionId: number): Promise { - const knex = await this.knex() - const [result] = await knex('l2_transactions') - // We filter out the child transactions because they should not be counted as separate transactions - .where({ transaction_id: transactionId, parent_id: null }) + const [replaced] = await knex('l2_transactions') + .where({ state_update_id: stateUpdateId }) + .andWhere({ state: 'replaced' }) .count() - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return Number(result!.count) + return toPreprocessedL2TransactionsStatistics( + countGroupedByType, + Number(replaced?.count ?? 0) + ) + } + + async getStatisticsByStateUpdateIdAndStarkKey( + stateUpdateId: number, + starkKey: StarkKey, + trx?: Knex.Transaction + ): Promise { + const knex = await this.knex(trx) + const countGroupedByType = (await knex('l2_transactions') + .select('type') + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where((qB) => + qB + .where({ stark_key_a: starkKey.toString() }) + .orWhere({ stark_key_b: starkKey.toString() }) + ) + .andWhere({ state_update_id: stateUpdateId }) + .count() + .groupBy('type')) as { + type: Exclude + count: number + }[] + + const [replaced] = await knex('l2_transactions') + .where({ state: 'replaced' }) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .andWhere((qB) => + qB + .where({ stark_key_a: starkKey.toString() }) + .orWhere({ stark_key_b: starkKey.toString() }) + ) + .andWhere({ state_update_id: stateUpdateId }) + .count() + return toPreprocessedUserL2TransactionsStatistics( + countGroupedByType, + Number(replaced?.count ?? 0) + ) } async getPaginatedWithoutMulti({ offset, limit }: PaginationOptions) { @@ -255,14 +328,19 @@ export class L2TransactionRepository extends BaseRepository { return rows.map(toRecord) } - async findById(id: number): Promise { - const knex = await this.knex() + async findById( + id: number, + trx?: Knex.Transaction + ): Promise { + const knex = await this.knex(trx) const row = await knex('l2_transactions').where({ id }).first() return row ? toRecord(row) : undefined } - async findByTransactionId(id: number): Promise { + async findAggregatedByTransactionId( + id: number + ): Promise { const knex = await this.knex() const originalTransaction = await knex('l2_transactions') .where({ transaction_id: id, parent_id: null }) @@ -284,8 +362,10 @@ export class L2TransactionRepository extends BaseRepository { return toAggregatedRecord(originalTransaction, alternativeTransactions) } - async findLatestStateUpdateId(): Promise { - const knex = await this.knex() + async findLatestStateUpdateId( + trx?: Knex.Transaction + ): Promise { + const knex = await this.knex(trx) const results = await knex('l2_transactions') .select('state_update_id') .orderBy('state_update_id', 'desc') @@ -317,7 +397,7 @@ function toRecord(row: L2TransactionRow): Record { state: row.state ? row.state : undefined, starkKeyA: row.stark_key_a ? StarkKey(row.stark_key_a) : undefined, starkKeyB: row.stark_key_b ? StarkKey(row.stark_key_b) : undefined, - data: decodeTransactionData(row.data), + data: decodeL2TransactionData(row.data), } } @@ -330,9 +410,106 @@ function toAggregatedRecord( transactionId: transaction.transaction_id, stateUpdateId: transaction.state_update_id, blockNumber: transaction.block_number, - originalTransaction: decodeTransactionData(transaction.data), + originalTransaction: decodeL2TransactionData(transaction.data), alternativeTransactions: alternatives.map((alternative) => - decodeTransactionData(alternative.data) + decodeL2TransactionData(alternative.data) + ), + } +} + +function toPreprocessedUserL2TransactionsStatistics( + countGroupedByType: { + type: Exclude + count: number + }[], + replacedCount: number +): PreprocessedUserL2TransactionsStatistics { + return { + depositCount: Number( + countGroupedByType.find((x) => x.type === 'Deposit')?.count ?? 0 + ), + withdrawalToAddressCount: Number( + countGroupedByType.find((x) => x.type === 'WithdrawalToAddress')?.count ?? + 0 + ), + forcedWithdrawalCount: Number( + countGroupedByType.find((x) => x.type === 'ForcedWithdrawal')?.count ?? 0 + ), + tradeCount: Number( + countGroupedByType.find((x) => x.type === 'Trade')?.count ?? 0 + ), + forcedTradeCount: Number( + countGroupedByType.find((x) => x.type === 'ForcedTrade')?.count ?? 0 + ), + transferCount: Number( + countGroupedByType.find((x) => x.type === 'Transfer')?.count ?? 0 + ), + conditionalTransferCount: Number( + countGroupedByType.find((x) => x.type === 'ConditionalTransfer')?.count ?? + 0 + ), + liquidateCount: Number( + countGroupedByType.find((x) => x.type === 'Liquidate')?.count ?? 0 + ), + deleverageCount: Number( + countGroupedByType.find((x) => x.type === 'Deleverage')?.count ?? 0 + ), + fundingTickCount: Number( + countGroupedByType.find((x) => x.type === 'FundingTick')?.count ?? 0 + ), + oraclePricesTickCount: Number( + countGroupedByType.find((x) => x.type === 'OraclePricesTick')?.count ?? 0 + ), + replacedTransactionsCount: replacedCount, + } +} + +function toPreprocessedL2TransactionsStatistics( + countGroupedByType: { + type: PerpetualL2TransactionData['type'] + count: number + }[], + replacedCount: number +): PreprocessedL2TransactionsStatistics { + return { + depositCount: Number( + countGroupedByType.find((x) => x.type === 'Deposit')?.count ?? 0 + ), + withdrawalToAddressCount: Number( + countGroupedByType.find((x) => x.type === 'WithdrawalToAddress')?.count ?? + 0 + ), + forcedWithdrawalCount: Number( + countGroupedByType.find((x) => x.type === 'ForcedWithdrawal')?.count ?? 0 + ), + tradeCount: Number( + countGroupedByType.find((x) => x.type === 'Trade')?.count ?? 0 + ), + forcedTradeCount: Number( + countGroupedByType.find((x) => x.type === 'ForcedTrade')?.count ?? 0 + ), + transferCount: Number( + countGroupedByType.find((x) => x.type === 'Transfer')?.count ?? 0 + ), + conditionalTransferCount: Number( + countGroupedByType.find((x) => x.type === 'ConditionalTransfer')?.count ?? + 0 + ), + liquidateCount: Number( + countGroupedByType.find((x) => x.type === 'Liquidate')?.count ?? 0 + ), + deleverageCount: Number( + countGroupedByType.find((x) => x.type === 'Deleverage')?.count ?? 0 + ), + fundingTickCount: Number( + countGroupedByType.find((x) => x.type === 'FundingTick')?.count ?? 0 + ), + oraclePricesTickCount: Number( + countGroupedByType.find((x) => x.type === 'OraclePricesTick')?.count ?? 0 + ), + multiTransactionCount: Number( + countGroupedByType.find((x) => x.type === 'MultiTransaction')?.count ?? 0 ), + replacedTransactionsCount: replacedCount, } } diff --git a/packages/backend/src/peripherals/database/PerpetualL2Transaction.test.ts b/packages/backend/src/peripherals/database/PerpetualL2Transaction.test.ts index 40b54e15f..ac21424a7 100644 --- a/packages/backend/src/peripherals/database/PerpetualL2Transaction.test.ts +++ b/packages/backend/src/peripherals/database/PerpetualL2Transaction.test.ts @@ -10,11 +10,11 @@ import { import { expect } from 'earl' import { - decodeTransactionData, + decodeL2TransactionData, encodeL2TransactionData, } from './PerpetualL2Transaction' -describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () => { +describe(`${encodeL2TransactionData.name} and ${decodeL2TransactionData.name}`, () => { it('can handle a Deposit transaction', () => { const data: PerpetualL2TransactionData = { type: 'Deposit', @@ -36,7 +36,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a WithdrawalToAddress transaction', () => { @@ -82,7 +82,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a ForcedWithdrawal transaction', () => { @@ -108,7 +108,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a Trade transaction', () => { @@ -206,7 +206,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a ForcedTrade transaction', () => { @@ -247,7 +247,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) @@ -295,7 +295,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a ConditionalTransfer transaction', () => { @@ -346,7 +346,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a Liquidate transaction', () => { @@ -412,7 +412,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a Deleverage transaction', () => { @@ -443,7 +443,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () }) expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a FundingTick transaction', () => { @@ -483,7 +483,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) it('can handle a OraclePricesTick transaction', () => { @@ -545,7 +545,7 @@ describe(`${encodeL2TransactionData.name} and ${decodeTransactionData.name}`, () expect(JSON.parse(JSON.stringify(encoded.data))).toEqual(encoded.data) - const decoded = decodeTransactionData(encoded.data) + const decoded = decodeL2TransactionData(encoded.data) expect(decoded).toEqual(data) }) }) diff --git a/packages/backend/src/peripherals/database/PerpetualL2Transaction.ts b/packages/backend/src/peripherals/database/PerpetualL2Transaction.ts index d3db78b99..5df8f311e 100644 --- a/packages/backend/src/peripherals/database/PerpetualL2Transaction.ts +++ b/packages/backend/src/peripherals/database/PerpetualL2Transaction.ts @@ -48,7 +48,7 @@ export function encodeL2TransactionData( return encodeL2Transaction(values) } -export function decodeTransactionData( +export function decodeL2TransactionData( values: ToJSON ): PerpetualL2TransactionData { if (values.type === 'MultiTransaction') { diff --git a/packages/backend/src/peripherals/database/PreprocessedL2TransactionsStatistics.test.ts b/packages/backend/src/peripherals/database/PreprocessedL2TransactionsStatistics.test.ts new file mode 100644 index 000000000..511ca1b59 --- /dev/null +++ b/packages/backend/src/peripherals/database/PreprocessedL2TransactionsStatistics.test.ts @@ -0,0 +1,51 @@ +import { expect } from 'earl' +import { it } from 'mocha' + +import { sumUpTransactionCount } from './PreprocessedL2TransactionsStatistics' + +describe(sumUpTransactionCount.name, () => { + it('returns 0 when statistics is undefined', () => { + const result = sumUpTransactionCount(undefined) + + expect(result).toEqual(0) + }) + + it('sums up all the values for PreprocessedL2TransactionsStatistics', () => { + const result = sumUpTransactionCount({ + depositCount: 1, + withdrawalToAddressCount: 2, + forcedWithdrawalCount: 3, + tradeCount: 4, + forcedTradeCount: 5, + transferCount: 6, + conditionalTransferCount: 7, + liquidateCount: 8, + deleverageCount: 9, + fundingTickCount: 10, + oraclePricesTickCount: 11, + multiTransactionCount: 12, + replacedTransactionsCount: 13, + }) + + expect(result).toEqual(66) + }) + + it('sums up all the values for PreprocessedUserL2TransactionsStatistics', () => { + const result = sumUpTransactionCount({ + depositCount: 1, + withdrawalToAddressCount: 2, + forcedWithdrawalCount: 3, + tradeCount: 4, + forcedTradeCount: 5, + transferCount: 6, + conditionalTransferCount: 7, + liquidateCount: 8, + deleverageCount: 9, + fundingTickCount: 10, + oraclePricesTickCount: 11, + replacedTransactionsCount: 13, + }) + + expect(result).toEqual(66) + }) +}) diff --git a/packages/backend/src/peripherals/database/PreprocessedL2TransactionsStatistics.ts b/packages/backend/src/peripherals/database/PreprocessedL2TransactionsStatistics.ts new file mode 100644 index 000000000..9e4e11a06 --- /dev/null +++ b/packages/backend/src/peripherals/database/PreprocessedL2TransactionsStatistics.ts @@ -0,0 +1,48 @@ +import { PerpetualL2TransactionData } from '@explorer/shared' + +type PreprocessedL2TransactionsStatisticsKeys = `${Uncapitalize< + PerpetualL2TransactionData['type'] +>}Count` + +export type PreprocessedL2TransactionsStatistics = { + [key in PreprocessedL2TransactionsStatisticsKeys]: number +} & { + replacedTransactionsCount: number +} + +export type PreprocessedUserL2TransactionsStatistics = Omit< + PreprocessedL2TransactionsStatistics, + 'multiTransactionCount' +> +// We do not need PreprocessedL2TransactionsCountsJSON type because all the fields are numbers + +export function sumUpTransactionCount( + statistics: + | PreprocessedL2TransactionsStatistics + | PreprocessedUserL2TransactionsStatistics + | undefined +) { + if (!statistics) return 0 + + const multiTransactionCount = isPreprocessedL2TransactionsStatistics( + statistics + ) + ? statistics.multiTransactionCount + : 0 + + const replacedAndMultiTransactionCount = + multiTransactionCount + statistics.replacedTransactionsCount + + return Object.values(statistics).reduce( + (sum, value) => sum + value, + -replacedAndMultiTransactionCount + ) +} + +function isPreprocessedL2TransactionsStatistics( + statistics: + | PreprocessedL2TransactionsStatistics + | PreprocessedUserL2TransactionsStatistics +): statistics is PreprocessedL2TransactionsStatistics { + return 'multiTransactionCount' in statistics +} diff --git a/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.test.ts b/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.test.ts index b0f476e5a..c6dfcfe82 100644 --- a/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.test.ts +++ b/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.test.ts @@ -4,6 +4,7 @@ import { expect } from 'earl' import { Knex } from 'knex' import { setupDatabaseTestSuite } from '../../test/database' +import { fakePreprocessedL2TransactionsStatistics } from '../../test/fakes' import { PreprocessedStateDetailsRecord, PreprocessedStateDetailsRepository, @@ -65,57 +66,215 @@ describe(PreprocessedStateDetailsRepository.name, () => { await trx.rollback() }) - it('adds preprocessed state update', async () => { - await repository.add(genericRecord, trx) + describe(PreprocessedStateDetailsRepository.prototype.countAll.name, () => { + it('counts all', async () => { + for (const stateUpdateId of [1900, 100, 200]) { + await repository.add( + { + ...genericRecord, + stateUpdateId, + }, + trx + ) + } + expect(await repository.countAll(trx)).toEqual(3) + }) }) - it('counts all', async () => { - for (const stateUpdateId of [1900, 100, 200]) { - await repository.add( - { - ...genericRecord, - stateUpdateId, - }, - trx - ) + describe( + PreprocessedStateDetailsRepository.prototype.deleteByStateUpdateId.name, + () => { + it('removes by state update id', async () => { + for (const stateUpdateId of [1900, 100, 200]) { + await repository.add( + { + ...genericRecord, + stateUpdateId, + }, + trx + ) + } + + await repository.deleteByStateUpdateId(30_003_000, trx) + expect(await repository.countAll(trx)).toEqual(3) + await repository.deleteByStateUpdateId(1900, trx) + expect(await repository.countAll(trx)).toEqual(2) + await repository.deleteByStateUpdateId(100, trx) + expect(await repository.countAll(trx)).toEqual(1) + await repository.deleteByStateUpdateId(200, trx) + expect(await repository.countAll(trx)).toEqual(0) + }) } - expect(await repository.countAll(trx)).toEqual(3) - }) + ) - it('removes by state update id', async () => { - for (const stateUpdateId of [1900, 100, 200]) { - await repository.add( - { + describe( + PreprocessedStateDetailsRepository.prototype + .findLastWithL2TransactionsStatistics.name, + () => { + it('returns undefined when no records', async () => { + await repository.add(genericRecord, trx) + + const result = await repository.findLastWithL2TransactionsStatistics( + trx + ) + + expect(result).toEqual(undefined) + }) + + it('returns most recent record with L2 transaction statistics', async () => { + const l2TransactionsStatistics = + fakePreprocessedL2TransactionsStatistics() + const cumulativeL2TransactionsStatistics = + fakePreprocessedL2TransactionsStatistics() + const id = await repository.add( + { + ...genericRecord, + stateUpdateId: 100, + l2TransactionsStatistics, + cumulativeL2TransactionsStatistics, + }, + trx + ) + await repository.add( + { + ...genericRecord, + stateUpdateId: 200, + }, + trx + ) + await repository.add( + { + ...genericRecord, + stateUpdateId: 1900, + }, + trx + ) + + const result = await repository.findLastWithL2TransactionsStatistics( + trx + ) + + expect(result).toEqual({ ...genericRecord, - stateUpdateId, - }, - trx - ) + id, + stateUpdateId: 100, + l2TransactionsStatistics, + cumulativeL2TransactionsStatistics, + }) + }) } + ) - await repository.deleteByStateUpdateId(30_003_000, trx) - expect(await repository.countAll(trx)).toEqual(3) - await repository.deleteByStateUpdateId(1900, trx) - expect(await repository.countAll(trx)).toEqual(2) - await repository.deleteByStateUpdateId(100, trx) - expect(await repository.countAll(trx)).toEqual(1) - await repository.deleteByStateUpdateId(200, trx) - expect(await repository.countAll(trx)).toEqual(0) - }) + describe( + PreprocessedStateDetailsRepository.prototype + .getAllWithoutL2TransactionStatisticsUpToStateUpdateId.name, + () => { + it('returns empty array when no records', async () => { + const result = + await repository.getAllWithoutL2TransactionStatisticsUpToStateUpdateId( + 1000, + trx + ) - it('gets paginated', async () => { - for (const stateUpdateId of [1900, 100, 200]) { - await repository.add( - { - ...genericRecord, - stateUpdateId, - }, + expect(result).toEqual([]) + }) + + it('returns all records without L2 transaction statistics up to state update id', async () => { + const id1 = await repository.add( + { ...genericRecord, stateUpdateId: 100 }, + trx + ) + const id2 = await repository.add( + { ...genericRecord, stateUpdateId: 200 }, + trx + ) + await repository.add( + { + ...genericRecord, + stateUpdateId: 100, + l2TransactionsStatistics: + fakePreprocessedL2TransactionsStatistics(), + }, + trx + ) + await repository.add({ ...genericRecord, stateUpdateId: 1900 }, trx) + + const results = + await repository.getAllWithoutL2TransactionStatisticsUpToStateUpdateId( + 1899, + trx + ) + + expect(results).toEqual([ + { + ...genericRecord, + stateUpdateId: 100, + id: id1, + l2TransactionsStatistics: undefined, + cumulativeL2TransactionsStatistics: undefined, + }, + { + ...genericRecord, + stateUpdateId: 200, + id: id2, + l2TransactionsStatistics: undefined, + cumulativeL2TransactionsStatistics: undefined, + }, + ]) + }) + } + ) + + describe( + PreprocessedStateDetailsRepository.prototype.getPaginated.name, + () => { + it('gets paginated', async () => { + for (const stateUpdateId of [1900, 100, 200]) { + await repository.add( + { + ...genericRecord, + stateUpdateId, + }, + trx + ) + } + const result1 = await repository.getPaginated( + { offset: 0, limit: 2 }, + trx + ) + expect(result1.map((r) => r.stateUpdateId)).toEqual([1900, 200]) + const result2 = await repository.getPaginated( + { offset: 2, limit: 2 }, + trx + ) + expect(result2.map((r) => r.stateUpdateId)).toEqual([100]) + }) + } + ) + + describe(`${PreprocessedStateDetailsRepository.prototype.add.name}, ${PreprocessedStateDetailsRepository.prototype.update.name} and ${PreprocessedStateDetailsRepository.prototype.findById.name}`, () => { + it('adds, updates, finds by id and state update id', async () => { + const id = await repository.add(genericRecord, trx) + const fieldsToUpdate = { + l2TransactionsStatistics: fakePreprocessedL2TransactionsStatistics(), + cumulativeL2TransactionsStatistics: + fakePreprocessedL2TransactionsStatistics(), + } + const updatedRecord = { + ...genericRecord, + ...fieldsToUpdate, + id, + } + + await repository.update(updatedRecord, trx) + const recordById = await repository.findById(id, trx) + const recordByStateUpdateId = await repository.findByStateUpdateId( + updatedRecord.stateUpdateId, trx ) - } - const result1 = await repository.getPaginated({ offset: 0, limit: 2 }, trx) - expect(result1.map((r) => r.stateUpdateId)).toEqual([1900, 200]) - const result2 = await repository.getPaginated({ offset: 2, limit: 2 }, trx) - expect(result2.map((r) => r.stateUpdateId)).toEqual([100]) + + expect(recordById).toEqual(updatedRecord) + expect(recordByStateUpdateId).toEqual(updatedRecord) + }) }) }) diff --git a/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.ts b/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.ts index aea4b1713..c8acba672 100644 --- a/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.ts +++ b/packages/backend/src/peripherals/database/PreprocessedStateDetailsRepository.ts @@ -4,6 +4,7 @@ import { Knex } from 'knex' import { PreprocessedStateDetailsRow } from 'knex/types/tables' import { PaginationOptions } from '../../model/PaginationOptions' +import { PreprocessedL2TransactionsStatistics } from './PreprocessedL2TransactionsStatistics' import { BaseRepository } from './shared/BaseRepository' import { Database } from './shared/Database' @@ -16,6 +17,14 @@ export interface PreprocessedStateDetailsRecord { timestamp: Timestamp assetUpdateCount: number forcedTransactionCount: number + l2TransactionsStatistics?: PreprocessedL2TransactionsStatistics + cumulativeL2TransactionsStatistics?: PreprocessedL2TransactionsStatistics +} + +export interface PreprocessedStateDetailsRecordWithL2TransactionsStatistics + extends PreprocessedStateDetailsRecord { + l2TransactionsStatistics: PreprocessedL2TransactionsStatistics + cumulativeL2TransactionsStatistics: PreprocessedL2TransactionsStatistics } export class PreprocessedStateDetailsRepository extends BaseRepository { @@ -25,9 +34,18 @@ export class PreprocessedStateDetailsRepository extends BaseRepository { /* eslint-disable @typescript-eslint/unbound-method */ this.add = this.wrapAdd(this.add) this.countAll = this.wrapAny(this.countAll) + this.findById = this.wrapFind(this.findById) + this.findByStateUpdateId = this.wrapFind(this.findByStateUpdateId) + this.findLastWithL2TransactionsStatistics = this.wrapFind( + this.findLastWithL2TransactionsStatistics + ) + this.getAllWithoutL2TransactionStatisticsUpToStateUpdateId = this.wrapGet( + this.getAllWithoutL2TransactionStatisticsUpToStateUpdateId + ) this.getPaginated = this.wrapGet(this.getPaginated) this.deleteAll = this.wrapDelete(this.deleteAll) this.deleteByStateUpdateId = this.wrapDelete(this.deleteByStateUpdateId) + this.update = this.wrapUpdate(this.update) /* eslint-enable @typescript-eslint/unbound-method */ } @@ -36,10 +54,12 @@ export class PreprocessedStateDetailsRepository extends BaseRepository { trx: Knex.Transaction ): Promise { const knex = await this.knex(trx) - await knex('preprocessed_state_details').insert( - toPreprocessedStateDetailsRow(row) - ) - return row.stateUpdateId + const results = await knex('preprocessed_state_details') + .insert(toPreprocessedStateDetailsRow(row)) + .returning('id') + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return results[0]!.id } async countAll(trx?: Knex.Transaction): Promise { @@ -49,6 +69,57 @@ export class PreprocessedStateDetailsRepository extends BaseRepository { return Number(result!.count) } + async findById( + id: number, + trx?: Knex.Transaction + ): Promise { + const knex = await this.knex(trx) + const record = await knex('preprocessed_state_details') + .where({ id }) + .first() + + return record ? toPreprocessedStateDetailsRecord(record) : undefined + } + + async findByStateUpdateId( + stateUpdateId: number, + trx?: Knex.Transaction + ): Promise { + const knex = await this.knex(trx) + const record = await knex('preprocessed_state_details') + .where({ state_update_id: stateUpdateId }) + .first() + + return record ? toPreprocessedStateDetailsRecord(record) : undefined + } + + async findLastWithL2TransactionsStatistics(trx?: Knex.Transaction) { + const knex = await this.knex(trx) + const record = await knex('preprocessed_state_details') + .whereNotNull('l2_transactions_statistics') + .orderBy('state_update_id', 'desc') + .first() + + return record + ? (toPreprocessedStateDetailsRecord( + record + ) as PreprocessedStateDetailsRecordWithL2TransactionsStatistics) + : undefined + } + + async getAllWithoutL2TransactionStatisticsUpToStateUpdateId( + stateUpdateId: number, + trx: Knex.Transaction + ) { + const knex = await this.knex(trx) + const results = await knex('preprocessed_state_details') + .whereNull('l2_transactions_statistics') + .andWhere('state_update_id', '<=', stateUpdateId) + .orderBy('state_update_id', 'asc') + + return results.map(toPreprocessedStateDetailsRecord) + } + async getPaginated( { offset, limit }: PaginationOptions, trx?: Knex.Transaction @@ -72,6 +143,18 @@ export class PreprocessedStateDetailsRepository extends BaseRepository { .where('state_update_id', stateUpdateId) .delete() } + + async update( + record: Pick & + Partial, + trx?: Knex.Transaction + ) { + const knex = await this.knex(trx) + const row = toPartialPreprocessedStateDetailsRow(record) + return await knex('preprocessed_state_details') + .where({ id: row.id }) + .update(row) + } } function toPreprocessedStateDetailsRecord( @@ -86,6 +169,9 @@ function toPreprocessedStateDetailsRecord( timestamp: Timestamp(row.timestamp), assetUpdateCount: row.asset_update_count, forcedTransactionCount: row.forced_transaction_count, + l2TransactionsStatistics: row.l2_transactions_statistics ?? undefined, + cumulativeL2TransactionsStatistics: + row.cumulative_l2_transactions_statistics ?? undefined, } } @@ -100,5 +186,32 @@ function toPreprocessedStateDetailsRow( timestamp: BigInt(record.timestamp.toString()), asset_update_count: record.assetUpdateCount, forced_transaction_count: record.forcedTransactionCount, + l2_transactions_statistics: record.l2TransactionsStatistics ?? null, + cumulative_l2_transactions_statistics: + record.cumulativeL2TransactionsStatistics ?? null, + } +} + +function toPartialPreprocessedStateDetailsRow( + record: Pick & + Partial +): Pick & + Partial { + return { + id: record.id, + state_update_id: record.stateUpdateId, + state_transition_hash: record.stateTransitionHash + ? record.stateTransitionHash.toString() + : undefined, + root_hash: record.rootHash ? record.rootHash.toString() : undefined, + block_number: record.blockNumber, + timestamp: record.timestamp + ? BigInt(record.timestamp.toString()) + : undefined, + asset_update_count: record.assetUpdateCount, + forced_transaction_count: record.forcedTransactionCount, + l2_transactions_statistics: record.l2TransactionsStatistics, + cumulative_l2_transactions_statistics: + record.cumulativeL2TransactionsStatistics, } } diff --git a/packages/backend/src/peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository.test.ts b/packages/backend/src/peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository.test.ts new file mode 100644 index 000000000..b552113e9 --- /dev/null +++ b/packages/backend/src/peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository.test.ts @@ -0,0 +1,108 @@ +import { Hash256, StarkKey } from '@explorer/types' +import { Logger } from '@l2beat/backend-tools' +import { expect } from 'earl' +import { Knex } from 'knex' + +import { setupDatabaseTestSuite } from '../../test/database' +import { fakePreprocessedL2TransactionsStatistics } from '../../test/fakes' +import { PreprocessedStateUpdateRepository } from './PreprocessedStateUpdateRepository' +import { PreprocessedUserL2TransactionsStatisticsRepository } from './PreprocessedUserL2TransactionsStatisticsRepository' + +const mockRecord = { + stateUpdateId: 1900, + starkKey: StarkKey.fake(), + l2TransactionsStatistics: fakePreprocessedL2TransactionsStatistics(), + cumulativeL2TransactionsStatistics: + fakePreprocessedL2TransactionsStatistics(), +} + +describe(PreprocessedUserL2TransactionsStatisticsRepository.name, () => { + const { database } = setupDatabaseTestSuite() + let trx: Knex.Transaction + + const repository = new PreprocessedUserL2TransactionsStatisticsRepository( + database, + new Logger({ format: 'pretty', logLevel: 'ERROR' }) + ) + + const preprocessedStateUpdateRepository = + new PreprocessedStateUpdateRepository(database, Logger.SILENT) + + beforeEach(async () => { + const knex = await database.getKnex() + trx = await knex.transaction() + // adding records to preprocessed_asset_history table + // to satisfy foreign key constraint of state_update_id + await preprocessedStateUpdateRepository.deleteAll(trx) + await preprocessedStateUpdateRepository.add( + { + stateUpdateId: 100, + stateTransitionHash: Hash256.fake('12000'), + }, + trx + ) + await preprocessedStateUpdateRepository.add( + { + stateUpdateId: 200, + stateTransitionHash: Hash256.fake('13000'), + }, + trx + ), + await preprocessedStateUpdateRepository.add( + { + stateUpdateId: 1900, + stateTransitionHash: Hash256.fake('19000'), + }, + trx + ) + await repository.deleteAll(trx) + }) + + afterEach(async () => { + await trx.rollback() + }) + + describe(`${PreprocessedUserL2TransactionsStatisticsRepository.prototype.add.name}, ${PreprocessedUserL2TransactionsStatisticsRepository.prototype.findLast.name}, ${PreprocessedUserL2TransactionsStatisticsRepository.prototype.findLatestByStarkKey.name}`, () => { + it('adds, finds current by stark key and finds last ', async () => { + const id = await repository.add(mockRecord, trx) + + const current = await repository.findLatestByStarkKey( + mockRecord.starkKey, + trx + ) + const last = await repository.findLast(trx) + + const expectedRecord = { + id, + ...mockRecord, + } + expect(current).toEqual(expectedRecord) + expect(last).toEqual(expectedRecord) + }) + }) + + describe( + PreprocessedUserL2TransactionsStatisticsRepository.prototype + .deleteByStateUpdateId.name, + () => { + it('removes by state update id', async () => { + const starkKey = StarkKey.fake('1234aa') + for (const stateUpdateId of [100, 200, 1900]) { + await repository.add( + { + ...mockRecord, + starkKey, + stateUpdateId, + }, + trx + ) + } + + await repository.deleteByStateUpdateId(1900, trx) + expect( + (await repository.findLatestByStarkKey(starkKey, trx))?.stateUpdateId + ).toEqual(200) + }) + } + ) +}) diff --git a/packages/backend/src/peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository.ts b/packages/backend/src/peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository.ts new file mode 100644 index 000000000..f03adbc93 --- /dev/null +++ b/packages/backend/src/peripherals/database/PreprocessedUserL2TransactionsStatisticsRepository.ts @@ -0,0 +1,101 @@ +import { StarkKey } from '@explorer/types' +import { Logger } from '@l2beat/backend-tools' +import { Knex } from 'knex' +import { PreprocessedUserL2TransactionsStatisticsRow } from 'knex/types/tables' + +import { PreprocessedUserL2TransactionsStatistics } from './PreprocessedL2TransactionsStatistics' +import { BaseRepository } from './shared/BaseRepository' +import { Database } from './shared/Database' + +export interface PreprocessedUserL2TransactionsStatisticsRecord { + id: number + stateUpdateId: number + starkKey: StarkKey + l2TransactionsStatistics: PreprocessedUserL2TransactionsStatistics + cumulativeL2TransactionsStatistics: PreprocessedUserL2TransactionsStatistics +} + +export class PreprocessedUserL2TransactionsStatisticsRepository extends BaseRepository { + constructor(database: Database, logger: Logger) { + super(database, logger) + /* eslint-disable @typescript-eslint/unbound-method */ + this.add = this.wrapAdd(this.add) + this.findLast = this.wrapFind(this.findLast) + this.findLatestByStarkKey = this.wrapFind(this.findLatestByStarkKey) + this.deleteByStateUpdateId = this.wrapDelete(this.deleteByStateUpdateId) + this.deleteAll = this.wrapDelete(this.deleteAll) + /* eslint-enable @typescript-eslint/unbound-method */ + } + + async add( + record: Omit, + trx: Knex.Transaction + ): Promise { + const knex = await this.knex(trx) + const results = await knex('preprocessed_user_l2_transactions_statistics') + .insert(toPreprocessedUserL2TransactionsStatisticsRow(record)) + .returning('id') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return results[0]!.id + } + + async findLast(trx?: Knex.Transaction) { + const knex = await this.knex(trx) + const row = await knex('preprocessed_user_l2_transactions_statistics') + .orderBy('state_update_id', 'desc') + .first() + + return row + ? toPreprocessedUserL2TransactionsStatisticsRecord(row) + : undefined + } + + async findLatestByStarkKey(starkKey: StarkKey, trx?: Knex.Transaction) { + const knex = await this.knex(trx) + const row = await knex('preprocessed_user_l2_transactions_statistics') + .where('stark_key', starkKey.toString()) + .orderBy('state_update_id', 'desc') + .first() + + return row + ? toPreprocessedUserL2TransactionsStatisticsRecord(row) + : undefined + } + + async deleteByStateUpdateId(stateUpdateId: number, trx: Knex.Transaction) { + const knex = await this.knex(trx) + return knex('preprocessed_user_l2_transactions_statistics') + .where('state_update_id', stateUpdateId) + .delete() + } + + async deleteAll(trx: Knex.Transaction) { + const knex = await this.knex(trx) + return knex('preprocessed_user_l2_transactions_statistics').delete() + } +} + +function toPreprocessedUserL2TransactionsStatisticsRecord( + row: PreprocessedUserL2TransactionsStatisticsRow +): PreprocessedUserL2TransactionsStatisticsRecord { + return { + id: row.id, + stateUpdateId: row.state_update_id, + starkKey: StarkKey(row.stark_key), + l2TransactionsStatistics: row.l2_transactions_statistics, + cumulativeL2TransactionsStatistics: + row.cumulative_l2_transactions_statistics, + } +} + +function toPreprocessedUserL2TransactionsStatisticsRow( + record: Omit +): Omit { + return { + state_update_id: record.stateUpdateId, + stark_key: record.starkKey.toString(), + l2_transactions_statistics: record.l2TransactionsStatistics, + cumulative_l2_transactions_statistics: + record.cumulativeL2TransactionsStatistics, + } +} diff --git a/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.test.ts b/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.test.ts index 86f59d1aa..ae4983b38 100644 --- a/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.test.ts +++ b/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.test.ts @@ -62,55 +62,103 @@ describe(PreprocessedUserStatisticsRepository.name, () => { await trx.rollback() }) - it('adds preprocessed state update', async () => { - await repository.add(mockRecord, trx) - }) + describe(`${PreprocessedUserStatisticsRepository.prototype.add.name} and ${PreprocessedUserStatisticsRepository.prototype.update.name}`, () => { + it('adds and updates', async () => { + const id = await repository.add(mockRecord, trx) - it('returns undefined when no current (most recent) statistics record', async () => { - const starkKey = StarkKey.fake('1234aa') - const last = await repository.findCurrentByStarkKey(starkKey, trx) - expect(last).toEqual(undefined) - }) + const last = await repository.findCurrentByStarkKey( + mockRecord.starkKey, + trx + ) - it('finds current (most recent) statistics record by Stark Key', async () => { - const starkKey = StarkKey.fake('1234aa') - const otherKey = StarkKey.fake('1235bb') - await repository.add( - { ...mockRecord, starkKey, timestamp: Timestamp(100) }, - trx - ) - await repository.add( - { ...mockRecord, starkKey, timestamp: Timestamp(200) }, - trx - ) - const mostRecent = { ...mockRecord, starkKey, timestamp: Timestamp(300) } - const id = await repository.add(mostRecent, trx) - await repository.add( - { ...mockRecord, starkKey: otherKey, timestamp: Timestamp(400) }, - trx - ) + expect(last).toEqual({ + id, + ...mockRecord, + prevHistoryId: undefined, + }) + + const updatedRecord = { + ...mockRecord, + id, + } + + await repository.update(updatedRecord, trx) + + const updated = await repository.findCurrentByStarkKey( + mockRecord.starkKey, + trx + ) - const current = await repository.findCurrentByStarkKey(starkKey, trx) - expect(current).toEqual({ ...mostRecent, id, prevHistoryId: undefined }) + expect(updated).toEqual({ + ...updatedRecord, + prevHistoryId: undefined, + }) + }) }) - it('removes by state update id', async () => { - const starkKey = StarkKey.fake('1234aa') - for (const stateUpdateId of [100, 200, 1900]) { - await repository.add( - { + describe( + PreprocessedUserStatisticsRepository.prototype.findCurrentByStarkKey.name, + () => { + it('returns undefined when no current (most recent) statistics record', async () => { + const starkKey = StarkKey.fake('1234aa') + const last = await repository.findCurrentByStarkKey(starkKey, trx) + expect(last).toEqual(undefined) + }) + + it('finds current (most recent) statistics record by Stark Key', async () => { + const starkKey = StarkKey.fake('1234aa') + const otherKey = StarkKey.fake('1235bb') + await repository.add( + { ...mockRecord, starkKey, timestamp: Timestamp(100) }, + trx + ) + await repository.add( + { ...mockRecord, starkKey, timestamp: Timestamp(200) }, + trx + ) + const mostRecent = { ...mockRecord, starkKey, - stateUpdateId, - timestamp: Timestamp(stateUpdateId * 1000), - }, - trx - ) + timestamp: Timestamp(300), + } + const id = await repository.add(mostRecent, trx) + await repository.add( + { ...mockRecord, starkKey: otherKey, timestamp: Timestamp(400) }, + trx + ) + + const current = await repository.findCurrentByStarkKey(starkKey, trx) + expect(current).toEqual({ + ...mostRecent, + id, + prevHistoryId: undefined, + }) + }) } + ) - await repository.deleteByStateUpdateId(1900, trx) - expect( - (await repository.findCurrentByStarkKey(starkKey, trx))?.stateUpdateId - ).toEqual(200) - }) + describe( + PreprocessedUserStatisticsRepository.prototype.deleteByStateUpdateId.name, + () => { + it('removes by state update id', async () => { + const starkKey = StarkKey.fake('1234aa') + for (const stateUpdateId of [100, 200, 1900]) { + await repository.add( + { + ...mockRecord, + starkKey, + stateUpdateId, + timestamp: Timestamp(stateUpdateId * 1000), + }, + trx + ) + } + + await repository.deleteByStateUpdateId(1900, trx) + expect( + (await repository.findCurrentByStarkKey(starkKey, trx))?.stateUpdateId + ).toEqual(200) + }) + } + ) }) diff --git a/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.ts b/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.ts index 534096e6f..945f4766b 100644 --- a/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.ts +++ b/packages/backend/src/peripherals/database/PreprocessedUserStatisticsRepository.ts @@ -25,6 +25,7 @@ export class PreprocessedUserStatisticsRepository extends BaseRepository { this.add = this.wrapAdd(this.add) this.findCurrentByStarkKey = this.wrapFind(this.findCurrentByStarkKey) + this.update = this.wrapUpdate(this.update) this.deleteByStateUpdateId = this.wrapDelete(this.deleteByStateUpdateId) this.deleteAll = this.wrapDelete(this.deleteAll) @@ -50,7 +51,20 @@ export class PreprocessedUserStatisticsRepository extends BaseRepository { .orderBy('state_update_id', 'desc') .first() - if (row) return toPreprocessedUserStatisticsRecord(row) + return row ? toPreprocessedUserStatisticsRecord(row) : undefined + } + + async update( + record: Pick & + Partial, + trx?: Knex.Transaction + ) { + const knex = await this.knex(trx) + const row = toPartialPreprocessedUserStatisticsRecord(record) + + return await knex('preprocessed_user_statistics') + .where({ id: record.id }) + .update(row) } async deleteByStateUpdateId(stateUpdateId: number, trx: Knex.Transaction) { @@ -81,6 +95,25 @@ function toPreprocessedUserStatisticsRecord( } } +function toPartialPreprocessedUserStatisticsRecord( + record: Pick & + Partial +): Pick & + Partial { + return { + id: record.id, + state_update_id: record.stateUpdateId, + block_number: record.blockNumber, + timestamp: record.timestamp + ? BigInt(record.timestamp.toString()) + : undefined, + stark_key: record.starkKey?.toString(), + asset_count: record.assetCount, + balance_change_count: record.balanceChangeCount, + prev_history_id: record.prevHistoryId ?? null, + } +} + function toPreprocessedUserStatisticsRow( record: Omit ): Omit { diff --git a/packages/backend/src/peripherals/database/migrations/037_preprocessed_l2_transaction.ts b/packages/backend/src/peripherals/database/migrations/037_preprocessed_l2_transaction.ts new file mode 100644 index 000000000..29b335fd7 --- /dev/null +++ b/packages/backend/src/peripherals/database/migrations/037_preprocessed_l2_transaction.ts @@ -0,0 +1,47 @@ +/* + ====== IMPORTANT NOTICE ====== + +DO NOT EDIT OR RENAME THIS FILE + +This is a migration file. Once created the file should not be renamed or edited, +because migrations are only run once on the production server. + +If you find that something was incorrectly set up in the `up` function you +should create a new migration file that fixes the issue. + +*/ +import { Knex } from 'knex' + +export async function up(knex: Knex) { + await knex.schema.alterTable('preprocessed_state_details', (table) => { + table.jsonb('l2_transactions_statistics') + table.jsonb('cumulative_l2_transactions_statistics') + }) + + await knex.schema.createTable( + 'preprocessed_user_l2_transactions_statistics', + (table) => { + table.increments('id').primary() + table + .integer('state_update_id') + .notNullable() + .references('state_update_id') + .inTable('preprocessed_state_updates') + table.string('stark_key').notNullable() + table.jsonb('l2_transactions_statistics').notNullable() + table.jsonb('cumulative_l2_transactions_statistics').notNullable() + table.index(['state_update_id', 'stark_key']) + } + ) +} + +export async function down(knex: Knex) { + await knex.schema.alterTable('preprocessed_state_details', (table) => { + table.dropColumns( + 'l2_transactions_statistics', + 'cumulative_l2_transactions_statistics' + ) + }) + + await knex.schema.dropTable('preprocessed_user_l2_transactions_statistics') +} diff --git a/packages/backend/src/peripherals/database/shared/types.ts b/packages/backend/src/peripherals/database/shared/types.ts index 470590b4d..e62412690 100644 --- a/packages/backend/src/peripherals/database/shared/types.ts +++ b/packages/backend/src/peripherals/database/shared/types.ts @@ -1,6 +1,10 @@ import { json } from '@explorer/types' import { PerpetualL2TransactionDataJson } from '../PerpetualL2Transaction' +import { + PreprocessedL2TransactionsStatistics, + PreprocessedUserL2TransactionsStatistics, +} from '../PreprocessedL2TransactionsStatistics' import { SentTransactionJSON } from '../transactions/SentTransaction' import { UserTransactionJSON, @@ -248,6 +252,8 @@ declare module 'knex/types/tables' { timestamp: bigint asset_update_count: number forced_transaction_count: number + l2_transactions_statistics: PreprocessedL2TransactionsStatistics | null + cumulative_l2_transactions_statistics: PreprocessedL2TransactionsStatistics | null } interface WithdrawableAssetRow { @@ -272,6 +278,14 @@ declare module 'knex/types/tables' { prev_history_id: number | null } + interface PreprocessedUserL2TransactionsStatisticsRow { + id: number + state_update_id: number + stark_key: string + l2_transactions_statistics: PreprocessedUserL2TransactionsStatistics + cumulative_l2_transactions_statistics: PreprocessedUserL2TransactionsStatistics + } + interface L2TransactionRow { id: number transaction_id: number @@ -314,6 +328,7 @@ declare module 'knex/types/tables' { preprocessed_state_details: PreprocessedStateDetailsRow withdrawable_assets: WithdrawableAssetRow preprocessed_user_statistics: PreprocessedUserStatisticsRow + preprocessed_user_l2_transactions_statistics: PreprocessedUserL2TransactionsStatisticsRow l2_transactions: L2TransactionRow } } diff --git a/packages/backend/src/peripherals/starkware/FetchClient.test.ts b/packages/backend/src/peripherals/starkware/FetchClient.test.ts index f7e437d15..a68cd04d6 100644 --- a/packages/backend/src/peripherals/starkware/FetchClient.test.ts +++ b/packages/backend/src/peripherals/starkware/FetchClient.test.ts @@ -24,6 +24,7 @@ describe(FetchClient.name, () => { it('should wait between retries', async () => { const delay = 25 + const threshold = 0.1 * delay const retries = 2 const startTime = Date.now() await expect( @@ -31,7 +32,7 @@ describe(FetchClient.name, () => { ).toBeRejectedWith(Error) const endTime = Date.now() expect(endTime - startTime).toBeGreaterThanOrEqual( - delay * retries - delay + delay * retries - delay - threshold ) }) diff --git a/packages/backend/src/test/fakes.ts b/packages/backend/src/test/fakes.ts index ddd85e16b..6040571a1 100644 --- a/packages/backend/src/test/fakes.ts +++ b/packages/backend/src/test/fakes.ts @@ -26,6 +26,7 @@ import { ForcedTransactionRecord, Updates, } from '../peripherals/database/ForcedTransactionRepository' +import { PreprocessedL2TransactionsStatistics } from '../peripherals/database/PreprocessedL2TransactionsStatistics' import { StateUpdateRecord } from '../peripherals/database/StateUpdateRepository' import { SentTransactionData } from '../peripherals/database/transactions/SentTransaction' import { SentTransactionRecord } from '../peripherals/database/transactions/SentTransactionRepository' @@ -380,3 +381,24 @@ export const fakeCollateralAsset: CollateralAsset = { ), price: 1_000_000n, } + +export const fakePreprocessedL2TransactionsStatistics = ( + obj?: PreprocessedL2TransactionsStatistics +): PreprocessedL2TransactionsStatistics => { + return { + depositCount: 12, + withdrawalToAddressCount: 55, + forcedWithdrawalCount: 32, + tradeCount: 11, + forcedTradeCount: 52, + transferCount: 24, + conditionalTransferCount: 65, + liquidateCount: 19, + deleverageCount: 10, + fundingTickCount: 1, + oraclePricesTickCount: 99, + multiTransactionCount: 5, + replacedTransactionsCount: 2, + ...obj, + } +} diff --git a/packages/backend/src/utils/sumNumericValuesByKey.test.ts b/packages/backend/src/utils/sumNumericValuesByKey.test.ts new file mode 100644 index 000000000..b8dc080d0 --- /dev/null +++ b/packages/backend/src/utils/sumNumericValuesByKey.test.ts @@ -0,0 +1,73 @@ +import { expect } from 'earl' + +import { fakePreprocessedL2TransactionsStatistics } from '../test/fakes' +import { sumNumericValuesByKey } from './sumNumericValuesByKey' + +describe(sumNumericValuesByKey.name, () => { + it('sums', () => { + const firstStatistics = fakePreprocessedL2TransactionsStatistics() + const secondStatistics = fakePreprocessedL2TransactionsStatistics() + const thirdStatistics = fakePreprocessedL2TransactionsStatistics() + + const result = sumNumericValuesByKey( + firstStatistics, + secondStatistics, + thirdStatistics + ) + + expect(result).toEqual({ + depositCount: + firstStatistics.depositCount + + secondStatistics.depositCount + + thirdStatistics.depositCount, + withdrawalToAddressCount: + firstStatistics.withdrawalToAddressCount + + secondStatistics.withdrawalToAddressCount + + thirdStatistics.withdrawalToAddressCount, + forcedWithdrawalCount: + firstStatistics.forcedWithdrawalCount + + secondStatistics.forcedWithdrawalCount + + thirdStatistics.forcedWithdrawalCount, + tradeCount: + firstStatistics.tradeCount + + secondStatistics.tradeCount + + thirdStatistics.tradeCount, + forcedTradeCount: + firstStatistics.forcedTradeCount + + secondStatistics.forcedTradeCount + + thirdStatistics.forcedTradeCount, + transferCount: + firstStatistics.transferCount + + secondStatistics.transferCount + + thirdStatistics.transferCount, + conditionalTransferCount: + firstStatistics.conditionalTransferCount + + secondStatistics.conditionalTransferCount + + thirdStatistics.conditionalTransferCount, + liquidateCount: + firstStatistics.liquidateCount + + secondStatistics.liquidateCount + + thirdStatistics.liquidateCount, + deleverageCount: + firstStatistics.deleverageCount + + secondStatistics.deleverageCount + + thirdStatistics.deleverageCount, + fundingTickCount: + firstStatistics.fundingTickCount + + secondStatistics.fundingTickCount + + thirdStatistics.fundingTickCount, + oraclePricesTickCount: + firstStatistics.oraclePricesTickCount + + secondStatistics.oraclePricesTickCount + + thirdStatistics.oraclePricesTickCount, + multiTransactionCount: + firstStatistics.multiTransactionCount + + secondStatistics.multiTransactionCount + + thirdStatistics.multiTransactionCount, + replacedTransactionsCount: + firstStatistics.replacedTransactionsCount + + secondStatistics.replacedTransactionsCount + + thirdStatistics.replacedTransactionsCount, + }) + }) +}) diff --git a/packages/backend/src/utils/sumNumericValuesByKey.ts b/packages/backend/src/utils/sumNumericValuesByKey.ts new file mode 100644 index 000000000..0f6cc9c88 --- /dev/null +++ b/packages/backend/src/utils/sumNumericValuesByKey.ts @@ -0,0 +1,15 @@ +export function sumNumericValuesByKey< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends Record, + V extends T[keyof T] +>(a: T, b: T, ...rest: T[]): T { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const res = [a, b, ...rest].reduce>((result, obj) => { + for (const k in obj) { + result[k as keyof T] = (((result[k] ?? 0) as V) + + (obj[k as keyof T] ?? 0)) as V + } + return result + }, {}) + return res as T +} diff --git a/packages/frontend/src/preview/routes.ts b/packages/frontend/src/preview/routes.ts index 0e2ea3891..949d909dd 100644 --- a/packages/frontend/src/preview/routes.ts +++ b/packages/frontend/src/preview/routes.ts @@ -126,6 +126,8 @@ const routes: Route[] = [ totalStateUpdates: 5123, forcedTransactions: repeat(6, randomHomeForcedTransactionEntry), totalForcedTransactions: 68, + l2Transactions: [], + totalL2Transactions: 0, offers: repeat(6, randomHomeOfferEntry), totalOffers: 7, }) @@ -136,16 +138,16 @@ const routes: Route[] = [ description: 'The home page for project that shared feeder gateway with us.', render: (ctx) => { - const context = getPerpetualPageContext(ctx) + const context = getPerpetualPageContext(ctx, { + showL2Transactions: true, + }) ctx.body = renderHomePage({ context, stateUpdates: repeat(6, randomHomeStateUpdateEntry), totalStateUpdates: 5123, - l2Transactions: { - data: repeat(6, randomPerpetualL2TransactionEntry), - total: 5123, - }, + l2Transactions: repeat(6, randomPerpetualL2TransactionEntry), + totalL2Transactions: 5123, tutorials: [], forcedTransactions: repeat(6, randomHomeForcedTransactionEntry), totalForcedTransactions: 68, @@ -166,6 +168,8 @@ const routes: Route[] = [ totalStateUpdates: 5123, forcedTransactions: repeat(6, randomHomeForcedTransactionEntry), totalForcedTransactions: 68, + l2Transactions: [], + totalL2Transactions: 0, offers: repeat(6, randomHomeOfferEntry), totalOffers: 7, }) @@ -290,6 +294,8 @@ const routes: Route[] = [ Number(ethereumTimestamp) - Math.random() * 12 * 60 * 60 * 1000 ) ), + l2Transactions: [], + totalL2Transactions: 0, balanceChanges: repeat(10, randomStateUpdateBalanceChangeEntry), priceChanges: repeat(15, randomStateUpdatePriceEntry), totalBalanceChanges: 231, @@ -304,7 +310,9 @@ const routes: Route[] = [ description: 'State update page with l2 transacitons.', render: (ctx) => { const ethereumTimestamp = randomTimestamp() - const context = getPerpetualPageContext(ctx) + const context = getPerpetualPageContext(ctx, { + showL2Transactions: true, + }) ctx.body = renderStateUpdatePage({ context, id: randomId(), @@ -325,10 +333,8 @@ const routes: Route[] = [ balanceChanges: repeat(10, randomStateUpdateBalanceChangeEntry), priceChanges: repeat(15, randomStateUpdatePriceEntry), totalBalanceChanges: 231, - l2Transactions: { - data: repeat(5, randomPerpetualL2TransactionEntry), - total: 1000, - }, + l2Transactions: repeat(5, randomPerpetualL2TransactionEntry), + totalL2Transactions: 1000, transactions: repeat(5, randomStateUpdateTransactionEntry), totalTransactions: 5, }) @@ -398,7 +404,9 @@ const routes: Route[] = [ path: '/users/recover', description: 'Stark key recovery page, the stark key is not known.', render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) ctx.body = renderUserRecoverPage({ context, }) @@ -409,7 +417,9 @@ const routes: Route[] = [ description: 'Stark key register page, the stark key is known but not registered.', render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) ctx.body = renderUserRegisterPage({ context: { @@ -428,7 +438,9 @@ const routes: Route[] = [ description: 'My user page, the stark key is known, but it’s not registered.', render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) const starkKey = context.user.starkKey ?? StarkKey.fake() ctx.body = renderUserPage({ @@ -449,6 +461,8 @@ const routes: Route[] = [ totalBalanceChanges: 3367, transactions: repeat(10, randomUserTransactionEntry), totalTransactions: 48, + l2Transactions: [], + totalL2Transactions: 0, offers: repeat(6, randomUserOfferEntry), totalOffers: 6, }) @@ -458,7 +472,9 @@ const routes: Route[] = [ path: '/users/me/registered', description: 'My user page, the stark key is known and registered.', render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) const starkKey = context.user.starkKey ?? StarkKey.fake() ctx.body = renderUserPage({ @@ -480,6 +496,8 @@ const routes: Route[] = [ totalBalanceChanges: 3367, transactions: repeat(10, randomUserTransactionEntry), totalTransactions: 48, + l2Transactions: [], + totalL2Transactions: 0, offers: repeat(6, randomUserOfferEntry), totalOffers: 6, }) @@ -506,6 +524,8 @@ const routes: Route[] = [ totalBalanceChanges: 3367, transactions: repeat(10, randomUserTransactionEntry), totalTransactions: 48, + l2Transactions: [], + totalL2Transactions: 0, offers: repeat(6, randomUserOfferEntry), totalOffers: 6, }) @@ -517,7 +537,9 @@ const routes: Route[] = [ description: 'Someone else’s user page for project that feeder gateway with us.', render: (ctx) => { - const context = getPerpetualPageContext(ctx) + const context = getPerpetualPageContext(ctx, { + showL2Transactions: true, + }) ctx.body = renderUserPage({ context, @@ -532,10 +554,8 @@ const routes: Route[] = [ totalBalanceChanges: 3367, transactions: repeat(10, randomUserTransactionEntry), totalTransactions: 48, - l2Transactions: { - data: repeat(6, randomPerpetualUserL2TransactionEntry), - total: 5123, - }, + l2Transactions: repeat(6, randomPerpetualUserL2TransactionEntry), + totalL2Transactions: 5123, offers: repeat(6, randomUserOfferEntry), totalOffers: 6, }) @@ -923,7 +943,9 @@ const routes: Route[] = [ path: '/forced/new/spot/withdraw', description: 'Form to create a new spot forced withdrawal.', render: (ctx) => { - const context = getSpotPageContext(ctx, true) + const context = getSpotPageContext(ctx, { + fallbackToFakeUser: true, + }) ctx.body = renderNewSpotForcedWithdrawPage({ context, starkKey: StarkKey.fake(), @@ -941,7 +963,9 @@ const routes: Route[] = [ path: '/forced/new/perpetual/withdraw', description: 'Form to create a new perpetual forced withdrawal.', render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) ctx.body = renderNewPerpetualForcedActionPage({ context, starkKey: StarkKey.fake(), @@ -959,7 +983,9 @@ const routes: Route[] = [ path: '/forced/new/perpetual/buy', description: 'Form to create a new perpetual forced buy.', render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) ctx.body = renderNewPerpetualForcedActionPage({ context, starkKey: StarkKey.fake(), @@ -978,7 +1004,9 @@ const routes: Route[] = [ description: 'Form to create a new perpetual forced sell.', breakAfter: true, render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) ctx.body = renderNewPerpetualForcedActionPage({ context, starkKey: StarkKey.fake(), @@ -1179,7 +1207,9 @@ const routes: Route[] = [ 'Offer view of a created perpetual forced trade. As viewed by the creator.', isOfferPage: true, render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) const offer = randomOfferDetails() ctx.body = renderOfferAndForcedTradePage({ context, @@ -1200,7 +1230,9 @@ const routes: Route[] = [ isOfferPage: true, render: (ctx) => { const offer = randomOfferDetails() - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) const taker = { ethereumAddress: context.user.address, starkKey: context.user.starkKey, @@ -1237,7 +1269,9 @@ const routes: Route[] = [ 'Offer view of an accepted perpetual forced trade. As viewed by the creator.', isOfferPage: true, render: (ctx) => { - const context = getPerpetualPageContext(ctx, true) + const context = getPerpetualPageContext(ctx, { + fallbackToFakeUser: true, + }) const maker = userParty(context.user) const taker = randomParty() const offer = randomOfferDetails() @@ -1556,20 +1590,31 @@ function getFakeUser() { function getPerpetualPageContext( ctx: Koa.Context, - fallbackToFakeUser: true + options?: { + fallbackToFakeUser?: true + showL2Transactions?: boolean + } ): PageContextWithUser<'perpetual'> function getPerpetualPageContext( ctx: Koa.Context, - fallbackToFakeUser?: false + options?: { + fallbackToFakeUser?: false + showL2Transactions?: boolean + } ): PageContext<'perpetual'> function getPerpetualPageContext( ctx: Koa.Context, - fallbackToFakeUser?: boolean + options?: { + fallbackToFakeUser?: boolean + showL2Transactions?: boolean + } ): PageContextWithUser<'perpetual'> | PageContext<'perpetual'> { - const user = getUser(ctx) ?? (fallbackToFakeUser ? getFakeUser() : undefined) + const user = + getUser(ctx) ?? (options?.fallbackToFakeUser ? getFakeUser() : undefined) return { user, + showL2Transactions: options?.showL2Transactions ?? false, instanceName: 'dYdX', chainId: 1, tradingMode: 'perpetual', @@ -1579,20 +1624,31 @@ function getPerpetualPageContext( function getSpotPageContext( ctx: Koa.Context, - fallbackToFakeUser: true + options?: { + fallbackToFakeUser?: true + showL2Transactions?: boolean + } ): PageContextWithUser<'spot'> function getSpotPageContext( ctx: Koa.Context, - fallbackToFakeUser?: false + options?: { + fallbackToFakeUser?: false + showL2Transactions?: boolean + } ): PageContext<'spot'> function getSpotPageContext( ctx: Koa.Context, - fallbackToFakeUser?: boolean + options?: { + fallbackToFakeUser?: boolean + showL2Transactions?: boolean + } ): PageContextWithUser<'spot'> | PageContext<'spot'> { - const user = getUser(ctx) ?? (fallbackToFakeUser ? getFakeUser() : undefined) + const user = + getUser(ctx) ?? (options?.fallbackToFakeUser ? getFakeUser() : undefined) return { user, + showL2Transactions: options?.showL2Transactions ?? false, instanceName: 'Myria', chainId: 1, tradingMode: 'spot', diff --git a/packages/frontend/src/view/components/table/TablePreview.tsx b/packages/frontend/src/view/components/table/TablePreview.tsx index adf52da7f..87ee08f4e 100644 --- a/packages/frontend/src/view/components/table/TablePreview.tsx +++ b/packages/frontend/src/view/components/table/TablePreview.tsx @@ -1,3 +1,4 @@ +import isNumber from 'lodash/isNumber' import React, { ReactNode } from 'react' import { formatInt } from '../../../utils/formatting/formatAmount' @@ -11,7 +12,7 @@ interface TablePreviewProps { entryShortNamePlural: string entryLongNamePlural: string visible: number - total: number + total: number | 'processing' children: ReactNode } @@ -21,6 +22,7 @@ export function TablePreview(props: TablePreviewProps) { 0 && ( <> You're viewing {formatInt(props.visible)} out of{' '} @@ -33,10 +35,12 @@ export function TablePreview(props: TablePreviewProps) { {props.children} {props.visible === 0 && (
- There are no {props.entryLongNamePlural} to view. + {props.total === 'processing' + ? `${props.entryLongNamePlural} are being processed...` + : `There are no ${props.entryLongNamePlural} to view.`}
)} - {props.total > props.visible && ( + {isNumber(props.total) && props.total > props.visible && (
View all {props.entryLongNamePlural} diff --git a/packages/frontend/src/view/components/table/TableWithPagination.tsx b/packages/frontend/src/view/components/table/TableWithPagination.tsx index a21687a27..0ec90e09c 100644 --- a/packages/frontend/src/view/components/table/TableWithPagination.tsx +++ b/packages/frontend/src/view/components/table/TableWithPagination.tsx @@ -1,3 +1,4 @@ +import isNumber from 'lodash/isNumber' import React, { ReactNode } from 'react' import { formatInt } from '../../../utils/formatting/formatAmount' @@ -13,64 +14,86 @@ interface TableWithPaginationProps { visible: number limit: number offset: number - total: number + total: number | 'processing' children: ReactNode } export function TableWithPagination(props: TableWithPaginationProps) { - const start = formatInt(1 + props.offset) - const end = formatInt(props.offset + props.visible) - const total = formatInt(props.total) - const currentPage = Math.floor(props.offset / props.limit) + 1 - const totalPages = Math.ceil(props.total / props.limit) + const totalPages = isNumber(props.total) + ? Math.ceil(props.total / props.limit) + : undefined return ( <> - You're viewing {start}-{end} out of {total}{' '} - {props.entryShortNamePlural} - - ) : ( - <> - You're viewing 0 out of {total} {props.entryShortNamePlural} - - ) - } + description={getDescription( + props.offset, + props.visible, + props.total, + props.entryShortNamePlural + )} > {props.children} {props.visible === 0 && (
- There are no {props.entryLongNamePlural} to view. + {props.total === 'processing' + ? `${props.entryLongNamePlural} are being processed...` + : `There are no ${props.entryLongNamePlural} to view.`}
)} -
-
- - + {totalPages !== undefined && totalPages !== 0 && ( +
+
+ + +
+
- -
+ )} + + ) +} + +function getDescription( + offset: number, + visible: number, + total: number | 'processing', + entryShortNamePlural: string +) { + if (total === 'processing') { + return undefined + } + + const start = formatInt(1 + offset) + const end = formatInt(offset + visible) + const formattedTotal = formatInt(total) + + return visible !== 0 ? ( + <> + You're viewing {start}-{end} out of {formattedTotal}{' '} + {entryShortNamePlural} + + ) : ( + <> + You're viewing 0 out of {formattedTotal} {entryShortNamePlural} ) } diff --git a/packages/frontend/src/view/pages/home/HomePage.tsx b/packages/frontend/src/view/pages/home/HomePage.tsx index e48b17d7c..acbc073f0 100644 --- a/packages/frontend/src/view/pages/home/HomePage.tsx +++ b/packages/frontend/src/view/pages/home/HomePage.tsx @@ -35,10 +35,8 @@ interface HomePageProps { tutorials?: HomeTutorialEntry[] stateUpdates: HomeStateUpdateEntry[] totalStateUpdates: number - l2Transactions?: { - data: PerpetualL2TransactionEntry[] - total: number - } + l2Transactions: PerpetualL2TransactionEntry[] + totalL2Transactions: number forcedTransactions: TransactionEntry[] totalForcedTransactions: number offers?: OfferEntry[] @@ -68,14 +66,14 @@ function HomePage(props: HomePageProps) { >
- {props.l2Transactions && ( + {props.context.showL2Transactions && ( diff --git a/packages/frontend/src/view/pages/state-update/StateUpdateL2TransactionsPage.tsx b/packages/frontend/src/view/pages/state-update/StateUpdateL2TransactionsPage.tsx index 87d8e5f61..d9c7ecca3 100644 --- a/packages/frontend/src/view/pages/state-update/StateUpdateL2TransactionsPage.tsx +++ b/packages/frontend/src/view/pages/state-update/StateUpdateL2TransactionsPage.tsx @@ -16,7 +16,7 @@ export interface StateUpdateL2TransactionsPageProps { l2Transactions: PerpetualL2TransactionEntry[] limit: number offset: number - total: number + total: number | 'processing' } export function renderStateUpdateL2TransactionsPage( diff --git a/packages/frontend/src/view/pages/state-update/StateUpdatePage.tsx b/packages/frontend/src/view/pages/state-update/StateUpdatePage.tsx index 57193f9f2..31a2f7a3d 100644 --- a/packages/frontend/src/view/pages/state-update/StateUpdatePage.tsx +++ b/packages/frontend/src/view/pages/state-update/StateUpdatePage.tsx @@ -33,10 +33,8 @@ interface StateUpdatePageProps extends StateUpdateStatsProps { totalBalanceChanges: number priceChanges?: PriceEntry[] transactions: TransactionEntry[] - l2Transactions?: { - data: PerpetualL2TransactionEntry[] - total: number - } + l2Transactions: PerpetualL2TransactionEntry[] + totalL2Transactions: number | 'processing' totalTransactions: number } @@ -53,14 +51,14 @@ function StateUpdatePage(props: StateUpdatePageProps) { > - {props.l2Transactions && ( + {props.context.showL2Transactions && ( diff --git a/packages/frontend/src/view/pages/state-update/common.ts b/packages/frontend/src/view/pages/state-update/common.ts index 9cda9931f..f17d42cf7 100644 --- a/packages/frontend/src/view/pages/state-update/common.ts +++ b/packages/frontend/src/view/pages/state-update/common.ts @@ -16,7 +16,7 @@ export const getTransactionTableProps = (id: string) => ({ export const getL2TransactionTableProps = (id: string) => ({ title: 'L2 transactions', - entryShortNamePlural: 'transactions', + entryShortNamePlural: 'L2 transactions', entryLongNamePlural: 'L2 transactions', path: `/state-updates/${id}/l2-transactions`, description: `L2 transactions included in #${id} state update`, diff --git a/packages/frontend/src/view/pages/user/UserPage.tsx b/packages/frontend/src/view/pages/user/UserPage.tsx index 110c4eecc..ec8844637 100644 --- a/packages/frontend/src/view/pages/user/UserPage.tsx +++ b/packages/frontend/src/view/pages/user/UserPage.tsx @@ -47,10 +47,8 @@ interface UserPageProps { totalBalanceChanges: number transactions: TransactionEntry[] totalTransactions: number - l2Transactions?: { - data: PerpetualL2TransactionEntry[] - total: number - } + l2Transactions: PerpetualL2TransactionEntry[] + totalL2Transactions: number | 'processing' offers?: OfferEntry[] totalOffers: number } @@ -99,14 +97,14 @@ function UserPage(props: UserPageProps) { isMine={isMine} /> - {props.l2Transactions && ( + {props.context.showL2Transactions && ( diff --git a/packages/shared/src/PageContext.ts b/packages/shared/src/PageContext.ts index ae4713b28..9c2bec654 100644 --- a/packages/shared/src/PageContext.ts +++ b/packages/shared/src/PageContext.ts @@ -16,6 +16,7 @@ interface PerpetualPageContext { chainId: number tradingMode: 'perpetual' collateralAsset: CollateralAsset + showL2Transactions: boolean } interface SpotPageContext { @@ -23,6 +24,7 @@ interface SpotPageContext { instanceName: InstanceName chainId: number tradingMode: 'spot' + showL2Transactions: boolean } export type PageContext = CheckTradingMode<