diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index f1890091..7d354d48 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest services: - redis: + redis-cache: image: redis options: >- --health-cmd "redis-cli ping" @@ -23,6 +23,15 @@ jobs: --health-retries 5 ports: - 6379:6379 + redis-queue: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6380:6379 steps: - name: Checkout code @@ -57,7 +66,8 @@ jobs: echo BITCOIN_ELECTRS_API_URL="{{ secrets.BITCOIN_ELECTRS_API_URL }}" >> .env echo CKB_EXPLORER_API_URL="${{ secrets.CKB_EXPLORER_API_URL }}" >> .env echo CKB_RPC_WEBSOCKET_URL="${{ secrets.CKB_RPC_WEBSOCKET_URL }}" >> .env - echo REDIS_URL="redis://localhost:6379" >> .env + echo REDIS_CACHE_URL="redis://localhost:6379" >> .env + echo REDIS_QUEUE_URL="redis://localhost:6380" >> .env echo DATABASE_URL="postgres://postgres:postgres@postgres:5432/explorer?sslmode=disable" >> .env cat .env pnpm run test diff --git a/backend/package.json b/backend/package.json index df1added..7578c85c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@utxo-stack-explorer/backend", - "version": "0.1.1", + "version": "0.2.0", "description": "", "author": "", "private": true, @@ -30,11 +30,13 @@ }, "dependencies": { "@apollo/server": "^4.11.0", + "@apollo/server-plugin-response-cache": "^4.1.3", "@as-integrations/fastify": "^2.1.1", "@cell-studio/mempool.js": "^2.5.3", "@ckb-lumos/bi": "^0.23.0", "@ckb-lumos/lumos": "^0.23.0", "@ckb-lumos/rpc": "^0.23.0", + "@nest-lab/throttler-storage-redis": "^1.0.0", "@nestjs/apollo": "^12.2.0", "@nestjs/axios": "^3.0.2", "@nestjs/bullmq": "^10.2.0", @@ -64,14 +66,15 @@ "fastify": "^4.28.1", "graphql": "^16.9.0", "graphql-query-complexity": "^1.0.0", + "ioredis": "^5.4.1", "lodash": "^4.17.21", - "nestjs-cacheable": "^1.0.0", "p-limit": "^3.1.0", "prisma": "^5.16.2", "redis": "^4.6.7", "reflect-metadata": "^0.2.0", "rpc-websockets": "^7.11.2", "rxjs": "^7.8.1", + "serialize-javascript": "^6.0.2", "ws": "^8.18.0", "zod": "^3.23.8" }, diff --git a/backend/redis-queue.conf b/backend/redis-queue.conf new file mode 100644 index 00000000..06175924 --- /dev/null +++ b/backend/redis-queue.conf @@ -0,0 +1,80 @@ +# Redis configuration +# +# Example: https://raw.githubusercontent.com/redis/redis/7.4/redis.conf + +################################## NETWORK ##################################### +bind 0.0.0.0 + +################################ SNAPSHOTTING ################################ + +# Save the DB to disk. +# +# save [ ...] +# +# Redis will save the DB if the given number of seconds elapsed and it +# surpassed the given number of write operations against the DB. +# +# Snapshotting can be completely disabled with a single empty string argument +# as in following example: +# +# save "" +# +# Unless specified otherwise, by default Redis will save the DB: +# * After 3600 seconds (an hour) if at least 1 change was performed +# * After 300 seconds (5 minutes) if at least 100 changes were performed +# * After 60 seconds if at least 10000 changes were performed +# +# You can set these explicitly by uncommenting the following line. +# +save 3600 1 300 100 60 10000 + +############################## APPEND ONLY MODE ############################### + +# By default Redis asynchronously dumps the dataset on disk. This mode is +# good enough in many applications, but an issue with the Redis process or +# a power outage may result into a few minutes of writes lost (depending on +# the configured save points). +# +# The Append Only File is an alternative persistence mode that provides +# much better durability. For instance using the default data fsync policy +# (see later in the config file) Redis can lose just one second of writes in a +# dramatic event like a server power outage, or a single write if something +# wrong with the Redis process itself happens, but the operating system is +# still running correctly. +# +# AOF and RDB persistence can be enabled at the same time without problems. +# If the AOF is enabled on startup Redis will load the AOF, that is the file +# with the better durability guarantees. +# +# Please check https://redis.io/topics/persistence for more information. +appendonly yes + +# Redis can create append-only base files in either RDB or AOF formats. Using +# the RDB format is always faster and more efficient, and disabling it is only +# supported for backward compatibility purposes. +aof-use-rdb-preamble yes + +# Set a memory usage limit to the specified amount of bytes. +# When the memory limit is reached Redis will try to remove keys +# according to the eviction policy selected (see maxmemory-policy). +# +# If Redis can't remove keys according to the policy, or if the policy is +# set to 'noeviction', Redis will start to reply with errors to commands +# that would use more memory, like SET, LPUSH, and so on, and will continue +# to reply to read-only commands like GET. +# +# This option is usually useful when using Redis as an LRU or LFU cache, or to +# set a hard memory limit for an instance (using the 'noeviction' policy). +# +# WARNING: If you have replicas attached to an instance with maxmemory on, +# the size of the output buffers needed to feed the replicas are subtracted +# from the used memory count, so that network problems / resyncs will +# not trigger a loop where keys are evicted, and in turn the output +# buffer of replicas is full with DELs of keys evicted triggering the deletion +# of more keys, and so forth until the database is completely emptied. +# +# In short... if you have replicas attached it is suggested that you set a lower +# limit for maxmemory so that there is some free RAM on the system for replica +# output buffers (but this is not needed if the policy is 'noeviction'). +maxmemory 2gb +maxmemory-policy noeviction diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0c11cbd8..7171aea5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,7 +7,6 @@ import { redisStore } from 'cache-manager-redis-yet'; import { Env } from './env'; import { CoreModule } from './core/core.module'; import { ApiModule } from './modules/api.module'; -import { CacheableModule } from 'nestjs-cacheable'; import { ScheduleModule } from '@nestjs/schedule'; import { BullModule } from '@nestjs/bullmq'; import configModule from './config'; @@ -18,13 +17,12 @@ import { BootstrapService } from './bootstrap.service'; imports: [ configModule, SentryModule.forRoot(), - CacheableModule.register(), CacheModule.registerAsync({ isGlobal: true, imports: [ConfigModule], useFactory: async (configService: ConfigService) => { const store = (await redisStore({ - url: configService.get('REDIS_URL'), + url: configService.get('REDIS_CACHE_URL'), isCacheable: (value) => value !== undefined, })) as unknown as CacheStore; return { @@ -36,7 +34,7 @@ import { BootstrapService } from './bootstrap.service'; BullModule.forRootAsync({ imports: [ConfigModule], useFactory: async (configService: ConfigService) => { - const url = new URL(configService.get('REDIS_URL')!); + const url = new URL(configService.get('REDIS_QUEUE_URL')!); return { connection: { host: url.hostname, diff --git a/backend/src/bootstrap.service.ts b/backend/src/bootstrap.service.ts index 7a01c186..edd93151 100644 --- a/backend/src/bootstrap.service.ts +++ b/backend/src/bootstrap.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from './core/database/prisma/prisma.service'; import { IndexerServiceFactory } from './core/indexer/indexer.factory'; +import cluster from 'node:cluster'; @Injectable() export class BootstrapService { @@ -9,7 +10,13 @@ export class BootstrapService { constructor( private prismaService: PrismaService, private IndexerServiceFactory: IndexerServiceFactory, - ) {} + ) { } + + public async bootstrap() { + if (cluster.isPrimary) { + await this.bootstrapAssetsIndex(); + } + } public async bootstrapAssetsIndex() { const chains = await this.prismaService.chain.findMany(); diff --git a/backend/src/cluster.service.ts b/backend/src/cluster.service.ts new file mode 100644 index 00000000..3fb03d47 --- /dev/null +++ b/backend/src/cluster.service.ts @@ -0,0 +1,30 @@ +import { Injectable, Logger } from '@nestjs/common'; +import cluster from 'node:cluster'; +import * as process from 'node:process'; +import * as os from 'node:os'; +import { envSchema } from './env'; + +const numCPUs = os.cpus().length; +const env = envSchema.parse(process.env); + +@Injectable() +export class ClusterService { + private static logger = new Logger(ClusterService.name); + + public static clusterize(callback: Function): void { + if (cluster.isPrimary) { + this.logger.log(`PRIMIRY PROCESS (${process.pid}) IS RUNNING `); + const workersNum = Math.min(env.CLUSTER_WORKERS_NUM, numCPUs); + for (let i = 0; i < workersNum; i++) { + cluster.fork(); + } + cluster.on('exit', (worker) => { + this.logger.log(`WORKER ${worker.process.pid} DIED, FORKING NEW ONE`); + cluster.fork(); + }); + } else { + this.logger.log(`WORKER PROCESS (${process.pid}) IS RUNNING`); + callback(); + } + } +} diff --git a/backend/src/constants.ts b/backend/src/constants.ts index c3263cab..1b0572f5 100644 --- a/backend/src/constants.ts +++ b/backend/src/constants.ts @@ -19,6 +19,7 @@ export const BtcTestnetTypeMap: Record = T[K] extends (...args: infer P) => any ? P : never; type MethodReturnType = T[K] extends (...args: any[]) => infer R ? R : never; @@ -88,6 +88,7 @@ export class BitcoinApiService { } } + @PLimit({ concurrency: 200 }) private async call( method: K, ...args: MethodParameters diff --git a/backend/src/core/blockchain/blockchain.service.ts b/backend/src/core/blockchain/blockchain.service.ts index 47f1e122..86bc882d 100644 --- a/backend/src/core/blockchain/blockchain.service.ts +++ b/backend/src/core/blockchain/blockchain.service.ts @@ -14,6 +14,7 @@ import { ONE_MONTH_MS } from 'src/common/date'; import { CKB_MIN_SAFE_CONFIRMATIONS } from 'src/constants'; import * as Sentry from '@sentry/nestjs'; import { Chain } from '@prisma/client'; +import { PLimit } from 'src/decorators/plimit.decorator'; class WebsocketError extends Error { constructor(message: string) { @@ -70,6 +71,12 @@ export class BlockchainService { }); } + @PLimit({ concurrency: 200 }) + private async call(method: string, params: any[]): Promise { + await this.websocketReady; + return this.websocket.call(method, params); + } + private handleReconnection() { if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; @@ -122,7 +129,7 @@ export class BlockchainService { ): Promise { await this.websocketReady; this.logger.debug(`get_transaction - txHash: ${txHash}`); - const response = await this.websocket.call('get_transaction', [txHash]); + const response = await this.call('get_transaction', [txHash]); const tx = response as TransactionWithStatusResponse; // XXX: we don't need these fields by default, remove them to save cache/memory space if (!withData) { @@ -162,7 +169,7 @@ export class BlockchainService { ): Promise { await this.websocketReady; this.logger.debug(`get_block - blockHash: ${blockHash}`); - const response = await this.websocket.call('get_block', [blockHash]); + const response = await this.call('get_block', [blockHash]); const block = response as Block; if (!withTxData) { block.transactions = block.transactions.map((tx) => { @@ -204,7 +211,7 @@ export class BlockchainService { ): Promise { await this.websocketReady; this.logger.debug(`get_block_by_number - blockNumber: ${blockNumber}`); - const response = await this.websocket.call('get_block_by_number', [ + const response = await this.call('get_block_by_number', [ BI.from(blockNumber).toHexString(), ]); const block = response as Block; @@ -231,7 +238,7 @@ export class BlockchainService { public async getBlockEconomicState(blockHash: string): Promise { await this.websocketReady; this.logger.debug(`get_block_economic_state - blockHash: ${blockHash}`); - const blockEconomicState = await this.websocket.call('get_block_economic_state', [blockHash]); + const blockEconomicState = await this.call('get_block_economic_state', [blockHash]); return blockEconomicState as BlockEconomicState; } @@ -244,10 +251,16 @@ export class BlockchainService { public async getTipBlockNumber(): Promise { await this.websocketReady; this.logger.debug('get_tip_block_number'); - const tipBlockNumber = await this.websocket.call('get_tip_block_number', []); + const tipBlockNumber = await this.call('get_tip_block_number', []); return BI.from(tipBlockNumber).toNumber(); } + @Cacheable({ + namespace: 'BlockchainService', + key: (searchKey: SearchKey, order: 'asc' | 'desc', limit: string, after?: string) => + `getTransactions:${JSON.stringify(searchKey)}:${order}:${limit}:${after}`, + ttl: 10_000, + }) public async getTransactions( searchKey: SearchKey, order: 'asc' | 'desc', @@ -258,11 +271,17 @@ export class BlockchainService { this.logger.debug( `get_transactions - searchKey: ${JSON.stringify(searchKey)}, order: ${order}, limit: ${limit}, after: ${after}`, ); - const result = await this.websocket.call('get_transactions', [searchKey, order, limit, after]); + const result = await this.call('get_transactions', [searchKey, order, limit, after]); const transactions = result as GetTransactionsResult; return transactions; } + @Cacheable({ + namespace: 'BlockchainService', + key: (searchKey: SearchKey, order: 'asc' | 'desc', limit: string, after?: string) => + `getCells:${JSON.stringify(searchKey)}:${order}:${limit}:${after}`, + ttl: 10_000, + }) public async getCells( searchKey: SearchKey, order: 'asc' | 'desc', @@ -274,7 +293,7 @@ export class BlockchainService { this.logger.debug( `get_cells - searchKey: ${JSON.stringify(searchKey)}, order: ${order}, limit: ${limit}, after: ${after}`, ); - const result = await this.websocket.call('get_cells', [searchKey, order, limit, after]); + const result = await this.call('get_cells', [searchKey, order, limit, after]); const cells = result as GetCellsResult; cells.objects = cells.objects.map((cell) => { if (!withData) { diff --git a/backend/src/core/core.service.ts b/backend/src/core/core.service.ts index 5fb1fea4..7ab16874 100644 --- a/backend/src/core/core.service.ts +++ b/backend/src/core/core.service.ts @@ -14,6 +14,8 @@ import { Env } from 'src/env'; import { Transaction } from './blockchain/blockchain.interface'; import { BlockchainServiceFactory } from './blockchain/blockchain.factory'; import { LeapDirection } from '@prisma/client'; +import { ONE_MONTH_MS } from 'src/common/date'; +import { Cacheable } from 'src/decorators/cacheable.decorator'; export const CELLBASE_TX_HASH = '0x0000000000000000000000000000000000000000000000000000000000000000'; @@ -72,6 +74,13 @@ export class CoreService { ); } + @Cacheable({ + namespace: 'CoreService', + key: (chainId: number, ckbTx: Transaction) => { + return `getLeapDirectionByCkbTx:${chainId}:${ckbTx.hash}`; + }, + ttl: ONE_MONTH_MS, + }) public async getLeapDirectionByCkbTx(chainId: number, ckbTx: Transaction) { const blockchainService = this.blockchainServiceFactory.getService(chainId); const inputCells = await Promise.all( diff --git a/backend/src/core/indexer/flow/assets.flow.ts b/backend/src/core/indexer/flow/assets.flow.ts index c020a51a..8dae2c7f 100644 --- a/backend/src/core/indexer/flow/assets.flow.ts +++ b/backend/src/core/indexer/flow/assets.flow.ts @@ -1,10 +1,12 @@ import { Logger } from '@nestjs/common'; import { AssetType, Chain } from '@prisma/client'; import { EventEmitter } from 'node:events'; -import { CKB_MIN_SAFE_CONFIRMATIONS } from 'src/constants'; +import { CKB_MIN_SAFE_CONFIRMATIONS, CKB_ONE_DAY_BLOCKS } from 'src/constants'; import { BlockchainService } from 'src/core/blockchain/blockchain.service'; import { PrismaService } from 'src/core/database/prisma/prisma.service'; import { IndexerQueueService } from '../indexer.queue'; +import { CronExpression, SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; export enum IndexerAssetsEvent { AssetIndexed = 'asset-indexed', @@ -19,11 +21,27 @@ export class IndexerAssetsFlow extends EventEmitter { private blockchainService: BlockchainService, private prismaService: PrismaService, private indexerQueueService: IndexerQueueService, + private schedulerRegistry: SchedulerRegistry, ) { super(); } public async start() { + const latestAsset = await this.getLatestAsset(); + if (latestAsset) { + this.logger.log(`Latest asset block number: ${latestAsset.blockNumber}`); + const tipBlockNumber = await this.blockchainService.getTipBlockNumber(); + if ( + tipBlockNumber - CKB_MIN_SAFE_CONFIRMATIONS - latestAsset.blockNumber < + CKB_ONE_DAY_BLOCKS + ) { + this.logger.log(`Latest asset is near tip block number, skip indexing assets...`); + this.startBlockAssetsIndexing(); + this.setupBlockAssetsIndexedListener(); + return; + } + } + const assetTypeScripts = await this.prismaService.assetType.findMany({ where: { chainId: this.chain.id }, }); @@ -32,6 +50,15 @@ export class IndexerAssetsFlow extends EventEmitter { this.setupAssetIndexedListener(assetTypeScripts.length); } + private async getLatestAsset() { + const latestAsset = await this.prismaService.asset.findFirst({ + select: { blockNumber: true }, + where: { chainId: this.chain.id }, + orderBy: { blockNumber: 'desc' }, + }); + return latestAsset; + } + private async indexAssets(assetType: AssetType) { const cursor = await this.indexerQueueService.getLatestAssetJobCursor(assetType); if (cursor === '0x') { @@ -62,7 +89,7 @@ export class IndexerAssetsFlow extends EventEmitter { private async startBlockAssetsIndexing() { const tipBlockNumber = await this.blockchainService.getTipBlockNumber(); - let latestIndexedBlockNumber = await this.indexerQueueService.getLatestIndexedBlock( + let latestIndexedBlockNumber = await this.indexerQueueService.getLatestIndexedAssetsBlock( this.chain.id, ); if (!latestIndexedBlockNumber) { @@ -75,7 +102,7 @@ export class IndexerAssetsFlow extends EventEmitter { } const targetBlockNumber = tipBlockNumber - CKB_MIN_SAFE_CONFIRMATIONS; if (targetBlockNumber <= latestIndexedBlockNumber) { - this.emit(IndexerAssetsEvent.BlockAssetsIndexed); + this.emit(IndexerAssetsEvent.BlockAssetsIndexed, latestIndexedBlockNumber); return; } @@ -88,9 +115,16 @@ export class IndexerAssetsFlow extends EventEmitter { private setupBlockAssetsIndexedListener() { this.on(IndexerAssetsEvent.BlockAssetsIndexed, () => { - setTimeout(() => { + if (this.schedulerRegistry.doesExist('cron', 'indexer-block-assets')) { + return; + } + + this.logger.log(`Scheduling block assets indexing cron job`); + const job = new CronJob(CronExpression.EVERY_10_SECONDS, () => { this.startBlockAssetsIndexing(); - }, 1000 * 10); + }); + this.schedulerRegistry.addCronJob('indexer-block-assets', job); + job.start(); }); } } diff --git a/backend/src/core/indexer/flow/transactions.flow.ts b/backend/src/core/indexer/flow/transactions.flow.ts index a34b6354..d2698b6c 100644 --- a/backend/src/core/indexer/flow/transactions.flow.ts +++ b/backend/src/core/indexer/flow/transactions.flow.ts @@ -6,6 +6,8 @@ import { BlockchainService } from 'src/core/blockchain/blockchain.service'; import { PrismaService } from 'src/core/database/prisma/prisma.service'; import { IndexerQueueService } from '../indexer.queue'; import { ONE_DAY_MS } from 'src/common/date'; +import { CronExpression, SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; const CKB_24_HOURS_BLOCK_NUMBER = ONE_DAY_MS / 10000; @@ -21,16 +23,17 @@ export class IndexerTransactionsFlow extends EventEmitter { private blockchainService: BlockchainService, private prismaService: PrismaService, private indexerQueueService: IndexerQueueService, + private schedulerRegistry: SchedulerRegistry, ) { super(); } public async start() { - this.startBlockAssetsIndexing(); - this.setupBlockAssetsIndexedListener(); + this.startBlockIndexing(); + this.setupBlockIndexedListener(); } - public async startBlockAssetsIndexing() { + public async startBlockIndexing() { const tipBlockNumber = await this.blockchainService.getTipBlockNumber(); let startBlockNumber = tipBlockNumber - CKB_24_HOURS_BLOCK_NUMBER; const targetBlockNumber = tipBlockNumber - CKB_MIN_SAFE_CONFIRMATIONS; @@ -55,11 +58,18 @@ export class IndexerTransactionsFlow extends EventEmitter { }); } - private setupBlockAssetsIndexedListener() { + private setupBlockIndexedListener() { this.on(IndexerTransactionsEvent.BlockIndexed, () => { - setTimeout(() => { - this.startBlockAssetsIndexing(); - }, 1000 * 10); + if (this.schedulerRegistry.doesExist('cron', 'indexer-transactions')) { + return; + } + + this.logger.log(`Scheduling block transactions indexing cron job`); + const job = new CronJob(CronExpression.EVERY_10_SECONDS, () => { + this.startBlockIndexing(); + }); + this.schedulerRegistry.addCronJob('indexer-transactions', job); + job.start(); }); } } diff --git a/backend/src/core/indexer/indexer.factory.ts b/backend/src/core/indexer/indexer.factory.ts index d3d6fb71..093a4bd6 100644 --- a/backend/src/core/indexer/indexer.factory.ts +++ b/backend/src/core/indexer/indexer.factory.ts @@ -4,6 +4,7 @@ import { IndexerService } from './indexer.service'; import { BlockchainServiceFactory } from '../blockchain/blockchain.factory'; import { IndexerQueueService } from './indexer.queue'; import { ModuleRef } from '@nestjs/core'; +import { SchedulerRegistry } from '@nestjs/schedule'; export class IndexerServiceFactoryError extends Error { constructor(message: string) { @@ -19,6 +20,7 @@ export class IndexerServiceFactory implements OnModuleDestroy { constructor( private blockchainServiceFactory: BlockchainServiceFactory, private prismaService: PrismaService, + private schedulerRegistry: SchedulerRegistry, private moduleRef: ModuleRef, ) {} @@ -43,6 +45,7 @@ export class IndexerServiceFactory implements OnModuleDestroy { blockchainService, this.prismaService, indexerQueueService, + this.schedulerRegistry, ); this.services.set(chain.id, service); } diff --git a/backend/src/core/indexer/indexer.health.ts b/backend/src/core/indexer/indexer.health.ts index 3afac5a5..ac27f98a 100644 --- a/backend/src/core/indexer/indexer.health.ts +++ b/backend/src/core/indexer/indexer.health.ts @@ -60,7 +60,16 @@ export class IndexerHealthIndicator extends HealthIndicator { const blockchainService = this.blockchainServiceFactory.getService(CKB_CHAIN_ID); const tipBlockNumber = await blockchainService.getTipBlockNumber(); const targetBlockNumber = tipBlockNumber - CKB_MIN_SAFE_CONFIRMATIONS; - const currentBlockNumber = await this.indexerQueueService.getLatestIndexedBlock(CKB_CHAIN_ID); + + let currentBlockNumber = await this.indexerQueueService.getLatestIndexedAssetsBlock(CKB_CHAIN_ID); + if (!currentBlockNumber) { + const latestAsset = await this.prismaService.asset.findFirst({ + select: { blockNumber: true }, + where: { chainId: CKB_CHAIN_ID }, + orderBy: { blockNumber: 'desc' }, + }); + currentBlockNumber = latestAsset?.blockNumber; + } const isHealthy = !!currentBlockNumber && currentBlockNumber >= targetBlockNumber - INDEEXR_HEALTH_THRESHOLD; diff --git a/backend/src/core/indexer/indexer.queue.ts b/backend/src/core/indexer/indexer.queue.ts index d0f7beb1..55049c96 100644 --- a/backend/src/core/indexer/indexer.queue.ts +++ b/backend/src/core/indexer/indexer.queue.ts @@ -76,13 +76,17 @@ export class IndexerQueueService { await this.cacheManager.set(`${INDEXER_ASSETS_QUEUE}:${typeHash}`, cursor || ''); } - public async getLatestIndexedBlock(chainId: number) { + public async getLatestIndexedAssetsBlock(chainId: number) { const blockNumber = await this.cacheManager.get( `${INDEXER_BLOCK_ASSETS_QUEUE}:${chainId}`, ); return blockNumber; } + public async setLatestIndexedAssetsBlock(chainId: number, blockNumber: number) { + await this.cacheManager.set(`${INDEXER_BLOCK_ASSETS_QUEUE}:${chainId}`, blockNumber); + } + public async addBlockAssetsJob(data: IndexerBlockAssetsJobData) { const { chainId, blockNumber } = data; const params = new URLSearchParams(); @@ -95,7 +99,7 @@ export class IndexerQueueService { `Added block assets job ${jobId} for chain ${chainId} with block number ${blockNumber}`, ); await this.blockAssetsQueue.add(jobId, data, { jobId }); - await this.cacheManager.set(`${INDEXER_BLOCK_ASSETS_QUEUE}:${chainId}`, blockNumber); + await this.setLatestIndexedAssetsBlock(chainId, blockNumber); } public async addLockJob(data: IndexerLockJobData) { diff --git a/backend/src/core/indexer/indexer.service.ts b/backend/src/core/indexer/indexer.service.ts index af8da99d..0e53181a 100644 --- a/backend/src/core/indexer/indexer.service.ts +++ b/backend/src/core/indexer/indexer.service.ts @@ -4,6 +4,7 @@ import { BlockchainService } from '../blockchain/blockchain.service'; import { PrismaService } from '../database/prisma/prisma.service'; import { IndexerQueueService } from './indexer.queue'; import { IndexerTransactionsFlow } from './flow/transactions.flow'; +import { SchedulerRegistry } from '@nestjs/schedule'; export class IndexerService { public assetsFlow: IndexerAssetsFlow; @@ -14,18 +15,21 @@ export class IndexerService { private blockchainService: BlockchainService, private prismaService: PrismaService, private indexerQueueService: IndexerQueueService, + private schedulerRegistry: SchedulerRegistry, ) { this.assetsFlow = new IndexerAssetsFlow( this.chain, this.blockchainService, this.prismaService, this.indexerQueueService, + this.schedulerRegistry, ); this.transactionsFlow = new IndexerTransactionsFlow( this.chain, this.blockchainService, this.prismaService, this.indexerQueueService, + this.schedulerRegistry, ); } diff --git a/backend/src/core/indexer/processor/block-assets.processor.ts b/backend/src/core/indexer/processor/block-assets.processor.ts index b2b00818..f84daf86 100644 --- a/backend/src/core/indexer/processor/block-assets.processor.ts +++ b/backend/src/core/indexer/processor/block-assets.processor.ts @@ -107,7 +107,7 @@ export class IndexerBlockAssetsProcessor extends WorkerHost { } else { const indexerServiceFactory = this.moduleRef.get(IndexerServiceFactory); const indexerService = await indexerServiceFactory.getService(chainId); - indexerService.assetsFlow.emit(IndexerAssetsEvent.BlockAssetsIndexed, block); + indexerService.assetsFlow.emit(IndexerAssetsEvent.BlockAssetsIndexed, blockNumber); return; } } diff --git a/backend/src/decorators/cache-control.decorator.ts b/backend/src/decorators/cache-control.decorator.ts new file mode 100644 index 00000000..ffc9632f --- /dev/null +++ b/backend/src/decorators/cache-control.decorator.ts @@ -0,0 +1,19 @@ +import { Directive } from '@nestjs/graphql'; + +interface CacheControlOptions { + maxAge?: number; + scope?: 'PRIVATE' | 'PUBLIC'; + inheritMaxAge?: boolean; +} + +export const CacheControl = ({ maxAge, scope = 'PUBLIC', inheritMaxAge }: CacheControlOptions) => { + const args = [ + `scope: ${scope}`, + maxAge !== undefined ? `maxAge: ${maxAge}` : null, + inheritMaxAge ? `inheritMaxAge: ${inheritMaxAge}` : null, + ] + .filter(Boolean) + .join(', '); + + return Directive(`@cacheControl(${args})`); +}; diff --git a/backend/src/decorators/cacheable.decorator.ts b/backend/src/decorators/cacheable.decorator.ts index 690da32d..a552f9a5 100644 --- a/backend/src/decorators/cacheable.decorator.ts +++ b/backend/src/decorators/cacheable.decorator.ts @@ -1,69 +1,84 @@ -// eslint-disable-next-line no-restricted-imports import { Cache, CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { CacheableRegisterOptions, Cacheable as _Cacheable } from 'nestjs-cacheable'; -import { cacheableHandle, generateComposedKey } from 'nestjs-cacheable/dist/cacheable.helper'; import { Env } from 'src/env'; +import serialize from 'serialize-javascript'; +import { createHash } from 'crypto'; -export interface CustomCacheableRegisterOptions extends CacheableRegisterOptions { +const logger = new Logger('Cacheable'); + +type KeyBuilder = string | ((...args: any[]) => string | string[]); + +interface CacheOptions { + key?: KeyBuilder; + namespace?: KeyBuilder; + ttl?: number; shouldCache?: (result: any, target: any) => boolean | Promise; } -const logger = new Logger('Cacheable'); +function extractKeys(keyBuilder: KeyBuilder, args: any[]): string[] { + const keys = typeof keyBuilder === 'function' ? keyBuilder(...args) : keyBuilder; + return Array.isArray(keys) ? keys : [keys]; +} + +function generateCacheKey(options: { + key?: KeyBuilder; + namespace?: KeyBuilder; + methodName: string; + args: any[]; +}): string { + let keys: string[]; + if (options.key) { + keys = extractKeys(options.key, options.args); + } else { + const hash = createHash('md5').update(serialize(options.args)).digest('hex'); + keys = [`${options.methodName}@${hash}`]; + } + + const namespace = options.namespace && extractKeys(options.namespace, options.args); + const composedKey = keys.map((key) => (namespace ? `${namespace[0]}:${key}` : key))[0]; + + return composedKey; +} + +export function Cacheable(options: CacheOptions = {}): MethodDecorator { + return function(target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + Inject(CACHE_MANAGER)(target, '__cacheManager'); + Inject(ConfigService)(target, '__configService'); + + descriptor.value = async function(...args: any[]) { + const cacheManager = this.__cacheManager as Cache; + if (!cacheManager) return originalMethod.apply(this, args); + + const configService = this.__configService as ConfigService; + const branch = configService.get('GIT_BRANCH') || 'unknown'; + const prefix = configService.get('CACHE_KEY_PREFIX'); + + const baseKey = generateCacheKey({ + methodName: String(propertyKey), + key: options.key, + namespace: options.namespace, + args, + }); + const fullCacheKey = `${prefix}-${branch}/${baseKey}`; + + const cachedValue = await cacheManager.get(fullCacheKey); + if (cachedValue) { + logger.debug(`Cache hit for key: ${baseKey}`); + return cachedValue; + } + + const result = await originalMethod.apply(this, args); + const shouldCache = options.shouldCache ? await options.shouldCache(result, this) : true; + + if (shouldCache) { + await cacheManager.set(fullCacheKey, result, options.ttl); + } -/** - * Cacheable decorator with custom options, based on the original Cacheable decorator from the nestjs-cacheable package. - * Adds a shouldCache option to determine whether the result should be cached. - * - * @example - * @Cacheable({ - * ttl: 1000, - * key: (args: any[]) => args[0], - * shouldCache: (result: any) => result !== null, - * }); - */ -export function Cacheable(options: CustomCacheableRegisterOptions): MethodDecorator { - const injectCacheService = Inject(CACHE_MANAGER); - const injectConfigService = Inject(ConfigService); - - return function (target, propertyKey, descriptor) { - // eslint-disable-next-line @typescript-eslint/ban-types - const originalMethod = descriptor.value as unknown as Function; - - injectCacheService(target, '__cacheManager'); - injectConfigService(target, '__configService'); - return { - ...descriptor, - value: async function (...args: any[]) { - const cacheManager = this.__cacheManager as Cache; - if (!cacheManager) return originalMethod.apply(this, args); - const composeOptions: Parameters[0] = { - methodName: String(propertyKey), - key: options.key, - namespace: options.namespace, - args, - }; - const [key] = generateComposedKey(composeOptions); - - const configService = this.__configService as ConfigService; - const branch = configService.get('GIT_BRANCH') || 'unknown'; - const prefix = configService.get('CACHE_KEY_PREFIX'); - - const returnVal = await cacheableHandle( - `${prefix}-${branch}/${key}`, - () => originalMethod.apply(this, args), - options.ttl, - ); - - // Remove the cache if shouldCache returns false - const shouldCache = options.shouldCache ? await options.shouldCache(returnVal, this) : true; - if (!shouldCache) { - logger.debug(`Removing cache for key: ${key}`); - await cacheManager.del(key); - } - return returnVal; - } as any, + return result; }; + return descriptor; }; } diff --git a/backend/src/env.ts b/backend/src/env.ts index 1ab52b6b..4afe3a93 100644 --- a/backend/src/env.ts +++ b/backend/src/env.ts @@ -10,7 +10,10 @@ export const envSchema = z .string() .default('true') .transform((value) => value === 'true'), - GRAPHQL_COMPLEXITY_LIMIT: z.coerce.number().default(100), + + GRAPHQL_COMPLEXITY_LIMIT: z.coerce.number().default(1000), + + CLUSTER_WORKERS_NUM: z.coerce.number().default(2), /** * CORS origin whitelist (split by comma) @@ -24,7 +27,8 @@ export const envSchema = z }), DATABASE_URL: z.string(), - REDIS_URL: z.string(), + REDIS_CACHE_URL: z.string(), + REDIS_QUEUE_URL: z.string(), BITCOIN_PRIMARY_DATA_PROVIDER: z.enum(['mempool', 'electrs']).default('mempool'), diff --git a/backend/src/filters/all-exceptions.filter.ts b/backend/src/filters/all-exceptions.filter.ts index 5f831d39..309a7b08 100644 --- a/backend/src/filters/all-exceptions.filter.ts +++ b/backend/src/filters/all-exceptions.filter.ts @@ -11,12 +11,15 @@ export class AllExceptionsFilter extends SentryGlobalGraphQLFilter { } catch(exception: unknown, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const request = ctx.getRequest(); - if (SKIP_REQUEST_URLS.includes(request.url)) { - const response = (exception as HttpException).getResponse(); - this.httpAdapterHost.httpAdapter.reply(ctx.getResponse(), response, 200); - return; + const type = host.getType(); + if (type === 'http') { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + if (SKIP_REQUEST_URLS.includes(request.url)) { + const response = (exception as HttpException).getResponse(); + this.httpAdapterHost.httpAdapter.reply(ctx.getResponse(), response, 200); + return; + } } super.catch(exception, host); diff --git a/backend/src/main.ts b/backend/src/main.ts index acb704c2..968c8158 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -5,6 +5,7 @@ import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify import { envSchema } from './env'; import { BootstrapService } from './bootstrap.service'; import { LogLevel } from '@nestjs/common'; +import { ClusterService } from './cluster.service'; const env = envSchema.parse(process.env); const LOGGER_LEVELS: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error']; @@ -41,4 +42,4 @@ async function bootstrap() { await app.listen(3000, '0.0.0.0'); } -bootstrap(); +ClusterService.clusterize(bootstrap); diff --git a/backend/src/middlewares/field-performance.middleware.ts b/backend/src/middlewares/field-performance.middleware.ts index ddb3850e..ff319db7 100644 --- a/backend/src/middlewares/field-performance.middleware.ts +++ b/backend/src/middlewares/field-performance.middleware.ts @@ -8,12 +8,7 @@ export const fieldPerformanceMiddleware: FieldMiddleware = async ( const now = performance.now(); const value = await next(); const executionTime = performance.now() - now; - - Sentry.setContext('graphql', { - executionTime, - field: ctx.info.fieldName, - parent: ctx.info.parentType.name, - }); + Sentry.setTag('graphql.field', `${ctx.info.parentType.name}.${ctx.info.fieldName}`); Sentry.setMeasurement('graphql.executionTime', executionTime, 'millisecond'); return value; }; diff --git a/backend/src/modules/api.module.ts b/backend/src/modules/api.module.ts index 3708d86f..c93a3e2d 100644 --- a/backend/src/modules/api.module.ts +++ b/backend/src/modules/api.module.ts @@ -2,7 +2,7 @@ import { join } from 'node:path'; import { ExecutionContext, Injectable, Module } from '@nestjs/common'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ConfigService } from '@nestjs/config'; -import { GqlExecutionContext, GraphQLModule } from '@nestjs/graphql'; +import { GqlExecutionContext, GraphQLModule, Int } from '@nestjs/graphql'; import { DataLoaderInterceptor } from 'src/common/dataloader'; import { Env } from 'src/env'; import { CkbModule } from './ckb/ckb.module'; @@ -15,8 +15,13 @@ import { ComplexityPlugin } from './complexity.plugin'; import * as Sentry from '@sentry/nestjs'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { FastifyReply, FastifyRequest } from 'fastify'; -import { SentryGlobalGraphQLFilter } from '@sentry/nestjs/setup'; +import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl'; +import responseCachePlugin from '@apollo/server-plugin-response-cache'; import { AllExceptionsFilter } from 'src/filters/all-exceptions.filter'; +import { DirectiveLocation, GraphQLBoolean, GraphQLDirective, GraphQLEnumType } from 'graphql'; +import { LoggingPlugin } from './logging.plugin'; +import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; +import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; @Injectable() export class GqlThrottlerGuard extends ThrottlerGuard { @@ -32,26 +37,75 @@ export class GqlThrottlerGuard extends ThrottlerGuard { ThrottlerModule.forRootAsync({ inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ + getTracker: (req: Record) => { + return req.ips.length ? req.ips[0] : req.ip; + }, throttlers: [ { ttl: configService.get('RATE_LIMIT_WINDOW_MS')!, limit: configService.get('RATE_LIMIT_PER_MINUTE')!, }, ], + storage: new ThrottlerStorageRedisService(configService.get('REDIS_CACHE_URL')), }), }), GraphQLModule.forRootAsync({ driver: ApolloDriver, - inject: [ConfigService], - useFactory: async (configService: ConfigService) => ({ + inject: [ConfigService, CACHE_MANAGER], + useFactory: async (configService: ConfigService, cacheManager: Cache) => ({ playground: configService.get('ENABLED_GRAPHQL_PLAYGROUND'), installSubscriptionHandlers: true, introspection: true, graphiql: true, autoSchemaFile: join(process.cwd(), 'src/schema.gql'), + plugins: [ + ApolloServerPluginCacheControl({ + defaultMaxAge: 10, + calculateHttpHeaders: true, + }), + responseCachePlugin(), + ], + cache: { + async get(key: string) { + const val = await cacheManager.get(key); + return val as string | undefined; + }, + async set(key: string, value: string, options?: { ttl: number | null }) { + const { ttl } = options || { ttl: null }; + await cacheManager.set(key, value, ttl ? ttl * 1000 : undefined); + }, + async delete(key: string) { + await cacheManager.del(key); + }, + }, buildSchemaOptions: { dateScalarMode: 'timestamp', fieldMiddleware: [fieldPerformanceMiddleware], + directives: [ + new GraphQLDirective({ + name: 'cacheControl', + args: { + maxAge: { type: Int }, + scope: { + type: new GraphQLEnumType({ + name: 'CacheControlScope', + values: { + PUBLIC: {}, + PRIVATE: {}, + }, + }), + }, + inheritMaxAge: { type: GraphQLBoolean }, + }, + locations: [ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + DirectiveLocation.UNION, + DirectiveLocation.QUERY, + ], + }), + ], }, context: (request: FastifyRequest, reply: FastifyReply) => { return { @@ -84,6 +138,7 @@ export class GqlThrottlerGuard extends ThrottlerGuard { useClass: AllExceptionsFilter, }, ComplexityPlugin, + LoggingPlugin, ], }) -export class ApiModule { } +export class ApiModule {} diff --git a/backend/src/modules/bitcoin/address/address.dataloader.ts b/backend/src/modules/bitcoin/address/address.dataloader.ts index aee9bec6..7a430f68 100644 --- a/backend/src/modules/bitcoin/address/address.dataloader.ts +++ b/backend/src/modules/bitcoin/address/address.dataloader.ts @@ -80,6 +80,9 @@ export class BitcoinAddressTransactionsLoader }; } } -export type BitcoinAddressTransactionsLoaderType = DataLoader; +export type BitcoinAddressTransactionsLoaderType = DataLoader< + GetAddressTxsParams, + BitcoinTransaction[] | null +>; export type BitcoinAddressTransactionsLoaderResponse = DataLoaderResponse; diff --git a/backend/src/modules/bitcoin/address/address.resolver.ts b/backend/src/modules/bitcoin/address/address.resolver.ts index ce7954dc..00abbfc6 100644 --- a/backend/src/modules/bitcoin/address/address.resolver.ts +++ b/backend/src/modules/bitcoin/address/address.resolver.ts @@ -10,6 +10,7 @@ import { BitcoinAddressTransactionsLoaderType, } from './address.dataloader'; import { ValidateBtcAddressPipe } from 'src/pipes/validate-address.pipe'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => BitcoinAddress) export class BitcoinAddressResolver { @@ -20,7 +21,7 @@ export class BitcoinAddressResolver { return BitcoinAddress.from(address); } - @ResolveField(() => Float) + @ResolveField(() => Float, { complexity: ComplexityType.RequestField }) public async satoshi( @Parent() address: BitcoinAddress, @Loader(BitcoinAddressLoader) addressLoader: BitcoinAddressLoaderType, @@ -32,7 +33,7 @@ export class BitcoinAddressResolver { return addressStats.chain_stats.funded_txo_sum - addressStats.chain_stats.spent_txo_sum; } - @ResolveField(() => Float) + @ResolveField(() => Float, { complexity: ComplexityType.RequestField }) public async pendingSatoshi( @Parent() address: BitcoinAddress, @Loader(BitcoinAddressLoader) addressLoader: BitcoinAddressLoaderType, @@ -44,7 +45,7 @@ export class BitcoinAddressResolver { return addressStats.mempool_stats.funded_txo_sum - addressStats.mempool_stats.spent_txo_sum; } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async transactionsCount( @Parent() address: BitcoinAddress, @Loader(BitcoinAddressLoader) addressLoader: BitcoinAddressLoaderType, @@ -57,7 +58,10 @@ export class BitcoinAddressResolver { return stats.chain_stats.tx_count; } - @ResolveField(() => [BitcoinTransaction], { nullable: true }) + @ResolveField(() => [BitcoinTransaction], { + nullable: true, + complexity: ({ childComplexity }) => ComplexityType.ListField * childComplexity, + }) public async transactions( @Parent() address: BitcoinAddress, @Loader(BitcoinAddressTransactionsLoader) diff --git a/backend/src/modules/bitcoin/bitcoin.resolver.ts b/backend/src/modules/bitcoin/bitcoin.resolver.ts index 7ab489fc..7a23d8b0 100644 --- a/backend/src/modules/bitcoin/bitcoin.resolver.ts +++ b/backend/src/modules/bitcoin/bitcoin.resolver.ts @@ -6,21 +6,22 @@ import { BitcoinBlockTxidsLoader, BitcoinBlockTxidsLoaderType, } from './block/dataloader/block-txids.dataloader'; +import { ComplexityType } from '../complexity.plugin'; // 60 * 24 = 1440 minutes const BLOCK_NUMBER_OF_24_HOURS = 144; @Resolver(() => BitcoinChainInfo) export class BitcoinResolver { - constructor(private bitcoinApiService: BitcoinApiService) {} + constructor(private bitcoinApiService: BitcoinApiService) { } - @Query(() => BitcoinChainInfo, { name: 'btcChainInfo' }) + @Query(() => BitcoinChainInfo, { name: 'btcChainInfo', complexity: ComplexityType.RequestField }) public async chainInfo(): Promise { const info = await this.bitcoinApiService.getBlockchainInfo(); return BitcoinChainInfo.from(info); } - @ResolveField(() => Float) + @ResolveField(() => Float, { complexity: ComplexityType.RequestField }) public async transactionsCountIn24Hours( @Parent() chainInfo: BitcoinBaseChainInfo, @Loader(BitcoinBlockTxidsLoader) blockTxidsLoader: BitcoinBlockTxidsLoaderType, @@ -39,7 +40,7 @@ export class BitcoinResolver { return count; } - @ResolveField(() => BitcoinFees) + @ResolveField(() => BitcoinFees, { complexity: ComplexityType.RequestField }) public async fees(): Promise { const fees = await this.bitcoinApiService.getFeesRecommended(); return BitcoinFees.from(fees); diff --git a/backend/src/modules/bitcoin/block/block.resolver.ts b/backend/src/modules/bitcoin/block/block.resolver.ts index 90d210be..20a225da 100644 --- a/backend/src/modules/bitcoin/block/block.resolver.ts +++ b/backend/src/modules/bitcoin/block/block.resolver.ts @@ -9,12 +9,17 @@ import { BitcoinBlockTransactionsLoaderType, } from './dataloader/block-transactions.dataloader'; import { BitcoinApiService } from 'src/core/bitcoin-api/bitcoin-api.service'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => BitcoinBlock) export class BitcoinBlockResolver { - constructor(private bitcoinApiService: BitcoinApiService) {} + constructor(private bitcoinApiService: BitcoinApiService) { } - @Query(() => BitcoinBlock, { name: 'btcBlock', nullable: true }) + @Query(() => BitcoinBlock, { + name: 'btcBlock', + nullable: true, + complexity: ComplexityType.RequestField, + }) public async getBlock( @Args('hashOrHeight', { type: () => String }) hashOrHeight: string, @Loader(BitcoinBlockLoader) blockLoader: BitcoinBlockLoaderType, @@ -26,7 +31,10 @@ export class BitcoinBlockResolver { return BitcoinBlock.from(block); } - @ResolveField(() => BitcoinAddress, { nullable: true }) + @ResolveField(() => BitcoinAddress, { + nullable: true, + complexity: ComplexityType.RequestField, + }) public async miner( @Parent() block: BitcoinBlock, @Loader(BitcoinBlockLoader) blockLoader: BitcoinBlockLoaderType, @@ -41,7 +49,10 @@ export class BitcoinBlockResolver { }; } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { + nullable: true, + complexity: ComplexityType.RequestField, + }) public async reward( @Parent() block: BitcoinBlock, @Loader(BitcoinBlockLoader) blockLoader: BitcoinBlockLoaderType, @@ -54,7 +65,7 @@ export class BitcoinBlockResolver { return detail.extras.reward; } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async totalFee( @Parent() block: BitcoinBlock, @Loader(BitcoinBlockLoader) blockLoader: BitcoinBlockLoaderType, @@ -67,7 +78,10 @@ export class BitcoinBlockResolver { return detail.extras.totalFees; } - @ResolveField(() => FeeRateRange, { nullable: true }) + @ResolveField(() => FeeRateRange, { + nullable: true, + complexity: ComplexityType.RequestField, + }) public async feeRateRange( @Parent() block: BitcoinBlock, @Loader(BitcoinBlockLoader) blockLoader: BitcoinBlockLoaderType, @@ -83,7 +97,10 @@ export class BitcoinBlockResolver { }; } - @ResolveField(() => [BitcoinTransaction], { nullable: true }) + @ResolveField(() => [BitcoinTransaction], { + nullable: true, + complexity: ({ childComplexity }) => ComplexityType.ListField * childComplexity, + }) public async transactions( @Parent() block: BitcoinBlock, @Loader(BitcoinBlockTransactionsLoader) blockTxsLoader: BitcoinBlockTransactionsLoaderType, @@ -103,7 +120,7 @@ export class BitcoinBlockResolver { return txs.map((tx) => BitcoinTransaction.from(tx)); } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async confirmations(@Parent() block: BitcoinBlock): Promise { const info = await this.bitcoinApiService.getBlockchainInfo(); return info.blocks - block.height; diff --git a/backend/src/modules/bitcoin/output/output.resolver.ts b/backend/src/modules/bitcoin/output/output.resolver.ts index 898780f7..6a27bb75 100644 --- a/backend/src/modules/bitcoin/output/output.resolver.ts +++ b/backend/src/modules/bitcoin/output/output.resolver.ts @@ -6,6 +6,7 @@ import { BitcoinTransactionOutSpendsLoader, BitcoinTransactionOutSpendsLoaderType, } from '../transaction/transaction.dataloader'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => BitcoinOutput) export class BitcoinOutputResolver { @@ -20,7 +21,10 @@ export class BitcoinOutputResolver { }; } - @ResolveField(() => BitcoinOutputStatus, { nullable: true }) + @ResolveField(() => BitcoinOutputStatus, { + nullable: true, + complexity: ComplexityType.RequestField, + }) public async status( @Parent() output: BitcoinOutput, @Loader(BitcoinTransactionOutSpendsLoader) diff --git a/backend/src/modules/bitcoin/transaction/transaction.model.ts b/backend/src/modules/bitcoin/transaction/transaction.model.ts index 929e395b..c6c694aa 100644 --- a/backend/src/modules/bitcoin/transaction/transaction.model.ts +++ b/backend/src/modules/bitcoin/transaction/transaction.model.ts @@ -2,6 +2,7 @@ import { Field, Float, Int, ObjectType } from '@nestjs/graphql'; import * as BitcoinApi from 'src/core/bitcoin-api/bitcoin-api.schema'; import { BitcoinOutput } from '../output/output.model'; import { BitcoinInput } from '../input/input.model'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @ObjectType({ description: 'Bitcoin Transaction' }) export class BitcoinTransaction { @@ -20,10 +21,10 @@ export class BitcoinTransaction { @Field(() => Int) version: number; - @Field(() => [BitcoinInput], { nullable: true }) + @Field(() => [BitcoinInput], { nullable: true, complexity: ComplexityType.ListField }) vin: BitcoinInput[]; - @Field(() => [BitcoinOutput]) + @Field(() => [BitcoinOutput], { complexity: ComplexityType.ListField }) vout: BitcoinOutput[]; @Field(() => Float) diff --git a/backend/src/modules/bitcoin/transaction/transaction.resolver.ts b/backend/src/modules/bitcoin/transaction/transaction.resolver.ts index b9a99eac..7d2f79a8 100644 --- a/backend/src/modules/bitcoin/transaction/transaction.resolver.ts +++ b/backend/src/modules/bitcoin/transaction/transaction.resolver.ts @@ -10,12 +10,17 @@ import { } from 'src/modules/rgbpp/transaction/transaction.dataloader'; import { BitcoinBlock } from '../block/block.model'; import { BitcoinBlockLoader, BitcoinBlockLoaderType } from '../block/dataloader/block.dataloader'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => BitcoinTransaction) export class BitcoinTransactionResolver { - constructor(private bitcoinApiService: BitcoinApiService) {} + constructor(private bitcoinApiService: BitcoinApiService) { } - @Query(() => BitcoinTransaction, { name: 'btcTransaction', nullable: true }) + @Query(() => BitcoinTransaction, { + name: 'btcTransaction', + nullable: true, + complexity: ComplexityType.RequestField, + }) public async getTransaction( @Args('txid') txid: string, @Loader(BitcoinTransactionLoader) txLoader: BitcoinTransactionLoaderType, @@ -27,7 +32,9 @@ export class BitcoinTransactionResolver { return BitcoinTransaction.from(transaction); } - @ResolveField(() => Float) + @ResolveField(() => Float, { + complexity: ComplexityType.RequestField, + }) public async confirmations(@Parent() tx: BitcoinTransaction): Promise { if (!tx.confirmed) { return 0; @@ -36,7 +43,7 @@ export class BitcoinTransactionResolver { return info.blocks - tx.blockHeight! + 1; } - @ResolveField(() => Date, { nullable: true }) + @ResolveField(() => Date, { nullable: true, complexity: ComplexityType.RequestField }) public async transactionTime(@Parent() tx: BitcoinTransaction): Promise { const [txTime] = await this.bitcoinApiService.getTransactionTimes({ txids: [tx.txid] }); if (!txTime) { @@ -45,7 +52,7 @@ export class BitcoinTransactionResolver { return new Date(txTime * 1000); } - @ResolveField(() => BitcoinBlock, { nullable: true }) + @ResolveField(() => BitcoinBlock, { nullable: true, complexity: ComplexityType.RequestField }) public async block( @Parent() tx: BitcoinTransaction, @Loader(BitcoinBlockLoader) blockLoader: BitcoinBlockLoaderType, @@ -60,7 +67,7 @@ export class BitcoinTransactionResolver { return BitcoinBlock.from(block); } - @ResolveField(() => RgbppTransaction, { nullable: true }) + @ResolveField(() => RgbppTransaction, { nullable: true, complexity: ComplexityType.RequestField }) public async rgbppTransaction( @Parent() tx: BitcoinTransaction, @Loader(RgbppTransactionLoader) txLoader: RgbppTransactionLoaderType, diff --git a/backend/src/modules/ckb/address/address.resolver.ts b/backend/src/modules/ckb/address/address.resolver.ts index e2dd9807..40e02799 100644 --- a/backend/src/modules/ckb/address/address.resolver.ts +++ b/backend/src/modules/ckb/address/address.resolver.ts @@ -14,6 +14,7 @@ import { } from '../transaction/transaction.dataloader'; import { ValidateCkbAddressPipe } from 'src/pipes/validate-address.pipe'; import { BI } from '@ckb-lumos/bi'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => CkbAddress) export class CkbAddressResolver { @@ -26,7 +27,7 @@ export class CkbAddressResolver { }; } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async shannon( @Parent() address: CkbAddress, @Loader(CkbAddressLoader) addressLoader: CkbAddressLoaderType, @@ -38,7 +39,7 @@ export class CkbAddressResolver { return Number(addressInfo[0].balance); } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async transactionsCount( @Parent() address: CkbAddress, @Loader(CkbAddressLoader) addressLoader: CkbAddressLoaderType, @@ -50,7 +51,10 @@ export class CkbAddressResolver { return Number(addressInfo[0].transactions_count); } - @ResolveField(() => [CkbTransaction], { nullable: true }) + @ResolveField(() => [CkbTransaction], { + nullable: true, + complexity: ({ args, childComplexity }) => (args.pageSize ?? 10) * childComplexity, + }) public async transactions( @Parent() address: CkbAddress, @Loader(CkbAddressTransactionsLoader) addressTxsLoader: CkbAddressTransactionsLoaderType, @@ -77,7 +81,7 @@ export class CkbAddressResolver { ); } - @ResolveField(() => CkbAddressBalance, { nullable: true }) + @ResolveField(() => CkbAddressBalance, { nullable: true, complexity: ComplexityType.RequestField }) public async balance( @Parent() address: CkbAddress, @Loader(CkbAddressLoader) addressLoader: CkbAddressLoaderType, diff --git a/backend/src/modules/ckb/block/block.resolver.ts b/backend/src/modules/ckb/block/block.resolver.ts index 6e2b870d..20b88d36 100644 --- a/backend/src/modules/ckb/block/block.resolver.ts +++ b/backend/src/modules/ckb/block/block.resolver.ts @@ -18,12 +18,17 @@ import { CkbRpcTransactionLoaderType, } from '../transaction/transaction.dataloader'; import { CkbRpcWebsocketService } from 'src/core/ckb-rpc/ckb-rpc-websocket.service'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => CkbBlock) export class CkbBlockResolver { - constructor(private ckbRpcService: CkbRpcWebsocketService) {} + constructor(private ckbRpcService: CkbRpcWebsocketService) { } - @Query(() => CkbBlock, { name: 'ckbBlock', nullable: true }) + @Query(() => CkbBlock, { + name: 'ckbBlock', + nullable: true, + complexity: ComplexityType.RequestField, + }) public async getBlock( @Args('heightOrHash', { type: () => String }) heightOrHash: string, @Loader(CkbRpcBlockLoader) rpcBlockLoader: CkbRpcBlockLoaderType, @@ -35,7 +40,7 @@ export class CkbBlockResolver { return CkbBlock.from(block); } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async totalFee( @Parent() block: CkbBlock, @Loader(CkbBlockEconomicStateLoader) blockEconomicLoader: CkbBlockEconomicStateLoaderType, @@ -47,7 +52,7 @@ export class CkbBlockResolver { return BI.from(blockEconomicState.txs_fee).toNumber(); } - @ResolveField(() => CkbAddress, { nullable: true }) + @ResolveField(() => CkbAddress, { nullable: true, complexity: ComplexityType.RequestField }) public async miner( @Parent() block: CkbBlock, @Loader(CkbExplorerBlockLoader) explorerBlockLoader: CkbExplorerBlockLoaderType, @@ -59,7 +64,7 @@ export class CkbBlockResolver { return CkbAddress.from(explorerBlock.miner_hash); } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async reward( @Parent() block: CkbBlock, @Loader(CkbExplorerBlockLoader) explorerBlockLoader: CkbExplorerBlockLoaderType, @@ -71,7 +76,10 @@ export class CkbBlockResolver { return toNumber(explorerBlock.miner_reward); } - @ResolveField(() => [CkbTransaction], { nullable: true }) + @ResolveField(() => [CkbTransaction], { + nullable: true, + complexity: ({ childComplexity }) => ComplexityType.ListField * childComplexity, + }) public async transactions( @Parent() { hash }: CkbBlock, @Loader(CkbRpcBlockLoader) rpcBlockLoader: CkbRpcBlockLoaderType, @@ -92,7 +100,7 @@ export class CkbBlockResolver { ); } - @ResolveField(() => Float) + @ResolveField(() => Float, { complexity: ComplexityType.RequestField }) public async size( @Parent() block: CkbBlock, @Loader(CkbExplorerBlockLoader) explorerBlockLoader: CkbExplorerBlockLoaderType, @@ -104,7 +112,7 @@ export class CkbBlockResolver { return explorerBlock.size; } - @ResolveField(() => Float) + @ResolveField(() => Float, { complexity: ComplexityType.RequestField }) public async confirmations( @Parent() block: CkbBlock, @Loader(CkbExplorerBlockLoader) explorerBlockLoader: CkbExplorerBlockLoaderType, diff --git a/backend/src/modules/ckb/cell/cell.resolver.ts b/backend/src/modules/ckb/cell/cell.resolver.ts index b1e33aea..b637d91f 100644 --- a/backend/src/modules/ckb/cell/cell.resolver.ts +++ b/backend/src/modules/ckb/cell/cell.resolver.ts @@ -11,15 +11,16 @@ import { CkbCell, CkbXUDTInfo, CkbCellStatus } from './cell.model'; import { CkbCellService } from './cell.service'; import { CellType } from '../script/script.model'; import { CkbScriptService } from '../script/script.service'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => CkbCell) export class CkbCellResolver { constructor( private ckbCellService: CkbCellService, private ckbScriptService: CkbScriptService, - ) {} + ) { } - @ResolveField(() => CkbXUDTInfo, { nullable: true }) + @ResolveField(() => CkbXUDTInfo, { nullable: true, complexity: ComplexityType.RequestField }) public async xudtInfo( @Parent() cell: CkbCell, @Loader(CkbExplorerTransactionLoader) explorerTxLoader: CkbExplorerTransactionLoaderType, @@ -32,7 +33,7 @@ export class CkbCellResolver { return this.ckbCellService.getXUDTInfoFromOutput(cell, output); } - @ResolveField(() => CkbCellStatus, { nullable: true }) + @ResolveField(() => CkbCellStatus, { nullable: true, complexity: ComplexityType.RequestField }) public async status( @Parent() cell: CkbCell, @Loader(CkbRpcTransactionLoader) rpcTxLoader: CkbRpcTransactionLoaderType, diff --git a/backend/src/modules/ckb/script/base/base-script.service.ts b/backend/src/modules/ckb/script/base/base-script.service.ts index fd842cab..17bd7085 100644 --- a/backend/src/modules/ckb/script/base/base-script.service.ts +++ b/backend/src/modules/ckb/script/base/base-script.service.ts @@ -9,6 +9,7 @@ import { Env } from 'src/env'; import { CellType } from '../script.model'; import { OrderType } from 'src/modules/api.model'; import * as Sentry from '@sentry/nestjs'; +import { Cacheable } from 'src/decorators/cacheable.decorator'; export abstract class BaseScriptService { protected logger = new Logger(BaseScriptService.name); @@ -17,7 +18,7 @@ export abstract class BaseScriptService { constructor( protected configService: ConfigService, protected ckbRpcService: CkbRpcWebsocketService, - ) {} + ) { } public static sortTransactionCmp(a: CkbRpc.IndexerCell, b: CkbRpc.IndexerCell, order: OrderType) { const blockNumberCmp = BI.from(b.block_number).sub(BI.from(a.block_number)).toNumber(); @@ -35,6 +36,12 @@ export abstract class BaseScriptService { return scripts.some((s) => isScriptEqual(s, { ...script, args: '0x' })); } + @Cacheable({ + namespace: 'BaseScriptService', + key: (limit: number, order: OrderType, after?: string) => + `getTransactions:${limit}:${order}:${after}`, + ttl: 10_000, + }) public async getTransactions( limit: number = 10, order: OrderType = OrderType.Desc, diff --git a/backend/src/modules/ckb/transaction/transaction.resolver.ts b/backend/src/modules/ckb/transaction/transaction.resolver.ts index 858552ce..1f874a4d 100644 --- a/backend/src/modules/ckb/transaction/transaction.resolver.ts +++ b/backend/src/modules/ckb/transaction/transaction.resolver.ts @@ -19,6 +19,7 @@ import { CkbScriptService } from '../script/script.service'; import { OrderType } from 'src/modules/api.model'; import { BaseScriptService } from '../script/base/base-script.service'; import * as Sentry from '@sentry/nestjs'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => CkbTransaction) export class CkbTransactionResolver { @@ -27,9 +28,12 @@ export class CkbTransactionResolver { constructor( private ckbTransactionService: CkbTransactionService, private ckbScriptService: CkbScriptService, - ) {} + ) { } - @Query(() => [CkbTransaction], { name: 'ckbTransactions' }) + @Query(() => [CkbTransaction], { + name: 'ckbTransactions', + complexity: ({ args, childComplexity }) => (args.limit ?? 10) * childComplexity, + }) public async getTransactions( @Args('types', { type: () => [CellType], nullable: true }) types: CellType[] | null, @Args('scriptKey', { type: () => CkbSearchKeyInput, nullable: true }) @@ -92,7 +96,11 @@ export class CkbTransactionResolver { throw new BadRequestException('One of types and scriptKey must be provided'); } - @Query(() => CkbTransaction, { name: 'ckbTransaction', nullable: true }) + @Query(() => CkbTransaction, { + name: 'ckbTransaction', + nullable: true, + complexity: ComplexityType.RequestField, + }) public async getTransaction( @Args('txHash') txHash: string, @Loader(CkbRpcTransactionLoader) rpcTxLoader: CkbRpcTransactionLoaderType, @@ -104,7 +112,10 @@ export class CkbTransactionResolver { return CkbTransaction.from(tx); } - @ResolveField(() => [CkbCell], { nullable: true }) + @ResolveField(() => [CkbCell], { + nullable: true, + complexity: ComplexityType.RequestField, + }) public async inputs( @Parent() tx: CkbTransaction, @Loader(CkbRpcTransactionLoader) rpcTxLoader: CkbRpcTransactionLoaderType, @@ -129,7 +140,7 @@ export class CkbTransactionResolver { ); } - @ResolveField(() => CkbBlock, { nullable: true }) + @ResolveField(() => CkbBlock, { nullable: true, complexity: ComplexityType.RequestField }) public async block( @Parent() tx: CkbTransaction, @Loader(CkbRpcBlockLoader) rpcBlockLoader: CkbRpcBlockLoaderType, @@ -141,7 +152,7 @@ export class CkbTransactionResolver { return CkbBlock.from(block); } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async fee( @Parent() tx: CkbTransaction, @Loader(CkbExplorerTransactionLoader) explorerTxLoader: CkbExplorerTransactionLoaderType, @@ -153,7 +164,7 @@ export class CkbTransactionResolver { return toNumber(explorerTx.transaction_fee); } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async feeRate( @Parent() tx: CkbTransaction, @Loader(CkbExplorerTransactionLoader) explorerTxLoader: CkbExplorerTransactionLoaderType, @@ -168,7 +179,7 @@ export class CkbTransactionResolver { return fee.mul(ratio).div(size).toNumber(); } - @ResolveField(() => Float) + @ResolveField(() => Float, { complexity: ComplexityType.RequestField }) public async confirmations(@Parent() tx: CkbTransaction): Promise { if (!tx.confirmed) { return 0; diff --git a/backend/src/modules/ckb/transaction/transaction.service.ts b/backend/src/modules/ckb/transaction/transaction.service.ts index e01be132..04873ad2 100644 --- a/backend/src/modules/ckb/transaction/transaction.service.ts +++ b/backend/src/modules/ckb/transaction/transaction.service.ts @@ -6,6 +6,7 @@ import { CkbExplorerService } from 'src/core/ckb-explorer/ckb-explorer.service'; import { CkbSearchKeyInput } from './transaction.model'; import { BI } from '@ckb-lumos/bi'; import { OrderType } from 'src/modules/api.model'; +import { Cacheable } from 'src/decorators/cacheable.decorator'; @Injectable() export class CkbTransactionService { @@ -29,6 +30,12 @@ export class CkbTransactionService { return this.ckbRpcService.getTipBlockNumber(); } + @Cacheable({ + namespace: 'CkbTransactionService', + key: (searchKey: CkbSearchKeyInput, order: OrderType, limit: number, after?: string) => + `getTransactions:${JSON.stringify(searchKey)}:${order}:${limit}:${after}`, + ttl: 10_000, + }) public async getTransactions( searchKey: CkbSearchKeyInput, order: OrderType = OrderType.Desc, diff --git a/backend/src/modules/complexity.plugin.ts b/backend/src/modules/complexity.plugin.ts index 652961fb..888727cd 100644 --- a/backend/src/modules/complexity.plugin.ts +++ b/backend/src/modules/complexity.plugin.ts @@ -6,9 +6,17 @@ import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphq import * as Sentry from '@sentry/nestjs'; import { ConfigService } from '@nestjs/config'; import { Env } from 'src/env'; +import { Logger } from '@nestjs/common'; + +export enum ComplexityType { + RequestField = 3, + ListField = 10, +} @Plugin() export class ComplexityPlugin implements ApolloServerPlugin { + private logger = new Logger(ComplexityPlugin.name); + constructor( private gqlSchemaHost: GraphQLSchemaHost, private configSErvice: ConfigService, @@ -19,7 +27,7 @@ export class ComplexityPlugin implements ApolloServerPlugin { const { schema } = this.gqlSchemaHost; return { - async didResolveOperation({ request, document }) { + didResolveOperation: async ({ request, document }) => { const complexity = getComplexity({ schema, operationName: request.operationName, @@ -33,15 +41,19 @@ export class ComplexityPlugin implements ApolloServerPlugin { return; } + Sentry.setMeasurement('graphql.complexity', complexity, 'none'); + this.logger.debug(`Query complexity: ${request.operationName} ${complexity}`); if (complexity > maxComplexity) { Sentry.setContext('graphql', { query: request.query, variables: request.variables, complexity, }); - throw new GraphQLError( + const error = new GraphQLError( `Query is too complex: ${complexity}. Maximum allowed complexity: ${maxComplexity}`, ); + Sentry.captureException(error); + throw error; } }, }; diff --git a/backend/src/modules/logging.plugin.ts b/backend/src/modules/logging.plugin.ts new file mode 100644 index 00000000..653fcced --- /dev/null +++ b/backend/src/modules/logging.plugin.ts @@ -0,0 +1,31 @@ +import { ApolloServerPlugin, GraphQLRequestContext, GraphQLRequestListener } from '@apollo/server'; +import { Plugin } from '@nestjs/apollo'; +import { Logger } from '@nestjs/common'; +import { FastifyRequest } from 'fastify'; + +interface Context { + request: FastifyRequest; +} + +@Plugin() +export class LoggingPlugin implements ApolloServerPlugin { + private readonly logger = new Logger(LoggingPlugin.name); + + async requestDidStart(requestContext: GraphQLRequestContext): Promise> { + const { request } = requestContext.contextValue; + if (request.url !== '/graphql') { + return {}; + } + + const body = request.body as { operationName: string; variables: Record, query: string }; + this.logger.log(`Request [${request.ip}] ${body.operationName} ${JSON.stringify(body.variables)}`); + + const start = performance.now(); + return { + willSendResponse: async () => { + const end = performance.now(); + this.logger.log(`Response [${request.ip}] ${body.operationName} ${end - start}ms`); + }, + }; + } +} diff --git a/backend/src/modules/rgbpp/address/address.service.ts b/backend/src/modules/rgbpp/address/address.service.ts index 25198916..c0575f75 100644 --- a/backend/src/modules/rgbpp/address/address.service.ts +++ b/backend/src/modules/rgbpp/address/address.service.ts @@ -2,10 +2,11 @@ import { BI } from '@ckb-lumos/bi'; import { Injectable } from '@nestjs/common'; import { CkbExplorerService } from 'src/core/ckb-explorer/ckb-explorer.service'; import { PrismaService } from 'src/core/database/prisma/prisma.service'; -import { CkbCell, CkbXUDTInfo } from 'src/modules/ckb/cell/cell.model'; +import { CkbXUDTInfo } from 'src/modules/ckb/cell/cell.model'; import { RgbppAsset } from '../asset/asset.model'; import { CkbRpcWebsocketService } from 'src/core/ckb-rpc/ckb-rpc-websocket.service'; import { CKB_CHAIN_ID } from 'src/constants'; +import { Cacheable } from 'src/decorators/cacheable.decorator'; @Injectable() export class RgbppAddressService { @@ -15,6 +16,11 @@ export class RgbppAddressService { private ckbRpcService: CkbRpcWebsocketService, ) { } + @Cacheable({ + namespace: 'RgbppAddressService', + key: (address: string) => `getAddressAssets:${address}`, + ttl: 10_000, + }) public async getAddressAssets(address: string) { const lockScript = await this.prismaService.lockScript.findMany({ where: { diff --git a/backend/src/modules/rgbpp/asset/asset.resolver.ts b/backend/src/modules/rgbpp/asset/asset.resolver.ts index 36aff6f6..e615477e 100644 --- a/backend/src/modules/rgbpp/asset/asset.resolver.ts +++ b/backend/src/modules/rgbpp/asset/asset.resolver.ts @@ -7,12 +7,13 @@ import { } from 'src/modules/bitcoin/transaction/transaction.dataloader'; import { RgbppService } from '../rgbpp.service'; import { Loader } from 'src/common/dataloader'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => RgbppAsset) export class RgbppAssetResolver { - constructor(private rgbppService: RgbppService) {} + constructor(private rgbppService: RgbppService) { } - @ResolveField(() => BitcoinOutput, { nullable: true }) + @ResolveField(() => BitcoinOutput, { nullable: true, complexity: ComplexityType.RequestField }) public async utxo( @Parent() asset: RgbppAsset, @Loader(BitcoinTransactionLoader) txLoader: BitcoinTransactionLoaderType, diff --git a/backend/src/modules/rgbpp/coin/coin.resolver.ts b/backend/src/modules/rgbpp/coin/coin.resolver.ts index 807977ce..c5064eb7 100644 --- a/backend/src/modules/rgbpp/coin/coin.resolver.ts +++ b/backend/src/modules/rgbpp/coin/coin.resolver.ts @@ -11,6 +11,7 @@ import { import { Layer, RgbppHolder } from '../statistic/statistic.model'; import { OrderType } from 'src/modules/api.model'; import { RgbppCoinService } from './coin.service'; +import { ComplexityType } from 'src/modules/complexity.plugin'; @Resolver(() => RgbppCoin) export class RgbppCoinResolver { @@ -19,7 +20,10 @@ export class RgbppCoinResolver { private rgbppCoinService: RgbppCoinService, ) { } - @Query(() => RgbppCoinList, { name: 'rgbppCoins' }) + @Query(() => RgbppCoinList, { + name: 'rgbppCoins', + complexity: ({ args, childComplexity }) => (args.pageSize ?? 10) * childComplexity, + }) public async coins( @Args('page', { type: () => Int, nullable: true }) page: number = 1, @Args('pageSize', { type: () => Int, nullable: true }) pageSize: number = 10, @@ -50,7 +54,10 @@ export class RgbppCoinResolver { return RgbppCoin.from(response.data.attributes); } - @ResolveField(() => [RgbppTransaction], { nullable: true }) + @ResolveField(() => [RgbppTransaction], { + nullable: true, + complexity: ({ args, childComplexity }) => (args.pageSize ?? 10) * childComplexity, + }) public async transactions( @Parent() coin: RgbppCoin, @Args('page', { type: () => Int, nullable: true }) page: number = 1, @@ -71,7 +78,7 @@ export class RgbppCoinResolver { return transactions.data.map((tx) => RgbppTransaction.fromCkbTransaction(tx.attributes)); } - @ResolveField(() => Float, { nullable: true }) + @ResolveField(() => Float, { nullable: true, complexity: ComplexityType.RequestField }) public async transactionsCount( @Parent() coin: RgbppCoin, @Loader(CkbExplorerXUDTTransactionsLoader) txsLoader: CkbExplorerXUDTTransactionsLoaderType, diff --git a/backend/src/modules/rgbpp/transaction/transaction.resolver.ts b/backend/src/modules/rgbpp/transaction/transaction.resolver.ts index 8f60cdf6..0a1065b2 100644 --- a/backend/src/modules/rgbpp/transaction/transaction.resolver.ts +++ b/backend/src/modules/rgbpp/transaction/transaction.resolver.ts @@ -16,6 +16,8 @@ import { RgbppTransactionLoader, RgbppTransactionLoaderType } from './transactio import { BitcoinApiService } from 'src/core/bitcoin-api/bitcoin-api.service'; import { BI } from '@ckb-lumos/bi'; import { LeapDirection } from '@prisma/client'; +import { ComplexityType } from 'src/modules/complexity.plugin'; +import { isDate } from 'lodash'; @Resolver(() => RgbppTransaction) export class RgbppTransactionResolver { @@ -24,7 +26,10 @@ export class RgbppTransactionResolver { private bitcoinApiService: BitcoinApiService, ) { } - @Query(() => RgbppLatestTransactionList, { name: 'rgbppLatestTransactions' }) + @Query(() => RgbppLatestTransactionList, { + name: 'rgbppLatestTransactions', + complexity: ({ args, childComplexity }) => (args.limit ?? 10) * childComplexity, + }) public async getRecentTransactions( @Args('limit', { type: () => Int, nullable: true }) limit: number = 10, ): Promise { @@ -37,7 +42,10 @@ export class RgbppTransactionResolver { }; } - @Query(() => RgbppLatestTransactionList, { name: 'rgbppLatestL1Transactions' }) + @Query(() => RgbppLatestTransactionList, { + name: 'rgbppLatestL1Transactions', + complexity: ({ args, childComplexity }) => (args.limit ?? 10) * childComplexity, + }) public async getLatestL1Transactions( @Args('limit', { type: () => Int, nullable: true }) limit: number = 10, ): Promise { @@ -49,7 +57,10 @@ export class RgbppTransactionResolver { }; } - @Query(() => RgbppLatestTransactionList, { name: 'rgbppLatestL2Transactions' }) + @Query(() => RgbppLatestTransactionList, { + name: 'rgbppLatestL2Transactions', + complexity: ({ args, childComplexity }) => (args.limit ?? 10) * childComplexity, + }) public async getLatestL2Transactions( @Args('limit', { type: () => Int, nullable: true }) limit: number = 10, ): Promise { @@ -61,7 +72,11 @@ export class RgbppTransactionResolver { }; } - @Query(() => RgbppTransaction, { name: 'rgbppTransaction', nullable: true }) + @Query(() => RgbppTransaction, { + name: 'rgbppTransaction', + nullable: true, + complexity: ComplexityType.RequestField, + }) public async getTransaction( @Args('txidOrTxHash') txidOrTxHash: string, @Loader(RgbppTransactionLoader) txLoader: RgbppTransactionLoaderType, @@ -70,7 +85,7 @@ export class RgbppTransactionResolver { return tx || null; } - @ResolveField(() => Date) + @ResolveField(() => Date, { complexity: ComplexityType.RequestField }) public async timestamp( @Parent() tx: RgbppTransaction, @Loader(BitcoinTransactionLoader) btcTxLoader: BitcoinTransactionLoaderType, @@ -98,10 +113,10 @@ export class RgbppTransactionResolver { const ckbTx = await ckbTxLoader.load(tx.ckbTxHash); return new Date(BI.from(ckbTx?.time_added_to_pool).toNumber()); } - return tx.blockTime; + return isDate(tx.blockTime) ? tx.blockTime : new Date(tx.blockTime); } - @ResolveField(() => LeapDirection, { nullable: true }) + @ResolveField(() => LeapDirection, { nullable: true, complexity: ComplexityType.RequestField }) public async leapDirection( @Parent() tx: RgbppTransaction, @Loader(CkbRpcTransactionLoader) ckbRpcTxLoader: CkbRpcTransactionLoaderType, @@ -113,7 +128,7 @@ export class RgbppTransactionResolver { return this.rgbppTransactionService.getLeapDirectionByCkbTx(ckbTx.transaction); } - @ResolveField(() => CkbTransaction, { nullable: true }) + @ResolveField(() => CkbTransaction, { nullable: true, complexity: ComplexityType.RequestField }) public async ckbTransaction( @Parent() tx: RgbppTransaction, @Loader(CkbRpcTransactionLoader) ckbRpcTxLoader: CkbRpcTransactionLoaderType, @@ -125,7 +140,10 @@ export class RgbppTransactionResolver { return CkbTransaction.from(ckbTx); } - @ResolveField(() => BitcoinTransaction, { nullable: true }) + @ResolveField(() => BitcoinTransaction, { + nullable: true, + complexity: ComplexityType.RequestField, + }) public async btcTransaction( @Parent() tx: RgbppTransaction, @Loader(BitcoinTransactionLoader) txLoader: BitcoinTransactionLoaderType, diff --git a/backend/src/modules/rgbpp/transaction/transaction.service.ts b/backend/src/modules/rgbpp/transaction/transaction.service.ts index 01ae041c..057008b9 100644 --- a/backend/src/modules/rgbpp/transaction/transaction.service.ts +++ b/backend/src/modules/rgbpp/transaction/transaction.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { BitcoinApiService } from 'src/core/bitcoin-api/bitcoin-api.service'; import { CkbExplorerService } from 'src/core/ckb-explorer/ckb-explorer.service'; -import { RgbppTransaction, RgbppLatestTransactionList } from './transaction.model'; +import { RgbppTransaction } from './transaction.model'; import { ConfigService } from '@nestjs/config'; import { Env } from 'src/env'; import { CkbRpcWebsocketService } from 'src/core/ckb-rpc/ckb-rpc-websocket.service'; @@ -12,7 +12,6 @@ import { RgbppService } from '../rgbpp.service'; import { BI, HashType } from '@ckb-lumos/lumos'; import { Cacheable } from 'src/decorators/cacheable.decorator'; import { ONE_MONTH_MS } from 'src/common/date'; -import { CkbScriptService } from 'src/modules/ckb/script/script.service'; import { LeapDirection } from '@prisma/client'; import { PrismaService } from 'src/core/database/prisma/prisma.service'; import { CKB_CHAIN_ID } from 'src/constants'; @@ -155,6 +154,11 @@ export class RgbppTransactionService { return null; } + @Cacheable({ + namespace: 'RgbppTransactionService', + key: (btcTx: BitcoinApiInterface.Transaction) => `queryRgbppLockTx:${btcTx.txid}`, + ttl: ONE_MONTH_MS, + }) public async queryRgbppLockTx(btcTx: BitcoinApiInterface.Transaction) { const ckbTxs = await Promise.all( btcTx.vout.map(async (_, index) => { @@ -192,6 +196,11 @@ export class RgbppTransactionService { return null; } + @Cacheable({ + namespace: 'RgbppTransactionService', + key: (btcTx: BitcoinApiInterface.Transaction) => `queryRgbppBtcTimeLockTx:${btcTx.txid}`, + ttl: ONE_MONTH_MS, + }) public async queryRgbppBtcTimeLockTx(btcTx: BitcoinApiInterface.Transaction) { const ckbTxs = ( await Promise.all( diff --git a/backend/src/schema.gql b/backend/src/schema.gql index 0d854527..0e9bbb87 100644 --- a/backend/src/schema.gql +++ b/backend/src/schema.gql @@ -2,6 +2,8 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +directive @cacheControl(maxAge: Int, scope: CacheControlScope, inheritMaxAge: Boolean) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION | QUERY + """CKB Script""" type CkbScript { codeHash: String! @@ -362,4 +364,9 @@ enum TransactionListSortType { AddressCountDesc CreatedTimeAsc CreatedTimeDesc +} + +enum CacheControlScope { + PUBLIC + PRIVATE } \ No newline at end of file diff --git a/docker-compose-preview.yaml b/docker-compose-preview.yaml index 6bf1b731..6c755de4 100644 --- a/docker-compose-preview.yaml +++ b/docker-compose-preview.yaml @@ -1,7 +1,9 @@ services: preview-explorer-backend: depends_on: - preview-redis: + preview-redis-cache: + condition: service_started + preview-redis-queue: condition: service_started preview-postgres: condition: service_healthy @@ -19,18 +21,31 @@ services: networks: - preview - preview-redis: + preview-redis-cache: # https://github.com/docker-library/redis/blob/b77450d/7.4/alpine/Dockerfile image: redis:7-alpine restart: unless-stopped volumes: # Redis' WORKDIR is /data - - preview-redis-data:/data + - preview-redis-cache-data:/data - ./backend/redis.conf:/usr/local/etc/redis/redis.conf:ro command: /usr/local/etc/redis/redis.conf networks: - preview + + preview-redis-queue: + # https://github.com/docker-library/redis/blob/b77450d/7.4/alpine/Dockerfile + image: redis:7-alpine + restart: unless-stopped + volumes: + # Redis' WORKDIR is /data + - preview-redis-queue-data:/data + - ./backend/redis-queue.conf:/usr/local/etc/redis/redis.conf:ro + command: /usr/local/etc/redis/redis.conf + networks: + - preview + preview-postgres: image: postgres:13 env_file: @@ -47,7 +62,8 @@ services: - preview volumes: - preview-redis-data: + preview-redis-cache-data: + preview-redis-queue-data: preview-pg-volume: networks: diff --git a/docker-compose.yaml b/docker-compose.yaml index cc7b5705..202626dd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,9 @@ services: ports: - '3000:3000' depends_on: - redis: + redis-cache: + condition: service_started + redis-queue: condition: service_started postgres: condition: service_healthy @@ -21,20 +23,30 @@ services: networks: - internal - redis: + redis-cache: # https://github.com/docker-library/redis/blob/b77450d/7.4/alpine/Dockerfile image: redis:7-alpine restart: unless-stopped - ports: - - '127.0.0.1:6379:6379' command: /usr/local/etc/redis/redis.conf volumes: # Redis' WORKDIR is /data - - redis-data:/data + - redis-cache-data:/data - ./backend/redis.conf:/usr/local/etc/redis/redis.conf:ro networks: - internal + redis-queue: + # https://github.com/docker-library/redis/blob/b77450d/7.4/alpine/Dockerfile + image: redis:7-alpine + restart: unless-stopped + command: /usr/local/etc/redis/redis.conf + volumes: + # Redis' WORKDIR is /data + - redis-queue-data:/data + - ./backend/redis-queue.conf:/usr/local/etc/redis/redis.conf:ro + networks: + - internal + postgres: image: postgres:13 env_file: @@ -53,7 +65,8 @@ services: - internal volumes: - redis-data: + redis-cache-data: + redis-queue-data: postgres_volume: networks: diff --git a/frontend/cspell.json b/frontend/cspell.json index 0ce55886..f13b74ca 100644 --- a/frontend/cspell.json +++ b/frontend/cspell.json @@ -31,7 +31,8 @@ "bech", "lumos", "hexify", - "bytify" + "bytify", + "nprogress" ], "ignorePaths": [ "pnpm-lock.yaml", diff --git a/frontend/package.json b/frontend/package.json index ad5027fe..306fcf3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a", "scripts": { - "prepare": "panda codegen", + "prepare": "panda codegen && graphql-codegen", "dev": "next dev", "build": "npm run lingui && next build", "start": "next start", @@ -16,7 +16,7 @@ }, "dependencies": { "@apollo/client": "^3.11.2", - "@ark-ui/react": "^3.5.0", + "@ark-ui/react": "^3.12.1", "@bitcoinerlab/secp256k1": "^1.1.1", "@ckb-lumos/lumos": "^0.23.0", "@graphql-typed-document-node/core": "^3.2.0", @@ -35,8 +35,10 @@ "lodash-es": "^4.17.21", "negotiator": "^0.6.3", "next": "14.2.4", + "next-nprogress-bar": "^2.3.13", "react": "^18", "react-dom": "^18", + "react-intersection-observer": "^9.13.1", "typed.js": "^2.1.0", "usehooks-ts": "^3.1.0", "zod": "^3.23.8" diff --git a/frontend/panda.config.ts b/frontend/panda.config.ts index e891285c..408bd0f7 100644 --- a/frontend/panda.config.ts +++ b/frontend/panda.config.ts @@ -4,8 +4,10 @@ import { createPreset } from '@park-ui/panda-preset' import { button } from '@/configs/ui-preset/button' import { hoverCard } from '@/configs/ui-preset/hover-card' import { iconButton } from '@/configs/ui-preset/icon-button' +import { numberInput } from '@/configs/ui-preset/number-input' import { pagination } from '@/configs/ui-preset/pagination' import { popover } from '@/configs/ui-preset/popover' +import { skeleton } from '@/configs/ui-preset/skeleton' import { table } from '@/configs/ui-preset/table' import { tabs } from '@/configs/ui-preset/tabs' import { tooltip } from '@/configs/ui-preset/tooltip' @@ -53,6 +55,7 @@ export default defineConfig({ recipes: { button, iconButton, + skeleton, }, slotRecipes: { table, @@ -61,6 +64,7 @@ export default defineConfig({ tabs, hoverCard, popover, + numberInput, }, tokens: { sizes: { diff --git a/frontend/src/app/[lang]/address/[address]/layout.tsx b/frontend/src/app/[lang]/address/[address]/layout.tsx index 0bb2daeb..efb2c552 100644 --- a/frontend/src/app/[lang]/address/[address]/layout.tsx +++ b/frontend/src/app/[lang]/address/[address]/layout.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation' import type { PropsWithChildren, ReactNode } from 'react' import { Flex, HStack, VStack } from 'styled-system/jsx' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import { BtcAddressOverview } from '@/components/btc/btc-address-overview' import { BtcAddressType } from '@/components/btc/btc-address-type' import { CkbAddressOverview } from '@/components/ckb/ckb-address-overview' @@ -13,7 +14,6 @@ import { Heading, Text } from '@/components/ui' import { graphql } from '@/gql' import { isValidBTCAddress } from '@/lib/btc/is-valid-btc-address' import { isValidCkbAddress } from '@/lib/ckb/is-valid-ckb-address' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' import { graphQLClient } from '@/lib/graphql' const btcAddressQuery = graphql(` @@ -44,9 +44,11 @@ const ckbAddressQuery = graphql(` export default async function Layout({ children, - params: { address }, -}: PropsWithChildren & { params: { address: string } }) { - const i18n = getI18nFromHeaders() + params: { address, lang }, +}: PropsWithChildren<{ + params: { address: string; lang: string } +}>) { + const i18n = getI18nInstance(lang) const isBtcAddress = isValidBTCAddress(address) const isCkbAddress = isValidCkbAddress(address) @@ -56,12 +58,12 @@ export default async function Layout({ if (isBtcAddress) { const data = await graphQLClient.request(btcAddressQuery, { address }) if (data?.btcAddress) { - overflow = + overflow = } } else if (isCkbAddress) { const data = await graphQLClient.request(ckbAddressQuery, { address }) if (data?.ckbAddress) { - overflow = + overflow = } } diff --git a/frontend/src/app/[lang]/address/[address]/transactions/btc-tx-list.tsx b/frontend/src/app/[lang]/address/[address]/transactions/btc-tx-list.tsx new file mode 100644 index 00000000..398c534b --- /dev/null +++ b/frontend/src/app/[lang]/address/[address]/transactions/btc-tx-list.tsx @@ -0,0 +1,67 @@ +'use client' + +import { Trans } from '@lingui/macro' +import { useInfiniteQuery } from '@tanstack/react-query' +import { compact } from 'lodash-es' +import { Center } from 'styled-system/jsx' + +import { BtcTransactionCardWithQueryInAddress } from '@/components/btc/btc-transaction-card-with-query-in-address' +import { InfiniteListBottom } from '@/components/infinite-list-bottom' +import { Loading } from '@/components/loading' +import { NoData } from '@/components/no-data' +import { QueryKey } from '@/constants/query-key' +import { graphql } from '@/gql' +import { graphQLClient } from '@/lib/graphql' + +const btcAddressTxsQuery = graphql(` + query BtcTransactionByAddress($address: String!, $afterTxid: String) { + btcAddress(address: $address) { + transactions(afterTxid: $afterTxid) { + txid + } + } + } +`) + +export function BtcTxList({ address }: { address: string }) { + const { data, isLoading, ...query } = useInfiniteQuery({ + queryKey: [QueryKey.BtcTransactionCardInAddressList, address], + async queryFn({ pageParam }) { + const { btcAddress } = await graphQLClient.request(btcAddressTxsQuery, { + address, + afterTxid: pageParam ? pageParam : undefined, + }) + return btcAddress + }, + select(data) { + return compact(data.pages.flatMap((page) => page?.transactions)) + }, + getNextPageParam(lastPage) { + if (!lastPage?.transactions?.length) return + return lastPage?.transactions?.[lastPage?.transactions?.length - 1].txid + }, + initialData: undefined, + initialPageParam: '', + }) + + if (isLoading) { + return + } + + if (!query.hasNextPage && !data?.length) { + return ( +
+ + No Transaction + +
+ ) + } + + return ( + <> + {data?.map(({ txid }) => )} + + + ) +} diff --git a/frontend/src/app/[lang]/address/[address]/transactions/btc.tsx b/frontend/src/app/[lang]/address/[address]/transactions/btc.tsx deleted file mode 100644 index d408cea9..00000000 --- a/frontend/src/app/[lang]/address/[address]/transactions/btc.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { t } from '@lingui/macro' -import { last } from 'lodash-es' -import { Center, HStack } from 'styled-system/jsx' - -import { BtcTransactionCardInAddress } from '@/components/btc/btc-transaction-card-in-address' -import { FailedFallback } from '@/components/failed-fallback' -import { NoData } from '@/components/no-data' -import { Button } from '@/components/ui' -import Link from '@/components/ui/link' -import { graphql } from '@/gql' -import { BitcoinTransaction, CkbTransaction } from '@/gql/graphql' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' -import { getUrl } from '@/lib/get-url' -import { graphQLClient } from '@/lib/graphql' - -const query = graphql(` - query BtcTransactionByAddress($address: String!, $afterTxid: String) { - btcAddress(address: $address) { - transactions(afterTxid: $afterTxid) { - txid - rgbppTransaction { - ckbTransaction { - isCellbase - blockNumber - hash - fee - feeRate - size - confirmed - confirmations - outputs { - txHash - index - capacity - cellType - type { - codeHash - hashType - args - } - lock { - codeHash - hashType - args - } - status { - consumed - txHash - index - } - xudtInfo { - symbol - amount - decimal - typeHash - } - } - inputs { - txHash - index - capacity - cellType - type { - codeHash - hashType - args - } - lock { - codeHash - hashType - args - } - xudtInfo { - symbol - amount - decimal - typeHash - } - status { - consumed - txHash - index - } - } - block { - timestamp - hash - } - } - } - blockHeight - blockHash - txid - version - size - locktime - weight - fee - feeRate - confirmed - confirmations - transactionTime - vin { - txid - vout - scriptsig - scriptsigAsm - isCoinbase - sequence - prevout { - scriptpubkey - scriptpubkeyAsm - scriptpubkeyType - scriptpubkeyAddress - value - status { - spent - txid - vin - } - address { - address - satoshi - pendingSatoshi - transactionsCount - } - } - } - vout { - scriptpubkey - scriptpubkeyAsm - scriptpubkeyType - scriptpubkeyAddress - value - status { - spent - txid - vin - } - address { - address - satoshi - pendingSatoshi - transactionsCount - } - } - } - } - } -`) - -export async function BtcTransactionsByAddress({ address }: { address: string }) { - const i18n = getI18nFromHeaders() - const url = getUrl() - const afterTxid = url.searchParams.get('afterTxid') - - const { btcAddress } = await graphQLClient.request(query, { - address, - afterTxid, - }) - - const nextCursor = last(btcAddress?.transactions)?.txid - - if (!btcAddress) { - return - } - - return ( - <> - {!btcAddress.transactions?.length ? ( -
- {t(i18n)`No Transaction`} -
- ) : ( - btcAddress.transactions?.map(({ rgbppTransaction, ...tx }) => { - return ( - - ) - }) - )} -
- - {afterTxid ? ( - - - - ) : null} - {nextCursor ? ( - - - - ) : null} - -
- - ) -} diff --git a/frontend/src/app/[lang]/address/[address]/transactions/ckb-tx-list.tsx b/frontend/src/app/[lang]/address/[address]/transactions/ckb-tx-list.tsx new file mode 100644 index 00000000..0bd76d5f --- /dev/null +++ b/frontend/src/app/[lang]/address/[address]/transactions/ckb-tx-list.tsx @@ -0,0 +1,69 @@ +'use client' + +import { Trans } from '@lingui/macro' +import { useInfiniteQuery } from '@tanstack/react-query' +import { compact } from 'lodash-es' +import { Center } from 'styled-system/jsx' + +import { CkbTransactionCardWithQueryInAddress } from '@/components/ckb/ckb-transaction-card-with-query-in-address' +import { InfiniteListBottom } from '@/components/infinite-list-bottom' +import { Loading } from '@/components/loading' +import { NoData } from '@/components/no-data' +import { QueryKey } from '@/constants/query-key' +import { graphql } from '@/gql' +import { graphQLClient } from '@/lib/graphql' + +const ckbAddressTxsQuery = graphql(` + query CkbTransactionByAddress($address: String!, $page: Int!, $pageSize: Int!) { + ckbAddress(address: $address) { + transactionsCount + transactions(page: $page, pageSize: $pageSize) { + hash + } + } + } +`) + +export function CKBTxList({ address }: { address: string }) { + const { data, isLoading, ...query } = useInfiniteQuery({ + queryKey: [QueryKey.CkbTransactionCardInAddressList, address], + async queryFn({ pageParam }) { + const { ckbAddress } = await graphQLClient.request(ckbAddressTxsQuery, { + address, + page: pageParam, + pageSize: 10, + }) + return ckbAddress + }, + select(data) { + return compact(data.pages.flatMap((page) => page?.transactions)) + }, + getNextPageParam(lastPage, _, pageParam) { + if (lastPage?.transactionsCount && pageParam * 10 >= lastPage?.transactionsCount) return + return pageParam + 1 + }, + initialData: undefined, + initialPageParam: 1, + }) + + if (isLoading) { + return + } + + if (!query.hasNextPage && !data?.length) { + return ( +
+ + No Transaction + +
+ ) + } + + return ( + <> + {data?.map(({ hash }) => )} + + + ) +} diff --git a/frontend/src/app/[lang]/address/[address]/transactions/ckb.tsx b/frontend/src/app/[lang]/address/[address]/transactions/ckb.tsx deleted file mode 100644 index 398cabf6..00000000 --- a/frontend/src/app/[lang]/address/[address]/transactions/ckb.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { t } from '@lingui/macro' -import { Center, HStack, VStack } from 'styled-system/jsx' - -import { CkbCellTables } from '@/components/ckb/ckb-cell-tables' -import { FailedFallback } from '@/components/failed-fallback' -import { IfBreakpoint } from '@/components/if-breakpoint' -import { NoData } from '@/components/no-data' -import { PaginationSearchParams } from '@/components/pagination-searchparams' -import { TransactionHeaderInAddress } from '@/components/transaction-header-in-address' -import { Text } from '@/components/ui' -import { UtxoOrCellFooter } from '@/components/utxo-or-cell-footer' -import { graphql } from '@/gql' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' -import { graphQLClient } from '@/lib/graphql' -import { resolveSearchParamsPage } from '@/lib/resolve-searchparams-page' -import { formatNumber } from '@/lib/string/format-number' - -const query = graphql(` - query CkbAddress($address: String!, $page: Int!, $pageSize: Int!) { - ckbAddress(address: $address) { - transactionsCount - transactions(page: $page, pageSize: $pageSize) { - isCellbase - blockNumber - hash - fee - size - feeRate - confirmations - inputs { - cellType - status { - consumed - txHash - index - } - txHash - index - capacity - type { - codeHash - hashType - args - } - lock { - codeHash - hashType - args - } - xudtInfo { - symbol - amount - decimal - typeHash - } - } - outputs { - txHash - cellType - index - capacity - type { - codeHash - hashType - args - } - lock { - codeHash - hashType - args - } - xudtInfo { - symbol - amount - decimal - typeHash - } - status { - consumed - txHash - index - } - } - block { - timestamp - } - } - } - } -`) - -export async function CkbTransactionsByAddress({ address }: { address: string }) { - const i18n = getI18nFromHeaders() - const page = resolveSearchParamsPage() - const pageSize = 10 - const { ckbAddress } = await graphQLClient.request(query, { - address, - page, - pageSize, - }) - - if (!ckbAddress) { - return - } - - const total = ckbAddress?.transactionsCount ?? 0 - - return ( - <> - {!ckbAddress.transactions?.length ? ( -
- {t(i18n)`No Transaction`} -
- ) : ( - ckbAddress.transactions?.map((tx) => { - return ( - - - - - - ) - }) - )} - - - {t(i18n)`Total ${formatNumber(total)} Items`} - - - - - ) -} diff --git a/frontend/src/app/[lang]/address/[address]/transactions/not-found.ts b/frontend/src/app/[lang]/address/[address]/transactions/not-found.ts new file mode 100644 index 00000000..38f7f3cb --- /dev/null +++ b/frontend/src/app/[lang]/address/[address]/transactions/not-found.ts @@ -0,0 +1,6 @@ +'use client' + +import { FailedFallback } from '@/components/failed-fallback' + +const Error = FailedFallback +export default Error diff --git a/frontend/src/app/[lang]/address/[address]/transactions/page.tsx b/frontend/src/app/[lang]/address/[address]/transactions/page.tsx index 38eb3bb4..d57adad2 100644 --- a/frontend/src/app/[lang]/address/[address]/transactions/page.tsx +++ b/frontend/src/app/[lang]/address/[address]/transactions/page.tsx @@ -1,19 +1,19 @@ import { notFound } from 'next/navigation' -import { BtcTransactionsByAddress } from '@/app/[lang]/address/[address]/transactions/btc' -import { CkbTransactionsByAddress } from '@/app/[lang]/address/[address]/transactions/ckb' +import { BtcTxList } from '@/app/[lang]/address/[address]/transactions/btc-tx-list' +import { CKBTxList } from '@/app/[lang]/address/[address]/transactions/ckb-tx-list' import { isValidBTCAddress } from '@/lib/btc/is-valid-btc-address' import { isValidCkbAddress } from '@/lib/ckb/is-valid-ckb-address' export const maxDuration = 30 -export default function Page({ params: { address } }: { params: { address: string } }) { +export default async function Page({ params: { address } }: { params: { address: string; lang: string } }) { if (isValidBTCAddress(address)) { - return + return } if (isValidCkbAddress(address)) { - return + return } return notFound() } diff --git a/frontend/src/app/[lang]/assets/coins/[typeHash]/holders/page.tsx b/frontend/src/app/[lang]/assets/coins/[typeHash]/holders/page.tsx index f418a628..06453aa9 100644 --- a/frontend/src/app/[lang]/assets/coins/[typeHash]/holders/page.tsx +++ b/frontend/src/app/[lang]/assets/coins/[typeHash]/holders/page.tsx @@ -1,16 +1,16 @@ +'use client' + import { t } from '@lingui/macro' import { VStack } from 'styled-system/jsx' import ComingSoonSVG from '@/assets/coming-soon.svg' import { Text } from '@/components/ui' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' export default function Page() { - const i18n = getI18nFromHeaders() return ( - {t(i18n)`Coming soon, please stay tuned`} + {t`Coming soon, please stay tuned`} ) } diff --git a/frontend/src/app/[lang]/assets/coins/[typeHash]/layout.tsx b/frontend/src/app/[lang]/assets/coins/[typeHash]/layout.tsx index 336b373a..00bbf6b6 100644 --- a/frontend/src/app/[lang]/assets/coins/[typeHash]/layout.tsx +++ b/frontend/src/app/[lang]/assets/coins/[typeHash]/layout.tsx @@ -3,12 +3,12 @@ import { notFound } from 'next/navigation' import { PropsWithChildren } from 'react' import { Box, Flex, Grid, styled } from 'styled-system/jsx' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import BtcIcon from '@/assets/chains/btc.svg' import { Copier } from '@/components/copier' import { LinkTabs } from '@/components/link-tabs' import { Text } from '@/components/ui' import { graphql } from '@/gql' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' import { graphQLClient } from '@/lib/graphql' const query = graphql(` @@ -22,10 +22,10 @@ const query = graphql(` `) export default async function AssetDetail({ - params: { typeHash }, children, -}: { params: { typeHash: string } } & PropsWithChildren) { - const i18n = getI18nFromHeaders() + params: { typeHash, lang }, +}: PropsWithChildren<{ params: { typeHash: string; lang: string } }>) { + const i18n = getI18nInstance(lang) const response = await graphQLClient.request(query, { typeHash }) if (!response.rgbppCoin) notFound() return ( diff --git a/frontend/src/app/[lang]/assets/coins/[typeHash]/transactions/page.tsx b/frontend/src/app/[lang]/assets/coins/[typeHash]/transactions/page.tsx index d4c143d5..12dad02f 100644 --- a/frontend/src/app/[lang]/assets/coins/[typeHash]/transactions/page.tsx +++ b/frontend/src/app/[lang]/assets/coins/[typeHash]/transactions/page.tsx @@ -2,15 +2,15 @@ import { t } from '@lingui/macro' import { notFound } from 'next/navigation' import { Box, HStack, VStack } from 'styled-system/jsx' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import { IfBreakpoint } from '@/components/if-breakpoint' import { LatestTxnListUI } from '@/components/latest-tx-list/ui' import { PaginationSearchParams } from '@/components/pagination-searchparams' import { Text } from '@/components/ui' import { graphql } from '@/gql' import { RgbppTransaction } from '@/gql/graphql' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' import { graphQLClient } from '@/lib/graphql' -import { resolveSearchParamsPage } from '@/lib/resolve-searchparams-page' +import { resolvePage } from '@/lib/resolve-page' import { formatNumber } from '@/lib/string/format-number' const query = graphql(` @@ -82,9 +82,15 @@ const query = graphql(` } `) -export default async function Page({ params: { typeHash } }: { params: { typeHash: string } }) { - const i18n = getI18nFromHeaders() - const page = resolveSearchParamsPage() +export default async function Page({ + params: { typeHash, lang }, + searchParams, +}: { + params: { typeHash: string; lang: string } + searchParams: { page?: string } +}) { + const i18n = getI18nInstance(lang) + const page = resolvePage(searchParams.page) const pageSize = 10 const response = await graphQLClient.request(query, { typeHash, page, pageSize }) if (!response.rgbppCoin) notFound() diff --git a/frontend/src/app/[lang]/assets/coins/page.tsx b/frontend/src/app/[lang]/assets/coins/page.tsx index baa547b5..b0dd9297 100644 --- a/frontend/src/app/[lang]/assets/coins/page.tsx +++ b/frontend/src/app/[lang]/assets/coins/page.tsx @@ -1,14 +1,15 @@ import { t } from '@lingui/macro' import { Box, HStack, VStack } from 'styled-system/jsx' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import { CoinList } from '@/components/coin-list' import { IfBreakpoint } from '@/components/if-breakpoint' import { PaginationSearchParams } from '@/components/pagination-searchparams' import { Text } from '@/components/ui' import { graphql } from '@/gql' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' import { graphQLClient } from '@/lib/graphql' -import { resolveSearchParamsPage } from '@/lib/resolve-searchparams-page' +import { resolvePage } from '@/lib/resolve-page' +import { formatNumber } from '@/lib/string/format-number' const query = graphql(` query RgbppCoins($page: Int!, $pageSize: Int!) { @@ -31,10 +32,15 @@ const query = graphql(` } `) -export default async function Page() { - const i18n = getI18nFromHeaders() - const page = resolveSearchParamsPage() - +export default async function Page({ + params, + searchParams, +}: { + params: { lang: string } + searchParams: { page?: string } +}) { + const i18n = getI18nInstance(params.lang) + const page = resolvePage(searchParams.page) const pageSize = 10 const response = await graphQLClient.request(query, { page, pageSize }) @@ -45,7 +51,7 @@ export default async function Page() { - {t(i18n)`Total ${response.rgbppCoins.total} Items`} + {t(i18n)`Total ${formatNumber(response.rgbppCoins.total)} Items`} diff --git a/frontend/src/app/[lang]/assets/layout.tsx b/frontend/src/app/[lang]/assets/layout.tsx index c53346e5..e976c635 100644 --- a/frontend/src/app/[lang]/assets/layout.tsx +++ b/frontend/src/app/[lang]/assets/layout.tsx @@ -1,12 +1,17 @@ import { t } from '@lingui/macro' -import type { PropsWithChildren } from 'react' +import { PropsWithChildren } from 'react' import { VStack } from 'styled-system/jsx' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import { LinkTabs } from '@/components/link-tabs' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' -export default function Layout({ children }: PropsWithChildren) { - const i18n = getI18nFromHeaders() +export default function Layout({ + params, + children, +}: PropsWithChildren<{ + params: { lang: string } +}>) { + const i18n = getI18nInstance(params.lang) return ( ) { const data = await graphQLClient.request(query, { hashOrHeight }) - if (!data?.btcBlock) notFound() + const i18n = getI18nInstance(lang) return ( - - + + ) { const data = await graphQLClient.request(query, { hashOrHeight }) - if (!data?.ckbBlock) notFound() + const i18n = getI18nInstance(lang) return ( - - + + - + {t(i18n)`Latest L1 RGB++ transaction`} diff --git a/frontend/src/app/[lang]/explorer/ckb/info.tsx b/frontend/src/app/[lang]/explorer/ckb/info.tsx index 881bae99..a2da9145 100644 --- a/frontend/src/app/[lang]/explorer/ckb/info.tsx +++ b/frontend/src/app/[lang]/explorer/ckb/info.tsx @@ -1,3 +1,4 @@ +import type { I18n } from '@lingui/core' import { t } from '@lingui/macro' import { Grid, HStack, VStack } from 'styled-system/jsx' @@ -8,11 +9,9 @@ import SpeedMediumIcon from '@/assets/speed/medium.svg' import { OverviewInfo, OverviewInfoItem, OverviewInfoTagLabel } from '@/components/overview-info' import { Heading } from '@/components/ui' import { graphql } from '@/gql' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' import { graphQLClient } from '@/lib/graphql' -export async function Info() { - const i18n = getI18nFromHeaders() +export async function Info({ i18n }: { i18n: I18n }) { const { ckbChainInfo, rgbppStatistic } = await graphQLClient.request( graphql(` query CkbChainInfo { diff --git a/frontend/src/app/[lang]/explorer/ckb/page.tsx b/frontend/src/app/[lang]/explorer/ckb/page.tsx index 8c6f94ee..3d845410 100644 --- a/frontend/src/app/[lang]/explorer/ckb/page.tsx +++ b/frontend/src/app/[lang]/explorer/ckb/page.tsx @@ -1,15 +1,16 @@ import { t } from '@lingui/macro' import { Box, Grid } from 'styled-system/jsx' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import { Info } from '@/app/[lang]/explorer/ckb/info' import { ExplorerTxList } from '@/components/explorer-tx-list' import { Heading } from '@/components/ui' import { graphql } from '@/gql' import { RgbppTransaction } from '@/gql/graphql' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' import { graphQLClient } from '@/lib/graphql' -export const revalidate = 5 +export const revalidate = 10 +export const dynamic = 'force-static' const query = graphql(` query RgbppLatestL2Transactions($limit: Int!) { @@ -48,13 +49,13 @@ const query = graphql(` } `) -export default async function Page() { - const i18n = getI18nFromHeaders() +export default async function Page({ params: { lang } }: { params: { lang: string } }) { + const i18n = getI18nInstance(lang) const { rgbppLatestL2Transactions } = await graphQLClient.request(query, { limit: 10 }) return ( - + {t(i18n)`Latest L2 RGB++ transaction`} diff --git a/frontend/src/app/[lang]/page.tsx b/frontend/src/app/[lang]/page.tsx index b1df0a80..228a1155 100644 --- a/frontend/src/app/[lang]/page.tsx +++ b/frontend/src/app/[lang]/page.tsx @@ -1,17 +1,25 @@ import { t } from '@lingui/macro' +import linguiConfig from 'lingui.config.mjs' import { LastRgbppTxnsTable } from 'src/components/latest-tx-list' import { Box, Center, Flex } from 'styled-system/jsx' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import HomeBgSVG from '@/assets/home-bg.svg' // import { HomeQuickInfo } from '@/components/home-quick-info' import { HomeTitle } from '@/components/home-title' import { NetworkCards } from '@/components/network-cards' import { SearchBar } from '@/components/search-bar' import { Heading } from '@/components/ui' -import { getI18nFromHeaders } from '@/lib/get-i18n-from-headers' -export default function Home() { - const i18n = getI18nFromHeaders() +export const dynamic = 'force-static' +export const revalidate = 3600 + +export async function generateStaticParams() { + return linguiConfig.locales.map((locale) => ({ lang: locale })) +} + +export default function Home({ params: { lang } }: { params: { lang: string } }) { + const i18n = getI18nInstance(lang) return ( <>
diff --git a/frontend/src/app/[lang]/transaction/[tx]/btc.tsx b/frontend/src/app/[lang]/transaction/[tx]/btc.tsx index b855d998..e15341e8 100644 --- a/frontend/src/app/[lang]/transaction/[tx]/btc.tsx +++ b/frontend/src/app/[lang]/transaction/[tx]/btc.tsx @@ -1,3 +1,4 @@ +import type { I18n } from '@lingui/core' import { VStack } from 'styled-system/jsx' import { BtcTransactionOverview } from '@/components/btc/btc-transaction-overview' @@ -11,10 +12,12 @@ export function BTCTransactionPage({ btcTransaction, ckbTransaction, leapDirection, + i18n, }: { btcTransaction: BitcoinTransaction ckbTransaction?: CkbTransaction | null leapDirection?: LeapDirection | null + i18n: I18n }) { return ( @@ -22,15 +25,17 @@ export function BTCTransactionPage({ type={resolveLayerTypeFromRGBppTransaction({ ckbTransaction, leapDirection, btcTransaction })} txid={btcTransaction.txid} confirmations={btcTransaction.confirmations} + i18n={i18n} /> - + - {ckbTransaction ? : null} + {ckbTransaction ? : null} ) } diff --git a/frontend/src/app/[lang]/transaction/[tx]/ckb.tsx b/frontend/src/app/[lang]/transaction/[tx]/ckb.tsx index 68fbf12a..c9e6a985 100644 --- a/frontend/src/app/[lang]/transaction/[tx]/ckb.tsx +++ b/frontend/src/app/[lang]/transaction/[tx]/ckb.tsx @@ -1,3 +1,4 @@ +import type { I18n } from '@lingui/core' import { VStack } from 'styled-system/jsx' import { BtcUtxos } from '@/components/btc/btc-utxos' @@ -11,10 +12,12 @@ export function CKBTransactionPage({ ckbTransaction, btcTransaction, leapDirection, + i18n, }: { ckbTransaction: CkbTransaction btcTransaction?: BitcoinTransaction | null leapDirection?: LeapDirection | null + i18n: I18n }) { return ( @@ -22,9 +25,10 @@ export function CKBTransactionPage({ type={resolveLayerTypeFromRGBppTransaction({ ckbTransaction, leapDirection, btcTransaction })} txid={ckbTransaction.hash} confirmations={ckbTransaction.confirmations} + i18n={i18n} /> - - + + {btcTransaction ? ( ) : null} diff --git a/frontend/src/app/[lang]/transaction/[tx]/page.tsx b/frontend/src/app/[lang]/transaction/[tx]/page.tsx index 37681cff..056f4055 100644 --- a/frontend/src/app/[lang]/transaction/[tx]/page.tsx +++ b/frontend/src/app/[lang]/transaction/[tx]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from 'next/navigation' +import { getI18nInstance } from '@/app/[lang]/appRouterI18n' import { BTCTransactionPage } from '@/app/[lang]/transaction/[tx]/btc' import { CKBTransactionPage } from '@/app/[lang]/transaction/[tx]/ckb' import { graphql } from '@/gql' @@ -7,6 +8,7 @@ import { BitcoinTransaction, CkbTransaction } from '@/gql/graphql' import { graphQLClient } from '@/lib/graphql' export const revalidate = 60 +export const dynamic = 'force-static' const rgbppTxQuery = graphql(` query RgbppTransaction($txidOrTxHash: String!) { @@ -263,7 +265,8 @@ const ckbTxQuery = graphql(` } `) -export default async function Page({ params: { tx } }: { params: { tx: string } }) { +export default async function Page({ params: { tx, lang } }: { params: { tx: string; lang: string } }) { + const i18n = getI18nInstance(lang) const { rgbppTransaction } = await graphQLClient.request(rgbppTxQuery, { txidOrTxHash: tx }) if (rgbppTransaction) { @@ -274,6 +277,7 @@ export default async function Page({ params: { tx } }: { params: { tx: string } btcTransaction={btcTransaction as BitcoinTransaction} ckbTransaction={ckbTransaction as CkbTransaction} leapDirection={rgbppTransaction?.leapDirection} + i18n={i18n} /> ) } @@ -284,6 +288,7 @@ export default async function Page({ params: { tx } }: { params: { tx: string } ckbTransaction={ckbTransaction as CkbTransaction} btcTransaction={btcTransaction as BitcoinTransaction} leapDirection={rgbppTransaction?.leapDirection} + i18n={i18n} /> ) } @@ -292,12 +297,12 @@ export default async function Page({ params: { tx } }: { params: { tx: string } const btcTxRes = await graphQLClient.request(btcTxQuery, { txid: tx }) if (btcTxRes.btcTransaction) { - return + return } const ckbTxRes = await graphQLClient.request(ckbTxQuery, { hash: tx }) if (ckbTxRes.ckbTransaction) { - return + return } notFound() diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 106a820b..6451b89f 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -26,11 +26,11 @@ export default function RootLayout({ children }: PropsWithChildren) { const locale = getLocaleFromHeaders() return ( - + {children} -