From 5c54334a24a6ccefcf8d8880a3b34196cf968489 Mon Sep 17 00:00:00 2001 From: ASuciuX Date: Fri, 12 Sep 2025 20:21:29 +0300 Subject: [PATCH 1/2] feat: add cursor pagination to sc events --- src/api/routes/contract.ts | 40 +++-- .../schemas/entities/transaction-events.ts | 2 +- src/api/schemas/responses/responses.ts | 11 +- src/datastore/common.ts | 7 + src/datastore/pg-store.ts | 92 ++++++++-- tests/api/smart-contract.test.ts | 162 ++++++++++++++++++ 6 files changed, 288 insertions(+), 26 deletions(-) diff --git a/src/api/routes/contract.ts b/src/api/routes/contract.ts index 679889d5bf..3e90de1e9a 100644 --- a/src/api/routes/contract.ts +++ b/src/api/routes/contract.ts @@ -9,7 +9,8 @@ import { LimitParam, OffsetParam } from '../schemas/params'; import { InvalidRequestError, InvalidRequestErrorType, NotFoundError } from '../../errors'; import { ClarityAbi } from '@stacks/transactions'; import { SmartContractSchema } from '../schemas/entities/smart-contracts'; -import { TransactionEventSchema } from '../schemas/entities/transaction-events'; +import { SmartContractLogTransactionEvent } from '../schemas/entities/transaction-events'; +import { ContractEventListResponseSchema } from '../schemas/responses/responses'; export const ContractRoutes: FastifyPluginAsync< Record, @@ -114,16 +115,14 @@ export const ContractRoutes: FastifyPluginAsync< querystring: Type.Object({ limit: LimitParam(ResourceType.Contract, 'Limit', 'max number of events to fetch'), offset: OffsetParam(), + cursor: Type.Optional( + Type.String({ + description: 'Cursor for pagination', + }) + ), }), response: { - 200: Type.Object( - { - limit: Type.Integer(), - offset: Type.Integer(), - results: Type.Array(TransactionEventSchema), - }, - { description: 'List of events' } - ), + 200: ContractEventListResponseSchema, }, }, }, @@ -131,16 +130,35 @@ export const ContractRoutes: FastifyPluginAsync< const { contract_id } = req.params; const limit = getPagingQueryLimit(ResourceType.Contract, req.query.limit); const offset = parsePagingQueryInput(req.query.offset ?? 0); + const cursor = req.query.cursor; + + // Validate cursor format if provided + if (cursor && !cursor.match(/^\d+-\d+-\d+$/)) { + throw new InvalidRequestError( + 'Invalid cursor format. Expected format: blockHeight-txIndex-eventIndex', + InvalidRequestErrorType.invalid_param + ); + } const eventsQuery = await fastify.db.getSmartContractEvents({ contractId: contract_id, limit, offset, + cursor, }); if (!eventsQuery.found) { throw new NotFoundError(`cannot find events for contract by ID}`); } - const parsedEvents = eventsQuery.result.map(event => parseDbEvent(event)); - await reply.send({ limit, offset, results: parsedEvents }); + const parsedEvents = eventsQuery.result.map((event: any) => parseDbEvent(event)); + const response = { + limit, + offset, + total: eventsQuery.total || 0, + results: parsedEvents as SmartContractLogTransactionEvent[], + next_cursor: eventsQuery.nextCursor || null, + prev_cursor: null, // TODO: Implement prev_cursor as well + cursor: cursor || null, + }; + await reply.send(response); } ); diff --git a/src/api/schemas/entities/transaction-events.ts b/src/api/schemas/entities/transaction-events.ts index 8570396711..004117d209 100644 --- a/src/api/schemas/entities/transaction-events.ts +++ b/src/api/schemas/entities/transaction-events.ts @@ -25,7 +25,7 @@ const AbstractTransactionEventSchema = Type.Object( ); type AbstractTransactionEvent = Static; -const SmartContractLogTransactionEventSchema = Type.Intersect( +export const SmartContractLogTransactionEventSchema = Type.Intersect( [ AbstractTransactionEventSchema, Type.Object({ diff --git a/src/api/schemas/responses/responses.ts b/src/api/schemas/responses/responses.ts index 9903fdcc1b..b97b4da16f 100644 --- a/src/api/schemas/responses/responses.ts +++ b/src/api/schemas/responses/responses.ts @@ -1,5 +1,5 @@ import { Static, Type } from '@sinclair/typebox'; -import { Nullable, OptionalNullable, PaginatedCursorResponse, PaginatedResponse } from '../util'; +import { OptionalNullable, PaginatedCursorResponse, PaginatedResponse } from '../util'; import { MempoolStatsSchema } from '../entities/mempool-transactions'; import { MempoolTransactionSchema, TransactionSchema } from '../entities/transactions'; import { MicroblockSchema } from '../entities/microblock'; @@ -7,7 +7,10 @@ import { AddressTransactionWithTransfersSchema, InboundStxTransferSchema, } from '../entities/addresses'; -import { TransactionEventSchema } from '../entities/transaction-events'; +import { + SmartContractLogTransactionEventSchema, + TransactionEventSchema, +} from '../entities/transaction-events'; import { BurnchainRewardSchema, BurnchainRewardSlotHolderSchema, @@ -184,5 +187,9 @@ export type RunFaucetResponse = Static; export const BlockListV2ResponseSchema = PaginatedCursorResponse(NakamotoBlockSchema); export type BlockListV2Response = Static; +export const ContractEventListResponseSchema = PaginatedCursorResponse( + SmartContractLogTransactionEventSchema +); + export const BlockSignerSignatureResponseSchema = PaginatedResponse(SignerSignatureSchema); export type BlockSignerSignatureResponse = Static; diff --git a/src/datastore/common.ts b/src/datastore/common.ts index ca48940914..8ebc86376c 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -1,3 +1,4 @@ +import { FoundOrNot } from 'src/helpers'; import { Block } from '../api/schemas/entities/block'; import { SyntheticPoxEventName } from '../pox-helpers'; import { PgBytea, PgJsonb, PgNumeric } from '@hirosystems/api-toolkit'; @@ -552,6 +553,12 @@ export interface DbSmartContractEvent extends DbEventBase { value: string; } +export type DbCursorPaginatedFoundOrNot = FoundOrNot & { + nextCursor?: string | null; + prevCursor?: string | null; + total: number; +}; + export interface DbStxLockEvent extends DbEventBase { event_type: DbEventTypeId.StxLock; locked_amount: bigint; diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index dc2d246314..0403ca69d3 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -42,6 +42,7 @@ import { DbSearchResult, DbSmartContract, DbSmartContractEvent, + DbCursorPaginatedFoundOrNot, DbStxBalance, DbStxEvent, DbStxLockEvent, @@ -101,6 +102,28 @@ import { parseBlockParam } from '../api/routes/v2/schemas'; export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations'); +// Cursor utilities for smart contract events +function createEventCursor(blockHeight: number, txIndex: number, eventIndex: number): string { + return `${blockHeight}-${txIndex}-${eventIndex}`; +} + +function parseEventCursor( + cursor: string +): { blockHeight: number; txIndex: number; eventIndex: number } | null { + const parts = cursor.split('-'); + if (parts.length !== 3) return null; + const blockHeight = parseInt(parts[0]); + const txIndex = parseInt(parts[1]); + const eventIndex = parseInt(parts[2]); + + // Validate that parsing was successful + if (isNaN(blockHeight) || isNaN(txIndex) || isNaN(eventIndex)) { + return null; + } + + return { blockHeight, txIndex, eventIndex }; +} + /** * This is the main interface between the API and the Postgres database. It contains all methods that * query the DB in search for blockchain data to be returned via endpoints or WebSockets/Socket.IO. @@ -2096,15 +2119,30 @@ export class PgStore extends BasePgStore { }); } - async getSmartContractEvents({ - contractId, - limit, - offset, - }: { + async getSmartContractEvents(args: { contractId: string; limit: number; - offset: number; - }): Promise> { + offset?: number; + cursor?: string; + }): Promise> { + const contractId = args.contractId; + const limit = args.limit; + const offset = args.offset ?? 0; + const cursor = args.cursor ?? null; + + // Parse cursor if provided + const parsedCursor = cursor ? parseEventCursor(cursor) : null; + + // Get total count first + const totalCountResult = await this.sql<{ count: string }[]>` + SELECT COUNT(*) as count + FROM contract_logs + WHERE contract_identifier = ${contractId} + AND canonical = true + AND microblock_canonical = true + `; + const totalCount = parseInt(totalCountResult[0]?.count || '0'); + const logResults = await this.sql< { event_index: number; @@ -2119,12 +2157,36 @@ export class PgStore extends BasePgStore { SELECT event_index, tx_id, tx_index, block_height, contract_identifier, topic, value FROM contract_logs - WHERE canonical = true AND microblock_canonical = true AND contract_identifier = ${contractId} + WHERE canonical = true + AND microblock_canonical = true + AND contract_identifier = ${contractId} + ${ + parsedCursor + ? this + .sql`AND (block_height, tx_index, event_index) < (${parsedCursor.blockHeight}, ${parsedCursor.txIndex}, ${parsedCursor.eventIndex})` + : this.sql`` + } ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC - LIMIT ${limit} - OFFSET ${offset} + LIMIT ${limit + 1} + ${cursor ? this.sql`` : this.sql`OFFSET ${offset}`} `; - const result = logResults.map(result => { + + // Check if there are more results (for next cursor) + const hasMore = logResults.length > limit; + const results = hasMore ? logResults.slice(0, limit) : logResults; + + // Generate next cursor from the last result + const nextCursor = + hasMore && results.length > 0 + ? createEventCursor( + results[results.length - 1].block_height, + results[results.length - 1].tx_index, + results[results.length - 1].event_index + ) + : null; + + // Map to DbSmartContractEvent format + const mappedResults = results.map(result => { const event: DbSmartContractEvent = { event_index: result.event_index, tx_id: result.tx_id, @@ -2138,7 +2200,13 @@ export class PgStore extends BasePgStore { }; return event; }); - return { found: true, result }; + + return { + found: true, + result: mappedResults, + nextCursor, + total: totalCount, + }; } async getSmartContractByTrait(args: { diff --git a/tests/api/smart-contract.test.ts b/tests/api/smart-contract.test.ts index 291db7b690..8f00910fe8 100644 --- a/tests/api/smart-contract.test.ts +++ b/tests/api/smart-contract.test.ts @@ -178,6 +178,10 @@ describe('smart contract tests', () => { expect(JSON.parse(fetchTx.text)).toEqual({ limit: 20, offset: 0, + total: 1, + cursor: null, + next_cursor: null, + prev_cursor: null, results: [ { event_index: 4, @@ -195,6 +199,164 @@ describe('smart contract tests', () => { db.eventEmitter.removeListener('smartContractLogUpdate', handler); }); + test('contract events cursor pagination', async () => { + const contractId = 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.test-contract'; + + // Create multiple blocks with contract events for pagination testing + for (let blockHeight = 1; blockHeight <= 10; blockHeight++) { + const blockBuilder = new TestBlockBuilder({ + block_height: blockHeight, + block_hash: `0x${blockHeight.toString().padStart(64, '0')}`, + index_block_hash: `0x${blockHeight.toString().padStart(64, '0')}`, + parent_index_block_hash: + blockHeight > 1 ? `0x${(blockHeight - 1).toString().padStart(64, '0')}` : '0x00', + parent_block_hash: + blockHeight > 1 ? `0x${(blockHeight - 1).toString().padStart(64, '0')}` : '0x00', + block_time: 1594647996 + blockHeight, + burn_block_time: 1594647996 + blockHeight, + burn_block_height: 123 + blockHeight, + }); + + // Add 2 transactions per block, each with 1 contract event + for (let txIndex = 0; txIndex < 2; txIndex++) { + const txId = `0x${blockHeight.toString().padStart(2, '0')}${txIndex + .toString() + .padStart(62, '0')}`; + + blockBuilder + .addTx({ + tx_id: txId, + type_id: DbTxTypeId.Coinbase, + coinbase_payload: bufferToHex(Buffer.from('test-payload')), + }) + .addTxContractLogEvent({ + contract_identifier: contractId, + topic: 'test-topic', + value: bufferToHex( + Buffer.from(serializeCV(bufferCVFromString(`event-${blockHeight}-${txIndex}`))) + ), + }); + } + + const blockData = blockBuilder.build(); + await db.update(blockData); + } + + // Basic pagination with limit (no cursor) + const page1 = await supertest(api.server) + .get(`/extended/v1/contract/${encodeURIComponent(contractId)}/events?limit=3`) + .expect(200); + + expect(page1.body).toMatchObject({ + limit: 3, + offset: 0, + total: 20, // Total events for this contract + results: expect.arrayContaining([ + expect.objectContaining({ + event_type: 'smart_contract_log', + contract_log: expect.objectContaining({ contract_id: contractId }), + }), + ]), + next_cursor: expect.any(String), + prev_cursor: null, + cursor: null, + }); + + expect(page1.body.results).toHaveLength(3); + const firstPageResults = page1.body.results; + const nextCursor = page1.body.next_cursor; + + // Use cursor for next page + const page2 = await supertest(api.server) + .get( + `/extended/v1/contract/${encodeURIComponent( + contractId + )}/events?limit=3&cursor=${nextCursor}` + ) + .expect(200); + + expect(page2.body).toMatchObject({ + limit: 3, + offset: 0, + total: 20, + results: expect.arrayContaining([ + expect.objectContaining({ + event_type: 'smart_contract_log', + contract_log: expect.objectContaining({ contract_id: contractId }), + }), + ]), + next_cursor: expect.any(String), + prev_cursor: null, + cursor: nextCursor, + }); + + expect(page2.body.results).toHaveLength(3); + + // Ensure different results between pages + const page1TxIds = firstPageResults.map((r: { tx_id: string }) => r.tx_id); + const page2TxIds = page2.body.results.map((r: { tx_id: string }) => r.tx_id); + expect(page1TxIds).not.toEqual(page2TxIds); + + // Backward compatibility - offset pagination still works + const offsetPage = await supertest(api.server) + .get(`/extended/v1/contract/${encodeURIComponent(contractId)}/events?limit=3&offset=3`) + .expect(200); + + expect(offsetPage.body).toMatchObject({ + limit: 3, + offset: 3, + total: 20, + results: expect.any(Array), + next_cursor: expect.any(String), + prev_cursor: null, + cursor: null, + }); + + // Invalid cursor returns 400 + const invalidCursor = await supertest(api.server) + .get(`/extended/v1/contract/${encodeURIComponent(contractId)}/events?cursor=invalid-cursor`) + .expect(400); + + // Cursor format validation - should be "blockHeight-txIndex-eventIndex" + const validCursorFormat = await supertest(api.server) + .get(`/extended/v1/contract/${encodeURIComponent(contractId)}/events?cursor=10-1-0&limit=2`) + .expect(200); + + expect(validCursorFormat.body.results).toHaveLength(2); + + // Complete pagination flow to demonstrate cursor behavior + let currentCursor: string | null = null; + let pageCount = 0; + const maxPages = 10; // Safety limit + const allPages: any[] = []; + + do { + const url: string = currentCursor + ? `/extended/v1/contract/${encodeURIComponent( + contractId + )}/events?limit=5&cursor=${currentCursor}` + : `/extended/v1/contract/${encodeURIComponent(contractId)}/events?limit=5`; + + const response: any = await supertest(api.server).get(url).expect(200); + + allPages.push(response.body); + currentCursor = response.body.next_cursor; + pageCount++; + + if (pageCount >= maxPages) break; // Safety break + } while (currentCursor); + + expect(pageCount).toBeGreaterThan(1); // Should have multiple pages + expect(currentCursor).toBeNull(); // Last page should have null next_cursor + + // Verify no overlapping results across pages + const allTxIds: string[] = allPages.flatMap(page => + (page.results as { tx_id: string }[]).map(r => r.tx_id) + ); + const uniqueTxIds = [...new Set(allTxIds)]; + expect(allTxIds.length).toBe(uniqueTxIds.length); // No duplicates + }); + test('get contract by ID', async () => { const contractWaiter = waiter(); const handler = (contractId: string) => contractWaiter.finish(contractId); From a89332e5d448f817b5ffd7993384eda987eff67c Mon Sep 17 00:00:00 2001 From: ASuciuX Date: Tue, 25 Nov 2025 14:02:20 +0200 Subject: [PATCH 2/2] fix: convert to block_hash pagination Co-authored-by: ASuciuX Co-authored-by: rmottley-hiro --- src/api/routes/contract.ts | 8 +- src/datastore/common.ts | 1 + src/datastore/pg-store.ts | 128 +++++++++++++++++++++++-------- tests/api/smart-contract.test.ts | 26 ++++--- 4 files changed, 119 insertions(+), 44 deletions(-) diff --git a/src/api/routes/contract.ts b/src/api/routes/contract.ts index 3e90de1e9a..9ba5d67bc0 100644 --- a/src/api/routes/contract.ts +++ b/src/api/routes/contract.ts @@ -117,7 +117,7 @@ export const ContractRoutes: FastifyPluginAsync< offset: OffsetParam(), cursor: Type.Optional( Type.String({ - description: 'Cursor for pagination', + description: 'Cursor for pagination in the format: indexBlockHash:txIndex:eventIndex', }) ), }), @@ -133,9 +133,9 @@ export const ContractRoutes: FastifyPluginAsync< const cursor = req.query.cursor; // Validate cursor format if provided - if (cursor && !cursor.match(/^\d+-\d+-\d+$/)) { + if (cursor && !cursor.match(/^[0-9a-fA-F]{64}:\d+:\d+$/)) { throw new InvalidRequestError( - 'Invalid cursor format. Expected format: blockHeight-txIndex-eventIndex', + 'Invalid cursor format. Expected format: indexBlockHash:txIndex:eventIndex', InvalidRequestErrorType.invalid_param ); } @@ -155,7 +155,7 @@ export const ContractRoutes: FastifyPluginAsync< total: eventsQuery.total || 0, results: parsedEvents as SmartContractLogTransactionEvent[], next_cursor: eventsQuery.nextCursor || null, - prev_cursor: null, // TODO: Implement prev_cursor as well + prev_cursor: eventsQuery.prevCursor || null, cursor: cursor || null, }; await reply.send(response); diff --git a/src/datastore/common.ts b/src/datastore/common.ts index 8ebc86376c..2fb725f5a4 100644 --- a/src/datastore/common.ts +++ b/src/datastore/common.ts @@ -556,6 +556,7 @@ export interface DbSmartContractEvent extends DbEventBase { export type DbCursorPaginatedFoundOrNot = FoundOrNot & { nextCursor?: string | null; prevCursor?: string | null; + currentCursor?: string | null; total: number; }; diff --git a/src/datastore/pg-store.ts b/src/datastore/pg-store.ts index 0403ca69d3..4ee4bea3a3 100644 --- a/src/datastore/pg-store.ts +++ b/src/datastore/pg-store.ts @@ -10,6 +10,7 @@ import { bnsNameFromSubdomain, ChainID, REPO_DIR, + normalizeHashString, } from '../helpers'; import { PgStoreEventEmitter } from './pg-store-event-emitter'; import { @@ -99,29 +100,30 @@ import * as path from 'path'; import { PgStoreV2 } from './pg-store-v2'; import { Fragment } from 'postgres'; import { parseBlockParam } from '../api/routes/v2/schemas'; +import { sql } from 'node-pg-migrate/dist/operations/other'; export const MIGRATIONS_DIR = path.join(REPO_DIR, 'migrations'); // Cursor utilities for smart contract events -function createEventCursor(blockHeight: number, txIndex: number, eventIndex: number): string { - return `${blockHeight}-${txIndex}-${eventIndex}`; +function createEventCursor(indexBlockHash: string, txIndex: number, eventIndex: number): string { + return `${indexBlockHash}:${txIndex}:${eventIndex}`; } function parseEventCursor( cursor: string -): { blockHeight: number; txIndex: number; eventIndex: number } | null { - const parts = cursor.split('-'); +): { indexBlockHash: string; txIndex: number; eventIndex: number } | null { + const parts = cursor.split(':'); if (parts.length !== 3) return null; - const blockHeight = parseInt(parts[0]); + const indexBlockHash = parts[0]; const txIndex = parseInt(parts[1]); const eventIndex = parseInt(parts[2]); - // Validate that parsing was successful - if (isNaN(blockHeight) || isNaN(txIndex) || isNaN(eventIndex)) { + // Validate that parsing was successful (indexBlockHash should be hex, txIndex and eventIndex should be numbers) + if (!indexBlockHash.match(/^[0-9a-fA-F]{64}$/) || isNaN(txIndex) || isNaN(eventIndex)) { return null; } - return { blockHeight, txIndex, eventIndex }; + return { indexBlockHash, txIndex, eventIndex }; } /** @@ -2143,48 +2145,112 @@ export class PgStore extends BasePgStore { `; const totalCount = parseInt(totalCountResult[0]?.count || '0'); + // If cursor is provided, look up the block_height from index_block_hash + let cursorBlockHeight: number | null = null; + let cursorFilter = this.sql``; + if (parsedCursor) { + const normalizedHash = normalizeHashString(parsedCursor.indexBlockHash); + if (normalizedHash === false) { + throw new Error(`Invalid index_block_hash in cursor: ${parsedCursor.indexBlockHash}`); + } + const blockHeightResult = await this.sql<{ block_height: number }[]>` + SELECT block_height + FROM blocks + WHERE index_block_hash = ${normalizedHash} AND canonical = true + LIMIT 1 + `; + if (blockHeightResult.length === 0) { + // Cursor references a block that doesn't exist or was re-orged + throw new Error( + `Block not found for cursor index_block_hash: ${parsedCursor.indexBlockHash}` + ); + } + cursorBlockHeight = blockHeightResult[0].block_height; + + cursorFilter = this + .sql`AND (block_height, tx_index, event_index) < (${cursorBlockHeight}, ${parsedCursor.txIndex}, ${parsedCursor.eventIndex})`; + } + const logResults = await this.sql< { event_index: number; tx_id: string; tx_index: number; block_height: number; + index_block_hash: string; contract_identifier: string; topic: string; value: string; }[] >` SELECT - event_index, tx_id, tx_index, block_height, contract_identifier, topic, value + event_index, tx_id, tx_index, block_height, encode(index_block_hash, 'hex') as index_block_hash, + contract_identifier, topic, value FROM contract_logs - WHERE canonical = true - AND microblock_canonical = true + WHERE canonical = true + AND microblock_canonical = true AND contract_identifier = ${contractId} - ${ - parsedCursor - ? this - .sql`AND (block_height, tx_index, event_index) < (${parsedCursor.blockHeight}, ${parsedCursor.txIndex}, ${parsedCursor.eventIndex})` - : this.sql`` - } + ${cursorFilter} ORDER BY block_height DESC, microblock_sequence DESC, tx_index DESC, event_index DESC LIMIT ${limit + 1} ${cursor ? this.sql`` : this.sql`OFFSET ${offset}`} `; - // Check if there are more results (for next cursor) - const hasMore = logResults.length > limit; - const results = hasMore ? logResults.slice(0, limit) : logResults; + // Check if there are more results (for prev cursor) + const hasNextPage = logResults.length > limit; + const results = hasNextPage ? logResults.slice(0, limit) : logResults; - // Generate next cursor from the last result - const nextCursor = - hasMore && results.length > 0 + // Generate prev cursor from the last result + const lastResult = results[results.length - 1]; + const prevCursor = + hasNextPage && results.length > 0 ? createEventCursor( - results[results.length - 1].block_height, - results[results.length - 1].tx_index, - results[results.length - 1].event_index + lastResult.index_block_hash, + lastResult.tx_index, + lastResult.event_index ) : null; + // Generate current cursor from the first result + const firstResult = results[0]; + const currentCursor = firstResult + ? createEventCursor( + firstResult.index_block_hash, + firstResult.tx_index, + firstResult.event_index + ) + : null; + + // Generate next cursor from the first result of last page + let nextCursor: string | null = null; + if (firstResult) { + const prevEvents = await this.sql< + { + event_index: number; + tx_index: number; + index_block_hash: string; + }[] + >` + SELECT event_index, tx_index, encode(index_block_hash, 'hex') as index_block_hash + FROM contract_logs + WHERE canonical = true + AND microblock_canonical = true + AND contract_identifier = ${contractId} + AND (block_height, tx_index, event_index) > + (${firstResult.block_height}, ${firstResult.tx_index}, ${firstResult.event_index}) + ORDER BY block_height ASC, microblock_sequence ASC, tx_index ASC, event_index ASC + OFFSET ${limit - 1} + LIMIT 1 + `; + if (prevEvents.length > 0) { + const event = prevEvents[0]; + nextCursor = createEventCursor(event.index_block_hash, event.tx_index, event.event_index); + } + } + + console.log({ nextCursor, prevCursor, currentCursor }); + console.log({ firstResult, lastResult }); + // Map to DbSmartContractEvent format const mappedResults = results.map(result => { const event: DbSmartContractEvent = { @@ -2205,6 +2271,8 @@ export class PgStore extends BasePgStore { found: true, result: mappedResults, nextCursor, + prevCursor, + currentCursor, total: totalCount, }; } @@ -3402,15 +3470,15 @@ export class PgStore extends BasePgStore { { address: string; balance: string; count: number; total_supply: string }[] >` WITH totals AS ( - SELECT + SELECT SUM(balance) AS total, COUNT(*)::int AS total_count FROM ft_balances WHERE token = ${args.token} ) - SELECT - fb.address, - fb.balance, + SELECT + fb.address, + fb.balance, ts.total AS total_supply, ts.total_count AS count FROM ft_balances fb diff --git a/tests/api/smart-contract.test.ts b/tests/api/smart-contract.test.ts index 8f00910fe8..dad654ef25 100644 --- a/tests/api/smart-contract.test.ts +++ b/tests/api/smart-contract.test.ts @@ -257,21 +257,21 @@ describe('smart contract tests', () => { contract_log: expect.objectContaining({ contract_id: contractId }), }), ]), - next_cursor: expect.any(String), - prev_cursor: null, + next_cursor: null, + prev_cursor: expect.any(String), cursor: null, }); expect(page1.body.results).toHaveLength(3); const firstPageResults = page1.body.results; - const nextCursor = page1.body.next_cursor; + const prevCursor = page1.body.prev_cursor; // Use cursor for next page const page2 = await supertest(api.server) .get( `/extended/v1/contract/${encodeURIComponent( contractId - )}/events?limit=3&cursor=${nextCursor}` + )}/events?limit=3&cursor=${prevCursor}` ) .expect(200); @@ -286,8 +286,8 @@ describe('smart contract tests', () => { }), ]), next_cursor: expect.any(String), - prev_cursor: null, - cursor: nextCursor, + prev_cursor: expect.any(String), + cursor: prevCursor, }); expect(page2.body.results).toHaveLength(3); @@ -302,13 +302,14 @@ describe('smart contract tests', () => { .get(`/extended/v1/contract/${encodeURIComponent(contractId)}/events?limit=3&offset=3`) .expect(200); + console.log('Offset Page Test: ', offsetPage.body); expect(offsetPage.body).toMatchObject({ limit: 3, offset: 3, total: 20, results: expect.any(Array), next_cursor: expect.any(String), - prev_cursor: null, + prev_cursor: expect.any(String), cursor: null, }); @@ -317,9 +318,14 @@ describe('smart contract tests', () => { .get(`/extended/v1/contract/${encodeURIComponent(contractId)}/events?cursor=invalid-cursor`) .expect(400); - // Cursor format validation - should be "blockHeight-txIndex-eventIndex" + // Cursor format validation - should be "indexBlockHash-txIndex-eventIndex" + // Using index_block_hash from block 10 (0x0000000000000000000000000000000000000000000000000000000000000010) const validCursorFormat = await supertest(api.server) - .get(`/extended/v1/contract/${encodeURIComponent(contractId)}/events?cursor=10-1-0&limit=2`) + .get( + `/extended/v1/contract/${encodeURIComponent( + contractId + )}/events?cursor=0000000000000000000000000000000000000000000000000000000000000010:1:0&limit=2` + ) .expect(200); expect(validCursorFormat.body.results).toHaveLength(2); @@ -340,7 +346,7 @@ describe('smart contract tests', () => { const response: any = await supertest(api.server).get(url).expect(200); allPages.push(response.body); - currentCursor = response.body.next_cursor; + currentCursor = response.body.prev_cursor; pageCount++; if (pageCount >= maxPages) break; // Safety break