From 1dc9af4312aebd02c51a0746708a12f98eff0911 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Nov 2026 16:48:52 +0000 Subject: [PATCH] fix: page address transactions by addressId with a lower size cap Resolve the address once and filter Transaction by addressId so MySQL can use Transaction_addressId_timestamp_idx. Cap this endpoint at 500 rows per page to avoid large offset scans. Co-authored-by: David --- constants/index.ts | 2 ++ pages/api/address/transactions/[address].ts | 10 +++++++--- services/transactionService.ts | 18 +++++++++++++----- tests/unittests/transactionService.test.ts | 13 +++++++++++-- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/constants/index.ts b/constants/index.ts index a471e3fa..d616942f 100644 --- a/constants/index.ts +++ b/constants/index.ts @@ -5,6 +5,7 @@ export interface ResponseMessage { message: string } export const TX_PAGE_SIZE_LIMIT = 2000 +export const ADDRESS_TX_PAGE_SIZE_LIMIT = 500 export const RESPONSE_MESSAGES = { SUCCESSFULLY_SYNCED_200: { statusCode: 200, message: 'Successfully synced.' }, STARTED_SYNC_200: { statusCode: 200, message: 'Sync started.' }, @@ -89,6 +90,7 @@ export const RESPONSE_MESSAGES = { COULD_NOT_DOWNLOAD_FILE_500: { statusCode: 500, message: 'Failed to download file.' }, INVALID_DATA_JSON_WITH_VARIABLES_400: (variables: string[]) => { return { statusCode: 400, message: `Data is not valid. Make sure that ${variables.join(', ')} are not inside quotes.` } }, PAGE_SIZE_LIMIT_EXCEEDED_400: { statusCode: 400, message: `Page size limit should be at most ${TX_PAGE_SIZE_LIMIT}.` }, + ADDRESS_PAGE_SIZE_LIMIT_EXCEEDED_400: { statusCode: 400, message: `Page size limit should be at most ${ADDRESS_TX_PAGE_SIZE_LIMIT}.` }, PAGE_SIZE_AND_PAGE_SHOULD_BE_NUMBERS_400: { statusCode: 400, message: 'pageSize and page parameters should be valid integers.' }, INVALID_OUTPUT_SCRIPT_LENGTH_500: (l: number) => { return { statusCode: 500, message: `Invalid outputScript length ${l}` } }, FAILED_TO_PARSE_TX_OP_RETURN_500: { statusCode: 500, message: 'Failed to parse OP_RETURN data in Tx.' }, diff --git a/pages/api/address/transactions/[address].ts b/pages/api/address/transactions/[address].ts index dfdcf1e7..34e4c14c 100644 --- a/pages/api/address/transactions/[address].ts +++ b/pages/api/address/transactions/[address].ts @@ -1,6 +1,6 @@ import { NextApiResponse, NextApiRequest } from 'next' import { parseAddress } from 'utils/validators' -import { DEFAULT_TX_PAGE_SIZE, RESPONSE_MESSAGES, TX_PAGE_SIZE_LIMIT } from 'constants/index' +import { ADDRESS_TX_PAGE_SIZE_LIMIT, DEFAULT_TX_PAGE_SIZE, RESPONSE_MESSAGES } from 'constants/index' import { fetchPaginatedAddressTransactions, getSimplifiedTransactions } from 'services/transactionService' import { upsertAddress } from 'services/addressService' import Cors from 'cors' @@ -27,8 +27,8 @@ export default async (req: NextApiRequest, res: NextApiResponse): Promise if (isNaN(page) || isNaN(pageSize)) { throw new Error(RESPONSE_MESSAGES.PAGE_SIZE_AND_PAGE_SHOULD_BE_NUMBERS_400.message) } - if (pageSize > TX_PAGE_SIZE_LIMIT) { - throw new Error(RESPONSE_MESSAGES.PAGE_SIZE_LIMIT_EXCEEDED_400.message) + if (pageSize > ADDRESS_TX_PAGE_SIZE_LIMIT) { + throw new Error(RESPONSE_MESSAGES.ADDRESS_PAGE_SIZE_LIMIT_EXCEEDED_400.message) } const address = parseAddress(req.query.address as string) @@ -62,6 +62,10 @@ export default async (req: NextApiRequest, res: NextApiResponse): Promise res.status(INVALID_ADDRESS_400.statusCode).json(INVALID_ADDRESS_400) break } + case RESPONSE_MESSAGES.ADDRESS_PAGE_SIZE_LIMIT_EXCEEDED_400.message: + res.status(RESPONSE_MESSAGES.ADDRESS_PAGE_SIZE_LIMIT_EXCEEDED_400.statusCode) + .json(RESPONSE_MESSAGES.ADDRESS_PAGE_SIZE_LIMIT_EXCEEDED_400) + break case NO_ADDRESS_FOUND_404.message: { res.status(NO_ADDRESS_FOUND_404.statusCode).json(NO_ADDRESS_FOUND_404) break diff --git a/services/transactionService.ts b/services/transactionService.ts index c503a183..1c80bb19 100644 --- a/services/transactionService.ts +++ b/services/transactionService.ts @@ -1,7 +1,7 @@ import prisma from 'prisma-local/clientInstance' import { Prisma, Transaction } from '@prisma/client' import { RESPONSE_MESSAGES, USD_QUOTE_ID, CAD_QUOTE_ID, N_OF_QUOTES, UPSERT_TRANSACTION_PRICES_ON_DB_TIMEOUT, SupportedQuotesType, NETWORK_IDS, PRICES_CONNECTION_BATCH_SIZE, PRICES_CONNECTION_TIMEOUT, HUMAN_READABLE_DATE_FORMAT } from 'constants/index' -import { fetchAddressBySubstring, fetchAddressById, fetchAddressesByPaybuttonId, addressExists } from 'services/addressService' +import { fetchAddressBySubstring, fetchAddressById, fetchAddressesByPaybuttonId } from 'services/addressService' import { AllPrices, QuoteValues, fetchPricesForNetworkAndTimestamp, flattenTimestamp } from 'services/priceService' import _ from 'lodash' import { CacheSet } from 'redis/index' @@ -319,12 +319,20 @@ export async function * generateTransactionsWithPaybuttonsAndPricesForAddress (a export async function fetchPaginatedAddressTransactions (addressString: string, page: number, pageSize: number, orderBy?: string, orderDesc = true): Promise { const orderByQuery = transactionOrderBy(orderBy, orderDesc) const parsedAddress = parseAddress(addressString) - await addressExists(parsedAddress, true) + const address = await prisma.address.findUnique({ + where: { + address: parsedAddress + }, + select: { + id: true + } + }) + if (address === null) { + throw new Error(RESPONSE_MESSAGES.NO_ADDRESS_FOUND_404.message) + } const txs = await prisma.transaction.findMany({ where: { - address: { - address: parsedAddress - } + addressId: address.id }, include: includeAddressAndPrices, orderBy: orderByQuery, diff --git a/tests/unittests/transactionService.test.ts b/tests/unittests/transactionService.test.ts index 7aac61a9..d4e81bd3 100644 --- a/tests/unittests/transactionService.test.ts +++ b/tests/unittests/transactionService.test.ts @@ -498,15 +498,24 @@ describe('address transaction orderBy blacklist', () => { const address = `ecash:${exampleAddresses.ecash}` beforeEach(() => { - prismaMock.address.findUniqueOrThrow.mockResolvedValue(mockedBCHAddress) - prisma.address.findUniqueOrThrow = prismaMock.address.findUniqueOrThrow + prismaMock.address.findUnique.mockResolvedValue({ id: mockedBCHAddress.id } as any) + prisma.address.findUnique = prismaMock.address.findUnique prismaMock.transaction.findMany.mockResolvedValue([]) prisma.transaction.findMany = prismaMock.transaction.findMany }) + it('queries by addressId after resolving the address', async () => { + await transactionService.fetchPaginatedAddressTransactions(address, 0, 10, 'timestamp', true) + expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + where: { addressId: mockedBCHAddress.id }, + orderBy: { timestamp: 'desc' } + })) + }) + it('sorts by an allowed nested address field', async () => { await transactionService.fetchPaginatedAddressTransactions(address, 0, 10, 'address.networkId', false) expect(prismaMock.transaction.findMany.mock.calls[0][0]).toEqual(expect.objectContaining({ + where: { addressId: mockedBCHAddress.id }, orderBy: { address: { networkId: 'asc' } } })) })