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 9ddba76564..b3dcf4692a 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 26a29c04a1..c8df605f9d 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. @@ -2089,15 +2112,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; @@ -2112,12 +2150,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, @@ -2131,7 +2193,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);