diff --git a/src/api/routes/tx.ts b/src/api/routes/tx.ts index 0c33d9104..a96e79546 100644 --- a/src/api/routes/tx.ts +++ b/src/api/routes/tx.ts @@ -44,7 +44,7 @@ import { TransactionSearchResponseSchema, TransactionTypeSchema, } from '../schemas/entities/transactions'; -import { PaginatedResponse } from '../schemas/util'; +import { PaginatedResponse, PaginatedCursorResponse } from '../schemas/util'; import { ErrorResponseSchema, MempoolStatsResponseSchema, @@ -78,6 +78,15 @@ export const TxRoutes: FastifyPluginAsync< querystring: Type.Object({ offset: OffsetParam(), limit: LimitParam(ResourceType.Tx), + cursor: Type.Optional( + Type.String({ + description: + 'Cursor for pagination. Use the cursor values returned in the response to navigate between pages.', + examples: [ + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:2147483647:10', + ], + }) + ), type: Type.Optional(Type.Array(TransactionTypeSchema)), unanchored: UnanchoredParamSchema, order: Type.Optional(Type.Enum({ asc: 'asc', desc: 'desc' })), @@ -137,7 +146,7 @@ export const TxRoutes: FastifyPluginAsync< exclude_function_args: ExcludeFunctionArgsParamSchema, }), response: { - 200: TransactionResultsSchema, + 200: PaginatedCursorResponse(TransactionSchema), }, }, }, @@ -181,9 +190,16 @@ export const TxRoutes: FastifyPluginAsync< contractId = req.query.contract_id; } - const { results: txResults, total } = await fastify.db.getTxList({ + const { + results: txResults, + total, + next_cursor, + prev_cursor, + current_cursor: cursor, + } = await fastify.db.getTxList({ offset, limit, + cursor: req.query.cursor, txTypeFilter, includeUnanchored: req.query.unanchored ?? false, fromAddress, @@ -197,7 +213,7 @@ export const TxRoutes: FastifyPluginAsync< sortBy: req.query.sort_by, }); const results = txResults.map(tx => parseDbTx(tx, excludeFunctionArgs)); - await reply.send({ limit, offset, total, results }); + await reply.send({ limit, offset, total, next_cursor, prev_cursor, cursor, results }); } ); diff --git a/src/api/routes/v2/blocks.ts b/src/api/routes/v2/blocks.ts index 4e355f413..534f7cfac 100644 --- a/src/api/routes/v2/blocks.ts +++ b/src/api/routes/v2/blocks.ts @@ -151,9 +151,16 @@ export const BlockRoutesV2: FastifyPluginAsync< querystring: Type.Object({ limit: LimitParam(ResourceType.Tx), offset: OffsetParam(), + cursor: Type.Optional( + Type.String({ + description: + 'Cursor for pagination. Use the cursor values returned in the response to navigate between pages.', + examples: ['2147483647:10'], + }) + ), }), response: { - 200: PaginatedResponse(TransactionSchema), + 200: PaginatedCursorResponse(TransactionSchema), }, }, }, @@ -162,7 +169,15 @@ export const BlockRoutesV2: FastifyPluginAsync< const query = req.query; try { - const { limit, offset, results, total } = await fastify.db.v2.getBlockTransactions({ + const { + limit, + offset, + results, + total, + next_cursor, + prev_cursor, + current_cursor: cursor, + } = await fastify.db.v2.getBlockTransactions({ block: params, ...query, }); @@ -170,6 +185,9 @@ export const BlockRoutesV2: FastifyPluginAsync< limit, offset, total, + next_cursor, + prev_cursor, + cursor, results: results.map(r => parseDbTx(r, false)), }; await reply.send(response); diff --git a/src/datastore/pg-store-v2.ts b/src/datastore/pg-store-v2.ts index 43afcde8f..9dc95b3cb 100644 --- a/src/datastore/pg-store-v2.ts +++ b/src/datastore/pg-store-v2.ts @@ -333,10 +333,36 @@ export class PgStoreV2 extends BasePgStoreModule { block: BlockIdParam; limit?: number; offset?: number; - }): Promise> { + cursor?: string; + }): Promise> { return await this.sqlTransaction(async sql => { const limit = args.limit ?? TransactionLimitParamSchema.default; const offset = args.offset ?? 0; + + // Parse cursor if provided (format: "microblockSequence:txIndex") + let cursorFilter = sql``; + if (args.cursor) { + const parts = args.cursor.split(':'); + if (parts.length !== 2) { + throw new InvalidRequestError( + 'Invalid cursor format', + InvalidRequestErrorType.invalid_param + ); + } + const [microblockSequenceStr, txIndexStr] = parts; + const microblockSequence = parseInt(microblockSequenceStr, 10); + const txIndex = parseInt(txIndexStr, 10); + if (isNaN(microblockSequence) || isNaN(txIndex)) { + throw new InvalidRequestError( + 'Invalid cursor format', + InvalidRequestErrorType.invalid_param + ); + } + cursorFilter = sql` + AND (microblock_sequence, tx_index) >= (${microblockSequence}, ${txIndex}) + `; + } + const txsQuery = await sql<(TxQueryResult & { total: number })[]>` WITH block_ptr AS ( SELECT index_block_hash FROM blocks @@ -362,17 +388,71 @@ export class PgStoreV2 extends BasePgStoreModule { WHERE canonical = true AND microblock_canonical = true AND index_block_hash = (SELECT index_block_hash FROM block_ptr) + ${cursorFilter} ORDER BY microblock_sequence ASC, tx_index ASC - LIMIT ${limit} - OFFSET ${offset} + LIMIT ${limit + 1} `; if (txsQuery.count === 0) throw new InvalidRequestError(`Block not found`, InvalidRequestErrorType.invalid_param); + + const hasNextPage = txsQuery.length > limit; + const results = hasNextPage ? txsQuery.slice(0, limit) : txsQuery; + + // Generate cursors + const lastResult = txsQuery[txsQuery.length - 1]; + const prevCursor = + hasNextPage && lastResult + ? `${lastResult.microblock_sequence}:${lastResult.tx_index}` + : null; + + const firstResult = results[0]; + const currentCursor = firstResult + ? `${firstResult.microblock_sequence}:${firstResult.tx_index}` + : null; + + // Generate next cursor by looking for the first item of the previous page + let nextCursor: string | null = null; + if (firstResult && limit > 1) { + const prevQuery = await sql< + { microblock_sequence: number; tx_index: number }[] + >` + SELECT microblock_sequence, tx_index + FROM txs + WHERE canonical = true + AND microblock_canonical = true + AND index_block_hash = ( + SELECT index_block_hash FROM blocks + WHERE ${ + args.block.type === 'latest' + ? sql`canonical = TRUE ORDER BY block_height DESC` + : args.block.type === 'hash' + ? sql`( + block_hash = ${normalizeHashString(args.block.hash)} + OR index_block_hash = ${normalizeHashString(args.block.hash)} + ) AND canonical = TRUE` + : sql`block_height = ${args.block.height} AND canonical = TRUE` + } + LIMIT 1 + ) + AND (microblock_sequence, tx_index) < (${firstResult.microblock_sequence}, ${firstResult.tx_index}) + ORDER BY microblock_sequence DESC, tx_index DESC + OFFSET ${limit - 1} + LIMIT 1 + `; + if (prevQuery.length > 0) { + const prev = prevQuery[0]; + nextCursor = `${prev.microblock_sequence}:${prev.tx_index}`; + } + } + return { limit, offset, - results: txsQuery.map(t => parseTxQueryResult(t)), + results: results.map(t => parseTxQueryResult(t)), total: txsQuery[0].total, + next_cursor: nextCursor, + prev_cursor: prevCursor, + current_cursor: currentCursor, }; }); } diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index c7d41b1d3..65a452b52 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -1409,6 +1409,7 @@ export class PgStore extends BasePgStore { async getTxList({ limit, offset, + cursor, txTypeFilter, includeUnanchored, fromAddress, @@ -1423,6 +1424,7 @@ export class PgStore extends BasePgStore { }: { limit: number; offset: number; + cursor?: string; txTypeFilter: TransactionType[]; includeUnanchored: boolean; fromAddress?: string; @@ -1434,7 +1436,13 @@ export class PgStore extends BasePgStore { nonce?: number; order?: 'desc' | 'asc'; sortBy?: 'block_height' | 'burn_block_time' | 'fee'; - }): Promise<{ results: DbTx[]; total: number }> { + }): Promise<{ + results: DbTx[]; + total: number; + next_cursor: string | null; + prev_cursor: string | null; + current_cursor: string | null; + }> { return await this.sqlTransaction(async sql => { const maxHeight = await this.getMaxBlockHeight(sql, { includeUnanchored }); const orderSql = order === 'asc' ? sql`ASC` : sql`DESC`; @@ -1455,6 +1463,43 @@ export class PgStore extends BasePgStore { throw new Error(`Invalid sortBy param: ${sortBy}`); } + // Parse cursor if provided (format: "indexBlockHash:microblockSequence:txIndex") + let cursorFilter = sql``; + if (cursor) { + const parts = cursor.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid cursor format'); + } + const [indexBlockHash, microblockSequenceStr, txIndexStr] = parts; + const microblockSequence = parseInt(microblockSequenceStr, 10); + const txIndex = parseInt(txIndexStr, 10); + if (!indexBlockHash || isNaN(microblockSequence) || isNaN(txIndex)) { + throw new Error('Invalid cursor format'); + } + + // Look up block_height from index_block_hash for the row-value comparison + const blockHeightQuery = await sql<{ block_height: number }[]>` + SELECT block_height FROM blocks WHERE index_block_hash = ${indexBlockHash} LIMIT 1 + `; + if (blockHeightQuery.length === 0) { + throw new Error('Invalid cursor: block not found'); + } + const blockHeight = blockHeightQuery[0].block_height; + + // Apply cursor filter based on sort direction + if (order === 'asc') { + cursorFilter = sql` + AND (block_height, microblock_sequence, tx_index) + >= (${blockHeight}, ${microblockSequence}, ${txIndex}) + `; + } else { + cursorFilter = sql` + AND (block_height, microblock_sequence, tx_index) + <= (${blockHeight}, ${microblockSequence}, ${txIndex}) + `; + } + } + const txTypeFilterSql = txTypeFilter.length > 0 ? sql`AND type_id IN ${sql(txTypeFilter.flatMap(t => getTxTypeId(t)))}` @@ -1513,13 +1558,90 @@ export class PgStore extends BasePgStore { ${contractIdFilterSql} ${contractFuncFilterSql} ${nonceFilterSql} + ${cursorFilter} ${orderBySql} - LIMIT ${limit} - OFFSET ${offset} + LIMIT ${limit + 1} `; - const parsed = resultQuery.map(r => parseTxQueryResult(r)); - return { results: parsed, total: totalQuery[0].count }; + const hasNextPage = resultQuery.length > limit; + const results = hasNextPage ? resultQuery.slice(0, limit) : resultQuery; + + // Generate cursors + const lastResult = resultQuery[resultQuery.length - 1]; + const prevCursor = + hasNextPage && lastResult + ? `${lastResult.index_block_hash}:${lastResult.microblock_sequence}:${lastResult.tx_index}` + : null; + + const firstResult = results[0]; + const currentCursor = firstResult + ? `${firstResult.index_block_hash}:${firstResult.microblock_sequence}:${firstResult.tx_index}` + : null; + + // Generate next cursor by looking for the first item of the previous page + let nextCursor: string | null = null; + if (firstResult && order !== 'asc') { + // For DESC order, look for items "before" our current first result (greater in DESC order) + const prevQuery = await sql< + { index_block_hash: string; microblock_sequence: number; tx_index: number }[] + >` + SELECT index_block_hash, microblock_sequence, tx_index + FROM txs + WHERE canonical = true AND microblock_canonical = true AND block_height <= ${maxHeight} + ${txTypeFilterSql} + ${fromAddressFilterSql} + ${toAddressFilterSql} + ${startTimeFilterSql} + ${endTimeFilterSql} + ${contractIdFilterSql} + ${contractFuncFilterSql} + ${nonceFilterSql} + AND (block_height, microblock_sequence, tx_index) + > (${firstResult.block_height}, ${firstResult.microblock_sequence}, ${firstResult.tx_index}) + ORDER BY block_height ASC, microblock_sequence ASC, tx_index ASC + OFFSET ${limit - 1} + LIMIT 1 + `; + if (prevQuery.length > 0) { + const prev = prevQuery[0]; + nextCursor = `${prev.index_block_hash}:${prev.microblock_sequence}:${prev.tx_index}`; + } + } else if (firstResult && order === 'asc') { + // For ASC order, look for items "before" our current first result (lesser in ASC order) + const prevQuery = await sql< + { index_block_hash: string; microblock_sequence: number; tx_index: number }[] + >` + SELECT index_block_hash, microblock_sequence, tx_index + FROM txs + WHERE canonical = true AND microblock_canonical = true AND block_height <= ${maxHeight} + ${txTypeFilterSql} + ${fromAddressFilterSql} + ${toAddressFilterSql} + ${startTimeFilterSql} + ${endTimeFilterSql} + ${contractIdFilterSql} + ${contractFuncFilterSql} + ${nonceFilterSql} + AND (block_height, microblock_sequence, tx_index) + < (${firstResult.block_height}, ${firstResult.microblock_sequence}, ${firstResult.tx_index}) + ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC + OFFSET ${limit - 1} + LIMIT 1 + `; + if (prevQuery.length > 0) { + const prev = prevQuery[0]; + nextCursor = `${prev.index_block_hash}:${prev.microblock_sequence}:${prev.tx_index}`; + } + } + + const parsed = results.map(r => parseTxQueryResult(r)); + return { + results: parsed, + total: totalQuery[0].count, + next_cursor: nextCursor, + prev_cursor: prevCursor, + current_cursor: currentCursor, + }; }); } diff --git a/tests/api/tx.test.ts b/tests/api/tx.test.ts index 86b1e6c2e..436788ed9 100644 --- a/tests/api/tx.test.ts +++ b/tests/api/tx.test.ts @@ -4888,4 +4888,161 @@ describe('tx tests', () => { expect(txRes.body.tx_type).toBe('contract_call'); expect(txRes.body.contract_call.function_args).toBeUndefined(); }); + + test('transaction list cursor pagination', async () => { + // Create multiple transactions across blocks for cursor pagination testing + const block1 = new TestBlockBuilder({ + block_height: 1, + index_block_hash: '0x10', + block_hash: '0x10', + }) + .addTx({ + tx_id: '0x1111000000000000000000000000000000000000000000000000000000000000', + type_id: DbTxTypeId.TokenTransfer, + token_transfer_recipient_address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + token_transfer_amount: 100n, + }) + .addTx({ + tx_id: '0x1112000000000000000000000000000000000000000000000000000000000000', + type_id: DbTxTypeId.TokenTransfer, + token_transfer_recipient_address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + token_transfer_amount: 200n, + }) + .build(); + + const block2 = new TestBlockBuilder({ + block_height: 2, + index_block_hash: '0x20', + block_hash: '0x20', + parent_index_block_hash: '0x10', + }) + .addTx({ + tx_id: '0x2221000000000000000000000000000000000000000000000000000000000000', + type_id: DbTxTypeId.TokenTransfer, + token_transfer_recipient_address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + token_transfer_amount: 300n, + }) + .addTx({ + tx_id: '0x2222000000000000000000000000000000000000000000000000000000000000', + type_id: DbTxTypeId.TokenTransfer, + token_transfer_recipient_address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + token_transfer_amount: 400n, + }) + .build(); + + const block3 = new TestBlockBuilder({ + block_height: 3, + index_block_hash: '0x30', + block_hash: '0x30', + parent_index_block_hash: '0x20', + }) + .addTx({ + tx_id: '0x3331000000000000000000000000000000000000000000000000000000000000', + type_id: DbTxTypeId.TokenTransfer, + token_transfer_recipient_address: 'STB44HYPYAT2BB2QE513NSP81HTMYWBJP02HPGK6', + token_transfer_amount: 500n, + }) + .build(); + + await db.update(block1); + await db.update(block2); + await db.update(block3); + + // Test 1: Fetch first page with cursor + const page1 = await supertest(api.server).get('/extended/v1/tx?limit=2').expect(200); + + expect(page1.body.limit).toBe(2); + expect(page1.body.offset).toBe(0); + expect(page1.body.total).toBe(5); + expect(page1.body.results).toHaveLength(2); + expect(page1.body.cursor).toBeDefined(); + expect(page1.body.prev_cursor).toBeDefined(); + expect(page1.body.next_cursor).toBeNull(); // No previous page + + // Results should be in DESC order (newest first) + expect(page1.body.results[0].tx_id).toBe( + '0x3331000000000000000000000000000000000000000000000000000000000000' + ); + expect(page1.body.results[1].tx_id).toBe( + '0x2222000000000000000000000000000000000000000000000000000000000000' + ); + + // Test 2: Navigate using prev_cursor (next page forward in time = older txs) + const page2 = await supertest(api.server) + .get(`/extended/v1/tx?cursor=${page1.body.prev_cursor}&limit=2`) + .expect(200); + + expect(page2.body.limit).toBe(2); + expect(page2.body.results).toHaveLength(2); + expect(page2.body.cursor).toBe(page1.body.prev_cursor); + expect(page2.body.prev_cursor).toBeDefined(); + expect(page2.body.next_cursor).toBe(page1.body.cursor); // Can go back to page 1 + + // These should be the next 2 oldest transactions + expect(page2.body.results[0].tx_id).toBe( + '0x2221000000000000000000000000000000000000000000000000000000000000' + ); + expect(page2.body.results[1].tx_id).toBe( + '0x1112000000000000000000000000000000000000000000000000000000000000' + ); + + // Test 3: Navigate back using next_cursor (should get first page again) + const page1Again = await supertest(api.server) + .get(`/extended/v1/tx?cursor=${page2.body.next_cursor}&limit=2`) + .expect(200); + + expect(page1Again.body.cursor).toBe(page2.body.next_cursor); + expect(page1Again.body.results).toHaveLength(2); + expect(page1Again.body.results[0].tx_id).toBe(page1.body.results[0].tx_id); + expect(page1Again.body.results[1].tx_id).toBe(page1.body.results[1].tx_id); + + // Test 4: Fetch last page + const page3 = await supertest(api.server) + .get(`/extended/v1/tx?cursor=${page2.body.prev_cursor}&limit=2`) + .expect(200); + + expect(page3.body.results).toHaveLength(1); // Only 1 transaction left + expect(page3.body.results[0].tx_id).toBe( + '0x1111000000000000000000000000000000000000000000000000000000000000' + ); + expect(page3.body.prev_cursor).toBeNull(); // No more pages forward + expect(page3.body.next_cursor).toBeDefined(); // Can go back + + // Test 5: Cursor with filters (type filter) + const filteredPage = await supertest(api.server) + .get('/extended/v1/tx?limit=2&type=token_transfer') + .expect(200); + + expect(filteredPage.body.results).toHaveLength(2); + expect(filteredPage.body.cursor).toBeDefined(); + expect(filteredPage.body.total).toBe(5); // All 5 are token transfers + + // Test 6: Navigate filtered results with cursor + const filteredPage2 = await supertest(api.server) + .get( + `/extended/v1/tx?cursor=${filteredPage.body.prev_cursor}&limit=2&type=token_transfer` + ) + .expect(200); + + expect(filteredPage2.body.results).toHaveLength(2); + expect(filteredPage2.body.cursor).toBe(filteredPage.body.prev_cursor); + }); + + test('transaction list cursor pagination with invalid cursor', async () => { + // Test with malformed cursor + const res1 = await supertest(api.server) + .get('/extended/v1/tx?cursor=invalid-cursor-format&limit=2') + .expect(500); + + expect(res1.body.error).toBeDefined(); + + // Test with cursor referencing non-existent block + const res2 = await supertest(api.server) + .get( + '/extended/v1/tx?cursor=0x9999999999999999999999999999999999999999999999999999999999999999:2147483647:0&limit=2' + ) + .expect(500); + + expect(res2.body.error).toBeDefined(); + }); });