From c611fac875d47044053c3a57a7fa1738f90da2a9 Mon Sep 17 00:00:00 2001 From: 0xChqrles Date: Thu, 20 Jun 2024 00:01:56 +0200 Subject: [PATCH 1/4] improve tx history pagination --- backend/src/routes/getTransactionHistory.ts | 49 +++++++++++++++------ backend/src/utils/pagination.ts | 9 ++++ 2 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 backend/src/utils/pagination.ts diff --git a/backend/src/routes/getTransactionHistory.ts b/backend/src/routes/getTransactionHistory.ts index 6346de7d..9e38d4b4 100644 --- a/backend/src/routes/getTransactionHistory.ts +++ b/backend/src/routes/getTransactionHistory.ts @@ -2,13 +2,30 @@ import { and, desc, eq, lt, or, sql } from 'drizzle-orm' import type { FastifyInstance } from 'fastify' import { usdcTransfer } from '@/db/schema' +import { fromCursorHash, toCursorHash } from '@/utils/pagination' import { addressRegex } from '.' +function getCursorQuery(cursor?: string): Parameters { + const [transferId, timestamp] = fromCursorHash(cursor) + + console.log(transferId, timestamp) + + return [ + or( + and( + timestamp ? eq(usdcTransfer.blockTimestamp, new Date(Number(timestamp) * 1000)) : undefined, + transferId ? lt(usdcTransfer.transferId, transferId) : undefined, + ), + timestamp ? lt(usdcTransfer.blockTimestamp, new Date(Number(timestamp) * 1000)) : undefined, + ), + ] +} + interface TransactionHistoryQuery { address: string - first: number - after?: number + first: string + after?: string } export function getTransactionHistory(fastify: FastifyInstance) { @@ -16,7 +33,8 @@ export function getTransactionHistory(fastify: FastifyInstance) { '/transaction_history', async (request, reply) => { - const { address, first, after } = request.query as TransactionHistoryQuery + const { address, first: firstStr, after } = request.query as TransactionHistoryQuery + const first = Number(firstStr) if (!address) { return reply.status(400).send({ error: 'Address is required.' }) @@ -31,13 +49,14 @@ export function getTransactionHistory(fastify: FastifyInstance) { return reply.status(400).send({ error: 'Invalid address format.' }) } - const firstTimestamp = after ? Number(after) : 0xfffffffffff // a timestamp large enough to be sure that it's in the future + const afterQuery = getCursorQuery(after) try { const txs = await fastify.db .select({ transaction_timestamp: usdcTransfer.blockTimestamp, amount: usdcTransfer.amount, + transfer_id: usdcTransfer.transferId, from: { nickname: sql`"from_user"."nickname"`, contract_address: sql`"from_user"."contract_address"`, @@ -52,17 +71,21 @@ export function getTransactionHistory(fastify: FastifyInstance) { .from(usdcTransfer) .leftJoin(sql`registration AS "from_user"`, eq(usdcTransfer.fromAddress, sql`"from_user"."contract_address"`)) .leftJoin(sql`registration AS "to_user"`, eq(usdcTransfer.toAddress, sql`"to_user"."contract_address"`)) - .where( - and( - lt(usdcTransfer.blockTimestamp, new Date(firstTimestamp)), - or(eq(usdcTransfer.fromAddress, address), eq(usdcTransfer.toAddress, address)), - ), - ) - .limit(first) - .orderBy(desc(usdcTransfer.blockTimestamp)) + .where(and(...afterQuery, or(eq(usdcTransfer.fromAddress, address), eq(usdcTransfer.toAddress, address)))) + .limit(Number(first) + 1) + .orderBy(desc(usdcTransfer.blockTimestamp), desc(usdcTransfer.transferId)) .execute() - return reply.status(200).send({ transactions: txs }) + // get pagination infos + const lastTx = txs.length ? txs[Math.min(txs.length - 1, first - 1)] : null + + const endCursor = lastTx + ? toCursorHash(lastTx.transfer_id, (lastTx.transaction_timestamp!.getTime() / 1000).toString()) + : null + + const hasNext = txs.length > first + + return reply.status(200).send({ transactions: txs.slice(0, first), endCursor, hasNext }) } catch (error) { console.error(error) return reply.status(500).send({ error: 'Internal server error' }) diff --git a/backend/src/utils/pagination.ts b/backend/src/utils/pagination.ts new file mode 100644 index 00000000..a6cdb29a --- /dev/null +++ b/backend/src/utils/pagination.ts @@ -0,0 +1,9 @@ +const CURSOR_SEPARATOR = '%' + +export function fromCursorHash(cursor?: string): string[] { + return cursor ? Buffer.from(cursor, 'base64').toString().split(CURSOR_SEPARATOR) : [] +} + +export function toCursorHash(...arr: string[]): string { + return Buffer.from(arr.join(CURSOR_SEPARATOR)).toString('base64') +} From 9ced28018f6dd4c2c27894c458607df79c48e10f Mon Sep 17 00:00:00 2001 From: 0xChqrles Date: Thu, 20 Jun 2024 09:17:20 +0200 Subject: [PATCH 2/4] remove useless logs --- backend/src/routes/getTransactionHistory.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/src/routes/getTransactionHistory.ts b/backend/src/routes/getTransactionHistory.ts index 9e38d4b4..bc957af2 100644 --- a/backend/src/routes/getTransactionHistory.ts +++ b/backend/src/routes/getTransactionHistory.ts @@ -9,8 +9,6 @@ import { addressRegex } from '.' function getCursorQuery(cursor?: string): Parameters { const [transferId, timestamp] = fromCursorHash(cursor) - console.log(transferId, timestamp) - return [ or( and( From 74c976ed7ca64e69692d1db6de3c20510b9cc1a2 Mon Sep 17 00:00:00 2001 From: 0xChqrles Date: Thu, 20 Jun 2024 09:24:59 +0200 Subject: [PATCH 3/4] set up max tx history page size --- backend/src/routes/getTransactionHistory.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/getTransactionHistory.ts b/backend/src/routes/getTransactionHistory.ts index bc957af2..cc7f59cf 100644 --- a/backend/src/routes/getTransactionHistory.ts +++ b/backend/src/routes/getTransactionHistory.ts @@ -6,6 +6,8 @@ import { fromCursorHash, toCursorHash } from '@/utils/pagination' import { addressRegex } from '.' +const MAX_PAGE_SIZE = 20 + function getCursorQuery(cursor?: string): Parameters { const [transferId, timestamp] = fromCursorHash(cursor) @@ -21,8 +23,8 @@ function getCursorQuery(cursor?: string): Parameters { } interface TransactionHistoryQuery { - address: string - first: string + address?: string + first?: string after?: string } @@ -32,14 +34,14 @@ export function getTransactionHistory(fastify: FastifyInstance) { async (request, reply) => { const { address, first: firstStr, after } = request.query as TransactionHistoryQuery - const first = Number(firstStr) + const first = Number(firstStr ?? MAX_PAGE_SIZE) if (!address) { return reply.status(400).send({ error: 'Address is required.' }) } - if (!first) { - return reply.status(400).send({ error: 'First is required.' }) + if (first > MAX_PAGE_SIZE) { + return reply.status(400).send({ error: `First cannot exceed ${MAX_PAGE_SIZE}.` }) } // Validate address format From 2728db379581e170eda1139eb8c11632be2a149c Mon Sep 17 00:00:00 2001 From: 0xChqrles Date: Thu, 20 Jun 2024 11:17:06 +0200 Subject: [PATCH 4/4] rename items field for tx history endpoint --- backend/src/routes/getTransactionHistory.ts | 2 +- backend/test/getTransactionHistory.test.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/getTransactionHistory.ts b/backend/src/routes/getTransactionHistory.ts index cc7f59cf..5907ccd7 100644 --- a/backend/src/routes/getTransactionHistory.ts +++ b/backend/src/routes/getTransactionHistory.ts @@ -85,7 +85,7 @@ export function getTransactionHistory(fastify: FastifyInstance) { const hasNext = txs.length > first - return reply.status(200).send({ transactions: txs.slice(0, first), endCursor, hasNext }) + return reply.status(200).send({ items: txs.slice(0, first), endCursor, hasNext }) } catch (error) { console.error(error) return reply.status(500).send({ error: 'Internal server error' }) diff --git a/backend/test/getTransactionHistory.test.ts b/backend/test/getTransactionHistory.test.ts index aff1fd22..c18f24d9 100644 --- a/backend/test/getTransactionHistory.test.ts +++ b/backend/test/getTransactionHistory.test.ts @@ -121,8 +121,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) const mockResponseObj = generateMockData(testAddress, first, startValue, true, test_user) - expect(response.json().transactions).toMatchObject(mockResponseObj) - expect(response.json().transactions).toHaveLength(first) + expect(response.json().items).toMatchObject(mockResponseObj) + expect(response.json().items).toHaveLength(first) }) test('should return the first 9 entries', async () => { @@ -137,8 +137,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) const mockResponseObj = generateMockData(testAddress, first, startValue, true, test_user) - expect(response.json().transactions).toMatchObject(mockResponseObj) - expect(response.json().transactions).toHaveLength(first) + expect(response.json().items).toMatchObject(mockResponseObj) + expect(response.json().items).toHaveLength(first) }) test('should return all the txs', async () => { const txsNb = 10 @@ -152,8 +152,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) const mockResponseObj = generateMockData(testAddress, txsNb, 0, true, test_user) - expect(response.json().transactions).toMatchObject(mockResponseObj) - expect(response.json().transactions).toHaveLength(txsNb) + expect(response.json().items).toMatchObject(mockResponseObj) + expect(response.json().items).toHaveLength(txsNb) }) test('should return empty list unknown address', async () => { @@ -166,8 +166,8 @@ describe('GET /transaction history route', () => { expect(response.statusCode).toBe(200) - expect(response.json().transactions).toMatchObject([]) - expect(response.json().transactions).toHaveLength(0) + expect(response.json().items).toMatchObject([]) + expect(response.json().items).toHaveLength(0) }) test('should return an error wrong address format', async () => {